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