use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Git profile containing user identity and authentication settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitProfile { /// Profile display name pub name: String, /// Git user name pub user_name: String, /// Git user email pub user_email: String, /// Profile settings #[serde(default)] pub settings: ProfileSettings, /// SSH configuration #[serde(default)] pub ssh: Option, /// GPG configuration #[serde(default)] pub gpg: Option, /// Signing key (for commit/tag signing) #[serde(default)] pub signing_key: Option, /// Profile description #[serde(default)] pub description: Option, /// Is this a work profile #[serde(default)] pub is_work: bool, /// Company/Organization name (for work profiles) #[serde(default)] pub organization: Option, /// Personal Access Tokens (PATs) for different services #[serde(default)] pub tokens: HashMap, /// Usage statistics #[serde(default)] pub usage: UsageStats, } impl GitProfile { /// Create a new basic profile pub fn new(name: String, user_name: String, user_email: String) -> Self { Self { name, user_name, user_email, settings: ProfileSettings::default(), ssh: None, gpg: None, signing_key: None, description: None, is_work: false, organization: None, tokens: HashMap::new(), usage: UsageStats::default(), } } /// Create a builder for fluent API pub fn builder() -> GitProfileBuilder { GitProfileBuilder::default() } /// Validate the profile pub fn validate(&self) -> Result<()> { if self.user_name.is_empty() { bail!("User name cannot be empty"); } if self.user_email.is_empty() { bail!("User email cannot be empty"); } crate::utils::validators::validate_email(&self.user_email)?; if let Some(ref ssh) = self.ssh { ssh.validate()?; } if let Some(ref gpg) = self.gpg { gpg.validate()?; } for token in self.tokens.values() { token.validate()?; } Ok(()) } /// Check if profile has SSH configured pub fn has_ssh(&self) -> bool { self.ssh.is_some() } /// Check if profile has GPG configured pub fn has_gpg(&self) -> bool { self.gpg.is_some() || self.signing_key.is_some() } /// Check if profile has any tokens configured pub fn has_tokens(&self) -> bool { !self.tokens.is_empty() } /// Get signing key (from GPG config or direct) pub fn signing_key(&self) -> Option<&str> { self.signing_key.as_deref() .or_else(|| self.gpg.as_ref().map(|g| g.key_id.as_str())) } /// Add a token to the profile pub fn add_token(&mut self, service: String, token: TokenConfig) { self.tokens.insert(service, token); } /// Get a token for a specific service pub fn get_token(&self, service: &str) -> Option<&TokenConfig> { self.tokens.get(service) } /// Remove a token from the profile pub fn remove_token(&mut self, service: &str) { self.tokens.remove(service); } /// Record usage of this profile pub fn record_usage(&mut self, repo_path: Option) { self.usage.last_used = Some(chrono::Utc::now().to_rfc3339()); self.usage.total_uses += 1; if let Some(repo) = repo_path { let count = self.usage.repo_usage.entry(repo).or_insert(0); *count += 1; } } /// Get usage statistics pub fn usage_stats(&self) -> &UsageStats { &self.usage } /// Apply this profile to a git repository (local config) pub fn apply_to_repo(&self, repo: &git2::Repository) -> Result<()> { let mut config = repo.config()?; config.set_str("user.name", &self.user_name)?; config.set_str("user.email", &self.user_email)?; if let Some(key) = self.signing_key() { config.set_str("user.signingkey", key)?; if self.settings.auto_sign_commits { config.set_bool("commit.gpgsign", true)?; } if self.settings.auto_sign_tags { config.set_bool("tag.gpgsign", true)?; } } if let Some(ref ssh) = self.ssh && let Some(ref key_path) = ssh.private_key_path { let path_str = key_path.display().to_string(); #[cfg(target_os = "windows")] { config.set_str("core.sshCommand", &format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?; } #[cfg(not(target_os = "windows"))] { config.set_str("core.sshCommand", &format!("ssh -i '{}'", path_str))?; } } Ok(()) } /// Apply this profile globally pub fn apply_global(&self) -> Result<()> { let mut config = git2::Config::open_default()?; config.set_str("user.name", &self.user_name)?; config.set_str("user.email", &self.user_email)?; if let Some(key) = self.signing_key() { config.set_str("user.signingkey", key)?; if self.settings.auto_sign_commits { config.set_bool("commit.gpgsign", true)?; } if self.settings.auto_sign_tags { config.set_bool("tag.gpgsign", true)?; } } if let Some(ref ssh) = self.ssh && let Some(ref key_path) = ssh.private_key_path { let path_str = key_path.display().to_string(); #[cfg(target_os = "windows")] { config.set_str("core.sshCommand", &format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?; } #[cfg(not(target_os = "windows"))] { config.set_str("core.sshCommand", &format!("ssh -i '{}'", path_str))?; } } Ok(()) } /// Compare with current git configuration pub fn compare_with_git_config(&self, repo: &git2::Repository) -> Result { let config = repo.config()?; let git_user_name = config.get_string("user.name").ok(); let git_user_email = config.get_string("user.email").ok(); let git_signing_key = config.get_string("user.signingkey").ok(); let mut comparison = ProfileComparison { profile_name: self.name.clone(), matches: true, differences: vec![], }; if git_user_name.as_deref() != Some(&self.user_name) { comparison.matches = false; comparison.differences.push(ConfigDifference { key: "user.name".to_string(), profile_value: self.user_name.clone(), git_value: git_user_name.unwrap_or_else(|| "".to_string()), }); } if git_user_email.as_deref() != Some(&self.user_email) { comparison.matches = false; comparison.differences.push(ConfigDifference { key: "user.email".to_string(), profile_value: self.user_email.clone(), git_value: git_user_email.unwrap_or_else(|| "".to_string()), }); } if let Some(profile_key) = self.signing_key() && git_signing_key.as_deref() != Some(profile_key) { comparison.matches = false; comparison.differences.push(ConfigDifference { key: "user.signingkey".to_string(), profile_value: profile_key.to_string(), git_value: git_signing_key.unwrap_or_else(|| "".to_string()), }); } Ok(comparison) } } /// Profile settings #[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Default)] pub struct ProfileSettings { /// Automatically sign commits #[serde(default)] pub auto_sign_commits: bool, /// Automatically sign tags #[serde(default)] pub auto_sign_tags: bool, /// Default commit format for this profile #[serde(default)] pub default_commit_format: Option, /// Use this profile for specific repositories (path patterns) #[serde(default)] pub repo_patterns: Vec, /// Preferred LLM provider for this profile #[serde(default)] pub llm_provider: Option, /// Custom commit message template #[serde(default)] pub commit_template: Option, } /// SSH configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SshConfig { /// SSH private key path pub private_key_path: Option, /// SSH public key path pub public_key_path: Option, /// SSH key passphrase (encrypted) #[serde(skip_serializing_if = "Option::is_none")] pub passphrase: Option, /// SSH agent forwarding #[serde(default)] pub agent_forwarding: bool, /// Custom SSH command #[serde(default)] pub ssh_command: Option, /// Known hosts file #[serde(default)] pub known_hosts_file: Option, } impl SshConfig { /// Validate SSH configuration pub fn validate(&self) -> Result<()> { if let Some(ref path) = self.private_key_path && !path.exists() { bail!("SSH private key does not exist: {:?}", path); } if let Some(ref path) = self.public_key_path && !path.exists() { bail!("SSH public key does not exist: {:?}", path); } Ok(()) } /// Get SSH command for git pub fn git_ssh_command(&self) -> Option { if let Some(ref cmd) = self.ssh_command { Some(cmd.clone()) } else if let Some(ref key_path) = self.private_key_path { let path_str = key_path.display().to_string(); #[cfg(target_os = "windows")] { Some(format!("ssh -i \"{}\"", path_str.replace('\\', "/"))) } #[cfg(not(target_os = "windows"))] { Some(format!("ssh -i '{}'", path_str)) } } else { None } } } /// GPG configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GpgConfig { /// GPG key ID pub key_id: String, /// GPG executable path #[serde(default = "default_gpg_program")] pub program: String, /// GPG home directory #[serde(default)] pub home_dir: Option, /// Key passphrase (encrypted) #[serde(skip_serializing_if = "Option::is_none")] pub passphrase: Option, /// Use GPG agent #[serde(default = "default_true")] pub use_agent: bool, } impl GpgConfig { /// Validate GPG configuration pub fn validate(&self) -> Result<()> { crate::utils::validators::validate_gpg_key_id(&self.key_id)?; Ok(()) } /// Get GPG program path pub fn program(&self) -> &str { &self.program } } /// Token configuration for services (GitHub, GitLab, etc.) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenConfig { /// Token type (personal, oauth, etc.) #[serde(default)] pub token_type: TokenType, /// Token scopes/permissions #[serde(default)] pub scopes: Vec, /// Expiration date (RFC3339) #[serde(default)] pub expires_at: Option, /// Last used timestamp #[serde(default)] pub last_used: Option, /// 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 (token stored separately in keyring) pub fn new(token_type: TokenType) -> Self { Self { 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.has_token && self.token_type != TokenType::None { bail!("Token is required for {:?}", self.token_type); } Ok(()) } /// Record token usage 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 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] #[derive(Default)] pub enum TokenType { #[default] None, Personal, OAuth, Deploy, App, } impl std::fmt::Display for TokenType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TokenType::None => write!(f, "none"), TokenType::Personal => write!(f, "personal"), TokenType::OAuth => write!(f, "oauth"), TokenType::Deploy => write!(f, "deploy"), TokenType::App => write!(f, "app"), } } } /// Usage statistics for a profile #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UsageStats { /// Total number of times this profile has been used #[serde(default)] pub total_uses: u64, /// Last used timestamp (RFC3339) #[serde(default)] pub last_used: Option, /// Repository-specific usage counts #[serde(default)] pub repo_usage: HashMap, } /// Comparison result between profile and git configuration #[derive(Debug, Clone)] pub struct ProfileComparison { pub profile_name: String, pub matches: bool, pub differences: Vec, } /// Configuration difference #[derive(Debug, Clone)] pub struct ConfigDifference { pub key: String, pub profile_value: String, pub git_value: String, } fn default_gpg_program() -> String { if cfg!(target_os = "windows") { "gpg.exe".to_string() } else { "gpg".to_string() } } fn default_true() -> bool { true } /// Git profile builder #[derive(Default)] pub struct GitProfileBuilder { name: Option, user_name: Option, user_email: Option, settings: ProfileSettings, ssh: Option, gpg: Option, signing_key: Option, description: Option, is_work: bool, organization: Option, tokens: HashMap, } impl GitProfileBuilder { pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); self } pub fn user_name(mut self, user_name: impl Into) -> Self { self.user_name = Some(user_name.into()); self } pub fn user_email(mut self, user_email: impl Into) -> Self { self.user_email = Some(user_email.into()); self } pub fn settings(mut self, settings: ProfileSettings) -> Self { self.settings = settings; self } pub fn ssh(mut self, ssh: SshConfig) -> Self { self.ssh = Some(ssh); self } pub fn gpg(mut self, gpg: GpgConfig) -> Self { self.gpg = Some(gpg); self } pub fn signing_key(mut self, key: impl Into) -> Self { self.signing_key = Some(key.into()); self } pub fn description(mut self, desc: impl Into) -> Self { self.description = Some(desc.into()); self } pub fn work(mut self, is_work: bool) -> Self { self.is_work = is_work; self } pub fn organization(mut self, org: impl Into) -> Self { self.organization = Some(org.into()); self } pub fn token(mut self, service: impl Into, token: TokenConfig) -> Self { self.tokens.insert(service.into(), token); self } pub fn build(self) -> Result { let name = self.name.ok_or_else(|| anyhow::anyhow!("Name is required"))?; let user_name = self.user_name.ok_or_else(|| anyhow::anyhow!("User name is required"))?; let user_email = self.user_email.ok_or_else(|| anyhow::anyhow!("User email is required"))?; Ok(GitProfile { name, user_name, user_email, settings: self.settings, ssh: self.ssh, gpg: self.gpg, signing_key: self.signing_key, description: self.description, is_work: self.is_work, organization: self.organization, tokens: self.tokens, usage: UsageStats::default(), }) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_profile_builder() { let profile = GitProfile::builder() .name("personal") .user_name("John Doe") .user_email("john@example.com") .description("Personal profile") .build() .unwrap(); assert_eq!(profile.name, "personal"); assert_eq!(profile.user_name, "John Doe"); assert_eq!(profile.user_email, "john@example.com"); assert!(profile.validate().is_ok()); } #[test] fn test_profile_validation() { let profile = GitProfile::new( "test".to_string(), "".to_string(), "invalid-email".to_string(), ); assert!(profile.validate().is_err()); } #[test] fn test_token_config() { let token = TokenConfig::new(TokenType::Personal); assert!(token.validate().is_ok()); } }