feat:(first commit)created repository and complete 0.1.0

This commit is contained in:
2026-01-30 14:18:32 +08:00
commit 5d4156e5e0
36 changed files with 8686 additions and 0 deletions

302
src/config/manager.rs Normal file
View File

@@ -0,0 +1,302 @@
use super::{AppConfig, GitProfile};
use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Configuration manager
pub struct ConfigManager {
config: AppConfig,
config_path: PathBuf,
modified: bool,
}
impl ConfigManager {
/// Create new config manager with default path
pub fn new() -> Result<Self> {
let config_path = AppConfig::default_path()?;
Self::with_path(&config_path)
}
/// Create config manager with specific path
pub fn with_path(path: &Path) -> Result<Self> {
let config = AppConfig::load(path)?;
Ok(Self {
config,
config_path: path.to_path_buf(),
modified: false,
})
}
/// Get configuration reference
pub fn config(&self) -> &AppConfig {
&self.config
}
/// Get mutable configuration reference
pub fn config_mut(&mut self) -> &mut AppConfig {
self.modified = true;
&mut self.config
}
/// Save configuration if modified
pub fn save(&mut self) -> Result<()> {
if self.modified {
self.config.save(&self.config_path)?;
self.modified = false;
}
Ok(())
}
/// Force save configuration
pub fn force_save(&self) -> Result<()> {
self.config.save(&self.config_path)
}
/// Get configuration file path
pub fn path(&self) -> &Path {
&self.config_path
}
// Profile management
/// Add a new profile
pub fn add_profile(&mut self, name: String, profile: GitProfile) -> Result<()> {
if self.config.profiles.contains_key(&name) {
bail!("Profile '{}' already exists", name);
}
self.config.profiles.insert(name, profile);
self.modified = true;
Ok(())
}
/// Remove a profile
pub fn remove_profile(&mut self, name: &str) -> Result<()> {
if !self.config.profiles.contains_key(name) {
bail!("Profile '{}' does not exist", name);
}
// Check if it's the default profile
if self.config.default_profile.as_ref() == Some(&name.to_string()) {
self.config.default_profile = None;
}
// Remove from repo mappings
self.config.repo_profiles.retain(|_, v| v != name);
self.config.profiles.remove(name);
self.modified = true;
Ok(())
}
/// Update a profile
pub fn update_profile(&mut self, name: &str, profile: GitProfile) -> Result<()> {
if !self.config.profiles.contains_key(name) {
bail!("Profile '{}' does not exist", name);
}
self.config.profiles.insert(name.to_string(), profile);
self.modified = true;
Ok(())
}
/// Get a profile
pub fn get_profile(&self, name: &str) -> Option<&GitProfile> {
self.config.profiles.get(name)
}
/// Get mutable profile
pub fn get_profile_mut(&mut self, name: &str) -> Option<&mut GitProfile> {
self.modified = true;
self.config.profiles.get_mut(name)
}
/// List all profile names
pub fn list_profiles(&self) -> Vec<&String> {
self.config.profiles.keys().collect()
}
/// Check if profile exists
pub fn has_profile(&self, name: &str) -> bool {
self.config.profiles.contains_key(name)
}
/// Set default profile
pub fn set_default_profile(&mut self, name: Option<String>) -> Result<()> {
if let Some(ref n) = name {
if !self.config.profiles.contains_key(n) {
bail!("Profile '{}' does not exist", n);
}
}
self.config.default_profile = name;
self.modified = true;
Ok(())
}
/// Get default profile
pub fn default_profile(&self) -> Option<&GitProfile> {
self.config
.default_profile
.as_ref()
.and_then(|name| self.config.profiles.get(name))
}
/// Get default profile name
pub fn default_profile_name(&self) -> Option<&String> {
self.config.default_profile.as_ref()
}
// Repository profile management
/// Get profile for repository
pub fn get_repo_profile(&self, repo_path: &str) -> Option<&GitProfile> {
self.config
.repo_profiles
.get(repo_path)
.and_then(|name| self.config.profiles.get(name))
}
/// Set profile for repository
pub fn set_repo_profile(&mut self, repo_path: String, profile_name: String) -> Result<()> {
if !self.config.profiles.contains_key(&profile_name) {
bail!("Profile '{}' does not exist", profile_name);
}
self.config.repo_profiles.insert(repo_path, profile_name);
self.modified = true;
Ok(())
}
/// Remove repository profile mapping
pub fn remove_repo_profile(&mut self, repo_path: &str) {
self.config.repo_profiles.remove(repo_path);
self.modified = true;
}
/// List repository profile mappings
pub fn list_repo_profiles(&self) -> &HashMap<String, String> {
&self.config.repo_profiles
}
/// Get effective profile for a repository (repo-specific -> default)
pub fn get_effective_profile(&self, repo_path: Option<&str>) -> Option<&GitProfile> {
if let Some(path) = repo_path {
if let Some(profile) = self.get_repo_profile(path) {
return Some(profile);
}
}
self.default_profile()
}
// LLM configuration
/// Get LLM provider
pub fn llm_provider(&self) -> &str {
&self.config.llm.provider
}
/// Set LLM provider
pub fn set_llm_provider(&mut self, provider: String) {
self.config.llm.provider = provider;
self.modified = true;
}
/// Get OpenAI API key
pub fn openai_api_key(&self) -> Option<&String> {
self.config.llm.openai.api_key.as_ref()
}
/// Set OpenAI API key
pub fn set_openai_api_key(&mut self, key: String) {
self.config.llm.openai.api_key = Some(key);
self.modified = true;
}
/// Get Anthropic API key
pub fn anthropic_api_key(&self) -> Option<&String> {
self.config.llm.anthropic.api_key.as_ref()
}
/// Set Anthropic API key
pub fn set_anthropic_api_key(&mut self, key: String) {
self.config.llm.anthropic.api_key = Some(key);
self.modified = true;
}
// Commit configuration
/// Get commit format
pub fn commit_format(&self) -> super::CommitFormat {
self.config.commit.format
}
/// Set commit format
pub fn set_commit_format(&mut self, format: super::CommitFormat) {
self.config.commit.format = format;
self.modified = true;
}
/// Check if auto-generate is enabled
pub fn auto_generate_commits(&self) -> bool {
self.config.commit.auto_generate
}
/// Set auto-generate commits
pub fn set_auto_generate_commits(&mut self, enabled: bool) {
self.config.commit.auto_generate = enabled;
self.modified = true;
}
// Tag configuration
/// Get version prefix
pub fn version_prefix(&self) -> &str {
&self.config.tag.version_prefix
}
/// Set version prefix
pub fn set_version_prefix(&mut self, prefix: String) {
self.config.tag.version_prefix = prefix;
self.modified = true;
}
// Changelog configuration
/// Get changelog path
pub fn changelog_path(&self) -> &str {
&self.config.changelog.path
}
/// Set changelog path
pub fn set_changelog_path(&mut self, path: String) {
self.config.changelog.path = path;
self.modified = true;
}
/// Export configuration to TOML string
pub fn export(&self) -> Result<String> {
toml::to_string_pretty(&self.config)
.context("Failed to serialize config")
}
/// Import configuration from TOML string
pub fn import(&mut self, toml_str: &str) -> Result<()> {
self.config = toml::from_str(toml_str)
.context("Failed to parse config")?;
self.modified = true;
Ok(())
}
/// Reset to default configuration
pub fn reset(&mut self) {
self.config = AppConfig::default();
self.modified = true;
}
}
impl Default for ConfigManager {
fn default() -> Self {
Self {
config: AppConfig::default(),
config_path: PathBuf::new(),
modified: false,
}
}
}

