Files
QuiCommit/src/config/mod.rs

735 lines
18 KiB
Rust

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,
UsageStats, 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<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,
/// 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 {
/// 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,
/// Kimi (Moonshot AI) configuration
#[serde(default)]
pub kimi: KimiConfig,
/// DeepSeek configuration
#[serde(default)]
pub deepseek: DeepSeekConfig,
/// OpenRouter configuration
#[serde(default)]
pub openrouter: OpenRouterConfig,
/// 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(),
kimi: KimiConfig::default(),
deepseek: DeepSeekConfig::default(),
openrouter: OpenRouterConfig::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(),
}
}
}
/// Kimi (Moonshot AI) configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KimiConfig {
/// API key
pub api_key: Option<String>,
/// Model to use
#[serde(default = "default_kimi_model")]
pub model: String,
/// API base URL (for custom endpoints)
#[serde(default = "default_kimi_base_url")]
pub base_url: String,
}
impl Default for KimiConfig {
fn default() -> Self {
Self {
api_key: None,
model: default_kimi_model(),
base_url: default_kimi_base_url(),
}
}
}
/// DeepSeek configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeepSeekConfig {
/// API key
pub api_key: Option<String>,
/// Model to use
#[serde(default = "default_deepseek_model")]
pub model: String,
/// API base URL (for custom endpoints)
#[serde(default = "default_deepseek_base_url")]
pub base_url: String,
}
impl Default for DeepSeekConfig {
fn default() -> Self {
Self {
api_key: None,
model: default_deepseek_model(),
base_url: default_deepseek_base_url(),
}
}
}
/// OpenRouter configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRouterConfig {
/// API key
pub api_key: Option<String>,
/// Model to use
#[serde(default = "default_openrouter_model")]
pub model: String,
/// API base URL (for custom endpoints)
#[serde(default = "default_openrouter_base_url")]
pub base_url: String,
}
impl Default for OpenRouterConfig {
fn default() -> Self {
Self {
api_key: None,
model: default_openrouter_model(),
base_url: default_openrouter_base_url(),
}
}
}
/// 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(),
}
}
}
/// 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<Self> {
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_max_tokens() -> u32 {
500
}
fn default_temperature() -> f32 {
0.6
}
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_kimi_model() -> String {
"moonshot-v1-8k".to_string()
}
fn default_kimi_base_url() -> String {
"https://api.moonshot.cn/v1".to_string()
}
fn default_deepseek_model() -> String {
"deepseek-chat".to_string()
}
fn default_deepseek_base_url() -> String {
"https://api.deepseek.com/v1".to_string()
}
fn default_openrouter_model() -> String {
"openai/gpt-3.5-turbo".to_string()
}
fn default_openrouter_base_url() -> String {
"https://openrouter.ai/api/v1".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()
}
fn default_output_language() -> String {
"en".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(())
}
}