From 8dd9e85b77a9515e0ce34d0792a2e50e0ebbeba3 Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Mon, 23 Mar 2026 17:59:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E5=9C=A8=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E5=AF=BC=E5=87=BA/=E5=AF=BC=E5=85=A5=E4=B8=AD=E5=8C=85?= =?UTF-8?q?=E5=90=AB=E4=B8=AA=E4=BA=BA=E8=AE=BF=E9=97=AE=E4=BB=A4=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/config.rs | 137 ++++++++++++++++- src/commands/profile.rs | 323 ++++++++++++++++++++++++++++++++++++++-- src/config/manager.rs | 106 +++++++++++-- src/config/mod.rs | 52 +++++++ src/config/profile.rs | 37 +++-- src/git/mod.rs | 96 ++++++++++++ src/utils/keyring.rs | 84 ++++++++++- 7 files changed, 787 insertions(+), 48 deletions(-) diff --git a/src/commands/config.rs b/src/commands/config.rs index f71ea07..3bd1d86 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,10 +1,10 @@ -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use colored::Colorize; use dialoguer::{Confirm, Input, Select, Password}; use std::path::PathBuf; -use crate::config::{Language, manager::ConfigManager}; +use crate::config::{Language, manager::ConfigManager, ExportData, EncryptedPat}; use crate::config::CommitFormat; use crate::utils::keyring::{get_supported_providers, get_default_model, get_default_base_url, provider_needs_api_key}; use crate::utils::crypto::{encrypt, decrypt}; @@ -777,9 +777,45 @@ impl ConfigCommand { }; if pwd.is_empty() { + let mut has_pats = false; + for (profile_name, profile) in manager.config().profiles.iter() { + for service in profile.tokens.keys() { + if manager.has_pat_for_profile(profile_name, service) { + has_pats = true; + break; + } + } + } + + if has_pats { + println!("{} {}", "⚠".yellow(), "WARNING: Exporting without encryption.".bold()); + println!(" {}", "Personal Access Tokens (PATs) stored in keyring will NOT be exported.".yellow()); + println!(" {}", "To export PATs securely, please enable encryption.".yellow()); + println!(); + } + toml } else { - let encrypted = encrypt(toml.as_bytes(), &pwd)?; + let mut encrypted_pats: Vec = Vec::new(); + + for (profile_name, profile) in manager.config().profiles.iter() { + for service in profile.tokens.keys() { + if let Ok(Some(pat_value)) = manager.get_pat_for_profile(profile_name, service) { + let encrypted_token = encrypt(pat_value.as_bytes(), &pwd)?; + encrypted_pats.push(EncryptedPat { + profile_name: profile_name.clone(), + service: service.clone(), + user_email: profile.user_email.clone(), + encrypted_token, + }); + } + } + } + + let export_data = ExportData::with_encrypted_pats(toml, encrypted_pats); + let export_json = serde_json::to_string(&export_data) + .context("Failed to serialize export data")?; + let encrypted = encrypt(export_json.as_bytes(), &pwd)?; format!("ENCRYPTED:{}", encrypted) } } else { @@ -806,7 +842,7 @@ impl ConfigCommand { async fn import_config(&self, file: &str, password: Option<&str>, config_path: &Option) -> Result<()> { let content = std::fs::read_to_string(file)?; - let config_content = if content.starts_with("ENCRYPTED:") { + let (config_content, encrypted_pats, pwd) = if content.starts_with("ENCRYPTED:") { let encrypted_data = content.strip_prefix("ENCRYPTED:").unwrap(); let pwd = if let Some(p) = password { @@ -817,21 +853,106 @@ impl ConfigCommand { .interact()? }; - match decrypt(encrypted_data, &pwd) { - Ok(decrypted) => String::from_utf8(decrypted) - .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in decrypted content: {}", e))?, + let decrypted = match decrypt(encrypted_data, &pwd) { + Ok(d) => d, Err(e) => { bail!("Failed to decrypt configuration: {}. Please check your password.", e); } + }; + + let decrypted_str = String::from_utf8(decrypted) + .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in decrypted content: {}", e))?; + + match serde_json::from_str::(&decrypted_str) { + Ok(export_data) => { + (export_data.config, Some(export_data.encrypted_pats), Some(pwd)) + } + Err(_) => { + (decrypted_str, None, Some(pwd)) + } } } else { - content + (content, None, None) }; let mut manager = self.get_manager(config_path)?; manager.import(&config_content)?; manager.save()?; + if let (Some(pats), Some(pwd)) = (encrypted_pats, pwd) { + if !pats.is_empty() { + println!(); + println!("{}", "Importing Personal Access Tokens...".bold()); + + let mut imported_count = 0; + let mut failed_count = 0; + + for pat in pats { + match decrypt(&pat.encrypted_token, &pwd) { + Ok(token_bytes) => { + match String::from_utf8(token_bytes) { + Ok(token_value) => { + if manager.keyring().is_available() { + match manager.store_pat_for_profile( + &pat.profile_name, + &pat.service, + &token_value + ) { + Ok(_) => { + println!(" {} Token for {} ({}) imported to keyring", + "✓".green(), + pat.profile_name.cyan(), + pat.service.yellow()); + imported_count += 1; + } + Err(e) => { + println!(" {} Failed to store token for {} ({}): {}", + "✗".red(), + pat.profile_name, + pat.service, + e); + failed_count += 1; + } + } + } else { + println!(" {} Keyring not available, cannot store token for {} ({})", + "⚠".yellow(), + pat.profile_name, + pat.service); + failed_count += 1; + } + } + Err(e) => { + println!(" {} Invalid token format for {} ({}): {}", + "✗".red(), + pat.profile_name, + pat.service, + e); + failed_count += 1; + } + } + } + Err(e) => { + println!(" {} Failed to decrypt token for {} ({}): {}", + "✗".red(), + pat.profile_name, + pat.service, + e); + failed_count += 1; + } + } + } + + println!(); + if imported_count > 0 { + println!("{} {} token(s) imported to keyring", "✓".green(), imported_count); + } + if failed_count > 0 { + println!("{} {} token(s) failed to import", "⚠".yellow(), failed_count); + } + } + } + println!("{} Configuration imported from {}", "✓".green(), file); Ok(()) } diff --git a/src/commands/profile.rs b/src/commands/profile.rs index 1700812..cd250b0 100644 --- a/src/commands/profile.rs +++ b/src/commands/profile.rs @@ -228,7 +228,7 @@ impl ProfileCommand { .interact()?; if setup_token { - self.setup_token_interactive(&mut profile).await?; + self.setup_token_interactive(&mut profile, &manager).await?; } manager.add_profile(name.clone(), profile)?; @@ -269,10 +269,12 @@ impl ProfileCommand { return Ok(()); } + manager.delete_all_pats_for_profile(name)?; + manager.remove_profile(name)?; manager.save()?; - println!("{} Profile '{}' removed", "✓".green(), name); + println!("{} Profile '{}' removed (including all stored tokens)", "✓".green(), name); Ok(()) } @@ -330,16 +332,171 @@ impl ProfileCommand { async fn show_profile(&self, name: Option<&str>, config_path: &Option) -> 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))? + match find_repo(std::env::current_dir()?.as_path()) { + Ok(repo) => { + self.show_repo_status(&repo, &manager, name).await + } + Err(_) => { + self.show_global_status(&manager, name).await + } + } + } + + async fn show_repo_status(&self, repo: &crate::git::GitRepo, manager: &ConfigManager, name: Option<&str>) -> Result<()> { + use crate::git::MergedUserConfig; + + let merged_config = MergedUserConfig::from_repo(repo.inner())?; + let repo_path = repo.path().to_string_lossy().to_string(); + + println!("{}", "\n📁 Current Repository Status".bold()); + println!("{}", "─".repeat(60)); + println!("Repository: {}", repo_path.cyan()); + + println!("\n{}", "Git User Configuration (merged local/global):".bold()); + println!("{}", "─".repeat(60)); + + self.print_config_entry("User name", &merged_config.name); + self.print_config_entry("User email", &merged_config.email); + self.print_config_entry("Signing key", &merged_config.signing_key); + self.print_config_entry("SSH command", &merged_config.ssh_command); + self.print_config_entry("Commit GPG sign", &merged_config.commit_gpgsign); + self.print_config_entry("Tag GPG sign", &merged_config.tag_gpgsign); + + let user_name = merged_config.name.value.clone().unwrap_or_default(); + let user_email = merged_config.email.value.clone().unwrap_or_default(); + let signing_key = merged_config.signing_key.value.as_deref(); + + let matching_profile = manager.find_matching_profile(&user_name, &user_email, signing_key); + let repo_profile_name = manager.get_repo_profile_name(&repo_path); + + println!("\n{}", "QuiCommit Profile Status:".bold()); + println!("{}", "─".repeat(60)); + + match (&matching_profile, repo_profile_name) { + (Some(profile), Some(mapped_name)) => { + if profile.name == *mapped_name { + println!("{} Profile '{}' is mapped to this repository", "✓".green(), profile.name.cyan()); + println!(" This repository's git config matches the saved profile."); + } else { + println!("{} Profile '{}' matches current config", "✓".green(), profile.name.cyan()); + println!(" But repository is mapped to different profile: {}", mapped_name.yellow()); + } + } + (Some(profile), None) => { + println!("{} Profile '{}' matches current config", "✓".green(), profile.name.cyan()); + println!(" {} This repository is not mapped to any profile.", "ℹ".yellow()); + } + (None, Some(mapped_name)) => { + println!("{} Repository is mapped to profile '{}'", "⚠".yellow(), mapped_name.cyan()); + println!(" But current git config does not match this profile!"); + + if let Some(mapped_profile) = manager.get_profile(mapped_name) { + println!("\n Mapped profile config:"); + println!(" user.name: {}", mapped_profile.user_name); + println!(" user.email: {}", mapped_profile.user_email); + } + } + (None, None) => { + println!("{} No matching profile found in QuiCommit", "✗".red()); + println!(" Current git identity is not saved as a QuiCommit profile."); + + let partial_matches = manager.find_partial_matches(&user_name, &user_email); + if !partial_matches.is_empty() { + println!("\n {} Similar profiles exist:", "ℹ".yellow()); + for p in partial_matches { + let same_name = p.user_name == user_name; + let same_email = p.user_email == user_email; + let reason = match (same_name, same_email) { + (true, true) => "same name & email", + (true, false) => "same name", + (false, true) => "same email", + (false, false) => "partial match", + }; + println!(" - {} ({})", p.name.cyan(), reason.dimmed()); + } + } + + if merged_config.is_complete() { + println!("\n {} Would you like to save this identity as a new profile?", "💡".yellow()); + let save = Confirm::new() + .with_prompt("Save current git identity as new profile?") + .default(true) + .interact()?; + + if save { + self.save_current_identity_as_profile(&merged_config, manager).await?; + } + } + } + } + + if let Some(profile_name) = name { + if let Some(profile) = manager.get_profile(profile_name) { + println!("\n{}", format!("Requested Profile: {}", profile_name).bold()); + println!("{}", "─".repeat(60)); + self.print_profile_details(profile); + } else { + println!("\n{} Profile '{}' not found", "✗".red(), profile_name); + } + } else if let Some(profile) = manager.default_profile() { + println!("\n{}", format!("Default Profile: {}", profile.name).bold()); + println!("{}", "─".repeat(60)); + self.print_profile_details(profile); + } + + Ok(()) + } + + async fn show_global_status(&self, manager: &ConfigManager, name: Option<&str>) -> Result<()> { + println!("{}", "\n⚠ Not in a Git Repository".bold().yellow()); + println!("{}", "─".repeat(60)); + println!("Run this command inside a git repository to see local config status."); + println!(); + + if let Some(profile_name) = name { + if let Some(profile) = manager.get_profile(profile_name) { + println!("{}", format!("Profile: {}", profile_name).bold()); + println!("{}", "─".repeat(40)); + self.print_profile_details(profile); + } else { + bail!("Profile '{}' not found", profile_name); + } + } else if let Some(profile) = manager.default_profile() { + println!("{}", format!("Default Profile: {}", profile.name).bold()); + println!("{}", "─".repeat(40)); + self.print_profile_details(profile); } else { - manager.default_profile() - .ok_or_else(|| anyhow::anyhow!("No default profile set"))? + println!("{}", "No default profile set.".yellow()); + println!("Run {} to create one.", "quicommit profile add".cyan()); + } + + Ok(()) + } + + fn print_config_entry(&self, label: &str, entry: &crate::git::ConfigEntry) { + use crate::git::ConfigSource; + + let source_indicator = match entry.source { + ConfigSource::Local => format!("[{}]", "local".green()), + ConfigSource::Global => format!("[{}]", "global".blue()), + ConfigSource::NotSet => format!("[{}]", "not set".dimmed()), }; - println!("{}", format!("\nProfile: {}", profile.name).bold()); - println!("{}", "─".repeat(40)); + match &entry.value { + Some(value) => { + println!("{} {}: {}", source_indicator, label, value); + if entry.local_value.is_some() && entry.global_value.is_some() { + println!(" {} local: {}", "├".dimmed(), entry.local_value.as_ref().unwrap()); + println!(" {} global: {}", "└".dimmed(), entry.global_value.as_ref().unwrap()); + } + } + None => { + println!("{} {}: {}", source_indicator, label, "".dimmed()); + } + } + } + + fn print_profile_details(&self, profile: &GitProfile) { println!("User name: {}", profile.user_name); println!("User email: {}", profile.user_email); @@ -384,6 +541,112 @@ impl ProfileCommand { println!(" Last used: {}", last_used); } } + } + + async fn save_current_identity_as_profile(&self, merged_config: &crate::git::MergedUserConfig, manager: &ConfigManager) -> Result<()> { + let config_path = manager.path().to_path_buf(); + let mut manager = ConfigManager::with_path(&config_path)?; + + let user_name = merged_config.name.value.clone().unwrap_or_default(); + let user_email = merged_config.email.value.clone().unwrap_or_default(); + + println!("\n{}", "Save New Profile".bold()); + println!("{}", "─".repeat(40)); + + let default_name = user_name.to_lowercase().replace(' ', "-"); + let profile_name: String = Input::new() + .with_prompt("Profile name") + .default(default_name) + .validate_with(|input: &String| { + validate_profile_name(input).map_err(|e| e.to_string()) + }) + .interact_text()?; + + if manager.has_profile(&profile_name) { + let overwrite = Confirm::new() + .with_prompt(&format!("Profile '{}' already exists. Overwrite?", profile_name)) + .default(false) + .interact()?; + if !overwrite { + println!("{}", "Cancelled.".yellow()); + return Ok(()); + } + } + + let description: String = Input::new() + .with_prompt("Description (optional)") + .default(format!("Imported from existing git config")) + .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(profile_name.clone(), user_name, user_email); + if !description.is_empty() { + profile.description = Some(description); + } + profile.is_work = is_work; + profile.organization = organization; + + if let Some(ref key) = merged_config.signing_key.value { + profile.signing_key = Some(key.clone()); + + let setup_gpg = Confirm::new() + .with_prompt("Configure GPG signing details?") + .default(true) + .interact()?; + + if setup_gpg { + profile.gpg = Some(self.setup_gpg_interactive().await?); + } + } + + if merged_config.ssh_command.is_set() { + let setup_ssh = Confirm::new() + .with_prompt("Configure SSH key details?") + .default(false) + .interact()?; + + if setup_ssh { + profile.ssh = Some(self.setup_ssh_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, &manager).await?; + } + + manager.add_profile(profile_name.clone(), profile)?; + manager.save()?; + + println!("{} Profile '{}' saved successfully", "✓".green(), profile_name.cyan()); + + let set_default = Confirm::new() + .with_prompt("Set as default profile?") + .default(true) + .interact()?; + + if set_default { + manager.set_default_profile(Some(profile_name.clone()))?; + manager.save()?; + println!("{} Set '{}' as default profile", "✓".green(), profile_name.cyan()); + } Ok(()) } @@ -578,6 +841,10 @@ impl ProfileCommand { bail!("Profile '{}' not found", profile_name); } + if !manager.keyring().is_available() { + bail!("Keyring is not available. Cannot store PAT securely. Please ensure your system keyring is accessible."); + } + println!("{}", format!("\nAdd token to profile '{}'", profile_name).bold()); println!("{}", "─".repeat(40)); @@ -605,15 +872,17 @@ impl ProfileCommand { .allow_empty(true) .interact_text()?; - let mut token = TokenConfig::new(token_value, token_type); + let mut token = TokenConfig::new(token_type); if !description.is_empty() { token.description = Some(description); } + manager.store_pat_for_profile(profile_name, service, &token_value)?; + manager.add_token_to_profile(profile_name, service.to_string(), token)?; manager.save()?; - println!("{} Token for '{}' added to profile '{}'", "✓".green(), service.cyan(), profile_name); + println!("{} Token for '{}' added to profile '{}' (stored securely in keyring)", "✓".green(), service.cyan(), profile_name); Ok(()) } @@ -638,7 +907,7 @@ impl ProfileCommand { manager.remove_token_from_profile(profile_name, service)?; manager.save()?; - println!("{} Token '{}' removed from profile '{}'", "✓".green(), service, profile_name); + println!("{} Token '{}' removed from profile '{}' (deleted from keyring)", "✓".green(), service, profile_name); Ok(()) } @@ -658,7 +927,14 @@ impl ProfileCommand { println!("{}", "─".repeat(40)); for (service, token) in &profile.tokens { - println!("{} ({})", service.cyan().bold(), token.token_type); + let has_token = manager.has_pat_for_profile(profile_name, service); + let status = if has_token { + format!("[{}]", "stored".green()) + } else { + format!("[{}]", "not stored".yellow()) + }; + + println!("{} {} ({})", service.cyan().bold(), status, token.token_type); if let Some(ref desc) = token.description { println!(" {}", desc); } @@ -785,7 +1061,18 @@ impl ProfileCommand { }) } - async fn setup_token_interactive(&self, profile: &mut GitProfile) -> Result<()> { + async fn setup_token_interactive(&self, profile: &mut GitProfile, manager: &ConfigManager) -> Result<()> { + if !manager.keyring().is_available() { + println!("{} Keyring is not available. Cannot store PAT securely.", "⚠".yellow()); + let continue_anyway = Confirm::new() + .with_prompt("Continue without secure token storage?") + .default(false) + .interact()?; + if !continue_anyway { + return Ok(()); + } + } + let service: String = Input::new() .with_prompt("Service name (e.g., github, gitlab)") .interact_text()?; @@ -794,7 +1081,13 @@ impl ProfileCommand { .with_prompt("Token value") .interact_text()?; - let token = TokenConfig::new(token_value, TokenType::Personal); + let token = TokenConfig::new(TokenType::Personal); + + if manager.keyring().is_available() { + manager.store_pat_for_profile(&profile.name, &service, &token_value)?; + println!("{} Token stored securely in keyring", "✓".green()); + } + profile.add_token(service, token); Ok(()) diff --git a/src/config/manager.rs b/src/config/manager.rs index 60b10d5..9ed317b 100644 --- a/src/config/manager.rs +++ b/src/config/manager.rs @@ -177,31 +177,82 @@ impl ConfigManager { // Token management - /// Add a token to a profile + /// Add a token to a profile (stores token in keyring) pub fn add_token_to_profile(&mut self, profile_name: &str, service: String, token: TokenConfig) -> Result<()> { + if !self.config.profiles.contains_key(profile_name) { + bail!("Profile '{}' does not exist", profile_name); + } + if let Some(profile) = self.config.profiles.get_mut(profile_name) { profile.add_token(service, token); self.modified = true; - Ok(()) + } + + Ok(()) + } + + /// Store a PAT token in keyring for a profile + pub fn store_pat_for_profile(&self, profile_name: &str, service: &str, token_value: &str) -> Result<()> { + let profile = self.get_profile(profile_name) + .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; + + let user_email = &profile.user_email; + + self.keyring.store_pat(profile_name, user_email, service, token_value) + } + + /// Get a PAT token from keyring for a profile + pub fn get_pat_for_profile(&self, profile_name: &str, service: &str) -> Result> { + let profile = self.get_profile(profile_name) + .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; + + let user_email = &profile.user_email; + + self.keyring.get_pat(profile_name, user_email, service) + } + + /// Check if a PAT token exists for a profile + pub fn has_pat_for_profile(&self, profile_name: &str, service: &str) -> bool { + if let Some(profile) = self.get_profile(profile_name) { + let user_email = &profile.user_email; + self.keyring.has_pat(profile_name, user_email, service) } else { - bail!("Profile '{}' does not exist", profile_name); + false } } - // /// Get a token from a profile - // pub fn get_token_from_profile(&self, profile_name: &str, service: &str) -> Option<&TokenConfig> { - // self.config.profiles.get(profile_name)?.get_token(service) - // } - - /// Remove a token from a profile + /// Remove a token from a profile (deletes from keyring) pub fn remove_token_from_profile(&mut self, profile_name: &str, service: &str) -> Result<()> { + if !self.config.profiles.contains_key(profile_name) { + bail!("Profile '{}' does not exist", profile_name); + } + + let user_email = self.config.profiles.get(profile_name).unwrap().user_email.clone(); + let services: Vec = self.config.profiles.get(profile_name).unwrap().tokens.keys().cloned().collect(); + + if !services.contains(&service.to_string()) { + bail!("Token for service '{}' not found in profile '{}'", service, profile_name); + } + + self.keyring.delete_pat(profile_name, &user_email, service)?; + if let Some(profile) = self.config.profiles.get_mut(profile_name) { profile.remove_token(service); self.modified = true; - Ok(()) - } else { - bail!("Profile '{}' does not exist", profile_name); } + + Ok(()) + } + + /// Delete all PAT tokens for a profile (used when removing a profile) + pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> { + if let Some(profile) = self.get_profile(profile_name) { + let user_email = &profile.user_email; + let services: Vec = profile.tokens.keys().cloned().collect(); + + self.keyring.delete_all_pats_for_profile(profile_name, user_email, &services)?; + } + Ok(()) } // /// List all tokens in a profile @@ -257,6 +308,37 @@ impl ConfigManager { profile.compare_with_git_config(repo) } + /// Find a profile that matches the given user config (name, email, signing_key) + pub fn find_matching_profile(&self, user_name: &str, user_email: &str, signing_key: Option<&str>) -> Option<&GitProfile> { + for profile in self.config.profiles.values() { + let name_match = profile.user_name == user_name; + let email_match = profile.user_email == user_email; + let key_match = match (signing_key, profile.signing_key()) { + (Some(git_key), Some(profile_key)) => git_key == profile_key, + (None, None) => true, + (Some(_), None) => false, + (None, Some(_)) => false, + }; + + if name_match && email_match && key_match { + return Some(profile); + } + } + None + } + + /// Find profiles that partially match (same name or same email) + pub fn find_partial_matches(&self, user_name: &str, user_email: &str) -> Vec<&GitProfile> { + self.config.profiles.values() + .filter(|p| p.user_name == user_name || p.user_email == user_email) + .collect() + } + + /// Get repo profile mapping + pub fn get_repo_profile_name(&self, repo_path: &str) -> Option<&String> { + self.config.repo_profiles.get(repo_path) + } + // LLM configuration /// Get LLM provider diff --git a/src/config/mod.rs b/src/config/mod.rs index 6061b10..6b1f198 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -520,3 +520,55 @@ impl AppConfig { // Ok(()) // } } + +/// Encrypted PAT data for export +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedPat { + /// Profile name + pub profile_name: String, + /// Service name (e.g., github, gitlab) + pub service: String, + /// User email (for keyring lookup) + pub user_email: String, + /// Encrypted token value + pub encrypted_token: String, +} + +/// Export data container with optional encrypted PATs +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportData { + /// Configuration content (TOML string) + pub config: String, + /// Encrypted PATs (only present when exporting with encryption) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub encrypted_pats: Vec, + /// Export version for future compatibility + #[serde(default = "default_export_version")] + pub export_version: String, +} + +fn default_export_version() -> String { + "1".to_string() +} + +impl ExportData { + pub fn new(config: String) -> Self { + Self { + config, + encrypted_pats: Vec::new(), + export_version: default_export_version(), + } + } + + pub fn with_encrypted_pats(config: String, pats: Vec) -> Self { + Self { + config, + encrypted_pats: pats, + export_version: default_export_version(), + } + } + + pub fn has_encrypted_pats(&self) -> bool { + !self.encrypted_pats.is_empty() + } +} diff --git a/src/config/profile.rs b/src/config/profile.rs index 900b71f..76c5f3d 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -423,10 +423,6 @@ impl GpgConfig { /// Token configuration for services (GitHub, GitLab, etc.) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenConfig { - /// Token value (encrypted) - #[serde(skip_serializing_if = "Option::is_none")] - pub token: Option, - /// Token type (personal, oauth, etc.) #[serde(default)] pub token_type: TokenType, @@ -446,25 +442,41 @@ pub struct TokenConfig { /// Description #[serde(default)] pub description: Option, + + /// Indicates if a token is stored in keyring + #[serde(default)] + pub has_token: bool, } impl TokenConfig { - /// Create a new token config - pub fn new(token: String, token_type: TokenType) -> Self { + /// Create a new token config (token stored separately in keyring) + pub fn new(token_type: TokenType) -> Self { Self { - token: Some(token), token_type, scopes: vec![], expires_at: None, last_used: None, description: None, + has_token: true, + } + } + + /// Create a new token config without token + pub fn without_token(token_type: TokenType) -> Self { + Self { + token_type, + scopes: vec![], + expires_at: None, + last_used: None, + description: None, + has_token: false, } } /// Validate token configuration pub fn validate(&self) -> Result<()> { - if self.token.is_none() && self.token_type != TokenType::None { - bail!("Token value is required for {:?}", self.token_type); + if !self.has_token && self.token_type != TokenType::None { + bail!("Token is required for {:?}", self.token_type); } Ok(()) } @@ -473,6 +485,11 @@ impl TokenConfig { pub fn record_usage(&mut self) { self.last_used = Some(chrono::Utc::now().to_rfc3339()); } + + /// Mark that a token is stored + pub fn set_has_token(&mut self, has_token: bool) { + self.has_token = has_token; + } } /// Token type @@ -675,7 +692,7 @@ mod tests { #[test] fn test_token_config() { - let token = TokenConfig::new("test-token".to_string(), TokenType::Personal); + let token = TokenConfig::new(TokenType::Personal); assert!(token.validate().is_ok()); } } diff --git a/src/git/mod.rs b/src/git/mod.rs index 8df4e56..7198fa7 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -1042,6 +1042,102 @@ impl<'a> GitConfigHelper<'a> { } } +/// Configuration source indicator +#[derive(Debug, Clone, PartialEq)] +pub enum ConfigSource { + Local, + Global, + NotSet, +} + +impl std::fmt::Display for ConfigSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigSource::Local => write!(f, "local"), + ConfigSource::Global => write!(f, "global"), + ConfigSource::NotSet => write!(f, "not set"), + } + } +} + +/// Single configuration entry with source information +#[derive(Debug, Clone)] +pub struct ConfigEntry { + pub value: Option, + pub source: ConfigSource, + pub local_value: Option, + pub global_value: Option, +} + +impl ConfigEntry { + pub fn new(local: Option, global: Option) -> Self { + let (value, source) = match (&local, &global) { + (Some(_), _) => (local.clone(), ConfigSource::Local), + (None, Some(_)) => (global.clone(), ConfigSource::Global), + (None, None) => (None, ConfigSource::NotSet), + }; + Self { + value, + source, + local_value: local, + global_value: global, + } + } + + pub fn is_set(&self) -> bool { + self.value.is_some() + } + + pub fn is_local(&self) -> bool { + self.source == ConfigSource::Local + } + + pub fn is_global(&self) -> bool { + self.source == ConfigSource::Global + } +} + +/// Merged user configuration with local/global source tracking +#[derive(Debug, Clone)] +pub struct MergedUserConfig { + pub name: ConfigEntry, + pub email: ConfigEntry, + pub signing_key: ConfigEntry, + pub ssh_command: ConfigEntry, + pub commit_gpgsign: ConfigEntry, + pub tag_gpgsign: ConfigEntry, +} + +impl MergedUserConfig { + pub fn from_repo(repo: &Repository) -> Result { + let local_config = repo.config().ok(); + let global_config = git2::Config::open_default().ok(); + + let get_entry = |key: &str| -> ConfigEntry { + let local = local_config.as_ref().and_then(|c| c.get_string(key).ok()); + let global = global_config.as_ref().and_then(|c| c.get_string(key).ok()); + ConfigEntry::new(local, global) + }; + + Ok(Self { + name: get_entry("user.name"), + email: get_entry("user.email"), + signing_key: get_entry("user.signingkey"), + ssh_command: get_entry("core.sshCommand"), + commit_gpgsign: get_entry("commit.gpgsign"), + tag_gpgsign: get_entry("tag.gpgsign"), + }) + } + + pub fn is_complete(&self) -> bool { + self.name.is_set() && self.email.is_set() + } + + pub fn has_local_overrides(&self) -> bool { + self.name.is_local() || self.email.is_local() || self.signing_key.is_local() || self.ssh_command.is_local() + } +} + /// User configuration for git #[derive(Debug, Clone)] pub struct UserConfig { diff --git a/src/utils/keyring.rs b/src/utils/keyring.rs index b17724a..fb68c81 100644 --- a/src/utils/keyring.rs +++ b/src/utils/keyring.rs @@ -4,6 +4,8 @@ use std::env; const SERVICE_NAME: &str = "quicommit"; const ENV_API_KEY: &str = "QUICOMMIT_API_KEY"; +const PAT_SERVICE_PREFIX: &str = "quicommit/pat"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KeyringStatus { Available, @@ -83,19 +85,16 @@ impl KeyringManager { } pub fn get_api_key(&self, provider: &str) -> Result> { - // 优先从环境变量获取 if let Ok(key) = env::var(ENV_API_KEY) { if !key.is_empty() { return Ok(Some(key)); } } - // keyring 不可用时直接返回 if !self.is_available() { return Ok(None); } - // 从 keyring 获取 let entry = keyring::Entry::new(SERVICE_NAME, provider) .context("Failed to create keyring entry")?; @@ -124,6 +123,85 @@ impl KeyringManager { self.get_api_key(provider).unwrap_or(None).is_some() } + fn make_pat_service_name(profile_name: &str) -> String { + format!("{}/{}", PAT_SERVICE_PREFIX, profile_name) + } + + pub fn store_pat(&self, profile_name: &str, user_email: &str, service: &str, token: &str) -> Result<()> { + if !self.is_available() { + bail!("Keyring is not available on this system"); + } + + let keyring_service = Self::make_pat_service_name(profile_name); + let keyring_user = format!("{}:{}", user_email, service); + + let entry = keyring::Entry::new(&keyring_service, &keyring_user) + .context("Failed to create keyring entry for PAT")?; + + entry.set_password(token) + .context("Failed to store PAT in keyring")?; + + eprintln!("[DEBUG] PAT stored in keyring: service={}, user={}", keyring_service, keyring_user); + + Ok(()) + } + + pub fn get_pat(&self, profile_name: &str, user_email: &str, service: &str) -> Result> { + if !self.is_available() { + return Ok(None); + } + + let keyring_service = Self::make_pat_service_name(profile_name); + let keyring_user = format!("{}:{}", user_email, service); + + let entry = keyring::Entry::new(&keyring_service, &keyring_user) + .context("Failed to create keyring entry for PAT")?; + + match entry.get_password() { + Ok(token) => { + eprintln!("[DEBUG] PAT retrieved from keyring: service={}, user={}", keyring_service, keyring_user); + Ok(Some(token)) + } + Err(keyring::Error::NoEntry) => { + eprintln!("[DEBUG] PAT not found in keyring: service={}, user={}", keyring_service, keyring_user); + Ok(None) + } + Err(e) => Err(e.into()), + } + } + + pub fn delete_pat(&self, profile_name: &str, user_email: &str, service: &str) -> Result<()> { + if !self.is_available() { + bail!("Keyring is not available on this system"); + } + + let keyring_service = Self::make_pat_service_name(profile_name); + let keyring_user = format!("{}:{}", user_email, service); + + let entry = keyring::Entry::new(&keyring_service, &keyring_user) + .context("Failed to create keyring entry for PAT")?; + + entry.delete_credential() + .context("Failed to delete PAT from keyring")?; + + eprintln!("[DEBUG] PAT deleted from keyring: service={}, user={}", keyring_service, keyring_user); + + Ok(()) + } + + pub fn has_pat(&self, profile_name: &str, user_email: &str, service: &str) -> bool { + self.get_pat(profile_name, user_email, service).unwrap_or(None).is_some() + } + + pub fn delete_all_pats_for_profile(&self, profile_name: &str, user_email: &str, services: &[String]) -> Result<()> { + for service in services { + if let Err(e) = self.delete_pat(profile_name, user_email, service) { + eprintln!("[DEBUG] Failed to delete PAT for service '{}': {}", service, e); + } + } + Ok(()) + } + pub fn get_status_message(&self) -> String { match self.status { KeyringStatus::Available => {