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 profile::{ GitProfile, TokenConfig, TokenType, ProfileComparison }; /// 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, /// All configured profiles #[serde(default)] pub profiles: HashMap, /// 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, /// Whether to encrypt sensitive data #[serde(default = "default_true")] pub encrypt_sensitive: bool, /// Theme settings #[serde(default)] pub theme: ThemeConfig, /// Language settings #[serde(default)] pub language: LanguageConfig, } 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(), language: LanguageConfig::default(), } } } /// LLM configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LlmConfig { /// Current LLM provider (ollama, openai, anthropic, kimi, deepseek, openrouter) #[serde(default = "default_llm_provider")] pub provider: String, /// Model to use (stored in config, not in keyring) #[serde(default = "default_model")] pub model: String, /// API base URL (optional, will use provider default if not set) pub base_url: Option, /// 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, /// API key storage method (keyring, config, environment) #[serde(default = "default_api_key_storage")] pub api_key_storage: String, /// API key (stored in config for fallback, encrypted if encrypt_sensitive is true) #[serde(default)] pub api_key: Option, } fn default_api_key_storage() -> String { "keyring".to_string() } impl Default for LlmConfig { fn default() -> Self { Self { provider: default_llm_provider(), model: default_model(), base_url: None, max_tokens: default_max_tokens(), temperature: default_temperature(), timeout: default_timeout(), api_key_storage: default_api_key_storage(), api_key: None, } } } /// 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, /// 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, } 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, } 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, } 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, } /// 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(), } } } /// Language configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LanguageConfig { /// Output language for messages (en, zh, etc.) #[serde(default = "default_output_language")] pub output_language: String, /// Keep commit types in English #[serde(default = "default_true")] pub keep_types_english: bool, /// Keep changelog types in English #[serde(default = "default_true")] pub keep_changelog_types_english: bool, } impl Default for LanguageConfig { fn default() -> Self { Self { output_language: default_output_language(), keep_types_english: true, keep_changelog_types_english: true, } } } /// Supported languages #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Language { English, Chinese, Japanese, Korean, Spanish, French, German, } impl Language { pub fn from_str(s: &str) -> Option { match s.to_lowercase().as_str() { "en" | "english" => Some(Language::English), "zh" | "chinese" | "zh-cn" | "zh-tw" => Some(Language::Chinese), "ja" | "japanese" => Some(Language::Japanese), "ko" | "korean" => Some(Language::Korean), "es" | "spanish" => Some(Language::Spanish), "fr" | "french" => Some(Language::French), "de" | "german" => Some(Language::German), _ => None, } } pub fn to_code(&self) -> &str { match self { Language::English => "en", Language::Chinese => "zh", Language::Japanese => "ja", Language::Korean => "ko", Language::Spanish => "es", Language::French => "fr", Language::German => "de", } } pub fn display_name(&self) -> &str { match self { Language::English => "English", Language::Chinese => "中文", Language::Japanese => "日本語", Language::Korean => "한국어", Language::Spanish => "Español", Language::French => "Français", Language::German => "Deutsch", } } } // 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_model() -> String { "llama2".to_string() } fn default_max_tokens() -> u32 { 500 } fn default_temperature() -> f32 { 0.6 } fn default_timeout() -> u64 { 30 } fn default_commit_format() -> CommitFormat { CommitFormat::Conventional } fn default_max_subject_length() -> usize { 100 } fn default_body_required_types() -> Vec { 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() } fn default_output_language() -> String { "en".to_string() } impl AppConfig { /// Load configuration from file pub fn load(path: &Path) -> Result { 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 { 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(()) // } } /// 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() } }