678 lines
18 KiB
Rust
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());
|
|
}
|
|
}
|