Files
QuiCommit/src/config/profile.rs
2026-05-26 17:43:42 +08:00

678 lines
18 KiB
Rust

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<SshConfig>,
/// GPG configuration
#[serde(default)]
pub gpg: Option<GpgConfig>,
/// Signing key (for commit/tag signing)
#[serde(default)]
pub signing_key: Option<String>,
/// Profile description
#[serde(default)]
pub description: Option<String>,
/// Is this a work profile
#[serde(default)]
pub is_work: bool,
/// Company/Organization name (for work profiles)
#[serde(default)]
pub organization: Option<String>,
/// Personal Access Tokens (PATs) for different services
#[serde(default)]
pub tokens: HashMap<String, TokenConfig>,
/// 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<String>) {
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<ProfileComparison> {
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(|| "<not set>".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(|| "<not set>".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(|| "<not set>".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<super::CommitFormat>,
/// Use this profile for specific repositories (path patterns)
#[serde(default)]
pub repo_patterns: Vec<String>,
/// Preferred LLM provider for this profile
#[serde(default)]
pub llm_provider: Option<String>,
/// Custom commit message template
#[serde(default)]
pub commit_template: Option<String>,
}
/// SSH configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig {
/// SSH private key path
pub private_key_path: Option<std::path::PathBuf>,
/// SSH public key path
pub public_key_path: Option<std::path::PathBuf>,
/// SSH key passphrase (encrypted)
#[serde(skip_serializing_if = "Option::is_none")]
pub passphrase: Option<String>,
/// SSH agent forwarding
#[serde(default)]
pub agent_forwarding: bool,
/// Custom SSH command
#[serde(default)]
pub ssh_command: Option<String>,
/// Known hosts file
#[serde(default)]
pub known_hosts_file: Option<std::path::PathBuf>,
}
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<String> {
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<std::path::PathBuf>,
/// Key passphrase (encrypted)
#[serde(skip_serializing_if = "Option::is_none")]
pub passphrase: Option<String>,
/// 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<String>,
/// Expiration date (RFC3339)
#[serde(default)]
pub expires_at: Option<String>,
/// Last used timestamp
#[serde(default)]
pub last_used: Option<String>,
/// Description
#[serde(default)]
pub description: Option<String>,
/// 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<String>,
/// Repository-specific usage counts
#[serde(default)]
pub repo_usage: HashMap<String, u64>,
}
/// Comparison result between profile and git configuration
#[derive(Debug, Clone)]
pub struct ProfileComparison {
pub profile_name: String,
pub matches: bool,
pub differences: Vec<ConfigDifference>,
}
/// 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<String>,
user_name: Option<String>,
user_email: Option<String>,
settings: ProfileSettings,
ssh: Option<SshConfig>,
gpg: Option<GpgConfig>,
signing_key: Option<String>,
description: Option<String>,
is_work: bool,
organization: Option<String>,
tokens: HashMap<String, TokenConfig>,
}
impl GitProfileBuilder {
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn user_name(mut self, user_name: impl Into<String>) -> Self {
self.user_name = Some(user_name.into());
self
}
pub fn user_email(mut self, user_email: impl Into<String>) -> 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<String>) -> Self {
self.signing_key = Some(key.into());
self
}
pub fn description(mut self, desc: impl Into<String>) -> 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<String>) -> Self {
self.organization = Some(org.into());
self
}
pub fn token(mut self, service: impl Into<String>, token: TokenConfig) -> Self {
self.tokens.insert(service.into(), token);
self
}
pub fn build(self) -> Result<GitProfile> {
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());
}
}