532
src/config/mod.rs Normal file
View File

@@ -0,0 +1,532 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub mod manager;
pub mod profile;
pub use manager::ConfigManager;
pub use profile::{GitProfile, ProfileSettings};
/// Application configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
/// Configuration version for migration
#[serde(default = "default_version")]
pub version: String,
/// Default profile name
pub default_profile: Option<String>,
/// All configured profiles
#[serde(default)]
pub profiles: HashMap<String, GitProfile>,
/// LLM configuration
#[serde(default)]
pub llm: LlmConfig,
/// Commit configuration
#[serde(default)]
pub commit: CommitConfig,
/// Tag configuration
#[serde(default)]
pub tag: TagConfig,
/// Changelog configuration
#[serde(default)]
pub changelog: ChangelogConfig,
/// Repository-specific profile mappings
#[serde(default)]
pub repo_profiles: HashMap<String, String>,
/// Whether to encrypt sensitive data
#[serde(default = "default_true")]
pub encrypt_sensitive: bool,
/// Theme settings
#[serde(default)]
pub theme: ThemeConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
version: default_version(),
default_profile: None,
profiles: HashMap::new(),
llm: LlmConfig::default(),
commit: CommitConfig::default(),
tag: TagConfig::default(),
changelog: ChangelogConfig::default(),
repo_profiles: HashMap::new(),
encrypt_sensitive: true,
theme: ThemeConfig::default(),
}
}
}
/// LLM configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
/// Default LLM provider
#[serde(default = "default_llm_provider")]
pub provider: String,
/// OpenAI configuration
#[serde(default)]
pub openai: OpenAiConfig,
/// Ollama configuration
#[serde(default)]
pub ollama: OllamaConfig,
/// Anthropic Claude configuration
#[serde(default)]
pub anthropic: AnthropicConfig,
/// Custom API configuration
#[serde(default)]
pub custom: Option<CustomLlmConfig>,
/// Maximum tokens for generation
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
/// Temperature for generation
#[serde(default = "default_temperature")]
pub temperature: f32,
/// Timeout in seconds
#[serde(default = "default_timeout")]
pub timeout: u64,
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
provider: default_llm_provider(),
openai: OpenAiConfig::default(),
ollama: OllamaConfig::default(),
anthropic: AnthropicConfig::default(),
custom: None,
max_tokens: default_max_tokens(),
temperature: default_temperature(),
timeout: default_timeout(),
}
}
}
/// OpenAI API configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiConfig {
/// API key
pub api_key: Option<String>,
/// Model to use
#[serde(default = "default_openai_model")]
pub model: String,
/// API base URL (for custom endpoints)
#[serde(default = "default_openai_base_url")]
pub base_url: String,
}
impl Default for OpenAiConfig {
fn default() -> Self {
Self {
api_key: None,
model: default_openai_model(),
base_url: default_openai_base_url(),
}
}
}
/// Ollama configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
/// Ollama server URL
#[serde(default = "default_ollama_url")]
pub url: String,
/// Model to use
#[serde(default = "default_ollama_model")]
pub model: String,
}
impl Default for OllamaConfig {
fn default() -> Self {
Self {
url: default_ollama_url(),
model: default_ollama_model(),
}
}
}
/// Anthropic Claude configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicConfig {
/// API key
pub api_key: Option<String>,
/// Model to use
#[serde(default = "default_anthropic_model")]
pub model: String,
}
impl Default for AnthropicConfig {
fn default() -> Self {
Self {
api_key: None,
model: default_anthropic_model(),
}
}
}
/// Custom LLM API configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomLlmConfig {
/// API endpoint URL
pub url: String,
/// API key (optional)
pub api_key: Option<String>,
/// Model name
pub model: String,
/// Request format template (JSON)
pub request_template: String,
/// Response path to extract content (e.g., "choices.0.message.content")
pub response_path: String,
}
/// Commit configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitConfig {
/// Default commit format
#[serde(default = "default_commit_format")]
pub format: CommitFormat,
/// Enable AI generation by default
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Allow empty commits
#[serde(default)]
pub allow_empty: bool,
/// Sign commits with GPG
#[serde(default)]
pub gpg_sign: bool,
/// Default scope (optional)
pub default_scope: Option<String>,
/// Maximum subject length
#[serde(default = "default_max_subject_length")]
pub max_subject_length: usize,
/// Require scope
#[serde(default)]
pub require_scope: bool,
/// Require body for certain types
#[serde(default)]
pub require_body: bool,
/// Types that require body
#[serde(default = "default_body_required_types")]
pub body_required_types: Vec<String>,
}
impl Default for CommitConfig {
fn default() -> Self {
Self {
format: default_commit_format(),
auto_generate: true,
allow_empty: false,
gpg_sign: false,
default_scope: None,
max_subject_length: default_max_subject_length(),
require_scope: false,
require_body: false,
body_required_types: default_body_required_types(),
}
}
}
/// Commit format
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CommitFormat {
Conventional,
Commitlint,
}
impl std::fmt::Display for CommitFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CommitFormat::Conventional => write!(f, "conventional"),
CommitFormat::Commitlint => write!(f, "commitlint"),
}
}
}
/// Tag configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagConfig {
/// Default version prefix (e.g., "v")
#[serde(default = "default_version_prefix")]
pub version_prefix: String,
/// Enable AI generation for tag messages
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Sign tags with GPG
#[serde(default)]
pub gpg_sign: bool,
/// Include changelog in annotated tags
#[serde(default = "default_true")]
pub include_changelog: bool,
/// Default annotation template
#[serde(default)]
pub annotation_template: Option<String>,
}
impl Default for TagConfig {
fn default() -> Self {
Self {
version_prefix: default_version_prefix(),
auto_generate: true,
gpg_sign: false,
include_changelog: true,
annotation_template: None,
}
}
}
/// Changelog configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogConfig {
/// Changelog file path
#[serde(default = "default_changelog_path")]
pub path: String,
/// Enable AI generation for changelog entries
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Changelog format
#[serde(default = "default_changelog_format")]
pub format: ChangelogFormat,
/// Include commit hashes
#[serde(default)]
pub include_hashes: bool,
/// Include authors
#[serde(default)]
pub include_authors: bool,
/// Group by type
#[serde(default = "default_true")]
pub group_by_type: bool,
/// Custom categories
#[serde(default)]
pub custom_categories: Vec<ChangelogCategory>,
}
impl Default for ChangelogConfig {
fn default() -> Self {
Self {
path: default_changelog_path(),
auto_generate: true,
format: default_changelog_format(),
include_hashes: false,
include_authors: false,
group_by_type: true,
custom_categories: vec![],
}
}
}
/// Changelog format
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ChangelogFormat {
KeepAChangelog,
GitHubReleases,
Custom,
}
/// Changelog category mapping
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogCategory {
/// Category title
pub title: String,
/// Commit types included in this category
pub types: Vec<String>,
}
/// Theme configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
/// Enable colors
#[serde(default = "default_true")]
pub colors: bool,
/// Enable icons
#[serde(default = "default_true")]
pub icons: bool,
/// Preferred date format
#[serde(default = "default_date_format")]
pub date_format: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
colors: true,
icons: true,
date_format: default_date_format(),
}
}
}
// Default value functions
fn default_version() -> String {
"1".to_string()
}
fn default_true() -> bool {
true
}
fn default_llm_provider() -> String {
"ollama".to_string()
}
fn default_max_tokens() -> u32 {
500
}
fn default_temperature() -> f32 {
0.7
}
fn default_timeout() -> u64 {
30
}
fn default_openai_model() -> String {
"gpt-4".to_string()
}
fn default_openai_base_url() -> String {
"https://api.openai.com/v1".to_string()
}
fn default_ollama_url() -> String {
"http://localhost:11434".to_string()
}
fn default_ollama_model() -> String {
"llama2".to_string()
}
fn default_anthropic_model() -> String {
"claude-3-sonnet-20240229".to_string()
}
fn default_commit_format() -> CommitFormat {
CommitFormat::Conventional
}
fn default_max_subject_length() -> usize {
100
}
fn default_body_required_types() -> Vec<String> {
vec!["feat".to_string(), "fix".to_string()]
}
fn default_version_prefix() -> String {
"v".to_string()
}
fn default_changelog_path() -> String {
"CHANGELOG.md".to_string()
}
fn default_changelog_format() -> ChangelogFormat {
ChangelogFormat::KeepAChangelog
}
fn default_date_format() -> String {
"%Y-%m-%d".to_string()
}
impl AppConfig {
/// Load configuration from file
pub fn load(path: &Path) -> Result<Self> {
if path.exists() {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
let config: AppConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
Ok(config)
} else {
Ok(Self::default())
}
}
/// Save configuration to file
pub fn save(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
fs::write(path, content)
.with_context(|| format!("Failed to write config file: {:?}", path))?;
Ok(())
}
/// Get default config path
pub fn default_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?;
Ok(config_dir.join("quicommit").join("config.toml"))
}
/// Get profile for a repository
pub fn get_profile_for_repo(&self, repo_path: &str) -> Option<&GitProfile> {
let profile_name = self.repo_profiles.get(repo_path)?;
self.profiles.get(profile_name)
}
/// Set profile for a repository
pub fn set_profile_for_repo(&mut self, repo_path: String, profile_name: String) -> Result<()> {
if !self.profiles.contains_key(&profile_name) {
anyhow::bail!("Profile '{}' does not exist", profile_name);
}
self.repo_profiles.insert(repo_path, profile_name);
Ok(())
}
}

