Files
QuiCommit/src/commands/profile.rs
SidneyZhang e822ba1f54 feat(commands):为所有命令添加config_path参数支持,实现自定义配置文件路径
♻️ refactor(config):重构ConfigManager,添加with_path_fresh方法用于初始化新配置
🔧 fix(git):改进跨平台路径处理,增强git仓库检测的鲁棒性
 test(tests):添加全面的集成测试,覆盖所有命令和跨平台场景
2026-02-14 14:28:11 +08:00

803 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use anyhow::{bail, Result};
use clap::{Parser, Subcommand};
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::config::{GitProfile, TokenConfig, TokenType};
use crate::config::profile::{GpgConfig, SshConfig};
use crate::git::find_repo;
use crate::utils::validators::validate_profile_name;
/// Manage Git profiles
#[derive(Parser)]
pub struct ProfileCommand {
#[command(subcommand)]
command: Option<ProfileSubcommand>,
}
#[derive(Subcommand)]
enum ProfileSubcommand {
/// Add a new profile
Add,
/// Remove a profile
Remove {
/// Profile name
name: String,
},
/// List all profiles
List,
/// Show profile details
Show {
/// Profile name
name: Option<String>,
},
/// Edit a profile
Edit {
/// Profile name
name: String,
},
/// Set default profile
SetDefault {
/// Profile name
name: String,
},
/// Set profile for current repository
SetRepo {
/// Profile name
name: String,
},
/// Apply profile to current repository
Apply {
/// Profile name (uses default if not specified)
name: Option<String>,
/// Apply globally instead of to current repo
#[arg(short, long)]
global: bool,
},
/// Switch between profiles interactively
Switch,
/// Copy/duplicate a profile
Copy {
/// Source profile name
from: String,
/// New profile name
to: String,
},
/// Manage tokens for a profile
Token {
#[command(subcommand)]
token_command: TokenSubcommand,
},
/// Check profile configuration against git
Check {
/// Profile name
name: Option<String>,
},
/// Show usage statistics
Stats {
/// Profile name
name: Option<String>,
},
}
#[derive(Subcommand)]
enum TokenSubcommand {
/// Add a token to a profile
Add {
/// Profile name
profile: String,
/// Service name (e.g., github, gitlab)
service: String,
},
/// Remove a token from a profile
Remove {
/// Profile name
profile: String,
/// Service name
service: String,
},
/// List tokens in a profile
List {
/// Profile name
profile: String,
},
}
impl ProfileCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
Some(ProfileSubcommand::Add) => self.add_profile(&config_path).await,
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name, &config_path).await,
Some(ProfileSubcommand::List) => self.list_profiles(&config_path).await,
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref(), &config_path).await,
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name, &config_path).await,
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name, &config_path).await,
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name, &config_path).await,
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global, &config_path).await,
Some(ProfileSubcommand::Switch) => self.switch_profile(&config_path).await,
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to, &config_path).await,
Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command, &config_path).await,
Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref(), &config_path).await,
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref(), &config_path).await,
None => self.list_profiles(&config_path).await,
}
}
fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
async fn add_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
println!("{}", "\nAdd new profile".bold());
println!("{}", "".repeat(40));
let name: String = Input::new()
.with_prompt("Profile name")
.validate_with(|input: &String| {
validate_profile_name(input).map_err(|e| e.to_string())
})
.interact_text()?;
if manager.has_profile(&name) {
bail!("Profile '{}' already exists", name);
}
let user_name: String = Input::new()
.with_prompt("Git user name")
.interact_text()?;
let user_email: String = Input::new()
.with_prompt("Git user email")
.validate_with(|input: &String| {
crate::utils::validators::validate_email(input).map_err(|e| e.to_string())
})
.interact_text()?;
let description: String = Input::new()
.with_prompt("Description (optional)")
.allow_empty(true)
.interact_text()?;
let is_work = Confirm::new()
.with_prompt("Is this a work profile?")
.default(false)
.interact()?;
let organization = if is_work {
Some(Input::new()
.with_prompt("Organization")
.interact_text()?)
} else {
None
};
let mut profile = GitProfile::new(name.clone(), user_name, user_email);
if !description.is_empty() {
profile.description = Some(description);
}
profile.is_work = is_work;
profile.organization = organization;
let setup_ssh = Confirm::new()
.with_prompt("Configure SSH key?")
.default(false)
.interact()?;
if setup_ssh {
profile.ssh = Some(self.setup_ssh_interactive().await?);
}
let setup_gpg = Confirm::new()
.with_prompt("Configure GPG signing?")
.default(false)
.interact()?;
if setup_gpg {
profile.gpg = Some(self.setup_gpg_interactive().await?);
}
let setup_token = Confirm::new()
.with_prompt("Add a Personal Access Token?")
.default(false)
.interact()?;
if setup_token {
self.setup_token_interactive(&mut profile).await?;
}
manager.add_profile(name.clone(), profile)?;
manager.save()?;
println!("{} Profile '{}' added successfully", "".green(), name.cyan());
if manager.default_profile().is_none() {
let set_default = Confirm::new()
.with_prompt("Set as default profile?")
.default(true)
.interact()?;
if set_default {
manager.set_default_profile(Some(name.clone()))?;
manager.save()?;
println!("{} Set '{}' as default profile", "".green(), name.cyan());
}
}
Ok(())
}
async fn remove_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(name) {
bail!("Profile '{}' not found", name);
}
let confirm = Confirm::new()
.with_prompt(&format!("Are you sure you want to remove profile '{}'?", name))
.default(false)
.interact()?;
if !confirm {
println!("{}", "Cancelled.".yellow());
return Ok(());
}
manager.remove_profile(name)?;
manager.save()?;
println!("{} Profile '{}' removed", "".green(), name);
Ok(())
}
async fn list_profiles(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let profiles = manager.list_profiles();
if profiles.is_empty() {
println!("{}", "No profiles configured.".yellow());
println!("Run {} to create one.", "quicommit profile add".cyan());
return Ok(());
}
let default = manager.default_profile_name();
println!("{}", "\nConfigured profiles:".bold());
println!("{}", "".repeat(60));
for name in profiles {
let profile = manager.get_profile(name).unwrap();
let is_default = default.map(|d| d == name).unwrap_or(false);
let marker = if is_default { "".green() } else { "".dimmed() };
let work_marker = if profile.is_work { " [work]".yellow() } else { "".normal() };
println!("{} {}{}", marker, name.cyan().bold(), work_marker);
println!(" {} <{}>", profile.user_name, profile.user_email);
if let Some(ref desc) = profile.description {
println!(" {}", desc.dimmed());
}
if profile.has_ssh() {
println!(" {} SSH configured", "🔑".to_string().dimmed());
}
if profile.has_gpg() {
println!(" {} GPG configured", "🔒".to_string().dimmed());
}
if profile.has_tokens() {
println!(" {} {} token(s)", "🔐".to_string().dimmed(), profile.tokens.len());
}
if let Some(ref usage) = profile.usage.last_used {
println!(" {} Last used: {}", "📊".to_string().dimmed(), usage.dimmed());
}
println!();
}
Ok(())
}
async fn show_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let profile = if let Some(n) = name {
manager.get_profile(n)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?
} else {
manager.default_profile()
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
};
println!("{}", format!("\nProfile: {}", profile.name).bold());
println!("{}", "".repeat(40));
println!("User name: {}", profile.user_name);
println!("User email: {}", profile.user_email);
if let Some(ref desc) = profile.description {
println!("Description: {}", desc);
}
println!("Work profile: {}", if profile.is_work { "yes".yellow() } else { "no".normal() });
if let Some(ref org) = profile.organization {
println!("Organization: {}", org);
}
if let Some(ref ssh) = profile.ssh {
println!("\n{}", "SSH Configuration:".bold());
if let Some(ref path) = ssh.private_key_path {
println!(" Private key: {:?}", path);
}
}
if let Some(ref gpg) = profile.gpg {
println!("\n{}", "GPG Configuration:".bold());
println!(" Key ID: {}", gpg.key_id);
println!(" Program: {}", gpg.program);
println!(" Use agent: {}", if gpg.use_agent { "yes" } else { "no" });
}
if !profile.tokens.is_empty() {
println!("\n{}", "Tokens:".bold());
for (service, token) in &profile.tokens {
println!(" {} ({})", service.cyan(), token.token_type);
if let Some(ref desc) = token.description {
println!(" {}", desc);
}
}
}
if profile.usage.total_uses > 0 {
println!("\n{}", "Usage Statistics:".bold());
println!(" Total uses: {}", profile.usage.total_uses);
if let Some(ref last_used) = profile.usage.last_used {
println!(" Last used: {}", last_used);
}
}
Ok(())
}
async fn edit_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let profile = manager.get_profile(name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?
.clone();
println!("{}", format!("\nEditing profile: {}", name).bold());
println!("{}", "".repeat(40));
let user_name: String = Input::new()
.with_prompt("Git user name")
.default(profile.user_name.clone())
.interact_text()?;
let user_email: String = Input::new()
.with_prompt("Git user email")
.default(profile.user_email.clone())
.validate_with(|input: &String| {
crate::utils::validators::validate_email(input).map_err(|e| e.to_string())
})
.interact_text()?;
let mut new_profile = GitProfile::new(name.to_string(), user_name, user_email);
new_profile.description = profile.description;
new_profile.is_work = profile.is_work;
new_profile.organization = profile.organization;
new_profile.ssh = profile.ssh;
new_profile.gpg = profile.gpg;
new_profile.tokens = profile.tokens;
new_profile.usage = profile.usage;
manager.update_profile(name, new_profile)?;
manager.save()?;
println!("{} Profile '{}' updated", "".green(), name);
Ok(())
}
async fn set_default(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_default_profile(Some(name.to_string()))?;
manager.save()?;
println!("{} Set '{}' as default profile", "".green(), name.cyan());
Ok(())
}
async fn set_repo(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let repo = find_repo(std::env::current_dir()?.as_path())?;
let repo_path = repo.path().to_string_lossy().to_string();
manager.set_repo_profile(repo_path.clone(), name.to_string())?;
// Get the profile and apply it to the repository
let profile = manager.get_profile(name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?;
profile.apply_to_repo(repo.inner())?;
manager.record_profile_usage(name, Some(repo_path))?;
manager.save()?;
println!("{} Set '{}' for current repository", "".green(), name.cyan());
println!("{} Applied profile '{}' to current repository", "".green(), name.cyan());
Ok(())
}
async fn apply_profile(&self, name: Option<&str>, global: bool, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let profile_name = if let Some(n) = name {
n.to_string()
} else {
manager.default_profile_name()
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
.clone()
};
let profile = manager.get_profile(&profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?
.clone();
let repo_path = if global {
None
} else {
let repo = find_repo(std::env::current_dir()?.as_path())?;
Some(repo.path().to_string_lossy().to_string())
};
if global {
profile.apply_global()?;
println!("{} Applied profile '{}' globally", "".green(), profile.name.cyan());
} else {
let repo = find_repo(std::env::current_dir()?.as_path())?;
profile.apply_to_repo(repo.inner())?;
println!("{} Applied profile '{}' to current repository", "".green(), profile.name.cyan());
}
manager.record_profile_usage(&profile_name, repo_path)?;
manager.save()?;
Ok(())
}
async fn switch_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let profiles: Vec<String> = manager.list_profiles()
.into_iter()
.map(|s| s.to_string())
.collect();
if profiles.is_empty() {
bail!("No profiles configured");
}
let default = manager.default_profile_name().map(|s| s.as_str());
let default_idx = default
.and_then(|d| profiles.iter().position(|p| p == d))
.unwrap_or(0);
let selection = Select::new()
.with_prompt("Select profile to switch to")
.items(&profiles)
.default(default_idx)
.interact()?;
let selected = &profiles[selection];
manager.set_default_profile(Some(selected.clone()))?;
manager.save()?;
println!("{} Switched to profile '{}'", "".green(), selected.cyan());
if find_repo(".").is_ok() {
let apply = Confirm::new()
.with_prompt("Apply to current repository?")
.default(true)
.interact()?;
if apply {
self.apply_profile(Some(selected), false, config_path).await?;
}
}
Ok(())
}
async fn copy_profile(&self, from: &str, to: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let source = manager.get_profile(from)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))?
.clone();
validate_profile_name(to)?;
let mut new_profile = source.clone();
new_profile.name = to.to_string();
new_profile.usage = Default::default();
manager.add_profile(to.to_string(), new_profile)?;
manager.save()?;
println!("{} Copied profile '{}' to '{}'", "".green(), from, to.cyan());
Ok(())
}
async fn handle_token_command(&self, cmd: &TokenSubcommand, config_path: &Option<PathBuf>) -> Result<()> {
match cmd {
TokenSubcommand::Add { profile, service } => self.add_token(profile, service, config_path).await,
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service, config_path).await,
TokenSubcommand::List { profile } => self.list_tokens(profile, config_path).await,
}
}
async fn add_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(profile_name) {
bail!("Profile '{}' not found", profile_name);
}
println!("{}", format!("\nAdd token to profile '{}'", profile_name).bold());
println!("{}", "".repeat(40));
let token_value: String = Input::new()
.with_prompt(&format!("Token for {}", service))
.interact_text()?;
let token_type_options = vec!["Personal", "OAuth", "Deploy", "App"];
let selection = Select::new()
.with_prompt("Token type")
.items(&token_type_options)
.default(0)
.interact()?;
let token_type = match selection {
0 => TokenType::Personal,
1 => TokenType::OAuth,
2 => TokenType::Deploy,
3 => TokenType::App,
_ => TokenType::Personal,
};
let description: String = Input::new()
.with_prompt("Description (optional)")
.allow_empty(true)
.interact_text()?;
let mut token = TokenConfig::new(token_value, token_type);
if !description.is_empty() {
token.description = Some(description);
}
manager.add_token_to_profile(profile_name, service.to_string(), token)?;
manager.save()?;
println!("{} Token for '{}' added to profile '{}'", "".green(), service.cyan(), profile_name);
Ok(())
}
async fn remove_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(profile_name) {
bail!("Profile '{}' not found", profile_name);
}
let confirm = Confirm::new()
.with_prompt(&format!("Remove token '{}' from profile '{}'?", service, profile_name))
.default(false)
.interact()?;
if !confirm {
println!("{}", "Cancelled.".yellow());
return Ok(());
}
manager.remove_token_from_profile(profile_name, service)?;
manager.save()?;
println!("{} Token '{}' removed from profile '{}'", "".green(), service, profile_name);
Ok(())
}
async fn list_tokens(&self, profile_name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let profile = manager.get_profile(profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
if profile.tokens.is_empty() {
println!("{} No tokens configured for profile '{}'", "".yellow(), profile_name);
return Ok(());
}
println!("{}", format!("\nTokens for profile '{}':", profile_name).bold());
println!("{}", "".repeat(40));
for (service, token) in &profile.tokens {
println!("{} ({})", service.cyan().bold(), token.token_type);
if let Some(ref desc) = token.description {
println!(" {}", desc);
}
if let Some(ref last_used) = token.last_used {
println!(" Last used: {}", last_used);
}
}
Ok(())
}
async fn check_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let profile_name = if let Some(n) = name {
n.to_string()
} else {
manager.default_profile_name()
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
.clone()
};
let repo = find_repo(std::env::current_dir()?.as_path())?;
let comparison = manager.check_profile_config(&profile_name, repo.inner())?;
println!("{}", format!("\nChecking profile '{}' against git configuration", profile_name).bold());
println!("{}", "".repeat(60));
if comparison.matches {
println!("{} Profile configuration matches git settings", "".green().bold());
} else {
println!("{} Profile configuration differs from git settings", "".red().bold());
println!("\n{}", "Differences:".bold());
for diff in &comparison.differences {
println!("\n {}:", diff.key.cyan());
println!(" Profile: {}", diff.profile_value.green());
println!(" Git: {}", diff.git_value.yellow());
}
}
Ok(())
}
async fn show_stats(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
if let Some(n) = name {
let profile = manager.get_profile(n)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?;
self.show_single_profile_stats(profile);
} else {
let profiles = manager.list_profiles();
if profiles.is_empty() {
println!("{}", "No profiles configured.".yellow());
return Ok(());
}
println!("{}", "\nProfile Usage Statistics:".bold());
println!("{}", "".repeat(60));
for profile_name in profiles {
if let Some(profile) = manager.get_profile(profile_name) {
self.show_single_profile_stats(profile);
println!();
}
}
}
Ok(())
}
fn show_single_profile_stats(&self, profile: &GitProfile) {
println!("{}", format!("\n{}", profile.name).bold());
println!(" Total uses: {}", profile.usage.total_uses);
if let Some(ref last_used) = profile.usage.last_used {
println!(" Last used: {}", last_used);
}
if !profile.usage.repo_usage.is_empty() {
println!(" Repositories:");
for (repo, count) in &profile.usage.repo_usage {
println!(" {} ({} uses)", repo, count);
}
}
}
async fn setup_ssh_interactive(&self) -> Result<SshConfig> {
use std::path::PathBuf;
let ssh_dir = dirs::home_dir()
.map(|h| h.join(".ssh"))
.unwrap_or_else(|| PathBuf::from("~/.ssh"));
let key_path: String = Input::new()
.with_prompt("SSH private key path")
.default(ssh_dir.join("id_rsa").display().to_string())
.interact_text()?;
Ok(SshConfig {
private_key_path: Some(PathBuf::from(key_path)),
public_key_path: None,
passphrase: None,
agent_forwarding: false,
ssh_command: None,
known_hosts_file: None,
})
}
async fn setup_gpg_interactive(&self) -> Result<GpgConfig> {
let key_id: String = Input::new()
.with_prompt("GPG key ID")
.interact_text()?;
Ok(GpgConfig {
key_id,
program: "gpg".to_string(),
home_dir: None,
passphrase: None,
use_agent: true,
})
}
async fn setup_token_interactive(&self, profile: &mut GitProfile) -> Result<()> {
let service: String = Input::new()
.with_prompt("Service name (e.g., github, gitlab)")
.interact_text()?;
let token_value: String = Input::new()
.with_prompt("Token value")
.interact_text()?;
let token = TokenConfig::new(token_value, TokenType::Personal);
profile.add_token(service, token);
Ok(())
}
}