412
src/config/profile.rs Normal file
View File

@@ -0,0 +1,412 @@
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
/// 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>,
}
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,
}
}
/// 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()?;
}
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()
}
/// Get signing key (from GPG config or direct)
pub fn signing_key(&self) -> Option<&str> {
self.signing_key
.as_ref()
.map(|s| s.as_str())
.or_else(|| self.gpg.as_ref().map(|g| g.key_id.as_str()))
}
/// Apply this profile to a git repository
pub fn apply_to_repo(&self, repo: &git2::Repository) -> Result<()> {
let mut config = repo.config()?;
// Set user info
config.set_str("user.name", &self.user_name)?;
config.set_str("user.email", &self.user_email)?;
// Set signing key if available
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)?;
}
}
// Set SSH if configured
if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path {
config.set_str("core.sshCommand",
&format!("ssh -i {}", key_path.display()))?;
}
}
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)?;
}
Ok(())
}
}
/// Profile settings
#[derive(Debug, Clone, Serialize, Deserialize)]
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>,
}
impl Default for ProfileSettings {
fn default() -> Self {
Self {
auto_sign_commits: false,
auto_sign_tags: false,
default_commit_format: None,
repo_patterns: vec![],
llm_provider: None,
commit_template: None,
}
}
}
/// 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 {
if !path.exists() {
bail!("SSH private key does not exist: {:?}", path);
}
}
if let Some(ref path) = self.public_key_path {
if !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 {
Some(format!("ssh -i '{}'", key_path.display()))
} 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
}
}
fn default_gpg_program() -> String {
"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>,
}
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 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,
})
}
}
#[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());
}
}