From 90074e6e324289a64087bbc30977a673405296b5 Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Wed, 27 May 2026 15:15:15 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=B9=B6=E4=BC=98=E5=8C=96=E5=AF=BC=E5=85=A5=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.rs | 7 +- src/commands/changelog.rs | 43 +- src/commands/commit.rs | 66 +- src/commands/config.rs | 945 ++++++++++++++++++++-------- src/commands/init.rs | 137 ++-- src/commands/profile.rs | 490 ++++++++++----- src/commands/tag.rs | 86 ++- src/config/manager.rs | 179 ++++-- src/config/mod.rs | 17 +- src/config/profile.rs | 161 ++--- src/generator/mod.rs | 78 ++- src/git/changelog.rs | 114 ++-- src/git/commit.rs | 107 ++-- src/git/mod.rs | 301 +++++---- src/git/tag.rs | 76 +-- src/i18n/messages.rs | 39 +- src/i18n/translator.rs | 12 +- src/llm/anthropic.rs | 23 +- src/llm/deepseek.rs | 52 +- src/llm/kimi.rs | 43 +- src/llm/mod.rs | 243 +++++-- src/llm/ollama.rs | 48 +- src/llm/openai.rs | 19 +- src/llm/openrouter.rs | 567 ++++++++--------- src/llm/thinking.rs | 9 +- src/main.rs | 8 +- src/utils/crypto.rs | 47 +- src/utils/editor.rs | 25 +- src/utils/formatter.rs | 24 +- src/utils/keyring.rs | 96 ++- src/utils/mod.rs | 10 +- src/utils/validators.rs | 60 +- tests/config_export_import_tests.rs | 234 +++++-- tests/integration_tests.rs | 213 +++++-- 34 files changed, 2931 insertions(+), 1648 deletions(-) diff --git a/build.rs b/build.rs index 9ee9ebc..da1f9bc 100644 --- a/build.rs +++ b/build.rs @@ -1,12 +1,13 @@ use std::env; - fn main() { // Only generate completions when explicitly requested if env::var("GENERATE_COMPLETIONS").is_ok() { - println!("cargo:warning=To generate shell completions, run: cargo run --bin quicommit -- completions"); + println!( + "cargo:warning=To generate shell completions, run: cargo run --bin quicommit -- completions" + ); } - + // Rerun if build.rs changes println!("cargo:rerun-if-changed=build.rs"); } diff --git a/src/commands/changelog.rs b/src/commands/changelog.rs index f391d7d..a468a51 100644 --- a/src/commands/changelog.rs +++ b/src/commands/changelog.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use chrono::Utc; use clap::Parser; use colored::Colorize; @@ -8,7 +8,7 @@ use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; use crate::generator::ContentGenerator; use crate::git::find_repo; -use crate::git::{changelog::*, CommitInfo}; +use crate::git::{CommitInfo, changelog::*}; use crate::i18n::{Messages, translate_changelog_category}; /// Generate changelog @@ -78,16 +78,20 @@ impl ChangelogCommand { // Initialize changelog if requested if self.init { - let path = self.output.clone() + let path = self + .output + .clone() .unwrap_or_else(|| PathBuf::from(&config.changelog.path)); - + init_changelog(&path)?; println!("{}", messages.initialized_changelog(&format!("{:?}", path))); return Ok(()); } // Determine output path - let output_path = self.output.clone() + let output_path = self + .output + .clone() .unwrap_or_else(|| PathBuf::from(&config.changelog.path)); // Determine format @@ -96,7 +100,10 @@ impl ChangelogCommand { Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog, Some("custom") => ChangelogFormat::Custom, None => ChangelogFormat::KeepAChangelog, - Some(f) => bail!("Unknown format: {}. Use: keep-a-changelog, github-releases", f), + Some(f) => bail!( + "Unknown format: {}. Use: keep-a-changelog, github-releases", + f + ), }; // Get version @@ -114,11 +121,11 @@ impl ChangelogCommand { // Get commits println!("{}", messages.fetching_commits()); let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?; - + if commits.is_empty() { bail!("{}", messages.no_commits_found()); } - + println!("{}", messages.found_commits(commits.len())); // Generate changelog @@ -170,7 +177,7 @@ impl ChangelogCommand { } else if existing.starts_with("# Changelog") { let lines: Vec<&str> = existing.lines().collect(); let mut header_end = 0; - + for (i, line) in lines.iter().enumerate() { if i == 0 && line.starts_with('#') { header_end = i + 1; @@ -180,10 +187,10 @@ impl ChangelogCommand { break; } } - + let header = lines[..header_end].join("\n"); let rest = lines[header_end..].join("\n"); - + format!("{}\n{}\n{}", header, changelog, rest) } else { format!("{}{}", CHANGELOG_HEADER, changelog) @@ -211,7 +218,9 @@ impl ChangelogCommand { println!("{}", messages.ai_generating_changelog()); let generator = ContentGenerator::new_with_think(&manager, self.think).await?; - generator.generate_changelog_entry(version, commits, language).await + generator + .generate_changelog_entry(version, commits, language) + .await } fn generate_with_template( @@ -222,14 +231,14 @@ impl ChangelogCommand { language: Language, ) -> Result { let manager = ConfigManager::new()?; - + let generator = ChangelogGenerator::new() .format(format) .include_hashes(self.include_hashes) .include_authors(self.include_authors); let changelog = generator.generate(version, Utc::now(), commits)?; - + // Translate changelog categories if configured if !manager.keep_changelog_types_english() { Ok(self.translate_changelog_categories(&changelog, language)) @@ -237,15 +246,15 @@ impl ChangelogCommand { Ok(changelog) } } - + fn translate_changelog_categories(&self, changelog: &str, language: Language) -> String { - changelog .lines() .map(|line| { if line.starts_with("## ") || line.starts_with("### ") { let category = line.trim_start_matches("## ").trim_start_matches("### "); - let translated_category = translate_changelog_category(category, language, false); + let translated_category = + translate_changelog_category(category, language, false); if line.starts_with("## ") { format!("## {}", translated_category) } else { diff --git a/src/commands/commit.rs b/src/commands/commit.rs index de69f6e..51e9745 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -1,14 +1,14 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use clap::Parser; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; use std::path::PathBuf; -use crate::config::{Language, manager::ConfigManager}; use crate::config::CommitFormat; +use crate::config::{Language, manager::ConfigManager}; use crate::generator::ContentGenerator; -use crate::git::{find_repo, GitRepo}; use crate::git::commit::{CommitBuilder, create_date_commit_message}; +use crate::git::{GitRepo, find_repo}; use crate::i18n::Messages; use crate::utils::validators::get_commit_types; @@ -92,7 +92,7 @@ impl CommitCommand { pub async fn execute(&self, config_path: Option) -> Result<()> { // Find git repository let repo = find_repo(std::env::current_dir()?.as_path())?; - + // Load configuration let manager = if let Some(ref path) = config_path { ConfigManager::with_path(path)? @@ -102,7 +102,7 @@ impl CommitCommand { let config = manager.config(); let language = manager.get_language().unwrap_or(Language::English); let messages = Messages::new(language); - + // Check for changes let status = repo.status_summary()?; if status.clean && !self.amend { @@ -123,7 +123,7 @@ impl CommitCommand { println!("{}", messages.auto_stage_changes().yellow()); repo.stage_all()?; println!("{}", messages.staged_all().green()); - + // Re-check status after staging to ensure changes are detected let new_status = repo.status_summary()?; if new_status.staged == 0 { @@ -183,14 +183,22 @@ impl CommitCommand { let result = if self.amend { if self.dry_run { - println!("\n{} {}", messages.dry_run(), "- commit not amended.".yellow()); + println!( + "\n{} {}", + messages.dry_run(), + "- commit not amended.".yellow() + ); return Ok(()); } self.amend_commit(&repo, &commit_message)?; None } else { if self.dry_run { - println!("\n{} {}", messages.dry_run(), "- commit not created.".yellow()); + println!( + "\n{} {}", + messages.dry_run(), + "- commit not created.".yellow() + ); return Ok(()); } CommitBuilder::new() @@ -200,7 +208,11 @@ impl CommitCommand { }; if let Some(commit_oid) = result { - println!("{} {}", messages.commit_created().green().bold(), commit_oid.to_string()[..8].to_string().cyan()); + println!( + "{} {}", + messages.commit_created().green().bold(), + commit_oid.to_string()[..8].to_string().cyan() + ); } else { println!("{} successfully", messages.commit_amended().green().bold()); } @@ -232,8 +244,9 @@ impl CommitCommand { } fn create_manual_commit(&self, format: CommitFormat) -> Result { - let description = self.message.clone() - .ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m "))?; + let description = self.message.clone().ok_or_else(|| { + anyhow::anyhow!("Description required for manual commit. Use -m ") + })?; // Try to extract commit type from message if not provided let commit_type = if let Some(ref ct) = self.commit_type { @@ -259,10 +272,16 @@ impl CommitCommand { builder.build_message() } - async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat, messages: &Messages) -> Result { + async fn generate_commit( + &self, + repo: &GitRepo, + format: CommitFormat, + messages: &Messages, + ) -> Result { let manager = ConfigManager::new()?; - let generator = ContentGenerator::new_with_think(&manager, self.think).await + let generator = ContentGenerator::new_with_think(&manager, self.think) + .await .context("Failed to initialize LLM. Use --manual for manual commit.")?; println!("{}", messages.ai_analyzing()); @@ -270,15 +289,23 @@ impl CommitCommand { let language = manager.get_language().unwrap_or(Language::English); let generated = if self.yes { - generator.generate_commit_from_repo(repo, format, language).await? + generator + .generate_commit_from_repo(repo, format, language) + .await? } else { - generator.generate_commit_interactive(repo, format, language).await? + generator + .generate_commit_interactive(repo, format, language) + .await? }; Ok(generated.to_conventional()) } - async fn create_interactive_commit(&self, format: CommitFormat, messages: &Messages) -> Result { + async fn create_interactive_commit( + &self, + format: CommitFormat, + messages: &Messages, + ) -> Result { let types = get_commit_types(format == CommitFormat::Commitlint); // Select type @@ -356,20 +383,21 @@ impl CommitCommand { if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - + let error_msg = if stderr.is_empty() { if stdout.is_empty() { "GPG signing failed. Please check:\n\ 1. GPG signing key is configured (git config --get user.signingkey)\n\ 2. GPG agent is running\n\ - 3. You can sign commits manually (try: git commit --amend -S)".to_string() + 3. You can sign commits manually (try: git commit --amend -S)" + .to_string() } else { stdout.to_string() } } else { stderr.to_string() }; - + bail!("Failed to amend commit: {}", error_msg); } diff --git a/src/commands/config.rs b/src/commands/config.rs index e95c4c0..a7f6eb1 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,13 +1,15 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use colored::Colorize; -use dialoguer::{Confirm, Input, Select, Password}; +use dialoguer::{Confirm, Input, Password, Select}; use std::path::PathBuf; -use crate::config::{Language, manager::ConfigManager, ExportData, EncryptedPat}; use crate::config::CommitFormat; -use crate::utils::keyring::{get_supported_providers, get_default_model, get_default_base_url, provider_needs_api_key}; -use crate::utils::crypto::{encrypt, decrypt}; +use crate::config::{EncryptedPat, ExportData, Language, manager::ConfigManager}; +use crate::utils::crypto::{decrypt, encrypt}; +use crate::utils::keyring::{ + get_default_base_url, get_default_model, get_supported_providers, provider_needs_api_key, +}; /// Mask API key with asterisks for security fn mask_api_key(key: Option<&str>) -> String { @@ -16,7 +18,7 @@ fn mask_api_key(key: Option<&str>) -> String { if k.len() <= 8 { "*".repeat(k.len()) } else { - format!("{}***{}", &k[..4], &k[k.len()-4..]) + format!("{}***{}", &k[..4], &k[k.len() - 4..]) } } None => "✗ not set".red().to_string(), @@ -34,13 +36,13 @@ pub struct ConfigCommand { enum ConfigSubcommand { /// Show current configuration Show, - + /// List all configuration information (with masked API keys) List, - + /// Edit configuration file Edit, - + /// Set configuration value Set { /// Key (e.g., llm.provider, commit.format) @@ -48,53 +50,53 @@ enum ConfigSubcommand { /// Value value: String, }, - + /// Get configuration value Get { /// Key key: String, }, - + /// Configure LLM provider (interactive) SetLlm { /// Provider (ollama, openai, anthropic, kimi, deepseek, openrouter) #[arg(value_name = "PROVIDER")] provider: Option, - + /// Model name #[arg(short, long)] model: Option, - + /// API base URL (optional) #[arg(short, long)] base_url: Option, - + /// API key (will be stored in system keyring) #[arg(short = 'k', long)] api_key: Option, }, - + /// Set API key for current provider (stored in system keyring) SetApiKey { /// API key key: String, }, - + /// Delete API key from system keyring DeleteApiKey, - + /// Set commit format SetCommitFormat { /// Format (conventional, commitlint) format: String, }, - + /// Set version prefix for tags SetVersionPrefix { /// Prefix (e.g., 'v') prefix: String, }, - + /// Set changelog path SetChangelogPath { /// Path @@ -125,38 +127,38 @@ enum ConfigSubcommand { #[arg(short, long)] force: bool, }, - + /// Export configuration Export { /// Output file (defaults to stdout) #[arg(short, long)] output: Option, - + /// Password for encryption (will prompt if not provided) #[arg(short = 'p', long)] password: Option, }, - + /// Import configuration Import { /// Input file #[arg(short, long)] file: String, - + /// Password for decryption (will prompt if file is encrypted) #[arg(short = 'p', long)] password: Option, }, - + /// List available LLM models ListModels, - + /// Test LLM connection TestLlm, - + /// Show config file path Path, - + /// Check keyring availability CheckKeyring, } @@ -167,22 +169,55 @@ impl ConfigCommand { Some(ConfigSubcommand::Show) => self.show_config(&config_path).await, Some(ConfigSubcommand::List) => self.list_config(&config_path).await, Some(ConfigSubcommand::Edit) => self.edit_config(&config_path).await, - Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value, &config_path).await, + Some(ConfigSubcommand::Set { key, value }) => { + self.set_value(key, value, &config_path).await + } Some(ConfigSubcommand::Get { key }) => self.get_value(key, &config_path).await, - Some(ConfigSubcommand::SetLlm { provider, model, base_url, api_key }) => { - self.set_llm(provider.as_deref(), model.as_deref(), base_url.as_deref(), api_key.as_deref(), &config_path).await + Some(ConfigSubcommand::SetLlm { + provider, + model, + base_url, + api_key, + }) => { + self.set_llm( + provider.as_deref(), + model.as_deref(), + base_url.as_deref(), + api_key.as_deref(), + &config_path, + ) + .await } Some(ConfigSubcommand::SetApiKey { key }) => self.set_api_key(key, &config_path).await, Some(ConfigSubcommand::DeleteApiKey) => self.delete_api_key(&config_path).await, - Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format, &config_path).await, - Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix, &config_path).await, - Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path, &config_path).await, - Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref(), &config_path).await, - Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep, &config_path).await, - Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await, + Some(ConfigSubcommand::SetCommitFormat { format }) => { + self.set_commit_format(format, &config_path).await + } + Some(ConfigSubcommand::SetVersionPrefix { prefix }) => { + self.set_version_prefix(prefix, &config_path).await + } + Some(ConfigSubcommand::SetChangelogPath { path }) => { + self.set_changelog_path(path, &config_path).await + } + Some(ConfigSubcommand::SetLanguage { language }) => { + self.set_language(language.as_deref(), &config_path).await + } + Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => { + self.set_keep_types_english(*keep, &config_path).await + } + Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => { + self.set_keep_changelog_types_english(*keep, &config_path) + .await + } Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await, - Some(ConfigSubcommand::Export { output, password }) => self.export_config(output.as_deref(), password.as_deref(), &config_path).await, - Some(ConfigSubcommand::Import { file, password }) => self.import_config(file, password.as_deref(), &config_path).await, + Some(ConfigSubcommand::Export { output, password }) => { + self.export_config(output.as_deref(), password.as_deref(), &config_path) + .await + } + Some(ConfigSubcommand::Import { file, password }) => { + self.import_config(file, password.as_deref(), &config_path) + .await + } Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await, Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await, Some(ConfigSubcommand::Path) => self.show_path(&config_path).await, @@ -207,10 +242,10 @@ impl ConfigCommand { async fn check_keyring(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; let keyring = manager.keyring(); - + println!("{}", "\nKeyring Status".bold()); println!("{}", "─".repeat(40)); - + if keyring.is_available() { println!("{} Keyring is available", "✓".green()); println!(" {}", keyring.get_status_message()); @@ -218,14 +253,14 @@ impl ConfigCommand { println!("{} Keyring is not available", "✗".red()); println!(" {}", keyring.get_status_message()); } - + println!("\n{}", "Environment Variables:".bold()); if let Ok(key) = std::env::var("QUICOMMIT_API_KEY") { println!(" QUICOMMIT_API_KEY: {}", mask_api_key(Some(&key))); } else { println!(" QUICOMMIT_API_KEY: {}", "not set".dimmed()); } - + let provider = manager.llm_provider(); let provider_env = format!("QUICOMMIT_{}_API_KEY", provider.to_uppercase()); if let Ok(key) = std::env::var(&provider_env) { @@ -233,7 +268,7 @@ impl ConfigCommand { } else { println!(" {}: {}", provider_env, "not set".dimmed()); } - + Ok(()) } @@ -243,51 +278,130 @@ impl ConfigCommand { println!("{}", "\nQuiCommit Configuration".bold()); println!("{}", "─".repeat(60)); - + println!("\n{}", "General:".bold()); println!(" Config file: {}", manager.path().display()); - println!(" Default profile: {}", - config.default_profile.as_deref().unwrap_or("(none)").cyan()); + println!( + " Default profile: {}", + config.default_profile.as_deref().unwrap_or("(none)").cyan() + ); println!(" Profiles: {}", config.profiles.len()); - + println!("\n{}", "LLM Configuration:".bold()); println!(" Provider: {}", config.llm.provider.cyan()); println!(" Model: {}", config.llm.model.cyan()); - + let base_url = manager.llm_base_url(); println!(" Base URL: {}", base_url); - + let api_key = manager.get_api_key(); println!(" API key: {}", mask_api_key(api_key.as_deref())); - + println!(" Max tokens: {}", config.llm.max_tokens); println!(" Temperature: {}", config.llm.temperature); println!(" Timeout: {}s", config.llm.timeout); - + println!("\n{}", "Commit Configuration:".bold()); println!(" Format: {}", config.commit.format.to_string().cyan()); - println!(" Auto-generate: {}", if config.commit.auto_generate { "yes".green() } else { "no".red() }); - println!(" GPG sign: {}", if config.commit.gpg_sign { "yes".green() } else { "no".red() }); + println!( + " Auto-generate: {}", + if config.commit.auto_generate { + "yes".green() + } else { + "no".red() + } + ); + println!( + " GPG sign: {}", + if config.commit.gpg_sign { + "yes".green() + } else { + "no".red() + } + ); println!(" Max subject length: {}", config.commit.max_subject_length); - + println!("\n{}", "Tag Configuration:".bold()); println!(" Version prefix: '{}'", config.tag.version_prefix); - println!(" Auto-generate: {}", if config.tag.auto_generate { "yes".green() } else { "no".red() }); - println!(" GPG sign: {}", if config.tag.gpg_sign { "yes".green() } else { "no".red() }); - println!(" Include changelog: {}", if config.tag.include_changelog { "yes".green() } else { "no".red() }); - + println!( + " Auto-generate: {}", + if config.tag.auto_generate { + "yes".green() + } else { + "no".red() + } + ); + println!( + " GPG sign: {}", + if config.tag.gpg_sign { + "yes".green() + } else { + "no".red() + } + ); + println!( + " Include changelog: {}", + if config.tag.include_changelog { + "yes".green() + } else { + "no".red() + } + ); + println!("\n{}", "Language Configuration:".bold()); let language = manager.get_language().unwrap_or(Language::English); println!(" Output language: {}", language.display_name().cyan()); - println!(" Keep commit types in English: {}", if manager.keep_types_english() { "yes".green() } else { "no".red() }); - println!(" Keep changelog types in English: {}", if manager.keep_changelog_types_english() { "yes".green() } else { "no".red() }); - + println!( + " Keep commit types in English: {}", + if manager.keep_types_english() { + "yes".green() + } else { + "no".red() + } + ); + println!( + " Keep changelog types in English: {}", + if manager.keep_changelog_types_english() { + "yes".green() + } else { + "no".red() + } + ); + println!("\n{}", "Changelog Configuration:".bold()); println!(" Path: {}", config.changelog.path); - println!(" Auto-generate: {}", if config.changelog.auto_generate { "yes".green() } else { "no".red() }); - println!(" Include hashes: {}", if config.changelog.include_hashes { "yes".green() } else { "no".red() }); - println!(" Include authors: {}", if config.changelog.include_authors { "yes".green() } else { "no".red() }); - println!(" Group by type: {}", if config.changelog.group_by_type { "yes".green() } else { "no".red() }); + println!( + " Auto-generate: {}", + if config.changelog.auto_generate { + "yes".green() + } else { + "no".red() + } + ); + println!( + " Include hashes: {}", + if config.changelog.include_hashes { + "yes".green() + } else { + "no".red() + } + ); + println!( + " Include authors: {}", + if config.changelog.include_authors { + "yes".green() + } else { + "no".red() + } + ); + println!( + " Group by type: {}", + if config.changelog.group_by_type { + "yes".green() + } else { + "no".red() + } + ); Ok(()) } @@ -298,62 +412,200 @@ impl ConfigCommand { println!("{}", "\nQuiCommit Configuration".bold()); println!("{}", "═".repeat(80)); - + println!("\n{}", "📁 General Configuration:".bold().blue()); println!(" Config file: {}", manager.path().display()); - println!(" Default profile: {}", - config.default_profile.as_deref().unwrap_or("(none)").cyan()); + println!( + " Default profile: {}", + config.default_profile.as_deref().unwrap_or("(none)").cyan() + ); println!(" Profiles: {} profile(s)", config.profiles.len()); - println!(" Repository mappings: {} mapping(s)", config.repo_profiles.len()); - + println!( + " Repository mappings: {} mapping(s)", + config.repo_profiles.len() + ); + println!("\n{}", "🤖 LLM Configuration:".bold().blue()); println!(" Provider: {}", config.llm.provider.cyan()); println!(" Model: {}", config.llm.model.cyan()); println!(" Base URL: {}", manager.llm_base_url()); - println!(" API Key: {}", mask_api_key(manager.get_api_key().as_deref())); + println!( + " API Key: {}", + mask_api_key(manager.get_api_key().as_deref()) + ); println!(" Max tokens: {}", config.llm.max_tokens); println!(" Temperature: {}", config.llm.temperature); println!(" Timeout: {}s", config.llm.timeout); - + println!("\n{}", "📝 Commit Configuration:".bold().blue()); println!(" Format: {}", config.commit.format.to_string().cyan()); - println!(" Auto-generate: {}", if config.commit.auto_generate { "✓ yes".green() } else { "✗ no".red() }); - println!(" Allow empty: {}", if config.commit.allow_empty { "✓ yes".green() } else { "✗ no".red() }); - println!(" GPG sign: {}", if config.commit.gpg_sign { "✓ yes".green() } else { "✗ no".red() }); - println!(" Default scope: {}", config.commit.default_scope.as_deref().unwrap_or("(none)").cyan()); + println!( + " Auto-generate: {}", + if config.commit.auto_generate { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " Allow empty: {}", + if config.commit.allow_empty { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " GPG sign: {}", + if config.commit.gpg_sign { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " Default scope: {}", + config + .commit + .default_scope + .as_deref() + .unwrap_or("(none)") + .cyan() + ); println!(" Max subject length: {}", config.commit.max_subject_length); - println!(" Require scope: {}", if config.commit.require_scope { "✓ yes".green() } else { "✗ no".red() }); - println!(" Require body: {}", if config.commit.require_body { "✓ yes".green() } else { "✗ no".red() }); + println!( + " Require scope: {}", + if config.commit.require_scope { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " Require body: {}", + if config.commit.require_body { + "✓ yes".green() + } else { + "✗ no".red() + } + ); if !config.commit.body_required_types.is_empty() { - println!(" Body required types: {}", config.commit.body_required_types.join(", ").cyan()); + println!( + " Body required types: {}", + config.commit.body_required_types.join(", ").cyan() + ); } - + println!("\n{}", "🏷️ Tag Configuration:".bold().blue()); println!(" Version prefix: '{}'", config.tag.version_prefix.cyan()); - println!(" Auto-generate: {}", if config.tag.auto_generate { "✓ yes".green() } else { "✗ no".red() }); - println!(" GPG sign: {}", if config.tag.gpg_sign { "✓ yes".green() } else { "✗ no".red() }); - println!(" Include changelog: {}", if config.tag.include_changelog { "✓ yes".green() } else { "✗ no".red() }); - println!(" Annotation template: {}", config.tag.annotation_template.as_deref().unwrap_or("(none)").cyan()); - + println!( + " Auto-generate: {}", + if config.tag.auto_generate { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " GPG sign: {}", + if config.tag.gpg_sign { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " Include changelog: {}", + if config.tag.include_changelog { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " Annotation template: {}", + config + .tag + .annotation_template + .as_deref() + .unwrap_or("(none)") + .cyan() + ); + println!("\n{}", "📋 Changelog Configuration:".bold().blue()); println!(" Path: {}", config.changelog.path); - println!(" Auto-generate: {}", if config.changelog.auto_generate { "✓ yes".green() } else { "✗ no".red() }); - println!(" Format: {}", format!("{:?}", config.changelog.format).cyan()); - println!(" Include hashes: {}", if config.changelog.include_hashes { "✓ yes".green() } else { "✗ no".red() }); - println!(" Include authors: {}", if config.changelog.include_authors { "✓ yes".green() } else { "✗ no".red() }); - println!(" Group by type: {}", if config.changelog.group_by_type { "✓ yes".green() } else { "✗ no".red() }); + println!( + " Auto-generate: {}", + if config.changelog.auto_generate { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " Format: {}", + format!("{:?}", config.changelog.format).cyan() + ); + println!( + " Include hashes: {}", + if config.changelog.include_hashes { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " Include authors: {}", + if config.changelog.include_authors { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!( + " Group by type: {}", + if config.changelog.group_by_type { + "✓ yes".green() + } else { + "✗ no".red() + } + ); if !config.changelog.custom_categories.is_empty() { - println!(" Custom categories: {} category(ies)", config.changelog.custom_categories.len()); + println!( + " Custom categories: {} category(ies)", + config.changelog.custom_categories.len() + ); } - + println!("\n{}", "🎨 Theme Configuration:".bold().blue()); - println!(" Colors: {}", if config.theme.colors { "✓ enabled".green() } else { "✗ disabled".red() }); - println!(" Icons: {}", if config.theme.icons { "✓ enabled".green() } else { "✗ disabled".red() }); + println!( + " Colors: {}", + if config.theme.colors { + "✓ enabled".green() + } else { + "✗ disabled".red() + } + ); + println!( + " Icons: {}", + if config.theme.icons { + "✓ enabled".green() + } else { + "✗ disabled".red() + } + ); println!(" Date format: {}", config.theme.date_format.cyan()); - + println!("\n{}", "🔒 Security:".bold().blue()); - println!(" Encrypt sensitive: {}", if config.encrypt_sensitive { "✓ yes".green() } else { "✗ no".red() }); - + println!( + " Encrypt sensitive: {}", + if config.encrypt_sensitive { + "✓ yes".green() + } else { + "✗ no".red() + } + ); + println!("\n{}", "🔑 Keyring:".bold().blue()); let keyring = manager.keyring(); if keyring.is_available() { @@ -376,7 +628,7 @@ impl ConfigCommand { async fn set_value(&self, key: &str, value: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; - + match key { "llm.provider" => manager.set_llm_provider(value.to_string()), "llm.model" => manager.set_llm_model(value.to_string()), @@ -422,17 +674,17 @@ impl ConfigCommand { "changelog.path" => manager.set_changelog_path(value.to_string()), _ => bail!("Unknown configuration key: {}", key), } - + manager.save()?; println!("{} Set {} = {}", "✓".green(), key.cyan(), value); - + Ok(()) } async fn get_value(&self, key: &str, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; let config = manager.config(); - + let value = match key { "llm.provider" => config.llm.provider.clone(), "llm.model" => config.llm.model.clone(), @@ -441,7 +693,11 @@ impl ConfigCommand { "llm.temperature" => config.llm.temperature.to_string(), "llm.timeout" => config.llm.timeout.to_string(), "llm.thinking_enabled" => config.llm.thinking_enabled.to_string(), - "llm.thinking_budget_tokens" => config.llm.thinking_budget_tokens.map(|v| v.to_string()).unwrap_or_else(|| "none".to_string()), + "llm.thinking_budget_tokens" => config + .llm + .thinking_budget_tokens + .map(|v| v.to_string()) + .unwrap_or_else(|| "none".to_string()), "llm.api_key_storage" => config.llm.api_key_storage.clone(), "commit.format" => config.commit.format.to_string(), "commit.auto_generate" => config.commit.auto_generate.to_string(), @@ -449,18 +705,29 @@ impl ConfigCommand { "changelog.path" => config.changelog.path.clone(), _ => bail!("Unknown configuration key: {}", key), }; - + println!("{}", value); Ok(()) } - async fn set_llm(&self, provider: Option<&str>, model: Option<&str>, base_url: Option<&str>, api_key: Option<&str>, config_path: &Option) -> Result<()> { + async fn set_llm( + &self, + provider: Option<&str>, + model: Option<&str>, + base_url: Option<&str>, + api_key: Option<&str>, + config_path: &Option, + ) -> Result<()> { let mut manager = self.get_manager(config_path)?; - + let selected_provider = if let Some(p) = provider { let providers = get_supported_providers(); if !providers.contains(&p) { - bail!("Invalid provider: {}. Valid options: {}", p, providers.join(", ")); + bail!( + "Invalid provider: {}. Valid options: {}", + p, + providers.join(", ") + ); } p.to_string() } else { @@ -471,23 +738,26 @@ impl ConfigCommand { "Anthropic Claude", "Kimi (Moonshot AI)", "DeepSeek", - "OpenRouter" + "OpenRouter", ]; - + let provider_idx = Select::new() .items(&provider_display_names) .default(0) .interact()?; - + let providers = get_supported_providers(); providers[provider_idx].to_string() }; let keyring = manager.keyring(); let keyring_available = keyring.is_available(); - + if !keyring_available && provider_needs_api_key(&selected_provider) { - println!("\n{}", "⚠ Keyring is not available on this system.".yellow()); + println!( + "\n{}", + "⚠ Keyring is not available on this system.".yellow() + ); println!("{}", keyring.get_status_message().yellow()); } @@ -514,7 +784,7 @@ impl ConfigCommand { .with_prompt("Use custom API base URL?") .default(false) .interact()?; - + if use_custom { let default_url = get_default_base_url(&selected_provider); let url: String = Input::new() @@ -537,23 +807,22 @@ impl ConfigCommand { .with_prompt("API key already exists. Update it?") .default(false) .interact()?; - + if overwrite { - let key: String = Input::new() - .with_prompt("API key") - .interact_text()?; + let key: String = Input::new().with_prompt("API key").interact_text()?; Some(key) } else { None } } else { - let key: String = Input::new() - .with_prompt("API key") - .interact_text()?; + let key: String = Input::new().with_prompt("API key").interact_text()?; Some(key) } } else { - println!("\n{}", "Please set the QUICOMMIT_API_KEY environment variable.".yellow()); + println!( + "\n{}", + "Please set the QUICOMMIT_API_KEY environment variable.".yellow() + ); None } } else { @@ -566,102 +835,137 @@ impl ConfigCommand { if let Some(key) = selected_api_key { manager.set_api_key(&key)?; - println!("\n{} API key stored securely in system keyring", "✓".green()); + println!( + "\n{} API key stored securely in system keyring", + "✓".green() + ); } manager.save()?; - + println!("\n{} LLM configuration updated", "✓".green()); println!(" Provider: {}", manager.llm_provider().cyan()); println!(" Model: {}", manager.llm_model().cyan()); println!(" Base URL: {}", manager.llm_base_url()); - + Ok(()) } async fn set_api_key(&self, key: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; - + let provider = manager.llm_provider().to_string(); if !provider_needs_api_key(&provider) { println!("{} {} does not require an API key", "ℹ".blue(), provider); return Ok(()); } - + let storage_method = manager.config().llm.api_key_storage.to_string(); - + match storage_method.as_str() { "keyring" => { if !manager.keyring().is_available() { - bail!("Keyring is not available. Set QUICOMMIT_API_KEY environment variable instead or change api_key_storage to 'config'."); + bail!( + "Keyring is not available. Set QUICOMMIT_API_KEY environment variable instead or change api_key_storage to 'config'." + ); } - + manager.set_api_key(key)?; - println!("{} API key stored securely in system keyring for {}", "✓".green(), provider.cyan()); - }, + println!( + "{} API key stored securely in system keyring for {}", + "✓".green(), + provider.cyan() + ); + } "config" => { // Store API key directly in config file manager.config_mut().llm.api_key = Some(key.to_string()); manager.save()?; - println!("{} API key stored in configuration file for {}", "✓".green(), provider.cyan()); - println!("{} Note: API key is stored in plain text. Consider using 'keyring' storage for better security.", "⚠".yellow()); - }, + println!( + "{} API key stored in configuration file for {}", + "✓".green(), + provider.cyan() + ); + println!( + "{} Note: API key is stored in plain text. Consider using 'keyring' storage for better security.", + "⚠".yellow() + ); + } "environment" => { - bail!("API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.", provider.to_uppercase()); - }, + bail!( + "API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.", + provider.to_uppercase() + ); + } _ => { bail!("Invalid API key storage method: {}", storage_method); } } - + Ok(()) } async fn delete_api_key(&self, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; - + let provider = manager.llm_provider().to_string(); let storage_method = manager.config().llm.api_key_storage.to_string(); - + match storage_method.as_str() { "keyring" => { if !manager.keyring().is_available() { bail!("Keyring is not available."); } - + manager.delete_api_key()?; - println!("{} API key deleted from system keyring for {}", "✓".green(), provider.cyan()); - }, + println!( + "{} API key deleted from system keyring for {}", + "✓".green(), + provider.cyan() + ); + } "config" => { // Remove API key from config file manager.config_mut().llm.api_key = None; manager.save()?; - println!("{} API key deleted from configuration file for {}", "✓".green(), provider.cyan()); - }, + println!( + "{} API key deleted from configuration file for {}", + "✓".green(), + provider.cyan() + ); + } "environment" => { - println!("{} API key storage set to 'environment'. Please remove QUICOMMIT_{}_API_KEY environment variable manually.", "ℹ".blue(), provider.to_uppercase()); - }, + println!( + "{} API key storage set to 'environment'. Please remove QUICOMMIT_{}_API_KEY environment variable manually.", + "ℹ".blue(), + provider.to_uppercase() + ); + } _ => { bail!("Invalid API key storage method: {}", storage_method); } } - + Ok(()) } async fn set_commit_format(&self, format: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; - + let format = match format { "conventional" => CommitFormat::Conventional, "commitlint" => CommitFormat::Commitlint, _ => bail!("Invalid format: {}. Use: conventional, commitlint", format), }; - + manager.set_commit_format(format); manager.save()?; - - println!("{} Commit format set to {}", "✓".green(), format.to_string().cyan()); + + println!( + "{} Commit format set to {}", + "✓".green(), + format.to_string().cyan() + ); Ok(()) } @@ -669,7 +973,7 @@ impl ConfigCommand { let mut manager = self.get_manager(config_path)?; manager.set_version_prefix(prefix.to_string()); manager.save()?; - + println!("{} Version prefix set to '{}'", "✓".green(), prefix.cyan()); Ok(()) } @@ -678,57 +982,80 @@ impl ConfigCommand { let mut manager = self.get_manager(config_path)?; manager.set_changelog_path(path.to_string()); manager.save()?; - + println!("{} Changelog path set to {}", "✓".green(), path.cyan()); Ok(()) } - async fn set_language(&self, language: Option<&str>, config_path: &Option) -> Result<()> { + async fn set_language( + &self, + language: Option<&str>, + config_path: &Option, + ) -> Result<()> { let mut manager = self.get_manager(config_path)?; - + let lang_code = if let Some(l) = language { l.to_string() } else { println!("{}", "Select Output Language:".bold()); - let languages = [("en", "English"), + let languages = [ + ("en", "English"), ("zh", "中文"), ("ja", "日本語"), ("ko", "한국어"), ("es", "Español"), ("fr", "Français"), - ("de", "Deutsch")]; - + ("de", "Deutsch"), + ]; + let lang_names: Vec<&str> = languages.iter().map(|(_, n)| *n).collect(); - let idx = Select::new() - .items(&lang_names) - .default(0) - .interact()?; - + let idx = Select::new().items(&lang_names).default(0).interact()?; + languages[idx].0.to_string() }; - + manager.set_output_language(lang_code.clone()); manager.save()?; - - println!("{} Output language set to {}", "✓".green(), lang_code.cyan()); + + println!( + "{} Output language set to {}", + "✓".green(), + lang_code.cyan() + ); Ok(()) } - async fn set_keep_types_english(&self, keep: bool, config_path: &Option) -> Result<()> { + async fn set_keep_types_english( + &self, + keep: bool, + config_path: &Option, + ) -> Result<()> { let mut manager = self.get_manager(config_path)?; manager.set_keep_types_english(keep); manager.save()?; - - println!("{} Keep commit types in English: {}", "✓".green(), keep.to_string().cyan()); + + println!( + "{} Keep commit types in English: {}", + "✓".green(), + keep.to_string().cyan() + ); Ok(()) } - async fn set_keep_changelog_types_english(&self, keep: bool, config_path: &Option) -> Result<()> { + async fn set_keep_changelog_types_english( + &self, + keep: bool, + config_path: &Option, + ) -> Result<()> { let mut manager = self.get_manager(config_path)?; manager.set_keep_changelog_types_english(keep); manager.save()?; - - println!("{} Keep changelog types in English: {}", "✓".green(), keep.to_string().cyan()); + + println!( + "{} Keep changelog types in English: {}", + "✓".green(), + keep.to_string().cyan() + ); Ok(()) } @@ -738,25 +1065,30 @@ impl ConfigCommand { .with_prompt("Reset all configuration to defaults?") .default(false) .interact()?; - + if !confirm { println!("Reset cancelled."); return Ok(()); } } - + let mut manager = self.get_manager(config_path)?; manager.reset(); manager.save()?; - + println!("{} Configuration reset to defaults", "✓".green()); Ok(()) } - async fn export_config(&self, output: Option<&str>, password: Option<&str>, config_path: &Option) -> Result<()> { + async fn export_config( + &self, + output: Option<&str>, + password: Option<&str>, + config_path: &Option, + ) -> Result<()> { let manager = self.get_manager(config_path)?; let toml = manager.export()?; - + let export_content = if let Some(_path) = output { let pwd = if let Some(p) = password { p.to_string() @@ -765,7 +1097,7 @@ impl ConfigCommand { .with_prompt("Encrypt the exported configuration?") .default(true) .interact()?; - + if confirm { let pwd1 = Password::new() .with_prompt("Enter encryption password") @@ -773,7 +1105,7 @@ impl ConfigCommand { let pwd2 = Password::new() .with_prompt("Confirm encryption password") .interact()?; - + if pwd1 != pwd2 { bail!("Passwords do not match"); } @@ -782,7 +1114,7 @@ impl ConfigCommand { String::new() } }; - + if pwd.is_empty() { let mut has_pats = false; for (profile_name, profile) in manager.config().profiles.iter() { @@ -793,21 +1125,34 @@ impl ConfigCommand { } } } - + if has_pats { - println!("{} {}", "⚠".yellow(), "WARNING: Exporting without encryption.".bold()); - println!(" {}", "Personal Access Tokens (PATs) stored in keyring will NOT be exported.".yellow()); - println!(" {}", "To export PATs securely, please enable encryption.".yellow()); + println!( + "{} {}", + "⚠".yellow(), + "WARNING: Exporting without encryption.".bold() + ); + println!( + " {}", + "Personal Access Tokens (PATs) stored in keyring will NOT be exported." + .yellow() + ); + println!( + " {}", + "To export PATs securely, please enable encryption.".yellow() + ); println!(); } - + toml } else { let mut encrypted_pats: Vec = Vec::new(); - + for (profile_name, profile) in manager.config().profiles.iter() { for service in profile.tokens.keys() { - if let Ok(Some(pat_value)) = manager.get_pat_for_profile(profile_name, service) { + if let Ok(Some(pat_value)) = + manager.get_pat_for_profile(profile_name, service) + { let encrypted_token = encrypt(pat_value.as_bytes(), &pwd)?; encrypted_pats.push(EncryptedPat { profile_name: profile_name.clone(), @@ -818,7 +1163,7 @@ impl ConfigCommand { } } } - + let export_data = ExportData::with_encrypted_pats(toml, encrypted_pats); let export_json = serde_json::to_string(&export_data) .context("Failed to serialize export data")?; @@ -828,12 +1173,16 @@ impl ConfigCommand { } else { toml }; - + match output { Some(path) => { std::fs::write(path, &export_content)?; if export_content.starts_with("ENCRYPTED:") { - println!("{} Configuration encrypted and exported to {}", "✓".green(), path); + println!( + "{} Configuration encrypted and exported to {}", + "✓".green(), + path + ); } else { println!("{} Configuration exported to {}", "✓".green(), path); } @@ -842,16 +1191,21 @@ impl ConfigCommand { println!("{}", export_content); } } - + Ok(()) } - async fn import_config(&self, file: &str, password: Option<&str>, config_path: &Option) -> Result<()> { + async fn import_config( + &self, + file: &str, + password: Option<&str>, + config_path: &Option, + ) -> Result<()> { let content = std::fs::read_to_string(file)?; - + let (config_content, encrypted_pats, pwd) = if content.starts_with("ENCRYPTED:") { let encrypted_data = content.strip_prefix("ENCRYPTED:").unwrap(); - + let pwd = if let Some(p) = password { p.to_string() } else { @@ -859,106 +1213,126 @@ impl ConfigCommand { .with_prompt("Enter decryption password") .interact()? }; - + let decrypted = match decrypt(encrypted_data, &pwd) { Ok(d) => d, Err(e) => { - bail!("Failed to decrypt configuration: {}. Please check your password.", e); + bail!( + "Failed to decrypt configuration: {}. Please check your password.", + e + ); } }; - + let decrypted_str = String::from_utf8(decrypted) .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in decrypted content: {}", e))?; - + match serde_json::from_str::(&decrypted_str) { - Ok(export_data) => { - (export_data.config, Some(export_data.encrypted_pats), Some(pwd)) - } - Err(_) => { - (decrypted_str, None, Some(pwd)) - } + Ok(export_data) => ( + export_data.config, + Some(export_data.encrypted_pats), + Some(pwd), + ), + Err(_) => (decrypted_str, None, Some(pwd)), } } else { (content, None, None) }; - + let mut manager = self.get_manager(config_path)?; manager.import(&config_content)?; manager.save()?; - + if let (Some(pats), Some(pwd)) = (encrypted_pats, pwd) - && !pats.is_empty() { - println!(); - println!("{}", "Importing Personal Access Tokens...".bold()); - - let mut imported_count = 0; - let mut failed_count = 0; - - for pat in pats { - match decrypt(&pat.encrypted_token, &pwd) { - Ok(token_bytes) => { - match String::from_utf8(token_bytes) { - Ok(token_value) => { - if manager.keyring().is_available() { - match manager.store_pat_for_profile( - &pat.profile_name, - &pat.service, - &token_value - ) { - Ok(_) => { - println!(" {} Token for {} ({}) imported to keyring", - "✓".green(), - pat.profile_name.cyan(), - pat.service.yellow()); - imported_count += 1; - } - Err(e) => { - println!(" {} Failed to store token for {} ({}): {}", - "✗".red(), - pat.profile_name, - pat.service, - e); - failed_count += 1; - } - } - } else { - println!(" {} Keyring not available, cannot store token for {} ({})", - "⚠".yellow(), - pat.profile_name, - pat.service); + && !pats.is_empty() + { + println!(); + println!("{}", "Importing Personal Access Tokens...".bold()); + + let mut imported_count = 0; + let mut failed_count = 0; + + for pat in pats { + match decrypt(&pat.encrypted_token, &pwd) { + Ok(token_bytes) => match String::from_utf8(token_bytes) { + Ok(token_value) => { + if manager.keyring().is_available() { + match manager.store_pat_for_profile( + &pat.profile_name, + &pat.service, + &token_value, + ) { + Ok(_) => { + println!( + " {} Token for {} ({}) imported to keyring", + "✓".green(), + pat.profile_name.cyan(), + pat.service.yellow() + ); + imported_count += 1; + } + Err(e) => { + println!( + " {} Failed to store token for {} ({}): {}", + "✗".red(), + pat.profile_name, + pat.service, + e + ); failed_count += 1; } } - Err(e) => { - println!(" {} Invalid token format for {} ({}): {}", - "✗".red(), - pat.profile_name, - pat.service, - e); - failed_count += 1; - } + } else { + println!( + " {} Keyring not available, cannot store token for {} ({})", + "⚠".yellow(), + pat.profile_name, + pat.service + ); + failed_count += 1; } } Err(e) => { - println!(" {} Failed to decrypt token for {} ({}): {}", - "✗".red(), - pat.profile_name, - pat.service, - e); + println!( + " {} Invalid token format for {} ({}): {}", + "✗".red(), + pat.profile_name, + pat.service, + e + ); failed_count += 1; } + }, + Err(e) => { + println!( + " {} Failed to decrypt token for {} ({}): {}", + "✗".red(), + pat.profile_name, + pat.service, + e + ); + failed_count += 1; } } - - println!(); - if imported_count > 0 { - println!("{} {} token(s) imported to keyring", "✓".green(), imported_count); - } - if failed_count > 0 { - println!("{} {} token(s) failed to import", "⚠".yellow(), failed_count); - } } - + + println!(); + if imported_count > 0 { + println!( + "{} {} token(s) imported to keyring", + "✓".green(), + imported_count + ); + } + if failed_count > 0 { + println!( + "{} {} token(s) failed to import", + "⚠".yellow(), + failed_count + ); + } + } + println!("{} Configuration imported from {}", "✓".green(), file); Ok(()) } @@ -966,10 +1340,10 @@ impl ConfigCommand { async fn list_models(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; let provider = manager.llm_provider(); - + println!("{}", "\nAvailable Models".bold()); println!("{}", "─".repeat(40)); - + match provider { "ollama" => { println!("Ollama models (local):"); @@ -987,14 +1361,20 @@ impl ConfigCommand { } "anthropic" => { println!("Anthropic Claude models:"); - println!(" Claude 4 (thinking): claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5"); - println!(" Claude 3.5: claude-3-opus-20240229, claude-3-sonnet-20240229, claude-3-haiku-20240307"); + println!( + " Claude 4 (thinking): claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5" + ); + println!( + " Claude 3.5: claude-3-opus-20240229, claude-3-sonnet-20240229, claude-3-haiku-20240307" + ); println!(" Legacy: claude-2.1, claude-2.0, claude-instant-1.2"); println!("\nUse --think/-t with Claude 4 models for extended thinking."); } "kimi" => { println!("Kimi (Moonshot AI) models:"); - println!(" K2 (thinking): kimi-k2.6, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo"); + println!( + " K2 (thinking): kimi-k2.6, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo" + ); println!(" K2 instruct: kimi-k2-instruct, kimi-k2-instruct-0905"); println!(" Legacy: moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k"); println!("\nUse --think/-t with K2 models for thinking mode."); @@ -1017,27 +1397,34 @@ impl ConfigCommand { println!("Unknown provider: {}", provider); } } - + println!("\nCurrent model: {}", manager.llm_model().cyan()); - + Ok(()) } async fn test_llm(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; - + println!("{}", "\nTesting LLM Connection...".bold()); println!(" Provider: {}", manager.llm_provider().cyan()); println!(" Model: {}", manager.llm_model().cyan()); println!(" Base URL: {}", manager.llm_base_url()); - + let has_key = manager.has_api_key(); if provider_needs_api_key(manager.llm_provider()) { - println!(" API Key: {}", if has_key { "✓ configured".green() } else { "✗ not set".red() }); + println!( + " API Key: {}", + if has_key { + "✓ configured".green() + } else { + "✗ not set".red() + } + ); } - + println!("\n{}", "Sending test request...".dimmed()); - + match crate::llm::test_connection(&manager).await { Ok(response) => { println!("{} Connection successful!", "✓".green()); @@ -1049,7 +1436,7 @@ impl ConfigCommand { return Err(e); } } - + Ok(()) } } diff --git a/src/commands/init.rs b/src/commands/init.rs index 8983d0f..ab8b504 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -4,11 +4,11 @@ use colored::Colorize; use dialoguer::{Confirm, Input, Select}; use std::path::PathBuf; -use crate::config::{GitProfile, Language}; use crate::config::manager::ConfigManager; use crate::config::profile::{GpgConfig, SshConfig}; +use crate::config::{GitProfile, Language}; use crate::i18n::Messages; -use crate::utils::keyring::{get_supported_providers, get_default_model, provider_needs_api_key}; +use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key}; use crate::utils::validators::validate_email; /// Initialize quicommit configuration @@ -28,23 +28,25 @@ impl InitCommand { let messages = Messages::new(Language::English); println!("{}", messages.initializing().bold().cyan()); - let config_path = config_path.unwrap_or_else(|| { - crate::config::AppConfig::default_path().unwrap() - }); - + let config_path = + config_path.unwrap_or_else(|| crate::config::AppConfig::default_path().unwrap()); + if config_path.exists() && !self.reset { if !self.yes { let overwrite = Confirm::new() .with_prompt("Configuration already exists. Overwrite?") .default(false) .interact()?; - + if !overwrite { println!("{}", "Initialization cancelled.".yellow()); return Ok(()); } } else { - println!("{}", "Configuration already exists. Use --reset to overwrite.".yellow()); + println!( + "{}", + "Configuration already exists. Use --reset to overwrite.".yellow() + ); return Ok(()); } } @@ -63,10 +65,10 @@ impl InitCommand { } manager.save()?; - + let language = manager.get_language().unwrap_or(Language::English); let messages = Messages::new(language); - + println!("{}", messages.init_success().bold().green()); println!("\n{}: {}", messages.config_file(), config_path.display()); println!("\n{}:", messages.next_steps()); @@ -79,15 +81,15 @@ impl InitCommand { async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> { let git_config = git2::Config::open_default()?; - - let user_name = git_config.get_string("user.name").unwrap_or_else(|_| "User".to_string()); - let user_email = git_config.get_string("user.email").unwrap_or_else(|_| "user@example.com".to_string()); - let profile = GitProfile::new( - "default".to_string(), - user_name, - user_email, - ); + let user_name = git_config + .get_string("user.name") + .unwrap_or_else(|_| "User".to_string()); + let user_email = git_config + .get_string("user.email") + .unwrap_or_else(|_| "user@example.com".to_string()); + + let profile = GitProfile::new("default".to_string(), user_name, user_email); manager.add_profile("default".to_string(), profile)?; manager.set_default_profile(Some("default".to_string()))?; @@ -102,18 +104,20 @@ impl InitCommand { println!("\n{}", messages.setup_profile().bold()); println!("\n{}", messages.select_output_language().bold()); - let languages = [Language::English, + let languages = [ + Language::English, Language::Chinese, Language::Japanese, Language::Korean, Language::Spanish, Language::French, - Language::German]; - let language_names: Vec = languages.iter().map(|l| l.display_name().to_string()).collect(); - let language_idx = Select::new() - .items(&language_names) - .default(0) - .interact()?; + Language::German, + ]; + let language_names: Vec = languages + .iter() + .map(|l| l.display_name().to_string()) + .collect(); + let language_idx = Select::new().items(&language_names).default(0).interact()?; let selected_language = languages[language_idx]; manager.set_output_language(selected_language.to_code().to_string()); @@ -126,12 +130,14 @@ impl InitCommand { .interact_text()?; let git_config = git2::Config::open_default().ok(); - - let default_name = git_config.as_ref() + + let default_name = git_config + .as_ref() .and_then(|c| c.get_string("user.name").ok()) .unwrap_or_default(); - - let default_email = git_config.as_ref() + + let default_email = git_config + .as_ref() .and_then(|c| c.get_string("user.email").ok()) .unwrap_or_default(); @@ -143,9 +149,7 @@ impl InitCommand { let user_email: String = Input::new() .with_prompt(messages.git_user_email()) .default(default_email) - .validate_with(|input: &String| { - validate_email(input).map_err(|e| e.to_string()) - }) + .validate_with(|input: &String| validate_email(input).map_err(|e| e.to_string())) .interact_text()?; let description: String = Input::new() @@ -159,9 +163,11 @@ impl InitCommand { .interact()?; let organization = if is_work { - Some(Input::new() - .with_prompt(messages.organization_name()) - .interact_text()?) + Some( + Input::new() + .with_prompt(messages.organization_name()) + .interact_text()?, + ) } else { None }; @@ -188,11 +194,7 @@ impl InitCommand { None }; - let mut profile = GitProfile::new( - profile_name.clone(), - user_name, - user_email, - ); + let mut profile = GitProfile::new(profile_name.clone(), user_name, user_email); if !description.is_empty() { profile.description = Some(description); @@ -207,16 +209,16 @@ impl InitCommand { manager.set_default_profile(Some(profile_name))?; println!("\n{}", messages.select_llm_provider().bold()); - + let provider_display_names = vec![ "Ollama (local)", "OpenAI", "Anthropic Claude", "Kimi (Moonshot AI)", "DeepSeek", - "OpenRouter" + "OpenRouter", ]; - + let provider_idx = Select::new() .items(&provider_display_names) .default(0) @@ -227,19 +229,28 @@ impl InitCommand { let keyring = manager.keyring(); let keyring_available = keyring.is_available(); - + if !keyring_available { - println!("\n{}", "⚠ Keyring is not available on this system.".yellow()); + println!( + "\n{}", + "⚠ Keyring is not available on this system.".yellow() + ); println!("{}", keyring.get_status_message().yellow()); } let api_key = if provider_needs_api_key(&provider) { let env_key = std::env::var("QUICOMMIT_API_KEY") - .or_else(|_| std::env::var(format!("QUICOMMIT_{}_API_KEY", provider.to_uppercase()))) + .or_else(|_| { + std::env::var(format!("QUICOMMIT_{}_API_KEY", provider.to_uppercase())) + }) .ok(); - + if let Some(_key) = env_key { - println!("\n{} {}", "✓".green(), "Found API key in environment variable.".green()); + println!( + "\n{} {}", + "✓".green(), + "Found API key in environment variable.".green() + ); None } else if keyring_available { let prompt = match provider.as_str() { @@ -250,13 +261,14 @@ impl InitCommand { "openrouter" => messages.openrouter_api_key(), _ => "API Key", }; - - let key: String = Input::new() - .with_prompt(prompt) - .interact_text()?; + + let key: String = Input::new().with_prompt(prompt).interact_text()?; Some(key) } else { - println!("\n{}", "Please set the QUICOMMIT_API_KEY environment variable.".yellow()); + println!( + "\n{}", + "Please set the QUICOMMIT_API_KEY environment variable.".yellow() + ); None } } else { @@ -280,11 +292,9 @@ impl InitCommand { .with_prompt("Use custom API base URL?") .default(false) .interact()?; - + if use_custom_url { - let url: String = Input::new() - .with_prompt("Base URL") - .interact_text()?; + let url: String = Input::new().with_prompt("Base URL").interact_text()?; Some(url) } else { None @@ -296,10 +306,15 @@ impl InitCommand { manager.set_llm_base_url(base_url); if let Some(key) = api_key - && provider_needs_api_key(&provider) { - manager.set_api_key(&key)?; - println!("\n{} {}", "✓".green(), "API key stored securely in system keyring.".green()); - } + && provider_needs_api_key(&provider) + { + manager.set_api_key(&key)?; + println!( + "\n{} {}", + "✓".green(), + "API key stored securely in system keyring.".green() + ); + } Ok(()) } diff --git a/src/commands/profile.rs b/src/commands/profile.rs index 01676fa..358e7a8 100644 --- a/src/commands/profile.rs +++ b/src/commands/profile.rs @@ -1,12 +1,12 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; use std::path::PathBuf; use crate::config::manager::ConfigManager; -use crate::config::{GitProfile, TokenConfig, TokenType}; use crate::config::profile::{GpgConfig, SshConfig}; +use crate::config::{GitProfile, TokenConfig, TokenType}; use crate::git::find_repo; use crate::utils::validators::validate_profile_name; @@ -21,74 +21,74 @@ pub struct ProfileCommand { enum ProfileSubcommand { /// Add a new profile Add, - + /// Remove a profile Remove { /// Profile name name: String, }, - + /// List all profiles List, - + /// Show profile details Show { /// Profile name name: Option, }, - + /// Edit a profile Edit { /// Profile name name: String, }, - + /// Set default profile SetDefault { /// Profile name name: String, }, - + /// Set profile for current repository SetRepo { /// Profile name name: String, }, - + /// Apply profile to current repository Apply { /// Profile name (uses default if not specified) name: Option, - + /// Apply globally instead of to current repo #[arg(short, long)] global: bool, }, - + /// Switch between profiles interactively Switch, - + /// Copy/duplicate a profile Copy { /// Source profile name from: String, - + /// New profile name to: String, }, - + /// Manage tokens for a profile Token { #[command(subcommand)] token_command: TokenSubcommand, }, - + /// Check profile configuration against git Check { /// Profile name name: Option, }, - + /// Show usage statistics Stats { /// Profile name @@ -102,20 +102,20 @@ enum TokenSubcommand { Add { /// Profile name profile: String, - + /// Service name (e.g., github, gitlab) service: String, }, - + /// Remove a token from a profile Remove { /// Profile name profile: String, - + /// Service name service: String, }, - + /// List tokens in a profile List { /// Profile name @@ -127,18 +127,35 @@ impl ProfileCommand { pub async fn execute(&self, config_path: Option) -> Result<()> { match &self.command { Some(ProfileSubcommand::Add) => self.add_profile(&config_path).await, - Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name, &config_path).await, + Some(ProfileSubcommand::Remove { name }) => { + self.remove_profile(name, &config_path).await + } Some(ProfileSubcommand::List) => self.list_profiles(&config_path).await, - Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref(), &config_path).await, + Some(ProfileSubcommand::Show { name }) => { + self.show_profile(name.as_deref(), &config_path).await + } Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name, &config_path).await, - Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name, &config_path).await, + Some(ProfileSubcommand::SetDefault { name }) => { + self.set_default(name, &config_path).await + } Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name, &config_path).await, - Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global, &config_path).await, + Some(ProfileSubcommand::Apply { name, global }) => { + self.apply_profile(name.as_deref(), *global, &config_path) + .await + } Some(ProfileSubcommand::Switch) => self.switch_profile(&config_path).await, - Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to, &config_path).await, - Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command, &config_path).await, - Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref(), &config_path).await, - Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref(), &config_path).await, + Some(ProfileSubcommand::Copy { from, to }) => { + self.copy_profile(from, to, &config_path).await + } + Some(ProfileSubcommand::Token { token_command }) => { + self.handle_token_command(token_command, &config_path).await + } + Some(ProfileSubcommand::Check { name }) => { + self.check_profile(name.as_deref(), &config_path).await + } + Some(ProfileSubcommand::Stats { name }) => { + self.show_stats(name.as_deref(), &config_path).await + } None => self.list_profiles(&config_path).await, } } @@ -158,18 +175,14 @@ impl ProfileCommand { let name: String = Input::new() .with_prompt("Profile name") - .validate_with(|input: &String| { - validate_profile_name(input).map_err(|e| e.to_string()) - }) + .validate_with(|input: &String| validate_profile_name(input).map_err(|e| e.to_string())) .interact_text()?; if manager.has_profile(&name) { bail!("Profile '{}' already exists", name); } - let user_name: String = Input::new() - .with_prompt("Git user name") - .interact_text()?; + let user_name: String = Input::new().with_prompt("Git user name").interact_text()?; let user_email: String = Input::new() .with_prompt("Git user email") @@ -189,15 +202,13 @@ impl ProfileCommand { .interact()?; let organization = if is_work { - Some(Input::new() - .with_prompt("Organization") - .interact_text()?) + Some(Input::new().with_prompt("Organization").interact_text()?) } else { None }; let mut profile = GitProfile::new(name.clone(), user_name, user_email); - + if !description.is_empty() { profile.description = Some(description); } @@ -234,7 +245,11 @@ impl ProfileCommand { manager.add_profile(name.clone(), profile)?; manager.save()?; - println!("{} Profile '{}' added successfully", "✓".green(), name.cyan()); + println!( + "{} Profile '{}' added successfully", + "✓".green(), + name.cyan() + ); if manager.default_profile().is_none() { let set_default = Confirm::new() @@ -260,7 +275,10 @@ impl ProfileCommand { } let confirm = Confirm::new() - .with_prompt(format!("Are you sure you want to remove profile '{}'?", name)) + .with_prompt(format!( + "Are you sure you want to remove profile '{}'?", + name + )) .default(false) .interact()?; @@ -270,11 +288,15 @@ impl ProfileCommand { } manager.delete_all_pats_for_profile(name)?; - + manager.remove_profile(name)?; manager.save()?; - println!("{} Profile '{}' removed (including all stored tokens)", "✓".green(), name); + println!( + "{} Profile '{}' removed (including all stored tokens)", + "✓".green(), + name + ); Ok(()) } @@ -283,7 +305,7 @@ impl ProfileCommand { let manager = self.get_manager(config_path)?; let profiles = manager.list_profiles(); - + if profiles.is_empty() { println!("{}", "No profiles configured.".yellow()); println!("Run {} to create one.", "quicommit profile add".cyan()); @@ -298,17 +320,25 @@ impl ProfileCommand { for name in profiles { let profile = manager.get_profile(name).unwrap(); let is_default = default.map(|d| d == name).unwrap_or(false); - - let marker = if is_default { "●".green() } else { "○".dimmed() }; - let work_marker = if profile.is_work { " [work]".yellow() } else { "".normal() }; - + + let marker = if is_default { + "●".green() + } else { + "○".dimmed() + }; + let work_marker = if profile.is_work { + " [work]".yellow() + } else { + "".normal() + }; + println!("{} {}{}", marker, name.cyan().bold(), work_marker); println!(" {} <{}>", profile.user_name, profile.user_email); - + if let Some(ref desc) = profile.description { println!(" {}", desc.dimmed()); } - + if profile.has_ssh() { println!(" {} SSH configured", "🔑".to_string().dimmed()); } @@ -316,13 +346,21 @@ impl ProfileCommand { println!(" {} GPG configured", "🔒".to_string().dimmed()); } if profile.has_tokens() { - println!(" {} {} token(s)", "🔐".to_string().dimmed(), profile.tokens.len()); + println!( + " {} {} token(s)", + "🔐".to_string().dimmed(), + profile.tokens.len() + ); } - + if let Some(ref usage) = profile.usage.last_used { - println!(" {} Last used: {}", "📊".to_string().dimmed(), usage.dimmed()); + println!( + " {} Last used: {}", + "📊".to_string().dimmed(), + usage.dimmed() + ); } - + println!(); } @@ -333,16 +371,17 @@ impl ProfileCommand { let manager = self.get_manager(config_path)?; match find_repo(std::env::current_dir()?.as_path()) { - Ok(repo) => { - self.show_repo_status(&repo, &manager, name).await - } - Err(_) => { - self.show_global_status(&manager, name).await - } + Ok(repo) => self.show_repo_status(&repo, &manager, name).await, + Err(_) => self.show_global_status(&manager, name).await, } } - async fn show_repo_status(&self, repo: &crate::git::GitRepo, manager: &ConfigManager, name: Option<&str>) -> Result<()> { + async fn show_repo_status( + &self, + repo: &crate::git::GitRepo, + manager: &ConfigManager, + name: Option<&str>, + ) -> Result<()> { use crate::git::MergedUserConfig; let merged_config = MergedUserConfig::from_repo(repo.inner())?; @@ -352,7 +391,10 @@ impl ProfileCommand { println!("{}", "─".repeat(60)); println!("Repository: {}", repo_path.cyan()); - println!("\n{}", "Git User Configuration (merged local/global):".bold()); + println!( + "\n{}", + "Git User Configuration (merged local/global):".bold() + ); println!("{}", "─".repeat(60)); self.print_config_entry("User name", &merged_config.name); @@ -375,21 +417,43 @@ impl ProfileCommand { match (&matching_profile, repo_profile_name) { (Some(profile), Some(mapped_name)) => { if profile.name == *mapped_name { - println!("{} Profile '{}' is mapped to this repository", "✓".green(), profile.name.cyan()); + println!( + "{} Profile '{}' is mapped to this repository", + "✓".green(), + profile.name.cyan() + ); println!(" This repository's git config matches the saved profile."); } else { - println!("{} Profile '{}' matches current config", "✓".green(), profile.name.cyan()); - println!(" But repository is mapped to different profile: {}", mapped_name.yellow()); + println!( + "{} Profile '{}' matches current config", + "✓".green(), + profile.name.cyan() + ); + println!( + " But repository is mapped to different profile: {}", + mapped_name.yellow() + ); } } (Some(profile), None) => { - println!("{} Profile '{}' matches current config", "✓".green(), profile.name.cyan()); - println!(" {} This repository is not mapped to any profile.", "ℹ".yellow()); + println!( + "{} Profile '{}' matches current config", + "✓".green(), + profile.name.cyan() + ); + println!( + " {} This repository is not mapped to any profile.", + "ℹ".yellow() + ); } (None, Some(mapped_name)) => { - println!("{} Repository is mapped to profile '{}'", "⚠".yellow(), mapped_name.cyan()); + println!( + "{} Repository is mapped to profile '{}'", + "⚠".yellow(), + mapped_name.cyan() + ); println!(" But current git config does not match this profile!"); - + if let Some(mapped_profile) = manager.get_profile(mapped_name) { println!("\n Mapped profile config:"); println!(" user.name: {}", mapped_profile.user_name); @@ -399,7 +463,7 @@ impl ProfileCommand { (None, None) => { println!("{} No matching profile found in QuiCommit", "✗".red()); println!(" Current git identity is not saved as a QuiCommit profile."); - + let partial_matches = manager.find_partial_matches(&user_name, &user_email); if !partial_matches.is_empty() { println!("\n {} Similar profiles exist:", "ℹ".yellow()); @@ -417,14 +481,18 @@ impl ProfileCommand { } if merged_config.is_complete() { - println!("\n {} Would you like to save this identity as a new profile?", "💡".yellow()); + println!( + "\n {} Would you like to save this identity as a new profile?", + "💡".yellow() + ); let save = Confirm::new() .with_prompt("Save current git identity as new profile?") .default(true) .interact()?; if save { - self.save_current_identity_as_profile(&merged_config, manager).await?; + self.save_current_identity_as_profile(&merged_config, manager) + .await?; } } } @@ -432,7 +500,10 @@ impl ProfileCommand { if let Some(profile_name) = name { if let Some(profile) = manager.get_profile(profile_name) { - println!("\n{}", format!("Requested Profile: {}", profile_name).bold()); + println!( + "\n{}", + format!("Requested Profile: {}", profile_name).bold() + ); println!("{}", "─".repeat(60)); self.print_profile_details(profile); } else { @@ -486,8 +557,16 @@ impl ProfileCommand { Some(value) => { println!("{} {}: {}", source_indicator, label, value); if entry.local_value.is_some() && entry.global_value.is_some() { - println!(" {} local: {}", "├".dimmed(), entry.local_value.as_ref().unwrap()); - println!(" {} global: {}", "└".dimmed(), entry.global_value.as_ref().unwrap()); + println!( + " {} local: {}", + "├".dimmed(), + entry.local_value.as_ref().unwrap() + ); + println!( + " {} global: {}", + "└".dimmed(), + entry.global_value.as_ref().unwrap() + ); } } None => { @@ -499,13 +578,20 @@ impl ProfileCommand { fn print_profile_details(&self, profile: &GitProfile) { println!("User name: {}", profile.user_name); println!("User email: {}", profile.user_email); - + if let Some(ref desc) = profile.description { println!("Description: {}", desc); } - - println!("Work profile: {}", if profile.is_work { "yes".yellow() } else { "no".normal() }); - + + println!( + "Work profile: {}", + if profile.is_work { + "yes".yellow() + } else { + "no".normal() + } + ); + if let Some(ref org) = profile.organization { println!("Organization: {}", org); } @@ -543,7 +629,11 @@ impl ProfileCommand { } } - async fn save_current_identity_as_profile(&self, merged_config: &crate::git::MergedUserConfig, manager: &ConfigManager) -> Result<()> { + async fn save_current_identity_as_profile( + &self, + merged_config: &crate::git::MergedUserConfig, + manager: &ConfigManager, + ) -> Result<()> { let config_path = manager.path().to_path_buf(); let mut manager = ConfigManager::with_path(&config_path)?; @@ -557,14 +647,15 @@ impl ProfileCommand { let profile_name: String = Input::new() .with_prompt("Profile name") .default(default_name) - .validate_with(|input: &String| { - validate_profile_name(input).map_err(|e| e.to_string()) - }) + .validate_with(|input: &String| validate_profile_name(input).map_err(|e| e.to_string())) .interact_text()?; if manager.has_profile(&profile_name) { let overwrite = Confirm::new() - .with_prompt(format!("Profile '{}' already exists. Overwrite?", profile_name)) + .with_prompt(format!( + "Profile '{}' already exists. Overwrite?", + profile_name + )) .default(false) .interact()?; if !overwrite { @@ -585,9 +676,7 @@ impl ProfileCommand { .interact()?; let organization = if is_work { - Some(Input::new() - .with_prompt("Organization") - .interact_text()?) + Some(Input::new().with_prompt("Organization").interact_text()?) } else { None }; @@ -601,12 +690,12 @@ impl ProfileCommand { if let Some(ref key) = merged_config.signing_key.value { profile.signing_key = Some(key.clone()); - + let setup_gpg = Confirm::new() .with_prompt("Configure GPG signing details?") .default(true) .interact()?; - + if setup_gpg { profile.gpg = Some(self.setup_gpg_interactive().await?); } @@ -617,7 +706,7 @@ impl ProfileCommand { .with_prompt("Configure SSH key details?") .default(false) .interact()?; - + if setup_ssh { profile.ssh = Some(self.setup_ssh_interactive().await?); } @@ -635,7 +724,11 @@ impl ProfileCommand { manager.add_profile(profile_name.clone(), profile)?; manager.save()?; - println!("{} Profile '{}' saved successfully", "✓".green(), profile_name.cyan()); + println!( + "{} Profile '{}' saved successfully", + "✓".green(), + profile_name.cyan() + ); let set_default = Confirm::new() .with_prompt("Set as default profile?") @@ -645,7 +738,11 @@ impl ProfileCommand { if set_default { manager.set_default_profile(Some(profile_name.clone()))?; manager.save()?; - println!("{} Set '{}' as default profile", "✓".green(), profile_name.cyan()); + println!( + "{} Set '{}' as default profile", + "✓".green(), + profile_name.cyan() + ); } Ok(()) @@ -654,7 +751,8 @@ impl ProfileCommand { async fn edit_profile(&self, name: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; - let profile = manager.get_profile(name) + let profile = manager + .get_profile(name) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))? .clone(); @@ -707,35 +805,51 @@ impl ProfileCommand { let repo = find_repo(std::env::current_dir()?.as_path())?; let repo_path = repo.path().to_string_lossy().to_string(); - + manager.set_repo_profile(repo_path.clone(), name.to_string())?; - + // Get the profile and apply it to the repository - let profile = manager.get_profile(name) + let profile = manager + .get_profile(name) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?; - + profile.apply_to_repo(repo.inner())?; manager.record_profile_usage(name, Some(repo_path))?; manager.save()?; - println!("{} Set '{}' for current repository", "✓".green(), name.cyan()); - println!("{} Applied profile '{}' to current repository", "✓".green(), name.cyan()); + println!( + "{} Set '{}' for current repository", + "✓".green(), + name.cyan() + ); + println!( + "{} Applied profile '{}' to current repository", + "✓".green(), + name.cyan() + ); Ok(()) } - async fn apply_profile(&self, name: Option<&str>, global: bool, config_path: &Option) -> Result<()> { + async fn apply_profile( + &self, + name: Option<&str>, + global: bool, + config_path: &Option, + ) -> Result<()> { let mut manager = self.get_manager(config_path)?; let profile_name = if let Some(n) = name { n.to_string() } else { - manager.default_profile_name() + manager + .default_profile_name() .ok_or_else(|| anyhow::anyhow!("No default profile set"))? .clone() }; - let profile = manager.get_profile(&profile_name) + let profile = manager + .get_profile(&profile_name) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))? .clone(); @@ -748,11 +862,19 @@ impl ProfileCommand { if global { profile.apply_global()?; - println!("{} Applied profile '{}' globally", "✓".green(), profile.name.cyan()); + println!( + "{} Applied profile '{}' globally", + "✓".green(), + profile.name.cyan() + ); } else { let repo = find_repo(std::env::current_dir()?.as_path())?; profile.apply_to_repo(repo.inner())?; - println!("{} Applied profile '{}' to current repository", "✓".green(), profile.name.cyan()); + println!( + "{} Applied profile '{}' to current repository", + "✓".green(), + profile.name.cyan() + ); } manager.record_profile_usage(&profile_name, repo_path)?; @@ -763,8 +885,9 @@ impl ProfileCommand { async fn switch_profile(&self, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; - - let profiles: Vec = manager.list_profiles() + + let profiles: Vec = manager + .list_profiles() .into_iter() .map(|s| s.to_string()) .collect(); @@ -785,7 +908,7 @@ impl ProfileCommand { .interact()?; let selected = &profiles[selection]; - + manager.set_default_profile(Some(selected.clone()))?; manager.save()?; @@ -798,17 +921,24 @@ impl ProfileCommand { .interact()?; if apply { - self.apply_profile(Some(selected), false, config_path).await?; + self.apply_profile(Some(selected), false, config_path) + .await?; } } Ok(()) } - async fn copy_profile(&self, from: &str, to: &str, config_path: &Option) -> Result<()> { + async fn copy_profile( + &self, + from: &str, + to: &str, + config_path: &Option, + ) -> Result<()> { let mut manager = self.get_manager(config_path)?; - let source = manager.get_profile(from) + let source = manager + .get_profile(from) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))? .clone(); @@ -821,20 +951,38 @@ impl ProfileCommand { manager.add_profile(to.to_string(), new_profile)?; manager.save()?; - println!("{} Copied profile '{}' to '{}'", "✓".green(), from, to.cyan()); + println!( + "{} Copied profile '{}' to '{}'", + "✓".green(), + from, + to.cyan() + ); Ok(()) } - async fn handle_token_command(&self, cmd: &TokenSubcommand, config_path: &Option) -> Result<()> { + async fn handle_token_command( + &self, + cmd: &TokenSubcommand, + config_path: &Option, + ) -> Result<()> { match cmd { - TokenSubcommand::Add { profile, service } => self.add_token(profile, service, config_path).await, - TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service, config_path).await, + TokenSubcommand::Add { profile, service } => { + self.add_token(profile, service, config_path).await + } + TokenSubcommand::Remove { profile, service } => { + self.remove_token(profile, service, config_path).await + } TokenSubcommand::List { profile } => self.list_tokens(profile, config_path).await, } } - async fn add_token(&self, profile_name: &str, service: &str, config_path: &Option) -> Result<()> { + async fn add_token( + &self, + profile_name: &str, + service: &str, + config_path: &Option, + ) -> Result<()> { let mut manager = self.get_manager(config_path)?; if !manager.has_profile(profile_name) { @@ -842,10 +990,15 @@ impl ProfileCommand { } if !manager.keyring().is_available() { - bail!("Keyring is not available. Cannot store PAT securely. Please ensure your system keyring is accessible."); + bail!( + "Keyring is not available. Cannot store PAT securely. Please ensure your system keyring is accessible." + ); } - println!("{}", format!("\nAdd token to profile '{}'", profile_name).bold()); + println!( + "{}", + format!("\nAdd token to profile '{}'", profile_name).bold() + ); println!("{}", "─".repeat(40)); let token_value: String = Input::new() @@ -878,16 +1031,26 @@ impl ProfileCommand { } manager.store_pat_for_profile(profile_name, service, &token_value)?; - + manager.add_token_to_profile(profile_name, service.to_string(), token)?; manager.save()?; - println!("{} Token for '{}' added to profile '{}' (stored securely in keyring)", "✓".green(), service.cyan(), profile_name); + println!( + "{} Token for '{}' added to profile '{}' (stored securely in keyring)", + "✓".green(), + service.cyan(), + profile_name + ); Ok(()) } - async fn remove_token(&self, profile_name: &str, service: &str, config_path: &Option) -> Result<()> { + async fn remove_token( + &self, + profile_name: &str, + service: &str, + config_path: &Option, + ) -> Result<()> { let mut manager = self.get_manager(config_path)?; if !manager.has_profile(profile_name) { @@ -895,7 +1058,10 @@ impl ProfileCommand { } let confirm = Confirm::new() - .with_prompt(format!("Remove token '{}' from profile '{}'?", service, profile_name)) + .with_prompt(format!( + "Remove token '{}' from profile '{}'?", + service, profile_name + )) .default(false) .interact()?; @@ -907,7 +1073,12 @@ impl ProfileCommand { manager.remove_token_from_profile(profile_name, service)?; manager.save()?; - println!("{} Token '{}' removed from profile '{}' (deleted from keyring)", "✓".green(), service, profile_name); + println!( + "{} Token '{}' removed from profile '{}' (deleted from keyring)", + "✓".green(), + service, + profile_name + ); Ok(()) } @@ -915,15 +1086,23 @@ impl ProfileCommand { async fn list_tokens(&self, profile_name: &str, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; - let profile = manager.get_profile(profile_name) + let profile = manager + .get_profile(profile_name) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; if profile.tokens.is_empty() { - println!("{} No tokens configured for profile '{}'", "ℹ".yellow(), profile_name); + println!( + "{} No tokens configured for profile '{}'", + "ℹ".yellow(), + profile_name + ); return Ok(()); } - println!("{}", format!("\nTokens for profile '{}':", profile_name).bold()); + println!( + "{}", + format!("\nTokens for profile '{}':", profile_name).bold() + ); println!("{}", "─".repeat(40)); for (service, token) in &profile.tokens { @@ -933,8 +1112,13 @@ impl ProfileCommand { } else { format!("[{}]", "not stored".yellow()) }; - - println!("{} {} ({})", service.cyan().bold(), status, token.token_type); + + println!( + "{} {} ({})", + service.cyan().bold(), + status, + token.token_type + ); if let Some(ref desc) = token.description { println!(" {}", desc); } @@ -952,7 +1136,8 @@ impl ProfileCommand { let profile_name = if let Some(n) = name { n.to_string() } else { - manager.default_profile_name() + manager + .default_profile_name() .ok_or_else(|| anyhow::anyhow!("No default profile set"))? .clone() }; @@ -960,15 +1145,28 @@ impl ProfileCommand { let repo = find_repo(std::env::current_dir()?.as_path())?; let comparison = manager.check_profile_config(&profile_name, repo.inner())?; - println!("{}", format!("\nChecking profile '{}' against git configuration", profile_name).bold()); + println!( + "{}", + format!( + "\nChecking profile '{}' against git configuration", + profile_name + ) + .bold() + ); println!("{}", "─".repeat(60)); if comparison.matches { - println!("{} Profile configuration matches git settings", "✓".green().bold()); + println!( + "{} Profile configuration matches git settings", + "✓".green().bold() + ); } else { - println!("{} Profile configuration differs from git settings", "✗".red().bold()); + println!( + "{} Profile configuration differs from git settings", + "✗".red().bold() + ); println!("\n{}", "Differences:".bold()); - + for diff in &comparison.differences { println!("\n {}:", diff.key.cyan()); println!(" Profile: {}", diff.profile_value.green()); @@ -983,13 +1181,14 @@ impl ProfileCommand { let manager = self.get_manager(config_path)?; if let Some(n) = name { - let profile = manager.get_profile(n) + let profile = manager + .get_profile(n) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?; self.show_single_profile_stats(profile); } else { let profiles = manager.list_profiles(); - + if profiles.is_empty() { println!("{}", "No profiles configured.".yellow()); return Ok(()); @@ -1012,7 +1211,7 @@ impl ProfileCommand { fn show_single_profile_stats(&self, profile: &GitProfile) { println!("{}", format!("\n{}", profile.name).bold()); println!(" Total uses: {}", profile.usage.total_uses); - + if let Some(ref last_used) = profile.usage.last_used { println!(" Last used: {}", last_used); } @@ -1048,9 +1247,7 @@ impl ProfileCommand { } async fn setup_gpg_interactive(&self) -> Result { - let key_id: String = Input::new() - .with_prompt("GPG key ID") - .interact_text()?; + let key_id: String = Input::new().with_prompt("GPG key ID").interact_text()?; Ok(GpgConfig { key_id, @@ -1061,9 +1258,16 @@ impl ProfileCommand { }) } - async fn setup_token_interactive(&self, profile: &mut GitProfile, manager: &ConfigManager) -> Result<()> { + async fn setup_token_interactive( + &self, + profile: &mut GitProfile, + manager: &ConfigManager, + ) -> Result<()> { if !manager.keyring().is_available() { - println!("{} Keyring is not available. Cannot store PAT securely.", "⚠".yellow()); + println!( + "{} Keyring is not available. Cannot store PAT securely.", + "⚠".yellow() + ); let continue_anyway = Confirm::new() .with_prompt("Continue without secure token storage?") .default(false) @@ -1077,17 +1281,15 @@ impl ProfileCommand { .with_prompt("Service name (e.g., github, gitlab)") .interact_text()?; - let token_value: String = Input::new() - .with_prompt("Token value") - .interact_text()?; + let token_value: String = Input::new().with_prompt("Token value").interact_text()?; let token = TokenConfig::new(TokenType::Personal); - + if manager.keyring().is_available() { manager.store_pat_for_profile(&profile.name, &service, &token_value)?; println!("{} Token stored securely in keyring", "✓".green()); } - + profile.add_token(service, token); Ok(()) diff --git a/src/commands/tag.rs b/src/commands/tag.rs index d97377c..cfce000 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use clap::Parser; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; @@ -6,11 +6,11 @@ use semver::Version; use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; -use crate::git::{find_repo, GitRepo}; use crate::generator::ContentGenerator; use crate::git::tag::{ - bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump, + TagBuilder, VersionBump, bump_version, get_latest_version, suggest_version_bump, }; +use crate::git::{GitRepo, find_repo}; use crate::i18n::Messages; /// Generate and create Git tags @@ -83,30 +83,37 @@ impl TagCommand { } else if let Some(bump_str) = &self.bump { // Calculate bumped version let prefix = &config.tag.version_prefix; - let latest = get_latest_version(&repo, prefix)? - .unwrap_or_else(|| Version::new(0, 0, 0)); - + let latest = + get_latest_version(&repo, prefix)?.unwrap_or_else(|| Version::new(0, 0, 0)); + let bump = VersionBump::from_str(bump_str)?; let new_version = bump_version(&latest, bump, None); - + format!("{}{}", prefix, new_version) } else { // Interactive mode - self.select_version_interactive(&repo, &config.tag.version_prefix, &messages).await? + self.select_version_interactive(&repo, &config.tag.version_prefix, &messages) + .await? }; // Validate tag name (if it looks like a version) - if tag_name.starts_with('v') || tag_name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { + if tag_name.starts_with('v') + || tag_name + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + { let version_str = tag_name.trim_start_matches('v'); if let Err(e) = crate::utils::validators::validate_semver(version_str) { println!("{}: {}", "Warning".yellow(), e); - + if !self.yes { let proceed = Confirm::new() .with_prompt("Proceed with this tag name anyway?") .default(true) .interact()?; - + if !proceed { bail!("{}", messages.tag_cancelled()); } @@ -120,7 +127,10 @@ impl TagCommand { } else if let Some(msg) = &self.message { Some(msg.clone()) } else if self.generate || (config.tag.auto_generate && !self.yes) { - Some(self.generate_tag_message(&repo, &tag_name, &messages).await?) + Some( + self.generate_tag_message(&repo, &tag_name, &messages) + .await?, + ) } else if !self.yes { Some(self.input_message_interactive(&tag_name, &messages)?) } else { @@ -188,12 +198,17 @@ impl TagCommand { Ok(()) } - async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str, messages: &Messages) -> Result { + async fn select_version_interactive( + &self, + repo: &GitRepo, + prefix: &str, + messages: &Messages, + ) -> Result { loop { let latest = get_latest_version(repo, prefix)?; - + println!("\n{}", messages.version_selection().bold()); - + if let Some(ref version) = latest { println!("{} {}{}", messages.latest_version(), prefix, version); } else { @@ -220,36 +235,46 @@ impl TagCommand { // Auto-detect let commits = repo.get_commits(50)?; let bump = suggest_version_bump(&commits); - let version = latest.as_ref() + let version = latest + .as_ref() .map(|v| bump_version(v, bump, None)) .unwrap_or_else(|| Version::new(0, 1, 0)); - - println!("{} {:?} → {}{}", messages.suggested_bump(), bump, prefix, version); - + + println!( + "{} {:?} → {}{}", + messages.suggested_bump(), + bump, + prefix, + version + ); + let confirm = Confirm::new() .with_prompt(messages.use_this_version()) .default(true) .interact()?; - + if confirm { return Ok(format!("{}{}", prefix, version)); } // User rejected, continue the loop } 1 => { - let version = latest.as_ref() + let version = latest + .as_ref() .map(|v| bump_version(v, VersionBump::Major, None)) .unwrap_or_else(|| Version::new(1, 0, 0)); return Ok(format!("{}{}", prefix, version)); } 2 => { - let version = latest.as_ref() + let version = latest + .as_ref() .map(|v| bump_version(v, VersionBump::Minor, None)) .unwrap_or_else(|| Version::new(0, 1, 0)); return Ok(format!("{}{}", prefix, version)); } 3 => { - let version = latest.as_ref() + let version = latest + .as_ref() .map(|v| bump_version(v, VersionBump::Patch, None)) .unwrap_or_else(|| Version::new(0, 0, 1)); return Ok(format!("{}{}", prefix, version)); @@ -272,7 +297,12 @@ impl TagCommand { } } - async fn generate_tag_message(&self, repo: &GitRepo, version: &str, messages: &Messages) -> Result { + async fn generate_tag_message( + &self, + repo: &GitRepo, + version: &str, + messages: &Messages, + ) -> Result { let manager = ConfigManager::new()?; let language = manager.get_language().unwrap_or(Language::English); @@ -290,17 +320,19 @@ impl TagCommand { println!("{}", messages.ai_generating_tag(commits.len())); let generator = ContentGenerator::new_with_think(&manager, self.think).await?; - generator.generate_tag_message(version, &commits, language).await + generator + .generate_tag_message(version, &commits, language) + .await } fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result { let default_msg = format!("Release {}", version); - + let use_editor = Confirm::new() .with_prompt(messages.open_editor()) .default(false) .interact()?; - + if use_editor { crate::utils::editor::edit_content(&default_msg) } else { diff --git a/src/config/manager.rs b/src/config/manager.rs index 8b3822d..1aaf20c 100644 --- a/src/config/manager.rs +++ b/src/config/manager.rs @@ -1,6 +1,8 @@ use super::{AppConfig, GitProfile, TokenConfig}; -use crate::utils::keyring::{KeyringManager, get_default_base_url, get_default_model, provider_needs_api_key}; -use anyhow::{bail, Context, Result}; +use crate::utils::keyring::{ + KeyringManager, get_default_base_url, get_default_model, provider_needs_api_key, +}; +use anyhow::{Context, Result, bail}; // use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -91,13 +93,13 @@ impl ConfigManager { if !self.config.profiles.contains_key(name) { bail!("Profile '{}' does not exist", name); } - + if self.config.default_profile.as_ref() == Some(&name.to_string()) { self.config.default_profile = None; } - + self.config.repo_profiles.retain(|_, v| v != name); - + self.config.profiles.remove(name); self.modified = true; Ok(()) @@ -137,9 +139,10 @@ impl ConfigManager { /// Set default profile pub fn set_default_profile(&mut self, name: Option) -> Result<()> { if let Some(ref n) = name - && !self.config.profiles.contains_key(n) { - bail!("Profile '{}' does not exist", n); - } + && !self.config.profiles.contains_key(n) + { + bail!("Profile '{}' does not exist", n); + } self.config.default_profile = name; self.modified = true; Ok(()) @@ -177,36 +180,49 @@ impl ConfigManager { // Token management /// Add a token to a profile (stores token in keyring) - pub fn add_token_to_profile(&mut self, profile_name: &str, service: String, token: TokenConfig) -> Result<()> { + pub fn add_token_to_profile( + &mut self, + profile_name: &str, + service: String, + token: TokenConfig, + ) -> Result<()> { if !self.config.profiles.contains_key(profile_name) { bail!("Profile '{}' does not exist", profile_name); } - + if let Some(profile) = self.config.profiles.get_mut(profile_name) { profile.add_token(service, token); self.modified = true; } - + Ok(()) } /// Store a PAT token in keyring for a profile - pub fn store_pat_for_profile(&self, profile_name: &str, service: &str, token_value: &str) -> Result<()> { - let profile = self.get_profile(profile_name) + pub fn store_pat_for_profile( + &self, + profile_name: &str, + service: &str, + token_value: &str, + ) -> Result<()> { + let profile = self + .get_profile(profile_name) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; - + let user_email = &profile.user_email; - - self.keyring.store_pat(profile_name, user_email, service, token_value) + + self.keyring + .store_pat(profile_name, user_email, service, token_value) } /// Get a PAT token from keyring for a profile pub fn get_pat_for_profile(&self, profile_name: &str, service: &str) -> Result> { - let profile = self.get_profile(profile_name) + let profile = self + .get_profile(profile_name) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; - + let user_email = &profile.user_email; - + self.keyring.get_pat(profile_name, user_email, service) } @@ -225,21 +241,40 @@ impl ConfigManager { if !self.config.profiles.contains_key(profile_name) { bail!("Profile '{}' does not exist", profile_name); } - - let user_email = self.config.profiles.get(profile_name).unwrap().user_email.clone(); - let services: Vec = self.config.profiles.get(profile_name).unwrap().tokens.keys().cloned().collect(); - + + let user_email = self + .config + .profiles + .get(profile_name) + .unwrap() + .user_email + .clone(); + let services: Vec = self + .config + .profiles + .get(profile_name) + .unwrap() + .tokens + .keys() + .cloned() + .collect(); + if !services.contains(&service.to_string()) { - bail!("Token for service '{}' not found in profile '{}'", service, profile_name); + bail!( + "Token for service '{}' not found in profile '{}'", + service, + profile_name + ); } - - self.keyring.delete_pat(profile_name, &user_email, service)?; - + + self.keyring + .delete_pat(profile_name, &user_email, service)?; + if let Some(profile) = self.config.profiles.get_mut(profile_name) { profile.remove_token(service); self.modified = true; } - + Ok(()) } @@ -248,8 +283,9 @@ impl ConfigManager { if let Some(profile) = self.get_profile(profile_name) { let user_email = &profile.user_email; let services: Vec = profile.tokens.keys().cloned().collect(); - - self.keyring.delete_all_pats_for_profile(profile_name, user_email, &services)?; + + self.keyring + .delete_all_pats_for_profile(profile_name, user_email, &services)?; } Ok(()) } @@ -301,14 +337,24 @@ impl ConfigManager { // } /// Check and compare profile with git configuration - pub fn check_profile_config(&self, profile_name: &str, repo: &git2::Repository) -> Result { - let profile = self.get_profile(profile_name) + pub fn check_profile_config( + &self, + profile_name: &str, + repo: &git2::Repository, + ) -> Result { + let profile = self + .get_profile(profile_name) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; profile.compare_with_git_config(repo) } /// Find a profile that matches the given user config (name, email, signing_key) - pub fn find_matching_profile(&self, user_name: &str, user_email: &str, signing_key: Option<&str>) -> Option<&GitProfile> { + pub fn find_matching_profile( + &self, + user_name: &str, + user_email: &str, + signing_key: Option<&str>, + ) -> Option<&GitProfile> { for profile in self.config.profiles.values() { let name_match = profile.user_name == user_name; let email_match = profile.user_email == user_email; @@ -318,7 +364,7 @@ impl ConfigManager { (Some(_), None) => false, (None, Some(_)) => false, }; - + if name_match && email_match && key_match { return Some(profile); } @@ -328,7 +374,9 @@ impl ConfigManager { /// Find profiles that partially match (same name or same email) pub fn find_partial_matches(&self, user_name: &str, user_email: &str) -> Vec<&GitProfile> { - self.config.profiles.values() + self.config + .profiles + .values() .filter(|p| p.user_name == user_name || p.user_email == user_email) .collect() } @@ -383,7 +431,11 @@ impl ConfigManager { /// Get API key from configured storage method pub fn get_api_key(&self) -> Option { // First try environment variables (always checked) - if let Some(key) = self.keyring.get_api_key(&self.config.llm.provider).unwrap_or(None) { + if let Some(key) = self + .keyring + .get_api_key(&self.config.llm.provider) + .unwrap_or(None) + { return Some(key); } @@ -400,20 +452,29 @@ impl ConfigManager { match self.config.llm.api_key_storage.as_str() { "keyring" => { if !self.keyring.is_available() { - bail!("Keyring is not available. Set QUICOMMIT_API_KEY environment variable instead or change api_key_storage to 'config'."); + bail!( + "Keyring is not available. Set QUICOMMIT_API_KEY environment variable instead or change api_key_storage to 'config'." + ); } - self.keyring.store_api_key(&self.config.llm.provider, api_key) - }, + self.keyring + .store_api_key(&self.config.llm.provider, api_key) + } "config" => { // We can't modify self.config here since self is immutable // This will be handled by the caller updating the config Ok(()) - }, + } "environment" => { - bail!("API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.", self.config.llm.provider.to_uppercase()); - }, + bail!( + "API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.", + self.config.llm.provider.to_uppercase() + ); + } _ => { - bail!("Invalid API key storage method: {}", self.config.llm.api_key_storage); + bail!( + "Invalid API key storage method: {}", + self.config.llm.api_key_storage + ); } } } @@ -425,16 +486,19 @@ impl ConfigManager { if self.keyring.is_available() { self.keyring.delete_api_key(&self.config.llm.provider)?; } - }, + } "config" => { // We can't modify self.config here since self is immutable // This will be handled by the caller updating the config - }, + } "environment" => { // Environment variables are not managed by the app - }, + } _ => { - bail!("Invalid API key storage method: {}", self.config.llm.api_key_storage); + bail!( + "Invalid API key storage method: {}", + self.config.llm.api_key_storage + ); } } Ok(()) @@ -447,7 +511,12 @@ impl ConfigManager { } // Check environment variables - if self.keyring.get_api_key(&self.config.llm.provider).unwrap_or(None).is_some() { + if self + .keyring + .get_api_key(&self.config.llm.provider) + .unwrap_or(None) + .is_some() + { return true; } @@ -467,19 +536,19 @@ impl ConfigManager { // /// Configure LLM provider with all settings // pub fn configure_llm(&mut self, provider: String, model: Option, base_url: Option, api_key: Option<&str>) -> Result<()> { // self.set_llm_provider(provider.clone()); - + // if let Some(m) = model { // self.set_llm_model(m); // } - + // self.set_llm_base_url(base_url); - + // if let Some(key) = api_key { // if provider_needs_api_key(&provider) { // self.set_api_key(key)?; // } // } - + // Ok(()) // } @@ -575,14 +644,12 @@ impl ConfigManager { /// Export configuration to TOML string pub fn export(&self) -> Result { - toml::to_string_pretty(&self.config) - .context("Failed to serialize config") + 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.config = toml::from_str(toml_str).context("Failed to parse config")?; self.modified = true; Ok(()) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 38383fa..3830235 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -7,10 +7,7 @@ use std::path::{Path, PathBuf}; pub mod manager; pub mod profile; -pub use profile::{ - GitProfile, TokenConfig, TokenType, - ProfileComparison -}; +pub use profile::{GitProfile, ProfileComparison, TokenConfig, TokenType}; /// Application configuration #[derive(Debug, Clone, Serialize, Deserialize)] @@ -494,24 +491,22 @@ impl AppConfig { /// Save configuration to file pub fn save(&self, path: &Path) -> Result<()> { - let content = toml::to_string_pretty(self) - .context("Failed to serialize config")?; - + 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")?; + let config_dir = dirs::config_dir().context("Could not find config directory")?; Ok(config_dir.join("quicommit").join("config.toml")) } diff --git a/src/config/profile.rs b/src/config/profile.rs index 61fa6c5..398e7a9 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -80,25 +80,25 @@ impl GitProfile { 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(()) } @@ -119,7 +119,8 @@ impl GitProfile { /// Get signing key (from GPG config or direct) pub fn signing_key(&self) -> Option<&str> { - self.signing_key.as_deref() + self.signing_key + .as_deref() .or_else(|| self.gpg.as_ref().map(|g| g.key_id.as_str())) } @@ -142,7 +143,7 @@ impl GitProfile { pub fn record_usage(&mut self, repo_path: Option) { 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; @@ -157,91 +158,95 @@ impl GitProfile { /// 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))?; - } + && 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))?; - } + && 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 { 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 { @@ -250,7 +255,7 @@ impl GitProfile { git_value: git_user_name.unwrap_or_else(|| "".to_string()), }); } - + if git_user_email.as_deref() != Some(&self.user_email) { comparison.matches = false; comparison.differences.push(ConfigDifference { @@ -259,24 +264,24 @@ impl GitProfile { git_value: git_user_email.unwrap_or_else(|| "".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(|| "".to_string()), - }); - } - + && 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(|| "".to_string()), + }); + } + Ok(comparison) } } /// Profile settings -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProfileSettings { /// Automatically sign commits #[serde(default)] @@ -303,7 +308,6 @@ pub struct ProfileSettings { pub commit_template: Option, } - /// SSH configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SshConfig { @@ -334,15 +338,17 @@ 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); - } - + && !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); - } - + && !path.exists() + { + bail!("SSH public key does not exist: {:?}", path); + } + Ok(()) } @@ -487,7 +493,6 @@ pub enum TokenType { App, } - impl std::fmt::Display for TokenType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -617,9 +622,15 @@ impl GitProfileBuilder { } pub fn build(self) -> Result { - 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"))?; + 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, @@ -665,7 +676,7 @@ mod tests { "".to_string(), "invalid-email".to_string(), ); - + assert!(profile.validate().is_err()); } diff --git a/src/generator/mod.rs b/src/generator/mod.rs index c9ddd11..f91e187 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -1,5 +1,5 @@ -use crate::config::{CommitFormat, Language}; use crate::config::manager::ConfigManager; +use crate::config::{CommitFormat, Language}; use crate::git::{CommitInfo, GitRepo}; use crate::llm::{GeneratedCommit, LlmClient}; use anyhow::{Context, Result}; @@ -64,8 +64,10 @@ impl ContentGenerator { } else { diff.to_string() }; - - self.llm_client.generate_commit_message(&truncated_diff, format, language).await + + self.llm_client + .generate_commit_message(&truncated_diff, format, language) + .await } /// Generate commit message from repository changes @@ -75,13 +77,14 @@ impl ContentGenerator { format: CommitFormat, language: Language, ) -> Result { - let diff = repo.get_staged_diff_sorted() + let diff = repo + .get_staged_diff_sorted() .context("Failed to get staged diff")?; - + if diff.is_empty() { anyhow::bail!("No staged changes to generate commit from"); } - + self.generate_commit_message(&diff, format, language).await } @@ -92,12 +95,12 @@ impl ContentGenerator { commits: &[CommitInfo], language: Language, ) -> Result { - let commit_messages: Vec = commits - .iter() - .map(|c| c.subject().to_string()) - .collect(); - - self.llm_client.generate_tag_message(version, &commit_messages, language).await + let commit_messages: Vec = + commits.iter().map(|c| c.subject().to_string()).collect(); + + self.llm_client + .generate_tag_message(version, &commit_messages, language) + .await } /// Generate changelog entry @@ -114,8 +117,10 @@ impl ContentGenerator { (commit_type, c.subject().to_string()) }) .collect(); - - self.llm_client.generate_changelog_entry(version, &typed_commits, language).await + + self.llm_client + .generate_changelog_entry(version, &typed_commits, language) + .await } /// Generate changelog from repository @@ -131,8 +136,9 @@ impl ContentGenerator { } else { repo.get_commits(50)? }; - - self.generate_changelog_entry(version, &commits, language).await + + self.generate_changelog_entry(version, &commits, language) + .await } /// Interactive commit generation with user feedback @@ -143,49 +149,53 @@ impl ContentGenerator { language: Language, ) -> Result { use dialoguer::Select; - + let diff = repo.get_staged_diff_sorted()?; - + if diff.is_empty() { anyhow::bail!("No staged changes"); } - + // Show diff summary let files = repo.get_staged_files()?; println!("\nStaged files ({}):", files.len()); for file in &files { println!(" • {}", file); } - + // Generate initial commit println!("\nGenerating commit message..."); - let mut generated = self.generate_commit_message(&diff, format, language).await?; - + let mut generated = self + .generate_commit_message(&diff, format, language) + .await?; + loop { println!("\n{}", "─".repeat(60)); println!("Generated commit message:"); println!("{}", "─".repeat(60)); println!("{}", generated.to_conventional()); println!("{}", "─".repeat(60)); - + let options = vec![ "✓ Accept and commit", "🔄 Regenerate", "✏️ Edit", "❌ Cancel", ]; - + let selection = Select::new() .with_prompt("What would you like to do?") .items(&options) .default(0) .interact()?; - + match selection { 0 => return Ok(generated), 1 => { println!("Regenerating..."); - generated = self.generate_commit_message(&diff, format, language).await?; + generated = self + .generate_commit_message(&diff, format, language) + .await?; } 2 => { let edited = crate::utils::editor::edit_content(&generated.to_conventional())?; @@ -199,7 +209,7 @@ impl ContentGenerator { fn parse_edited_commit(&self, edited: &str, _format: CommitFormat) -> Result { let parsed = crate::git::commit::parse_commit_message(edited); - + Ok(GeneratedCommit { commit_type: parsed.commit_type.unwrap_or_else(|| "chore".to_string()), scope: parsed.scope, @@ -236,11 +246,15 @@ pub mod fallback { let has_code = files.iter().any(|f| { f.ends_with(".rs") || f.ends_with(".py") || f.ends_with(".js") || f.ends_with(".ts") }); - - let has_docs = files.iter().any(|f| f.ends_with(".md") || f.contains("README")); - - let has_tests = files.iter().any(|f| f.contains("test") || f.contains("spec")); - + + let has_docs = files + .iter() + .any(|f| f.ends_with(".md") || f.contains("README")); + + let has_tests = files + .iter() + .any(|f| f.contains("test") || f.contains("spec")); + if has_tests { "test: update tests".to_string() } else if has_docs { diff --git a/src/git/changelog.rs b/src/git/changelog.rs index 09e351d..a148922 100644 --- a/src/git/changelog.rs +++ b/src/git/changelog.rs @@ -95,9 +95,7 @@ impl ChangelogGenerator { ChangelogFormat::GitHubReleases => { self.generate_github_releases(version, date, commits) } - ChangelogFormat::Custom => { - self.generate_custom(version, date, commits) - } + ChangelogFormat::Custom => self.generate_custom(version, date, commits), } } @@ -110,13 +108,13 @@ impl ChangelogGenerator { commits: &[CommitInfo], ) -> Result<()> { let entry = self.generate(version, date, commits)?; - + let existing = if changelog_path.exists() { fs::read_to_string(changelog_path)? } else { String::new() }; - + let new_content = if existing.is_empty() { format!("{}{}", CHANGELOG_HEADER, entry) } else if existing.starts_with(CHANGELOG_HEADER) { @@ -124,7 +122,7 @@ impl ChangelogGenerator { } else if existing.starts_with("# Changelog") { let lines: Vec<&str> = existing.lines().collect(); let mut header_end = 0; - + for (i, line) in lines.iter().enumerate() { if i == 0 && line.starts_with('#') { header_end = i + 1; @@ -134,18 +132,18 @@ impl ChangelogGenerator { break; } } - + let header = lines[..header_end].join("\n"); let rest = lines[header_end..].join("\n"); - + format!("{}\n{}\n{}", header, entry, rest) } else { format!("{}{}", CHANGELOG_HEADER, entry) }; - + fs::write(changelog_path, new_content) .with_context(|| format!("Failed to write changelog: {:?}", changelog_path))?; - + Ok(()) } @@ -157,10 +155,10 @@ impl ChangelogGenerator { ) -> Result { let date_str = date.format("%Y-%m-%d").to_string(); let mut output = format!("## [{}] - {}\n\n", version, date_str); - + if self.group_by_type { let _grouped = self.group_commits(commits); - + // Standard categories let categories = vec![ ("Added", vec!["feat"]), @@ -170,7 +168,7 @@ impl ChangelogGenerator { ("Fixed", vec!["fix"]), ("Security", vec!["security"]), ]; - + for (title, types) in &categories { let items: Vec<&CommitInfo> = commits .iter() @@ -182,7 +180,7 @@ impl ChangelogGenerator { } }) .collect(); - + if !items.is_empty() { output.push_str(&format!("### {}\n\n", title)); for commit in items { @@ -192,13 +190,13 @@ impl ChangelogGenerator { output.push('\n'); } } - + // Other changes let categorized: Vec = categories .iter() .flat_map(|(_, types)| types.iter().map(|s| s.to_string())) .collect(); - + let other: Vec<&CommitInfo> = commits .iter() .filter(|c| { @@ -209,7 +207,7 @@ impl ChangelogGenerator { } }) .collect(); - + if !other.is_empty() { output.push_str("### Other\n\n"); for commit in other { @@ -224,7 +222,7 @@ impl ChangelogGenerator { output.push('\n'); } } - + Ok(output) } @@ -235,19 +233,19 @@ impl ChangelogGenerator { commits: &[CommitInfo], ) -> Result { let mut output = "## What's Changed\n\n".to_string(); - + // Group by type let mut features = vec![]; let mut fixes = vec![]; let mut docs = vec![]; let mut other = vec![]; let mut breaking = vec![]; - + for commit in commits { if commit.message.contains("BREAKING CHANGE") { breaking.push(commit); } - + if let Some(ref t) = commit.commit_type() { match t.as_str() { "feat" => features.push(commit), @@ -259,7 +257,7 @@ impl ChangelogGenerator { other.push(commit); } } - + if !breaking.is_empty() { output.push_str("### ⚠ Breaking Changes\n\n"); for commit in breaking { @@ -267,7 +265,7 @@ impl ChangelogGenerator { } output.push('\n'); } - + if !features.is_empty() { output.push_str("### 🚀 Features\n\n"); for commit in features { @@ -275,7 +273,7 @@ impl ChangelogGenerator { } output.push('\n'); } - + if !fixes.is_empty() { output.push_str("### 🐛 Bug Fixes\n\n"); for commit in fixes { @@ -283,7 +281,7 @@ impl ChangelogGenerator { } output.push('\n'); } - + if !docs.is_empty() { output.push_str("### 📚 Documentation\n\n"); for commit in docs { @@ -291,14 +289,14 @@ impl ChangelogGenerator { } output.push('\n'); } - + if !other.is_empty() { output.push_str("### Other Changes\n\n"); for commit in other { output.push_str(&self.format_commit_github(commit)); } } - + Ok(output) } @@ -312,7 +310,7 @@ impl ChangelogGenerator { if !self.custom_categories.is_empty() { let date_str = date.format("%Y-%m-%d").to_string(); let mut output = format!("## [{}] - {}\n\n", version, date_str); - + for category in &self.custom_categories { let items: Vec<&CommitInfo> = commits .iter() @@ -324,7 +322,7 @@ impl ChangelogGenerator { } }) .collect(); - + if !items.is_empty() { output.push_str(&format!("### {}\n\n", category.title)); for commit in items { @@ -334,7 +332,7 @@ impl ChangelogGenerator { output.push('\n'); } } - + Ok(output) } else { // Fall back to keep-a-changelog @@ -344,30 +342,35 @@ impl ChangelogGenerator { fn format_commit(&self, commit: &CommitInfo) -> String { let mut line = format!("- {}", commit.subject()); - + if self.include_hashes { line.push_str(&format!(" ({})", &commit.short_id)); } - + if self.include_authors { line.push_str(&format!(" - @{}", commit.author)); } - + line } fn format_commit_github(&self, commit: &CommitInfo) -> String { - format!("- {} by @{} in {}\n", commit.subject(), commit.author, &commit.short_id) + format!( + "- {} by @{} in {}\n", + commit.subject(), + commit.author, + &commit.short_id + ) } fn group_commits<'a>(&self, commits: &'a [CommitInfo]) -> HashMap> { let mut groups: HashMap> = HashMap::new(); - + for commit in commits { let commit_type = commit.commit_type().unwrap_or_else(|| "other".to_string()); groups.entry(commit_type).or_default().push(commit); } - + groups } } @@ -380,8 +383,7 @@ impl Default for ChangelogGenerator { /// Read existing changelog pub fn read_changelog(path: &Path) -> Result { - fs::read_to_string(path) - .with_context(|| format!("Failed to read changelog: {:?}", path)) + fs::read_to_string(path).with_context(|| format!("Failed to read changelog: {:?}", path)) } /// Initialize new changelog file @@ -389,10 +391,10 @@ pub fn init_changelog(path: &Path) -> Result<()> { if path.exists() { anyhow::bail!("Changelog already exists at {:?}", path); } - + fs::write(path, CHANGELOG_HEADER) .with_context(|| format!("Failed to create changelog: {:?}", path))?; - + Ok(()) } @@ -403,7 +405,7 @@ pub fn generate_from_history( to_ref: Option<&str>, ) -> Result> { let to_ref = to_ref.unwrap_or("HEAD"); - + if let Some(from) = from_tag { repo.get_commits_between(from, to_ref) } else { @@ -413,11 +415,7 @@ pub fn generate_from_history( } /// Update version links in changelog -pub fn update_version_links( - changelog: &str, - version: &str, - compare_url: &str, -) -> String { +pub fn update_version_links(changelog: &str, version: &str, compare_url: &str) -> String { // Add version link at the end of changelog format!("{}\n[{}]: {}\n", changelog, version, compare_url) } @@ -425,27 +423,29 @@ pub fn update_version_links( /// Parse changelog to extract versions pub fn parse_versions(changelog: &str) -> Vec<(String, String)> { let mut versions = vec![]; - + for line in changelog.lines() { if line.starts_with("## [") && let Some(start) = line.find('[') - && let Some(end) = line.find(']') { - let version = &line[start + 1..end]; - if version != "Unreleased" - && let Some(date_start) = line.find(" - ") { - let date = &line[date_start + 3..].trim(); - versions.push((version.to_string(), date.to_string())); - } - } + && let Some(end) = line.find(']') + { + let version = &line[start + 1..end]; + if version != "Unreleased" + && let Some(date_start) = line.find(" - ") + { + let date = &line[date_start + 3..].trim(); + versions.push((version.to_string(), date.to_string())); + } + } } - + versions } /// Get unreleased changes pub fn get_unreleased_changes(repo: &GitRepo) -> Result> { let tags = repo.get_tags()?; - + if let Some(latest_tag) = tags.first() { repo.get_commits_between(&latest_tag.name, "HEAD") } else { diff --git a/src/git/commit.rs b/src/git/commit.rs index 0d5bfc3..d1c159c 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -1,5 +1,5 @@ use super::GitRepo; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use chrono::Local; /// Commit builder for creating commits @@ -119,10 +119,14 @@ impl CommitBuilder { return Ok(msg.clone()); } - let commit_type = self.commit_type.as_ref() + let commit_type = self + .commit_type + .as_ref() .ok_or_else(|| anyhow::anyhow!("Commit type is required"))?; - - let description = self.description.as_ref() + + let description = self + .description + .as_ref() .ok_or_else(|| anyhow::anyhow!("Description is required"))?; let message = match self.format { @@ -166,45 +170,46 @@ impl CommitBuilder { fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> { use std::process::Command; - + let mut args = vec!["commit", "--amend"]; - + if self.no_verify { args.push("--no-verify"); } - + args.push("-m"); args.push(message); - + if self.sign { args.push("-S"); } - + let output = Command::new("git") .args(&args) .current_dir(repo.path()) .output()?; - + if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - + let error_msg = if stderr.is_empty() { if stdout.is_empty() { "GPG signing failed. Please check:\n\ 1. GPG signing key is configured (git config --get user.signingkey)\n\ 2. GPG agent is running\n\ - 3. You can sign commits manually (try: git commit --amend -S)".to_string() + 3. You can sign commits manually (try: git commit --amend -S)" + .to_string() } else { stdout.to_string() } } else { stderr.to_string() }; - + bail!("Failed to amend commit: {}", error_msg); } - + Ok(()) } } @@ -219,7 +224,7 @@ impl Default for CommitBuilder { pub fn create_date_commit_message(prefix: Option<&str>) -> String { let now = Local::now(); let date_str = now.format("%Y-%m-%d").to_string(); - + match prefix { Some(p) => format!("{}: {}", p, date_str), None => format!("chore: update {}", date_str), @@ -229,58 +234,65 @@ pub fn create_date_commit_message(prefix: Option<&str>) -> String { /// Commit type suggestions based on diff pub fn suggest_commit_type(diff: &str) -> Vec<&'static str> { let mut suggestions = vec![]; - + // Check for test files if diff.contains("test") || diff.contains("spec") || diff.contains("__tests__") { suggestions.push("test"); } - + // Check for documentation if diff.contains("README") || diff.contains(".md") || diff.contains("docs/") { suggestions.push("docs"); } - + // Check for configuration files - if diff.contains("config") || diff.contains(".json") || diff.contains(".yaml") || diff.contains(".toml") { + if diff.contains("config") + || diff.contains(".json") + || diff.contains(".yaml") + || diff.contains(".toml") + { suggestions.push("chore"); } - + // Check for dependencies - if diff.contains("Cargo.toml") || diff.contains("package.json") || diff.contains("requirements.txt") { + if diff.contains("Cargo.toml") + || diff.contains("package.json") + || diff.contains("requirements.txt") + { suggestions.push("build"); } - + // Check for CI if diff.contains(".github/") || diff.contains(".gitlab-") || diff.contains("Jenkinsfile") { suggestions.push("ci"); } - + // Default suggestions if suggestions.is_empty() { suggestions.extend(&["feat", "fix", "refactor"]); } - + suggestions } /// Parse existing commit message pub fn parse_commit_message(message: &str) -> ParsedCommit { let lines: Vec<&str> = message.lines().collect(); - + if lines.is_empty() { return ParsedCommit::default(); } - + let first_line = lines[0]; - + // Try to parse as conventional commit if let Some(colon_pos) = first_line.find(':') { let type_part = &first_line[..colon_pos]; let description = first_line[colon_pos + 1..].trim(); - + let breaking = type_part.ends_with('!'); let type_part = type_part.trim_end_matches('!'); - + let (commit_type, scope) = if let Some(open) = type_part.find('(') { if let Some(close) = type_part.find(')') { let t = &type_part[..open]; @@ -292,42 +304,51 @@ pub fn parse_commit_message(message: &str) -> ParsedCommit { } else { (Some(type_part.to_string()), None) }; - + // Extract body and footer let mut body_lines = vec![]; let mut footer_lines = vec![]; let mut in_footer = false; - + for line in &lines[1..] { if line.trim().is_empty() { continue; } - - if line.starts_with("BREAKING CHANGE:") || - line.starts_with("Closes") || - line.starts_with("Fixes") || - line.starts_with("Refs") || - line.starts_with("Co-authored-by:") { + + if line.starts_with("BREAKING CHANGE:") + || line.starts_with("Closes") + || line.starts_with("Fixes") + || line.starts_with("Refs") + || line.starts_with("Co-authored-by:") + { in_footer = true; } - + if in_footer { footer_lines.push(line.to_string()); } else { body_lines.push(line.to_string()); } } - + return ParsedCommit { commit_type, scope, description: Some(description.to_string()), - body: if body_lines.is_empty() { None } else { Some(body_lines.join("\n")) }, - footer: if footer_lines.is_empty() { None } else { Some(footer_lines.join("\n")) }, + body: if body_lines.is_empty() { + None + } else { + Some(body_lines.join("\n")) + }, + footer: if footer_lines.is_empty() { + None + } else { + Some(footer_lines.join("\n")) + }, breaking, }; } - + // Non-conventional commit ParsedCommit { description: Some(first_line.to_string()), @@ -351,7 +372,7 @@ impl ParsedCommit { pub fn to_message(&self, format: crate::config::CommitFormat) -> String { let commit_type = self.commit_type.as_deref().unwrap_or("chore"); let description = self.description.as_deref().unwrap_or("update"); - + match format { crate::config::CommitFormat::Conventional => { crate::utils::formatter::format_conventional_commit( diff --git a/src/git/mod.rs b/src/git/mod.rs index ec865f6..2646060 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -1,49 +1,49 @@ -use anyhow::{bail, Context, Result}; -use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType}; -use std::path::{Path, PathBuf, Component}; +use anyhow::{Context, Result, bail}; +use git2::{Config, ObjectType, Oid, Repository, Signature, StatusOptions}; use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; pub mod changelog; pub mod commit; pub mod tag; - fn normalize_path_for_git2(path: &Path) -> PathBuf { let mut normalized = path.to_path_buf(); - + #[cfg(target_os = "windows")] { let path_str = path.to_string_lossy(); if path_str.starts_with(r"\\?\") - && let Some(stripped) = path_str.strip_prefix(r"\\?\") { - normalized = PathBuf::from(stripped); - } + && let Some(stripped) = path_str.strip_prefix(r"\\?\") + { + normalized = PathBuf::from(stripped); + } if path_str.starts_with(r"\\?\UNC\") - && let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") { - normalized = PathBuf::from(format!(r"\\{}", stripped)); - } + && let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") + { + normalized = PathBuf::from(format!(r"\\{}", stripped)); + } } - + normalized } fn get_absolute_path>(path: P) -> Result { let path = path.as_ref(); - + if path.is_absolute() { return Ok(normalize_path_for_git2(path)); } - - let current_dir = std::env::current_dir() - .with_context(|| "Failed to get current directory")?; - + + let current_dir = std::env::current_dir().with_context(|| "Failed to get current directory")?; + let absolute = current_dir.join(path); Ok(normalize_path_for_git2(&absolute)) } fn resolve_path_without_canonicalize(path: &Path) -> PathBuf { let mut components = Vec::new(); - + for component in path.components() { match component { Component::ParentDir => { @@ -57,25 +57,25 @@ fn resolve_path_without_canonicalize(path: &Path) -> PathBuf { _ => components.push(component), } } - + let mut result = PathBuf::new(); for component in components { result.push(component.as_os_str()); } - + normalize_path_for_git2(&result) } fn try_open_repo_with_git2(path: &Path) -> Result { let normalized = normalize_path_for_git2(path); - + let discover_opts = git2::RepositoryOpenFlags::empty(); let ceiling_dirs: [&str; 0] = []; - + let repo = Repository::open_ext(&normalized, discover_opts, ceiling_dirs) .or_else(|_| Repository::discover(&normalized)) .or_else(|_| Repository::open(&normalized)); - + repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e)) } @@ -85,34 +85,34 @@ fn try_open_repo_with_git_cli(path: &Path) -> Result { .current_dir(path) .output() .context("Failed to execute git command")?; - + if !output.status.success() { bail!("git CLI failed to find repository"); } - + let stdout = String::from_utf8_lossy(&output.stdout); let git_root = stdout.trim(); - + if git_root.is_empty() { bail!("git CLI returned empty path"); } - + let git_root_path = PathBuf::from(git_root); let normalized = normalize_path_for_git2(&git_root_path); - + Repository::open(&normalized) .with_context(|| format!("Failed to open repo from git CLI path: {:?}", normalized)) } fn diagnose_repo_issue(path: &Path) -> String { let mut issues = Vec::new(); - + if !path.exists() { issues.push(format!("Path does not exist: {:?}", path)); } else if !path.is_dir() { issues.push(format!("Path is not a directory: {:?}", path)); } - + let git_dir = path.join(".git"); if git_dir.exists() { if git_dir.is_dir() { @@ -128,7 +128,7 @@ fn diagnose_repo_issue(path: &Path) -> String { } } else { issues.push("No .git found in current directory".to_string()); - + let mut current = path; let mut depth = 0; while let Some(parent) = current.parent() { @@ -144,7 +144,7 @@ fn diagnose_repo_issue(path: &Path) -> String { current = parent; } } - + #[cfg(target_os = "windows")] { let path_str = path.to_string_lossy(); @@ -155,11 +155,11 @@ fn diagnose_repo_issue(path: &Path) -> String { issues.push("WARNING: Path has mixed path separators".to_string()); } } - + if let Ok(current_dir) = std::env::current_dir() { issues.push(format!("Current working directory: {:?}", current_dir)); } - + issues.join("\n ") } @@ -172,17 +172,15 @@ pub struct GitRepo { impl GitRepo { pub fn open>(path: P) -> Result { let path = path.as_ref(); - + let absolute_path = get_absolute_path(path)?; let resolved_path = resolve_path_without_canonicalize(&absolute_path); - - let repo = try_open_repo_with_git2(&resolved_path) - .or_else(|git2_err| { - try_open_repo_with_git_cli(&resolved_path) - .map_err(|cli_err| { - let diagnosis = diagnose_repo_issue(&resolved_path); - anyhow::anyhow!( - "Failed to open git repository:\n\ + + let repo = try_open_repo_with_git2(&resolved_path).or_else(|git2_err| { + try_open_repo_with_git_cli(&resolved_path).map_err(|cli_err| { + let diagnosis = diagnose_repo_issue(&resolved_path); + anyhow::anyhow!( + "Failed to open git repository:\n\ \n\ === git2 Error ===\n {}\n\ \n\ @@ -195,17 +193,20 @@ impl GitRepo { 2. Run: git status (to verify git works)\n\ 3. Run: git config --global --add safe.directory \"*\"\n\ 4. Check file permissions", - git2_err, cli_err, diagnosis - ) - }) - })?; - - let repo_path = repo.workdir() + git2_err, + cli_err, + diagnosis + ) + }) + })?; + + let repo_path = repo + .workdir() .map(|p| p.to_path_buf()) .unwrap_or_else(|| resolved_path.clone()); - + let config = repo.config().ok(); - + Ok(Self { repo, path: normalize_path_for_git2(&repo_path), @@ -246,7 +247,11 @@ impl GitRepo { pub fn get_user_name(&self) -> Result { self.get_config("user.name")? .or_else(|| std::env::var("GIT_AUTHOR_NAME").ok()) - .ok_or_else(|| anyhow::anyhow!("User name not configured. Set it with: git config user.name \"Your Name\"")) + .ok_or_else(|| { + anyhow::anyhow!( + "User name not configured. Set it with: git config user.name \"Your Name\"" + ) + }) } /// Get the configured user email @@ -258,7 +263,8 @@ impl GitRepo { /// Get the configured GPG signing key pub fn get_signing_key(&self) -> Result> { - Ok(self.get_config("user.signingkey")? + Ok(self + .get_config("user.signingkey")? .or_else(|| std::env::var("GIT_SIGNING_KEY").ok())) } @@ -285,13 +291,9 @@ impl GitRepo { if let Some(program) = self.get_config("gpg.program")? { return Ok(program); } - - let default_gpg = if cfg!(windows) { - "gpg.exe" - } else { - "gpg" - }; - + + let default_gpg = if cfg!(windows) { "gpg.exe" } else { "gpg" }; + Ok(default_gpg.to_string()) } @@ -299,10 +301,13 @@ impl GitRepo { pub fn create_signature(&self) -> Result> { let name = self.get_user_name()?; let email = self.get_user_email()?; - let time = git2::Time::new(std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64, 0); + let time = git2::Time::new( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + 0, + ); Signature::new(&name, &email, &time).map_err(Into::into) } @@ -346,7 +351,7 @@ impl GitRepo { /// then lock files like Cargo.lock pub fn get_staged_diff_sorted(&self) -> Result { let diff = self.get_staged_diff()?; - + if diff.is_empty() { return Ok(diff); } @@ -382,9 +387,7 @@ impl GitRepo { }); // Combine sorted diffs - let sorted_diff: String = file_diffs.into_iter() - .map(|(_, diff)| diff) - .collect(); + let sorted_diff: String = file_diffs.into_iter().map(|(_, diff)| diff).collect(); Ok(sorted_diff) } @@ -412,20 +415,31 @@ fn extract_file_from_diff_line(line: &str) -> String { fn file_importance_score(filename: &str) -> i32 { // Priority list for important file types let important_extensions = [ - ".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".java", ".cpp", ".c", ".rust", - ".vue", ".svelte", ".html", ".css", ".scss", ".sass", ".less", + ".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".java", ".cpp", ".c", ".rust", ".vue", + ".svelte", ".html", ".css", ".scss", ".sass", ".less", ]; - + // Config files that are important but less than source code let config_files = [ - "Cargo.toml", "package.json", "go.mod", "go.sum", "pom.xml", - "Makefile", "CMakeLists.txt", "build.gradle", "gradle.properties", + "Cargo.toml", + "package.json", + "go.mod", + "go.sum", + "pom.xml", + "Makefile", + "CMakeLists.txt", + "build.gradle", + "gradle.properties", ]; - + // Lock files - lowest priority let lock_files = [ - "Cargo.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", - "Gemfile.lock", "composer.lock", + "Cargo.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "Gemfile.lock", + "composer.lock", ]; // Check lock files first (lowest priority) @@ -498,18 +512,22 @@ impl GitRepo { /// Get list of staged files pub fn get_staged_files(&self) -> Result> { - let statuses = self.repo.statuses(Some( - StatusOptions::new() - .include_untracked(false), - ))?; + let statuses = self + .repo + .statuses(Some(StatusOptions::new().include_untracked(false)))?; let mut files = vec![]; for entry in statuses.iter() { let status = entry.status(); - if (status.is_index_new() || status.is_index_modified() || status.is_index_deleted() || status.is_index_renamed() || status.is_index_typechange()) - && let Some(path) = entry.path() { - files.push(path.to_string()); - } + if (status.is_index_new() + || status.is_index_modified() + || status.is_index_deleted() + || status.is_index_renamed() + || status.is_index_typechange()) + && let Some(path) = entry.path() + { + files.push(path.to_string()); + } } Ok(files) @@ -629,20 +647,21 @@ impl GitRepo { if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - + let error_msg = if stderr.is_empty() { if stdout.is_empty() { "GPG signing failed. Please check:\n\ 1. GPG signing key is configured (git config --get user.signingkey)\n\ 2. GPG agent is running\n\ - 3. You can sign commits manually (try: git commit -S -m 'test')".to_string() + 3. You can sign commits manually (try: git commit -S -m 'test')" + .to_string() } else { stdout.to_string() } } else { stderr.to_string() }; - + bail!("Failed to create signed commit: {}", error_msg); } @@ -655,7 +674,8 @@ impl GitRepo { let head = self.repo.head()?; if head.is_branch() { - let name = head.shorthand() + let name = head + .shorthand() .ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?; Ok(name.to_string()) } else { @@ -666,7 +686,8 @@ impl GitRepo { /// Get current commit hash (short) pub fn current_commit_short(&self) -> Result { let head = self.repo.head()?; - let oid = head.target() + let oid = head + .target() .ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?; Ok(oid.to_string()[..8].to_string()) } @@ -674,7 +695,8 @@ impl GitRepo { /// Get current commit hash (full) pub fn current_commit(&self) -> Result { let head = self.repo.head()?; - let oid = head.target() + let oid = head + .target() .ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?; Ok(oid.to_string()) } @@ -773,13 +795,7 @@ impl GitRepo { if sign { self.create_signed_tag_with_git2(name, msg, &sig, target.id())?; } else { - self.repo.tag( - name, - target.as_object(), - &sig, - msg, - false, - )?; + self.repo.tag(name, target.as_object(), &sig, msg, false)?; } } else { self.repo.tag( @@ -795,7 +811,13 @@ impl GitRepo { } /// Create signed tag using git CLI - fn create_signed_tag_with_git2(&self, name: &str, message: &str, _signature: &Signature, _target_id: Oid) -> Result<()> { + fn create_signed_tag_with_git2( + &self, + name: &str, + message: &str, + _signature: &Signature, + _target_id: Oid, + ) -> Result<()> { let output = std::process::Command::new("git") .args(["tag", "-s", name, "-m", message]) .current_dir(&self.path) @@ -810,7 +832,12 @@ impl GitRepo { } /// Create GPG signature for arbitrary content - fn create_gpg_signature_for_content(&self, _content: &str, _gpg_program: &str, _signing_key: &str) -> Result { + fn create_gpg_signature_for_content( + &self, + _content: &str, + _gpg_program: &str, + _signing_key: &str, + ) -> Result { Ok(String::new()) } @@ -838,7 +865,8 @@ impl GitRepo { /// Get remote URL pub fn get_remote_url(&self, remote: &str) -> Result { let remote_obj = self.repo.find_remote(remote)?; - let url = remote_obj.url() + let url = remote_obj + .url() .ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?; Ok(url.to_string()) } @@ -889,9 +917,10 @@ impl GitRepo { } // Conflicted files (both columns are U or DD, AA, etc.) - if (index_status == 'U' || worktree_status == 'U') || - (index_status == 'A' && worktree_status == 'A') || - (index_status == 'D' && worktree_status == 'D') { + if (index_status == 'U' || worktree_status == 'U') + || (index_status == 'A' && worktree_status == 'A') + || (index_status == 'D' && worktree_status == 'D') + { conflicted += 1; } } @@ -982,49 +1011,51 @@ impl StatusSummary { pub fn find_repo>(start_path: P) -> Result { let start_path = start_path.as_ref(); - + let absolute_start = get_absolute_path(start_path)?; let resolved_start = resolve_path_without_canonicalize(&absolute_start); - + if let Ok(repo) = GitRepo::open(&resolved_start) { return Ok(repo); } - + let mut current = resolved_start.as_path(); let mut attempted_paths = vec![current.to_string_lossy().to_string()]; - + let max_depth = 50; let mut depth = 0; - + while let Some(parent) = current.parent() { depth += 1; if depth > max_depth { break; } - + attempted_paths.push(parent.to_string_lossy().to_string()); - + if let Ok(repo) = GitRepo::open(parent) { return Ok(repo); } current = parent; } - + if let Ok(output) = std::process::Command::new("git") .args(["rev-parse", "--show-toplevel"]) .current_dir(&resolved_start) .output() - && output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let git_root = stdout.trim(); - if !git_root.is_empty() - && let Ok(repo) = GitRepo::open(git_root) { - return Ok(repo); - } + && output.status.success() + { + let stdout = String::from_utf8_lossy(&output.stdout); + let git_root = stdout.trim(); + if !git_root.is_empty() + && let Ok(repo) = GitRepo::open(git_root) + { + return Ok(repo); } - + } + let diagnosis = diagnose_repo_issue(&resolved_start); - + bail!( "No git repository found.\n\ \n\ @@ -1238,7 +1269,10 @@ impl MergedUserConfig { } pub fn has_local_overrides(&self) -> bool { - self.name.is_local() || self.email.is_local() || self.signing_key.is_local() || self.ssh_command.is_local() + self.name.is_local() + || self.email.is_local() + || self.signing_key.is_local() + || self.ssh_command.is_local() } } @@ -1265,23 +1299,38 @@ impl UserConfig { diffs.push(ConfigDiff { key: "user.name".to_string(), left: self.name.clone().unwrap_or_else(|| "".to_string()), - right: other.name.clone().unwrap_or_else(|| "".to_string()), + right: other + .name + .clone() + .unwrap_or_else(|| "".to_string()), }); } if self.email != other.email { diffs.push(ConfigDiff { key: "user.email".to_string(), - left: self.email.clone().unwrap_or_else(|| "".to_string()), - right: other.email.clone().unwrap_or_else(|| "".to_string()), + left: self + .email + .clone() + .unwrap_or_else(|| "".to_string()), + right: other + .email + .clone() + .unwrap_or_else(|| "".to_string()), }); } if self.signing_key != other.signing_key { diffs.push(ConfigDiff { key: "user.signingkey".to_string(), - left: self.signing_key.clone().unwrap_or_else(|| "".to_string()), - right: other.signing_key.clone().unwrap_or_else(|| "".to_string()), + left: self + .signing_key + .clone() + .unwrap_or_else(|| "".to_string()), + right: other + .signing_key + .clone() + .unwrap_or_else(|| "".to_string()), }); } diff --git a/src/git/tag.rs b/src/git/tag.rs index 91dc5e2..5cff9b1 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -1,5 +1,5 @@ use super::GitRepo; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use semver::Version; /// Tag builder for creating tags @@ -69,19 +69,19 @@ impl TagBuilder { /// Build tag message pub fn build_message(&self) -> Result { - let message = self.message.as_ref() - .cloned() - .unwrap_or_else(|| { - let name = self.name.as_deref().unwrap_or("unknown"); - format!("Release {}", name) - }); - + let message = self.message.as_ref().cloned().unwrap_or_else(|| { + let name = self.name.as_deref().unwrap_or("unknown"); + format!("Release {}", name) + }); + Ok(message) } /// Execute tag creation pub fn execute(&self, repo: &GitRepo) -> Result<()> { - let name = self.name.as_ref() + let name = self + .name + .as_ref() .ok_or_else(|| anyhow::anyhow!("Tag name is required"))?; if !self.force { @@ -105,10 +105,10 @@ impl TagBuilder { /// Execute and push tag pub fn execute_and_push(&self, repo: &GitRepo, remote: &str) -> Result<()> { self.execute(repo)?; - + let name = self.name.as_ref().unwrap(); repo.push(remote, &format!("refs/tags/{}", name))?; - + Ok(()) } } @@ -136,7 +136,10 @@ impl VersionBump { "minor" => Ok(Self::Minor), "patch" => Ok(Self::Patch), "prerelease" | "pre" => Ok(Self::Prerelease), - _ => bail!("Invalid version bump: {}. Use: major, minor, patch, prerelease", s), + _ => bail!( + "Invalid version bump: {}. Use: major, minor, patch, prerelease", + s + ), } } @@ -149,7 +152,7 @@ impl VersionBump { /// Get latest version tag from repository pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result> { let tags = repo.get_tags()?; - + let mut versions: Vec = tags .iter() .filter_map(|t| { @@ -158,9 +161,9 @@ pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result VersionBump { let mut has_breaking = false; let mut has_feature = false; let mut has_fix = false; - + for commit in commits { let msg = commit.message.to_lowercase(); - - if msg.contains("breaking change") || msg.contains("breaking-change") || msg.contains("breaking_change") { + + if msg.contains("breaking change") + || msg.contains("breaking-change") + || msg.contains("breaking_change") + { has_breaking = true; } - + if let Some(commit_type) = commit.commit_type() { match commit_type.as_str() { "feat" => has_feature = true, @@ -199,7 +205,7 @@ pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump { } } } - + if has_breaking { VersionBump::Major } else if has_feature { @@ -214,20 +220,20 @@ pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump { /// Generate tag message from commits pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> String { let mut message = format!("Release {}\n\n", version); - + // Group commits by type let mut features = vec![]; let mut fixes = vec![]; let mut other = vec![]; let mut breaking = vec![]; - + for commit in commits { let subject = commit.subject(); - + if commit.message.contains("BREAKING CHANGE") { breaking.push(subject.to_string()); } - + if let Some(commit_type) = commit.commit_type() { match commit_type.as_str() { "feat" => features.push(subject.to_string()), @@ -238,7 +244,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str other.push(subject.to_string()); } } - + // Build message if !breaking.is_empty() { message.push_str("## Breaking Changes\n\n"); @@ -247,7 +253,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str } message.push('\n'); } - + if !features.is_empty() { message.push_str("## Features\n\n"); for item in &features { @@ -255,7 +261,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str } message.push('\n'); } - + if !fixes.is_empty() { message.push_str("## Bug Fixes\n\n"); for item in &fixes { @@ -263,36 +269,36 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str } message.push('\n'); } - + if !other.is_empty() { message.push_str("## Other Changes\n\n"); for item in &other { message.push_str(&format!("- {}\n", item)); } } - + message } /// Tag deletion helper pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()> { repo.delete_tag(name)?; - + if let Some(remote) = remote { use std::process::Command; - + let refspec = format!(":refs/tags/{}", name); let output = Command::new("git") .args(["push", remote, &refspec]) .current_dir(repo.path()) .output()?; - + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Failed to delete remote tag: {}", stderr); } } - + Ok(()) } @@ -303,7 +309,7 @@ pub fn list_tags( limit: Option, ) -> Result> { let tags = repo.get_tags()?; - + let filtered: Vec<_> = tags .into_iter() .filter(|t| { @@ -314,7 +320,7 @@ pub fn list_tags( } }) .collect(); - + if let Some(limit) = limit { Ok(filtered.into_iter().take(limit).collect()) } else { diff --git a/src/i18n/messages.rs b/src/i18n/messages.rs index 32d6b54..6aac085 100644 --- a/src/i18n/messages.rs +++ b/src/i18n/messages.rs @@ -267,7 +267,9 @@ impl Messages { Language::Chinese => "没有可提交的更改。工作树是干净的。", Language::Japanese => "コミットする変更がありません。作業ツリーはクリーンです。", Language::Korean => "커밋할 변경 사항이 없습니다. 작업 트리가 깨끗합니다.", - Language::Spanish => "No hay cambios para hacer commit. El árbol de trabajo está limpio.", + Language::Spanish => { + "No hay cambios para hacer commit. El árbol de trabajo está limpio." + } Language::French => "Aucun changement à commiter. L'arbre de travail est propre.", Language::German => "Keine Änderungen zum Committen. Arbeitsbaum ist sauber.", } @@ -289,11 +291,19 @@ impl Messages { match self.language { Language::English => "No files staged. Auto-staging all changes...", Language::Chinese => "没有暂存文件。自动暂存所有更改...", - Language::Japanese => "ステージされたファイルがありません。すべての変更を自動ステージ中...", + Language::Japanese => { + "ステージされたファイルがありません。すべての変更を自動ステージ中..." + } Language::Korean => "스테이징된 파일이 없습니다. 모든 변경 사항을 자동 스테이징 중...", - Language::Spanish => "No hay archivos preparados. Preparando automáticamente todos los cambios...", - Language::French => "Aucun fichier indexé. Indexation automatique de tous les changements...", - Language::German => "Keine Dateien bereitgestellt. Alle Änderungen werden automatisch bereitgestellt...", + Language::Spanish => { + "No hay archivos preparados. Preparando automáticamente todos los cambios..." + } + Language::French => { + "Aucun fichier indexé. Indexation automatique de tous les changements..." + } + Language::German => { + "Keine Dateien bereitgestellt. Alle Änderungen werden automatisch bereitgestellt..." + } } } @@ -359,12 +369,23 @@ impl Messages { pub fn ai_generating_tag(&self, count: usize) -> String { match self.language { - Language::English => format!("🤖 AI is generating tag message from {} commits...", count), + Language::English => { + format!("🤖 AI is generating tag message from {} commits...", count) + } Language::Chinese => format!("🤖 AI 正在从 {} 个提交生成标签消息...", count), - Language::Japanese => format!("🤖 AIが{}個のコミットからタグメッセージを生成しています...", count), + Language::Japanese => format!( + "🤖 AIが{}個のコミットからタグメッセージを生成しています...", + count + ), Language::Korean => format!("🤖 AI가 {}개의 커밋에서 태그 메시지를 생성 중...", count), - Language::Spanish => format!("🤖 La IA está generando mensaje de etiqueta desde {} commits...", count), - Language::French => format!("🤖 L'IA génère le message d'étiquette à partir de {} commits...", count), + Language::Spanish => format!( + "🤖 La IA está generando mensaje de etiqueta desde {} commits...", + count + ), + Language::French => format!( + "🤖 L'IA génère le message d'étiquette à partir de {} commits...", + count + ), Language::German => format!("🤖 KI generiert Tag-Nachricht aus {} Commits...", count), } } diff --git a/src/i18n/translator.rs b/src/i18n/translator.rs index ed1c097..c622e9f 100644 --- a/src/i18n/translator.rs +++ b/src/i18n/translator.rs @@ -7,7 +7,11 @@ pub struct Translator { } impl Translator { - pub fn new(language: Language, keep_types_english: bool, keep_changelog_types_english: bool) -> Self { + pub fn new( + language: Language, + keep_types_english: bool, + keep_changelog_types_english: bool, + ) -> Self { Self { language, keep_types_english, @@ -227,7 +231,11 @@ pub fn translate_commit_type(commit_type: &str, language: Language, keep_english translator.translate_commit_type(commit_type) } -pub fn translate_changelog_category(category: &str, language: Language, keep_english: bool) -> String { +pub fn translate_changelog_category( + category: &str, + language: Language, + keep_english: bool, +) -> String { let translator = Translator::new(language, true, keep_english); translator.translate_changelog_category(category) } diff --git a/src/llm/anthropic.rs b/src/llm/anthropic.rs index fc30352..ba5f358 100644 --- a/src/llm/anthropic.rs +++ b/src/llm/anthropic.rs @@ -1,6 +1,6 @@ use super::thinking::ThinkingStateManager; -use super::{create_http_client, LlmProvider}; -use anyhow::{bail, Context, Result}; +use super::{LlmProvider, create_http_client}; +use anyhow::{Context, Result, bail}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -522,13 +522,13 @@ impl AnthropicClient { } } "content_block_stop" => { - if in_thinking { - if let Some(state) = thinking_state { - state.end_thinking(); - } - in_thinking = false; - } - } + if in_thinking { + if let Some(state) = thinking_state { + state.end_thinking(); + } + in_thinking = false; + } + } _ => {} } } @@ -618,10 +618,7 @@ mod tests { let json = r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#; let event: SseEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.event_type, "content_block_start"); - assert_eq!( - event.content_block.unwrap().content_type, - "thinking" - ); + assert_eq!(event.content_block.unwrap().content_type, "thinking"); } #[test] diff --git a/src/llm/deepseek.rs b/src/llm/deepseek.rs index 082eb61..2ab1d73 100644 --- a/src/llm/deepseek.rs +++ b/src/llm/deepseek.rs @@ -1,6 +1,6 @@ use super::thinking::ThinkingStateManager; -use super::{create_http_client, LlmProvider}; -use anyhow::{bail, Context, Result}; +use super::{LlmProvider, create_http_client}; +use anyhow::{Context, Result, bail}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -459,34 +459,39 @@ impl DeepSeekClient { for choice in &chunk.choices { // 处理 reasoning_content if let Some(ref reasoning) = choice.delta.reasoning_content - && !reasoning.is_empty() { - if !has_reasoning { - has_reasoning = true; - if let Some(state) = thinking_state { - state.start_thinking(); - } + && !reasoning.is_empty() + { + if !has_reasoning { + has_reasoning = true; + if let Some(state) = thinking_state { + state.start_thinking(); } - // reasoning_content 不对外输出,仅用于内部状态判断 - continue; } + // reasoning_content 不对外输出,仅用于内部状态判断 + continue; + } // 处理 content if let Some(ref content) = choice.delta.content - && !content.is_empty() { - // reasoning 结束,content 开始出现时移除 thinking 标识 - if has_reasoning && !has_content - && let Some(state) = thinking_state { - state.end_thinking(); - } - has_content = true; - content_buffer.push_str(content); + && !content.is_empty() + { + // reasoning 结束,content 开始出现时移除 thinking 标识 + if has_reasoning + && !has_content + && let Some(state) = thinking_state + { + state.end_thinking(); } + has_content = true; + content_buffer.push_str(content); + } // 检查 finish_reason if let Some(ref reason) = choice.finish_reason - && reason == "stop" { - stream_ended = true; - } + && reason == "stop" + { + stream_ended = true; + } } } Err(_) => { @@ -612,9 +617,6 @@ mod tests { let json = r#"{"content":null,"reasoning_content":"Let me think..."}"#; let delta: StreamDelta = serde_json::from_str(json).unwrap(); assert!(delta.content.is_none()); - assert_eq!( - delta.reasoning_content, - Some("Let me think...".to_string()) - ); + assert_eq!(delta.reasoning_content, Some("Let me think...".to_string())); } } diff --git a/src/llm/kimi.rs b/src/llm/kimi.rs index fb46163..7339ceb 100644 --- a/src/llm/kimi.rs +++ b/src/llm/kimi.rs @@ -1,6 +1,6 @@ use super::thinking::ThinkingStateManager; -use super::{create_http_client, LlmProvider}; -use anyhow::{bail, Context, Result}; +use super::{LlmProvider, create_http_client}; +use anyhow::{Context, Result, bail}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -431,30 +431,35 @@ impl KimiClient { Ok(chunk) => { for choice in &chunk.choices { if let Some(ref reasoning) = choice.delta.reasoning_content - && !reasoning.is_empty() { - if !has_reasoning { - has_reasoning = true; - if let Some(state) = thinking_state { - state.start_thinking(); - } + && !reasoning.is_empty() + { + if !has_reasoning { + has_reasoning = true; + if let Some(state) = thinking_state { + state.start_thinking(); } - continue; } + continue; + } if let Some(ref content) = choice.delta.content - && !content.is_empty() { - if has_reasoning && !has_content - && let Some(state) = thinking_state { - state.end_thinking(); - } - has_content = true; - content_buffer.push_str(content); + && !content.is_empty() + { + if has_reasoning + && !has_content + && let Some(state) = thinking_state + { + state.end_thinking(); } + has_content = true; + content_buffer.push_str(content); + } if let Some(ref reason) = choice.finish_reason - && reason == "stop" { - stream_ended = true; - } + && reason == "stop" + { + stream_ended = true; + } } } Err(_) => { diff --git a/src/llm/mod.rs b/src/llm/mod.rs index bafbadd..17286cb 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -1,21 +1,21 @@ -use anyhow::{bail, Context, Result}; +use crate::config::Language; +use anyhow::{Context, Result, bail}; use async_trait::async_trait; use std::time::Duration; -use crate::config::Language; +pub mod anthropic; +pub mod deepseek; +pub mod kimi; pub mod ollama; pub mod openai; -pub mod anthropic; -pub mod kimi; -pub mod deepseek; pub mod openrouter; pub mod thinking; +pub use anthropic::AnthropicClient; +pub use deepseek::DeepSeekClient; +pub use kimi::KimiClient; pub use ollama::OllamaClient; pub use openai::OpenAiClient; -pub use anthropic::AnthropicClient; -pub use kimi::KimiClient; -pub use deepseek::DeepSeekClient; pub use openrouter::OpenRouterClient; /// LLM provider trait @@ -23,13 +23,13 @@ pub use openrouter::OpenRouterClient; pub trait LlmProvider: Send + Sync { /// Generate text from prompt async fn generate(&self, prompt: &str) -> Result; - + /// Generate with system prompt async fn generate_with_system(&self, system: &str, user: &str) -> Result; - + /// Check if provider is available async fn is_available(&self) -> bool; - + /// Get provider name fn name(&self) -> &str; } @@ -84,13 +84,14 @@ impl LlmClient { let api_key = manager.get_api_key(); let provider: Box = match provider { - "ollama" => { - Box::new(OllamaClient::new(&base_url, model) + "ollama" => Box::new( + OllamaClient::new(&base_url, model) .with_max_tokens(client_config.max_tokens) - .with_temperature(client_config.temperature)) - } + .with_temperature(client_config.temperature), + ), "openai" => { - let key = api_key.as_ref() + let key = api_key + .as_ref() .ok_or_else(|| anyhow::anyhow!("OpenAI API key not configured"))?; let thinking_state = if thinking_enabled { Some(thinking::create_console_thinking_state()) @@ -108,7 +109,8 @@ impl LlmClient { Box::new(client) } "anthropic" => { - let key = api_key.as_ref() + let key = api_key + .as_ref() .ok_or_else(|| anyhow::anyhow!("Anthropic API key not configured"))?; let thinking_state = if thinking_enabled { Some(thinking::create_console_thinking_state()) @@ -128,7 +130,8 @@ impl LlmClient { Box::new(client) } "kimi" => { - let key = api_key.as_ref() + let key = api_key + .as_ref() .ok_or_else(|| anyhow::anyhow!("Kimi API key not configured"))?; let thinking_state = if thinking_enabled { Some(thinking::create_console_thinking_state()) @@ -146,7 +149,8 @@ impl LlmClient { Box::new(client) } "deepseek" => { - let key = api_key.as_ref() + let key = api_key + .as_ref() .ok_or_else(|| anyhow::anyhow!("DeepSeek API key not configured"))?; let thinking_state = if thinking_enabled { Some(thinking::create_console_thinking_state()) @@ -164,12 +168,15 @@ impl LlmClient { Box::new(client) } "openrouter" => { - let key = api_key.as_ref() + let key = api_key + .as_ref() .ok_or_else(|| anyhow::anyhow!("OpenRouter API key not configured"))?; - Box::new(OpenRouterClient::with_base_url(key, model, &base_url)? - .with_max_tokens(client_config.max_tokens) - .with_temperature(client_config.temperature) - .with_timeout(client_config.timeout)?) + Box::new( + OpenRouterClient::with_base_url(key, model, &base_url)? + .with_max_tokens(client_config.max_tokens) + .with_temperature(client_config.temperature) + .with_timeout(client_config.timeout)?, + ) } _ => bail!("Unknown LLM provider: {}", provider), }; @@ -196,7 +203,7 @@ impl LlmClient { language: Language, ) -> Result { let system_prompt = get_commit_system_prompt(format, language); - + // Add language instruction to the prompt let language_instruction = match language { Language::Chinese => "\n\n请用中文生成提交消息。", @@ -207,10 +214,13 @@ impl LlmClient { Language::German => "\n\nBitte generieren Sie die Commit-Nachricht auf Deutsch.", Language::English => "", }; - + let prompt = format!("{}{}", diff, language_instruction); - let response = self.provider.generate_with_system(system_prompt, &prompt).await?; - + let response = self + .provider + .generate_with_system(system_prompt, &prompt) + .await?; + self.parse_commit_response(&response, format) } @@ -223,7 +233,7 @@ impl LlmClient { ) -> Result { let system_prompt = get_tag_system_prompt(language); let commits_text = commits.join("\n"); - + // Add language instruction to the prompt let language_instruction = match language { Language::Chinese => "\n\n请用中文生成标签消息。", @@ -234,10 +244,15 @@ impl LlmClient { Language::German => "\n\nBitte generieren Sie die Tag-Nachricht auf Deutsch.", Language::English => "", }; - - let prompt = format!("Version: {}\n\nCommits:\n{}{}", version, commits_text, language_instruction); - - self.provider.generate_with_system(system_prompt, &prompt).await + + let prompt = format!( + "Version: {}\n\nCommits:\n{}{}", + version, commits_text, language_instruction + ); + + self.provider + .generate_with_system(system_prompt, &prompt) + .await } /// Generate changelog entry @@ -248,13 +263,13 @@ impl LlmClient { language: Language, ) -> Result { let system_prompt = get_changelog_system_prompt(language); - + let commits_text = commits .iter() .map(|(t, m)| format!("- [{}] {}", t, m)) .collect::>() .join("\n"); - + // Add language instruction to the prompt let language_instruction = match language { Language::Chinese => "\n\n请用中文生成变更日志。", @@ -265,10 +280,15 @@ impl LlmClient { Language::German => "\n\nBitte generieren Sie das Changelog auf Deutsch.", Language::English => "", }; - - let prompt = format!("Version: {}\n\nCommits:\n{}{}", version, commits_text, language_instruction); - - self.provider.generate_with_system(system_prompt, &prompt).await + + let prompt = format!( + "Version: {}\n\nCommits:\n{}{}", + version, commits_text, language_instruction + ); + + self.provider + .generate_with_system(system_prompt, &prompt) + .await } /// Check if provider is available @@ -277,8 +297,16 @@ impl LlmClient { } /// Parse commit response from LLM - fn parse_commit_response(&self, response: &str, format: crate::config::CommitFormat) -> Result { - let lines: Vec<&str> = response.lines() + fn parse_commit_response( + &self, + response: &str, + format: crate::config::CommitFormat, + ) -> Result { + // Clean markdown code fences from the response + let cleaned = Self::strip_code_fences(response); + + let lines: Vec<&str> = cleaned + .lines() .map(|l| l.trim()) .filter(|l| !l.is_empty()) .collect(); @@ -295,28 +323,89 @@ impl LlmClient { ); } - let first_line = lines[0]; - + // Find the line most likely to be the commit subject + let first_line = Self::find_commit_subject_line(&lines, format); + // Parse based on format match format { crate::config::CommitFormat::Conventional => { - self.parse_conventional_commit(first_line, lines) + self.parse_conventional_commit(first_line, &lines, response) } crate::config::CommitFormat::Commitlint => { - self.parse_commitlint_commit(first_line, lines) + self.parse_commitlint_commit(first_line, &lines, response) } } } + /// Remove surrounding markdown code fences (```) from LLM output + fn strip_code_fences(response: &str) -> String { + let mut lines: Vec<&str> = response.lines().collect(); + + // Strip leading fence lines (``` or ```lang) + while lines.first().map_or(false, |l| l.trim().starts_with("```")) { + lines.remove(0); + } + + // Strip trailing fence lines + while lines.last().map_or(false, |l| l.trim() == "```") { + lines.pop(); + } + + lines.join("\n") + } + + /// Find the line that is most likely the commit subject among extracted lines + fn find_commit_subject_line<'a>( + lines: &[&'a str], + format: crate::config::CommitFormat, + ) -> &'a str { + let valid_types = crate::utils::validators::get_commit_types(matches!( + format, + crate::config::CommitFormat::Commitlint + )); + + // First pass: line starting with a known type that also has proper syntax + // (e.g. "type:", "type(scope):", "type!:") + for &line in lines { + let trimmed = line.trim(); + for &t in valid_types { + if let Some(rest) = trimmed.strip_prefix(t) { + if rest.starts_with(':') || rest.starts_with('(') || rest.starts_with("!:") { + return trimmed; + } + } + } + } + + // Second pass: any line containing a colon (generic "prefix: description") + for &line in lines { + if line.contains(':') { + return line.trim(); + } + } + + // Fallback: return the first line as-is + lines[0].trim() + } + fn parse_conventional_commit( &self, first_line: &str, - lines: Vec<&str>, + lines: &[&str], + raw_response: &str, ) -> Result { // Parse: type(scope)!: description let parts: Vec<&str> = first_line.splitn(2, ':').collect(); if parts.len() != 2 { - bail!("Invalid conventional commit format: missing colon"); + let preview: String = raw_response.chars().take(300).collect(); + bail!( + "Invalid conventional commit format: missing colon.\n\ + Parsed subject line: '{}'\n\ + Raw response preview: '{}'\n\ + Expected: [optional scope]: ", + first_line, + preview + ); } let type_part = parts[0]; @@ -339,7 +428,7 @@ impl LlmClient { }; // Extract body and footer - let (body, footer) = self.extract_body_footer(&lines); + let (body, footer) = self.extract_body_footer(lines); Ok(GeneratedCommit { commit_type, @@ -354,12 +443,21 @@ impl LlmClient { fn parse_commitlint_commit( &self, first_line: &str, - lines: Vec<&str>, + lines: &[&str], + raw_response: &str, ) -> Result { // Similar parsing but with commitlint rules let parts: Vec<&str> = first_line.splitn(2, ':').collect(); if parts.len() != 2 { - bail!("Invalid commit format: missing colon"); + let preview: String = raw_response.chars().take(300).collect(); + bail!( + "Invalid commit format: missing colon.\n\ + Parsed subject line: '{}'\n\ + Raw response preview: '{}'\n\ + Expected: [optional scope]: ", + first_line, + preview + ); } let type_part = parts[0]; @@ -405,8 +503,14 @@ impl LlmClient { } // Look for footer markers - let footer_markers = ["BREAKING CHANGE:", "Closes", "Fixes", "Refs", "Co-authored-by:"]; - + let footer_markers = [ + "BREAKING CHANGE:", + "Closes", + "Fixes", + "Refs", + "Co-authored-by:", + ]; + let mut body_lines = vec![]; let mut footer_lines = vec![]; let mut in_footer = false; @@ -415,7 +519,7 @@ impl LlmClient { if footer_markers.iter().any(|m| line.starts_with(m)) { in_footer = true; } - + if in_footer { footer_lines.push(*line); } else { @@ -485,17 +589,34 @@ pub(crate) fn create_http_client(timeout: Duration) -> Result { } /// Get commit system prompt based on format and language -fn get_commit_system_prompt(format: crate::config::CommitFormat, language: Language) -> &'static str { +fn get_commit_system_prompt( + format: crate::config::CommitFormat, + language: Language, +) -> &'static str { match (format, language) { - (crate::config::CommitFormat::Conventional, Language::Chinese) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ZH, - (crate::config::CommitFormat::Conventional, Language::Japanese) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_JA, - (crate::config::CommitFormat::Conventional, Language::Korean) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_KO, - (crate::config::CommitFormat::Conventional, Language::Spanish) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ES, - (crate::config::CommitFormat::Conventional, Language::French) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_FR, - (crate::config::CommitFormat::Conventional, Language::German) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_DE, + (crate::config::CommitFormat::Conventional, Language::Chinese) => { + CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ZH + } + (crate::config::CommitFormat::Conventional, Language::Japanese) => { + CONVENTIONAL_COMMIT_SYSTEM_PROMPT_JA + } + (crate::config::CommitFormat::Conventional, Language::Korean) => { + CONVENTIONAL_COMMIT_SYSTEM_PROMPT_KO + } + (crate::config::CommitFormat::Conventional, Language::Spanish) => { + CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ES + } + (crate::config::CommitFormat::Conventional, Language::French) => { + CONVENTIONAL_COMMIT_SYSTEM_PROMPT_FR + } + (crate::config::CommitFormat::Conventional, Language::German) => { + CONVENTIONAL_COMMIT_SYSTEM_PROMPT_DE + } (crate::config::CommitFormat::Conventional, _) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT, (crate::config::CommitFormat::Commitlint, Language::Chinese) => COMMITLINT_SYSTEM_PROMPT_ZH, - (crate::config::CommitFormat::Commitlint, Language::Japanese) => COMMITLINT_SYSTEM_PROMPT_JA, + (crate::config::CommitFormat::Commitlint, Language::Japanese) => { + COMMITLINT_SYSTEM_PROMPT_JA + } (crate::config::CommitFormat::Commitlint, Language::Korean) => COMMITLINT_SYSTEM_PROMPT_KO, (crate::config::CommitFormat::Commitlint, Language::Spanish) => COMMITLINT_SYSTEM_PROMPT_ES, (crate::config::CommitFormat::Commitlint, Language::French) => COMMITLINT_SYSTEM_PROMPT_FR, diff --git a/src/llm/ollama.rs b/src/llm/ollama.rs index 33540f8..f544ed7 100644 --- a/src/llm/ollama.rs +++ b/src/llm/ollama.rs @@ -1,4 +1,4 @@ -use super::{create_http_client, LlmProvider}; +use super::{LlmProvider, create_http_client}; use anyhow::{Context, Result}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -50,8 +50,8 @@ struct ModelInfo { impl OllamaClient { /// Create new Ollama client pub fn new(base_url: &str, model: &str) -> Self { - let client = create_http_client(Duration::from_secs(120)) - .expect("Failed to create HTTP client"); + let client = + create_http_client(Duration::from_secs(120)).expect("Failed to create HTTP client"); Self { base_url: base_url.trim_end_matches('/').to_string(), @@ -65,8 +65,7 @@ impl OllamaClient { /// Set timeout pub fn with_timeout(mut self, timeout: Duration) -> Self { - self.client = create_http_client(timeout) - .expect("Failed to create HTTP client"); + self.client = create_http_client(timeout).expect("Failed to create HTTP client"); self } @@ -88,49 +87,51 @@ impl OllamaClient { /// List available models pub async fn list_models(&self) -> Result> { let url = format!("{}/api/tags", self.base_url); - - let response = self.client + + let response = self + .client .get(&url) .send() .await .context("Failed to list Ollama models")?; - + if !response.status().is_success() { let status = response.status(); let text = response.text().await.unwrap_or_default(); anyhow::bail!("Ollama API error: {} - {}", status, text); } - + let result: ListModelsResponse = response .json() .await .context("Failed to parse Ollama response")?; - + Ok(result.models.into_iter().map(|m| m.name).collect()) } /// Pull a model pub async fn pull_model(&self, model: &str) -> Result<()> { let url = format!("{}/api/pull", self.base_url); - + let request = serde_json::json!({ "name": model, "stream": false, }); - - let response = self.client + + let response = self + .client .post(&url) .json(&request) .send() .await .context("Failed to pull Ollama model")?; - + if !response.status().is_success() { let status = response.status(); let text = response.text().await.unwrap_or_default(); anyhow::bail!("Ollama pull error: {} - {}", status, text); } - + Ok(()) } @@ -151,13 +152,13 @@ impl LlmProvider for OllamaClient { async fn generate_with_system(&self, system: &str, user: &str) -> Result { let url = format!("{}/api/generate", self.base_url); - + let system = if system.is_empty() { None } else { Some(system.to_string()) }; - + let request = GenerateRequest { model: self.model.clone(), prompt: user.to_string(), @@ -168,31 +169,32 @@ impl LlmProvider for OllamaClient { num_predict: Some(self.max_tokens), }, }; - - let response = self.client + + let response = self + .client .post(&url) .json(&request) .send() .await .context("Failed to send request to Ollama")?; - + if !response.status().is_success() { let status = response.status(); let text = response.text().await.unwrap_or_default(); anyhow::bail!("Ollama API error: {} - {}", status, text); } - + let result: GenerateResponse = response .json() .await .context("Failed to parse Ollama response")?; - + Ok(result.response.trim().to_string()) } async fn is_available(&self) -> bool { let url = format!("{}/api/tags", self.base_url); - + match self.client.get(&url).send().await { Ok(response) => response.status().is_success(), Err(_) => false, diff --git a/src/llm/openai.rs b/src/llm/openai.rs index 8785ab3..0a7df63 100644 --- a/src/llm/openai.rs +++ b/src/llm/openai.rs @@ -1,6 +1,6 @@ use super::thinking::ThinkingStateManager; -use super::{create_http_client, LlmProvider}; -use anyhow::{bail, Context, Result}; +use super::{LlmProvider, create_http_client}; +use anyhow::{Context, Result, bail}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -411,7 +411,8 @@ impl OpenAiClient { if let Some(ref content) = choice.delta.content && !content.is_empty() { - if has_reasoning && !has_content + if has_reasoning + && !has_content && let Some(state) = thinking_state { state.end_thinking(); @@ -465,12 +466,7 @@ pub struct AzureOpenAiClient { } impl AzureOpenAiClient { - pub fn new( - endpoint: &str, - api_key: &str, - deployment: &str, - api_version: &str, - ) -> Result { + pub fn new(endpoint: &str, api_key: &str, deployment: &str, api_version: &str) -> Result { let client = create_http_client(Duration::from_secs(60))?; Ok(Self { @@ -642,10 +638,7 @@ mod tests { let json = r#"{"content":null,"reasoning_content":"Let me think..."}"#; let delta: StreamDelta = serde_json::from_str(json).unwrap(); assert!(delta.content.is_none()); - assert_eq!( - delta.reasoning_content, - Some("Let me think...".to_string()) - ); + assert_eq!(delta.reasoning_content, Some("Let me think...".to_string())); } #[test] diff --git a/src/llm/openrouter.rs b/src/llm/openrouter.rs index d5bc665..475d6f3 100644 --- a/src/llm/openrouter.rs +++ b/src/llm/openrouter.rs @@ -1,281 +1,286 @@ -use super::{create_http_client, LlmProvider}; -use anyhow::{bail, Context, Result}; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::time::Duration; - -/// OpenRouter API client -pub struct OpenRouterClient { - base_url: String, - api_key: String, - model: String, - client: reqwest::Client, - max_tokens: u32, - temperature: f32, - top_p: Option, -} - -#[derive(Debug, Serialize)] -struct ChatCompletionRequest { - model: String, - messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - max_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - temperature: Option, - stream: bool, -} - -#[derive(Debug, Serialize, Deserialize)] -struct Message { - role: String, - content: String, -} - -#[derive(Debug, Deserialize)] -struct ChatCompletionResponse { - choices: Vec, -} - -#[derive(Debug, Deserialize)] -struct Choice { - message: Message, -} - -#[derive(Debug, Deserialize)] -struct ErrorResponse { - error: ApiError, -} - -#[derive(Debug, Deserialize)] -struct ApiError { - message: String, - #[serde(rename = "type")] - error_type: String, -} - -impl OpenRouterClient { - /// Create new OpenRouter client - pub fn new(api_key: &str, model: &str) -> Result { - let client = create_http_client(Duration::from_secs(60))?; - - Ok(Self { - base_url: "https://openrouter.ai/api/v1".to_string(), - api_key: api_key.to_string(), - model: model.to_string(), - client, - max_tokens: 500, - temperature: 0.7, - top_p: None, - }) - } - - /// Create with custom base URL - pub fn with_base_url(api_key: &str, model: &str, base_url: &str) -> Result { - let client = create_http_client(Duration::from_secs(60))?; - - Ok(Self { - base_url: base_url.trim_end_matches('/').to_string(), - api_key: api_key.to_string(), - model: model.to_string(), - client, - max_tokens: 500, - temperature: 0.7, - top_p: None, - }) - } - - /// Set timeout - pub fn with_timeout(mut self, timeout: Duration) -> Result { - self.client = create_http_client(timeout)?; - Ok(self) - } - - pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { - self.max_tokens = max_tokens; - self - } - - pub fn with_temperature(mut self, temperature: f32) -> Self { - self.temperature = temperature; - self - } - - pub fn with_top_p(mut self, top_p: f32) -> Self { - self.top_p = Some(top_p); - self - } - - /// List available models - pub async fn list_models(&self) -> Result> { - let url = format!("{}/models", self.base_url); - - let response = self.client - .get(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .header("HTTP-Referer", "https://quicommit.dev") - .header("X-Title", "QuiCommit") - .send() - .await - .context("Failed to list OpenRouter models")?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - bail!("OpenRouter API error: {} - {}", status, text); - } - - #[derive(Deserialize)] - struct ModelsResponse { - data: Vec, - } - - #[derive(Deserialize)] - struct Model { - id: String, - } - - let result: ModelsResponse = response - .json() - .await - .context("Failed to parse OpenRouter response")?; - - Ok(result.data.into_iter().map(|m| m.id).collect()) - } - - /// Validate API key - pub async fn validate_key(&self) -> Result { - match self.list_models().await { - Ok(_) => Ok(true), - Err(e) => { - let err_str = e.to_string(); - if err_str.contains("401") || err_str.contains("Unauthorized") { - Ok(false) - } else { - Err(e) - } - } - } - } -} - -#[async_trait] -impl LlmProvider for OpenRouterClient { - async fn generate(&self, prompt: &str) -> Result { - let messages = vec![ - Message { - role: "user".to_string(), - content: prompt.to_string(), - }, - ]; - - self.chat_completion(messages).await - } - - async fn generate_with_system(&self, system: &str, user: &str) -> Result { - let mut messages = vec![]; - - if !system.is_empty() { - messages.push(Message { - role: "system".to_string(), - content: system.to_string(), - }); - } - - messages.push(Message { - role: "user".to_string(), - content: user.to_string(), - }); - - self.chat_completion(messages).await - } - - async fn is_available(&self) -> bool { - self.validate_key().await.unwrap_or(false) - } - - fn name(&self) -> &str { - "openrouter" - } -} - -impl OpenRouterClient { - async fn chat_completion(&self, messages: Vec) -> Result { - let url = format!("{}/chat/completions", self.base_url); - - let request = ChatCompletionRequest { - model: self.model.clone(), - messages, - max_tokens: Some(self.max_tokens), - temperature: Some(self.temperature), - stream: false, - }; - - let response = self.client - .post(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .header("Content-Type", "application/json") - .header("HTTP-Referer", "https://quicommit.dev") - .header("X-Title", "QuiCommit") - .json(&request) - .send() - .await - .context("Failed to send request to OpenRouter")?; - - let status = response.status(); - - if !status.is_success() { - let text = response.text().await.unwrap_or_default(); - - // Try to parse error - if let Ok(error) = serde_json::from_str::(&text) { - bail!("OpenRouter API error: {} ({})", error.error.message, error.error.error_type); - } - - bail!("OpenRouter API error: {} - {}", status, text); - } - - let result: ChatCompletionResponse = response - .json() - .await - .context("Failed to parse OpenRouter response")?; - - result.choices - .into_iter() - .next() - .map(|c| c.message.content.trim().to_string()) - .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) - } -} - -/// Popular OpenRouter models -pub const OPENROUTER_MODELS: &[&str] = &[ - "openai/gpt-3.5-turbo", - "openai/gpt-4", - "openai/gpt-4-turbo", - "anthropic/claude-3-opus", - "anthropic/claude-3-sonnet", - "anthropic/claude-3-haiku", - "google/gemini-pro", - "meta-llama/llama-2-70b-chat", - "mistralai/mixtral-8x7b-instruct", - "01-ai/yi-34b-chat", -]; - -/// Check if a model name is valid -pub fn is_valid_model(_model: &str) -> bool { - // Since OpenRouter supports many models, we'll allow any model name - // but provide some popular ones as suggestions - true -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_model_validation() { - assert!(is_valid_model("openai/gpt-4")); - assert!(is_valid_model("custom/model")); - } -} \ No newline at end of file +use super::{LlmProvider, create_http_client}; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// OpenRouter API client +pub struct OpenRouterClient { + base_url: String, + api_key: String, + model: String, + client: reqwest::Client, + max_tokens: u32, + temperature: f32, + top_p: Option, +} + +#[derive(Debug, Serialize)] +struct ChatCompletionRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + stream: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatCompletionResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Choice { + message: Message, +} + +#[derive(Debug, Deserialize)] +struct ErrorResponse { + error: ApiError, +} + +#[derive(Debug, Deserialize)] +struct ApiError { + message: String, + #[serde(rename = "type")] + error_type: String, +} + +impl OpenRouterClient { + /// Create new OpenRouter client + pub fn new(api_key: &str, model: &str) -> Result { + let client = create_http_client(Duration::from_secs(60))?; + + Ok(Self { + base_url: "https://openrouter.ai/api/v1".to_string(), + api_key: api_key.to_string(), + model: model.to_string(), + client, + max_tokens: 500, + temperature: 0.7, + top_p: None, + }) + } + + /// Create with custom base URL + pub fn with_base_url(api_key: &str, model: &str, base_url: &str) -> Result { + let client = create_http_client(Duration::from_secs(60))?; + + Ok(Self { + base_url: base_url.trim_end_matches('/').to_string(), + api_key: api_key.to_string(), + model: model.to_string(), + client, + max_tokens: 500, + temperature: 0.7, + top_p: None, + }) + } + + /// Set timeout + pub fn with_timeout(mut self, timeout: Duration) -> Result { + self.client = create_http_client(timeout)?; + Ok(self) + } + + pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { + self.max_tokens = max_tokens; + self + } + + pub fn with_temperature(mut self, temperature: f32) -> Self { + self.temperature = temperature; + self + } + + pub fn with_top_p(mut self, top_p: f32) -> Self { + self.top_p = Some(top_p); + self + } + + /// List available models + pub async fn list_models(&self) -> Result> { + let url = format!("{}/models", self.base_url); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("HTTP-Referer", "https://quicommit.dev") + .header("X-Title", "QuiCommit") + .send() + .await + .context("Failed to list OpenRouter models")?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + bail!("OpenRouter API error: {} - {}", status, text); + } + + #[derive(Deserialize)] + struct ModelsResponse { + data: Vec, + } + + #[derive(Deserialize)] + struct Model { + id: String, + } + + let result: ModelsResponse = response + .json() + .await + .context("Failed to parse OpenRouter response")?; + + Ok(result.data.into_iter().map(|m| m.id).collect()) + } + + /// Validate API key + pub async fn validate_key(&self) -> Result { + match self.list_models().await { + Ok(_) => Ok(true), + Err(e) => { + let err_str = e.to_string(); + if err_str.contains("401") || err_str.contains("Unauthorized") { + Ok(false) + } else { + Err(e) + } + } + } + } +} + +#[async_trait] +impl LlmProvider for OpenRouterClient { + async fn generate(&self, prompt: &str) -> Result { + let messages = vec![Message { + role: "user".to_string(), + content: prompt.to_string(), + }]; + + self.chat_completion(messages).await + } + + async fn generate_with_system(&self, system: &str, user: &str) -> Result { + let mut messages = vec![]; + + if !system.is_empty() { + messages.push(Message { + role: "system".to_string(), + content: system.to_string(), + }); + } + + messages.push(Message { + role: "user".to_string(), + content: user.to_string(), + }); + + self.chat_completion(messages).await + } + + async fn is_available(&self) -> bool { + self.validate_key().await.unwrap_or(false) + } + + fn name(&self) -> &str { + "openrouter" + } +} + +impl OpenRouterClient { + async fn chat_completion(&self, messages: Vec) -> Result { + let url = format!("{}/chat/completions", self.base_url); + + let request = ChatCompletionRequest { + model: self.model.clone(), + messages, + max_tokens: Some(self.max_tokens), + temperature: Some(self.temperature), + stream: false, + }; + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .header("HTTP-Referer", "https://quicommit.dev") + .header("X-Title", "QuiCommit") + .json(&request) + .send() + .await + .context("Failed to send request to OpenRouter")?; + + let status = response.status(); + + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + + // Try to parse error + if let Ok(error) = serde_json::from_str::(&text) { + bail!( + "OpenRouter API error: {} ({})", + error.error.message, + error.error.error_type + ); + } + + bail!("OpenRouter API error: {} - {}", status, text); + } + + let result: ChatCompletionResponse = response + .json() + .await + .context("Failed to parse OpenRouter response")?; + + result + .choices + .into_iter() + .next() + .map(|c| c.message.content.trim().to_string()) + .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) + } +} + +/// Popular OpenRouter models +pub const OPENROUTER_MODELS: &[&str] = &[ + "openai/gpt-3.5-turbo", + "openai/gpt-4", + "openai/gpt-4-turbo", + "anthropic/claude-3-opus", + "anthropic/claude-3-sonnet", + "anthropic/claude-3-haiku", + "google/gemini-pro", + "meta-llama/llama-2-70b-chat", + "mistralai/mixtral-8x7b-instruct", + "01-ai/yi-34b-chat", +]; + +/// Check if a model name is valid +pub fn is_valid_model(_model: &str) -> bool { + // Since OpenRouter supports many models, we'll allow any model name + // but provide some popular ones as suggestions + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_model_validation() { + assert!(is_valid_model("openai/gpt-4")); + assert!(is_valid_model("custom/model")); + } +} diff --git a/src/llm/thinking.rs b/src/llm/thinking.rs index a0c43f5..861a916 100644 --- a/src/llm/thinking.rs +++ b/src/llm/thinking.rs @@ -1,5 +1,5 @@ -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// 统一的思考状态管理器,用于管理模型思考状态的显示与隐藏 pub struct ThinkingStateManager { @@ -115,10 +115,9 @@ mod tests { let events: Arc>> = Arc::new(Mutex::new(Vec::new())); let events_clone = events.clone(); - let manager = ThinkingStateManager::new() - .on_thinking_start(move || { - events_clone.lock().unwrap().push("start".to_string()); - }); + let manager = ThinkingStateManager::new().on_thinking_start(move || { + events_clone.lock().unwrap().push("start".to_string()); + }); let events_clone2 = events.clone(); let manager = manager.on_thinking_end(move || { diff --git a/src/main.rs b/src/main.rs index 45bf95b..9628f7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,12 +14,12 @@ mod llm; mod utils; use commands::{ - changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand, - init::InitCommand, profile::ProfileCommand, tag::TagCommand, + changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand, init::InitCommand, + profile::ProfileCommand, tag::TagCommand, }; /// QuiCommit - AI-powered Git assistant -/// +/// /// A powerful tool that helps you generate conventional commits, tags, and changelogs /// using AI (LLM APIs or local Ollama models). Manage multiple Git profiles for different /// work contexts seamlessly. @@ -83,7 +83,7 @@ async fn main() -> Result<()> { 2 => "debug", _ => "trace", }; - + tracing_subscriber::fmt() .with_env_filter(log_level) .with_target(false) diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 2b55eae..325921a 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -1,9 +1,9 @@ use aes_gcm::{ - aead::{Aead, KeyInit}, Aes256Gcm, Nonce, + aead::{Aead, KeyInit}, }; use anyhow::{Context, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use rand::Rng; use std::fs; use std::path::Path; @@ -18,63 +18,62 @@ pub fn encrypt(data: &[u8], password: &str) -> Result { rand::thread_rng().fill(&mut salt); let mut nonce_bytes = [0u8; NONCE_LEN]; rand::thread_rng().fill(&mut nonce_bytes); - + let key = derive_key(password, &salt)?; - let cipher = Aes256Gcm::new_from_slice(&key) - .context("Failed to create cipher")?; + let cipher = Aes256Gcm::new_from_slice(&key).context("Failed to create cipher")?; let nonce = Nonce::from_slice(&nonce_bytes); - + let encrypted = cipher .encrypt(nonce, data) .map_err(|e| anyhow::anyhow!("Encryption failed: {:?}", e))?; - + // Combine salt + nonce + encrypted data let mut result = Vec::with_capacity(SALT_LEN + NONCE_LEN + encrypted.len()); result.extend_from_slice(&salt); result.extend_from_slice(&nonce_bytes); result.extend_from_slice(&encrypted); - + Ok(BASE64.encode(&result)) } /// Decrypt data with password pub fn decrypt(encrypted_data: &str, password: &str) -> Result> { - let data = BASE64.decode(encrypted_data) + let data = BASE64 + .decode(encrypted_data) .context("Invalid base64 encoding")?; - + if data.len() < SALT_LEN + NONCE_LEN { anyhow::bail!("Invalid encrypted data format"); } - + let salt = &data[..SALT_LEN]; let nonce_bytes = &data[SALT_LEN..SALT_LEN + NONCE_LEN]; let encrypted = &data[SALT_LEN + NONCE_LEN..]; - + let key = derive_key(password, salt)?; - let cipher = Aes256Gcm::new_from_slice(&key) - .context("Failed to create cipher")?; + let cipher = Aes256Gcm::new_from_slice(&key).context("Failed to create cipher")?; let nonce = Nonce::from_slice(nonce_bytes); - + let decrypted = cipher .decrypt(nonce, encrypted) .map_err(|e| anyhow::anyhow!("Decryption failed: {:?}", e))?; - + Ok(decrypted) } /// Derive key from password using simple method fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> { - use sha2::{Sha256, Digest}; - + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); hasher.update(salt); hasher.update(password.as_bytes()); hasher.update(b"quicommit_key_derivation_v1"); - + let hash = hasher.finalize(); let mut key = [0u8; KEY_LEN]; key.copy_from_slice(&hash[..KEY_LEN]); - + Ok(key) } @@ -97,7 +96,7 @@ pub fn decrypt_from_file(path: &Path, password: &str) -> Result> { pub fn generate_token(length: usize) -> String { const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let mut rng = rand::thread_rng(); - + (0..length) .map(|_| { let idx = rng.gen_range(0..CHARSET.len()); @@ -122,10 +121,10 @@ mod tests { fn test_encrypt_decrypt() { let data = b"Hello, World!"; let password = "my_secret_password"; - + let encrypted = encrypt(data, password).unwrap(); let decrypted = decrypt(&encrypted, password).unwrap(); - + assert_eq!(data.to_vec(), decrypted); } @@ -133,7 +132,7 @@ mod tests { fn test_wrong_password() { let data = b"Hello, World!"; let encrypted = encrypt(data, "correct_password").unwrap(); - + assert!(decrypt(&encrypted, "wrong_password").is_err()); } } diff --git a/src/utils/editor.rs b/src/utils/editor.rs index bf25897..b622149 100644 --- a/src/utils/editor.rs +++ b/src/utils/editor.rs @@ -9,15 +9,12 @@ pub fn edit_content(initial_content: &str) -> Result { /// Edit file in user's default editor pub fn edit_file(path: &Path) -> Result { - let content = fs::read_to_string(path) - .unwrap_or_default(); - - let edited = edit::edit(&content) - .context("Failed to open editor")?; - - fs::write(path, &edited) - .with_context(|| format!("Failed to write file: {:?}", path))?; - + let content = fs::read_to_string(path).unwrap_or_default(); + + let edited = edit::edit(&content).context("Failed to open editor")?; + + fs::write(path, &edited).with_context(|| format!("Failed to write file: {:?}", path))?; + Ok(edited) } @@ -27,11 +24,10 @@ pub fn edit_temp(initial_content: &str, extension: &str) -> Result { .suffix(&format!(".{}", extension)) .tempfile() .context("Failed to create temp file")?; - + let path = temp_file.path(); - fs::write(path, initial_content) - .context("Failed to write temp file")?; - + fs::write(path, initial_content).context("Failed to write temp file")?; + edit_file(path) } @@ -65,7 +61,6 @@ pub fn get_editor() -> String { /// Check if editor is available pub fn check_editor() -> Result<()> { let editor = get_editor(); - which::which(&editor) - .with_context(|| format!("Editor '{}' not found in PATH", editor))?; + which::which(&editor).with_context(|| format!("Editor '{}' not found in PATH", editor))?; Ok(()) } diff --git a/src/utils/formatter.rs b/src/utils/formatter.rs index a8ba245..80d4869 100644 --- a/src/utils/formatter.rs +++ b/src/utils/formatter.rs @@ -10,7 +10,7 @@ pub fn format_conventional_commit( breaking: bool, ) -> String { let mut message = String::new(); - + message.push_str(commit_type); if let Some(s) = scope { message.push_str(&format!("({})", s)); @@ -19,15 +19,15 @@ pub fn format_conventional_commit( message.push('!'); } message.push_str(&format!(": {}", description)); - + if let Some(b) = body { message.push_str(&format!("\n\n{}", b)); } - + if let Some(f) = footer { message.push_str(&format!("\n\n{}", f)); } - + message } @@ -41,27 +41,27 @@ pub fn format_commitlint_commit( references: Option<&[&str]>, ) -> String { let mut message = String::new(); - + message.push_str(commit_type); if let Some(s) = scope { message.push_str(&format!("({})", s)); } message.push_str(&format!(": {}", subject)); - + if let Some(refs) = references { for reference in refs { message.push_str(&format!(" #{}", reference)); } } - + if let Some(b) = body { message.push_str(&format!("\n\n{}", b)); } - + if let Some(f) = footer { message.push_str(&format!("\n\n{}", f)); } - + message } @@ -73,7 +73,7 @@ pub fn wrap_text(text: &str, width: usize) -> String { /// Clean commit message (remove comments, extra whitespace) pub fn clean_message(message: &str) -> String { let comment_regex = Regex::new(r"^#.*$").unwrap(); - + message .lines() .filter(|line| !comment_regex.is_match(line.trim())) @@ -97,7 +97,7 @@ mod tests { Some("Closes #123"), false, ); - + assert!(msg.contains("feat(auth): add login functionality")); assert!(msg.contains("This adds OAuth2 login support.")); assert!(msg.contains("Closes #123")); @@ -113,7 +113,7 @@ mod tests { Some("BREAKING CHANGE: response format changed"), true, ); - + assert!(msg.starts_with("feat!: change API response format")); } } diff --git a/src/utils/keyring.rs b/src/utils/keyring.rs index d7c849a..63926a6 100644 --- a/src/utils/keyring.rs +++ b/src/utils/keyring.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use std::env; const SERVICE_NAME: &str = "quicommit"; @@ -78,7 +78,8 @@ impl KeyringManager { let entry = keyring::Entry::new(SERVICE_NAME, provider) .context("Failed to create keyring entry")?; - entry.set_password(api_key) + entry + .set_password(api_key) .context("Failed to store API key")?; Ok(()) @@ -86,9 +87,10 @@ impl KeyringManager { pub fn get_api_key(&self, provider: &str) -> Result> { if let Ok(key) = env::var(ENV_API_KEY) - && !key.is_empty() { - return Ok(Some(key)); - } + && !key.is_empty() + { + return Ok(Some(key)); + } if !self.is_available() { return Ok(None); @@ -112,7 +114,8 @@ impl KeyringManager { let entry = keyring::Entry::new(SERVICE_NAME, provider) .context("Failed to create keyring entry")?; - entry.delete_credential() + entry + .delete_credential() .context("Failed to delete API key")?; Ok(()) @@ -126,7 +129,13 @@ impl KeyringManager { format!("{}/{}", PAT_SERVICE_PREFIX, profile_name) } - pub fn store_pat(&self, profile_name: &str, user_email: &str, service: &str, token: &str) -> Result<()> { + pub fn store_pat( + &self, + profile_name: &str, + user_email: &str, + service: &str, + token: &str, + ) -> Result<()> { if !self.is_available() { bail!("Keyring is not available on this system"); } @@ -137,15 +146,24 @@ impl KeyringManager { let entry = keyring::Entry::new(&keyring_service, &keyring_user) .context("Failed to create keyring entry for PAT")?; - entry.set_password(token) + entry + .set_password(token) .context("Failed to store PAT in keyring")?; - eprintln!("[DEBUG] PAT stored in keyring: service={}, user={}", keyring_service, keyring_user); + eprintln!( + "[DEBUG] PAT stored in keyring: service={}, user={}", + keyring_service, keyring_user + ); Ok(()) } - pub fn get_pat(&self, profile_name: &str, user_email: &str, service: &str) -> Result> { + pub fn get_pat( + &self, + profile_name: &str, + user_email: &str, + service: &str, + ) -> Result> { if !self.is_available() { return Ok(None); } @@ -158,11 +176,17 @@ impl KeyringManager { match entry.get_password() { Ok(token) => { - eprintln!("[DEBUG] PAT retrieved from keyring: service={}, user={}", keyring_service, keyring_user); + eprintln!( + "[DEBUG] PAT retrieved from keyring: service={}, user={}", + keyring_service, keyring_user + ); Ok(Some(token)) } Err(keyring::Error::NoEntry) => { - eprintln!("[DEBUG] PAT not found in keyring: service={}, user={}", keyring_service, keyring_user); + eprintln!( + "[DEBUG] PAT not found in keyring: service={}, user={}", + keyring_service, keyring_user + ); Ok(None) } Err(e) => Err(e.into()), @@ -180,22 +204,36 @@ impl KeyringManager { let entry = keyring::Entry::new(&keyring_service, &keyring_user) .context("Failed to create keyring entry for PAT")?; - entry.delete_credential() + entry + .delete_credential() .context("Failed to delete PAT from keyring")?; - eprintln!("[DEBUG] PAT deleted from keyring: service={}, user={}", keyring_service, keyring_user); + eprintln!( + "[DEBUG] PAT deleted from keyring: service={}, user={}", + keyring_service, keyring_user + ); Ok(()) } pub fn has_pat(&self, profile_name: &str, user_email: &str, service: &str) -> bool { - self.get_pat(profile_name, user_email, service).unwrap_or(None).is_some() + self.get_pat(profile_name, user_email, service) + .unwrap_or(None) + .is_some() } - pub fn delete_all_pats_for_profile(&self, profile_name: &str, user_email: &str, services: &[String]) -> Result<()> { + pub fn delete_all_pats_for_profile( + &self, + profile_name: &str, + user_email: &str, + services: &[String], + ) -> Result<()> { for service in services { if let Err(e) = self.delete_pat(profile_name, user_email, service) { - eprintln!("[DEBUG] Failed to delete PAT for service '{}': {}", service, e); + eprintln!( + "[DEBUG] Failed to delete PAT for service '{}': {}", + service, e + ); } } Ok(()) @@ -259,7 +297,14 @@ pub fn get_default_model(provider: &str) -> &'static str { } pub fn get_supported_providers() -> &'static [&'static str] { - &["ollama", "openai", "anthropic", "kimi", "deepseek", "openrouter"] + &[ + "ollama", + "openai", + "anthropic", + "kimi", + "deepseek", + "openrouter", + ] } pub fn provider_needs_api_key(provider: &str) -> bool { @@ -273,10 +318,19 @@ mod tests { #[test] fn test_get_default_base_url() { assert_eq!(get_default_base_url("openai"), "https://api.openai.com/v1"); - assert_eq!(get_default_base_url("anthropic"), "https://api.anthropic.com/v1"); + assert_eq!( + get_default_base_url("anthropic"), + "https://api.anthropic.com/v1" + ); assert_eq!(get_default_base_url("kimi"), "https://api.moonshot.cn/v1"); - assert_eq!(get_default_base_url("deepseek"), "https://api.deepseek.com/v1"); - assert_eq!(get_default_base_url("openrouter"), "https://openrouter.ai/api/v1"); + assert_eq!( + get_default_base_url("deepseek"), + "https://api.deepseek.com/v1" + ); + assert_eq!( + get_default_base_url("openrouter"), + "https://openrouter.ai/api/v1" + ); assert_eq!(get_default_base_url("ollama"), "http://localhost:11434"); } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9fc3e03..6e14760 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -32,10 +32,10 @@ pub fn print_info(msg: &str) { pub fn confirm(prompt: &str) -> Result { print!("{} [y/N] ", prompt); io::stdout().flush()?; - + let mut input = String::new(); io::stdin().read_line(&mut input)?; - + Ok(input.trim().to_lowercase().starts_with('y')) } @@ -43,17 +43,17 @@ pub fn confirm(prompt: &str) -> Result { pub fn input(prompt: &str) -> Result { print!("{}: ", prompt); io::stdout().flush()?; - + let mut input = String::new(); io::stdin().read_line(&mut input)?; - + Ok(input.trim().to_string()) } /// Get password input (hidden) pub fn password_input(prompt: &str) -> Result { use dialoguer::Password; - + Password::new() .with_prompt(prompt) .interact() diff --git a/src/utils/validators.rs b/src/utils/validators.rs index 75e3dba..821143f 100644 --- a/src/utils/validators.rs +++ b/src/utils/validators.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use lazy_static::lazy_static; use regex::Regex; @@ -67,7 +67,7 @@ lazy_static! { /// Validate conventional commit message pub fn validate_conventional_commit(message: &str) -> Result<()> { let first_line = message.lines().next().unwrap_or(""); - + if !CONVENTIONAL_COMMIT_REGEX.is_match(first_line) { bail!( "Invalid conventional commit format. Expected: [optional scope]: \n\ @@ -75,32 +75,32 @@ pub fn validate_conventional_commit(message: &str) -> Result<()> { CONVENTIONAL_TYPES.join(", ") ); } - + if first_line.len() > 100 { bail!("Commit subject too long (max 100 characters)"); } - + Ok(()) } /// Validate @commitlint commit message pub fn validate_commitlint_commit(message: &str) -> Result<()> { let first_line = message.lines().next().unwrap_or(""); - + let parts: Vec<&str> = first_line.splitn(2, ':').collect(); if parts.len() != 2 { bail!("Invalid commit format. Expected: [optional scope]: "); } - + let type_part = parts[0]; let subject = parts[1].trim(); - + let commit_type = type_part .split('(') .next() .unwrap_or("") .trim_end_matches('!'); - + if !COMMITLINT_TYPES.contains(&commit_type) { bail!( "Invalid commit type: '{}'. Valid types: {}", @@ -108,27 +108,32 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> { COMMITLINT_TYPES.join(", ") ); } - + if subject.is_empty() { bail!("Commit subject cannot be empty"); } - + if subject.len() < 4 { bail!("Commit subject too short (min 4 characters)"); } - + if subject.len() > 100 { bail!("Commit subject too long (max 100 characters)"); } - - if subject.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) { + + if subject + .chars() + .next() + .map(|c| c.is_uppercase()) + .unwrap_or(false) + { bail!("Commit subject should not start with uppercase letter"); } - + if subject.ends_with('.') { bail!("Commit subject should not end with a period"); } - + Ok(()) } @@ -137,25 +142,25 @@ pub fn validate_scope(scope: &str) -> Result<()> { if scope.is_empty() { bail!("Scope cannot be empty"); } - + if !SCOPE_REGEX.is_match(scope) { bail!("Invalid scope format. Use lowercase letters, numbers, and hyphens only"); } - + Ok(()) } /// Validate semantic version tag pub fn validate_semver(version: &str) -> Result<()> { let version = version.trim_start_matches('v'); - + if !SEMVER_REGEX.is_match(version) { bail!( "Invalid semantic version format. Expected: MAJOR.MINOR.PATCH[-prerelease][+build]\n\ Examples: 1.0.0, 1.2.3-beta, v2.0.0+build123" ); } - + Ok(()) } @@ -164,7 +169,7 @@ pub fn validate_email(email: &str) -> Result<()> { if !EMAIL_REGEX.is_match(email) { bail!("Invalid email address format"); } - + Ok(()) } @@ -173,7 +178,7 @@ pub fn validate_gpg_key_id(key_id: &str) -> Result<()> { if !GPG_KEY_ID_REGEX.is_match(key_id) { bail!("Invalid GPG key ID format. Expected 16-40 hexadecimal characters"); } - + Ok(()) } @@ -182,15 +187,18 @@ pub fn validate_profile_name(name: &str) -> Result<()> { if name.is_empty() { bail!("Profile name cannot be empty"); } - + if name.len() > 50 { bail!("Profile name too long (max 50 characters)"); } - - if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { bail!("Profile name can only contain letters, numbers, hyphens, and underscores"); } - + Ok(()) } @@ -201,7 +209,7 @@ pub fn is_valid_commit_type(commit_type: &str, use_commitlint: bool) -> bool { } else { CONVENTIONAL_TYPES }; - + types.contains(&commit_type) } diff --git a/tests/config_export_import_tests.rs b/tests/config_export_import_tests.rs index 3d35ac4..3a306a4 100644 --- a/tests/config_export_import_tests.rs +++ b/tests/config_export_import_tests.rs @@ -20,7 +20,12 @@ mod config_export { init_quicommit(&config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["config", "export", "--config", config_path.to_str().unwrap()]); + cmd.args(&[ + "config", + "export", + "--config", + config_path.to_str().unwrap(), + ]); cmd.assert() .success() @@ -37,10 +42,14 @@ mod config_export { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "export", - "--config", config_path.to_str().unwrap(), - "--output", export_path.to_str().unwrap(), - "--password", "" + "config", + "export", + "--config", + config_path.to_str().unwrap(), + "--output", + export_path.to_str().unwrap(), + "--password", + "", ]); cmd.assert() @@ -48,10 +57,13 @@ mod config_export { .stdout(predicate::str::contains("Configuration exported")); assert!(export_path.exists(), "Export file should be created"); - + let content = fs::read_to_string(&export_path).unwrap(); assert!(content.contains("version"), "Export should contain version"); - assert!(content.contains("[llm]"), "Export should contain LLM config"); + assert!( + content.contains("[llm]"), + "Export should contain LLM config" + ); } #[test] @@ -63,10 +75,14 @@ mod config_export { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "export", - "--config", config_path.to_str().unwrap(), - "--output", export_path.to_str().unwrap(), - "--password", "test_password_123" + "config", + "export", + "--config", + config_path.to_str().unwrap(), + "--output", + export_path.to_str().unwrap(), + "--password", + "test_password_123", ]); cmd.assert() @@ -74,10 +90,16 @@ mod config_export { .stdout(predicate::str::contains("encrypted and exported")); assert!(export_path.exists(), "Export file should be created"); - + let content = fs::read_to_string(&export_path).unwrap(); - assert!(content.starts_with("ENCRYPTED:"), "Encrypted file should start with ENCRYPTED:"); - assert!(!content.contains("[llm]"), "Encrypted content should not be readable"); + assert!( + content.starts_with("ENCRYPTED:"), + "Encrypted file should start with ENCRYPTED:" + ); + assert!( + !content.contains("[llm]"), + "Encrypted content should not be readable" + ); } } @@ -89,7 +111,7 @@ mod config_import { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let import_path = temp_dir.path().join("import.toml"); - + let plain_config = r#" version = "1" @@ -139,9 +161,12 @@ keep_changelog_types_english = true let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "import", - "--config", config_path.to_str().unwrap(), - "--file", import_path.to_str().unwrap() + "config", + "import", + "--config", + config_path.to_str().unwrap(), + "--file", + import_path.to_str().unwrap(), ]); cmd.assert() @@ -149,7 +174,13 @@ keep_changelog_types_english = true .stdout(predicate::str::contains("Configuration imported")); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["config", "get", "llm.provider", "--config", config_path.to_str().unwrap()]); + cmd.args(&[ + "config", + "get", + "llm.provider", + "--config", + config_path.to_str().unwrap(), + ]); cmd.assert() .success() .stdout(predicate::str::contains("openai")); @@ -161,38 +192,56 @@ keep_changelog_types_english = true let config_path1 = temp_dir.path().join("config1.toml"); let config_path2 = temp_dir.path().join("config2.toml"); let export_path = temp_dir.path().join("encrypted.toml"); - + init_quicommit(&config_path1); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "set", "llm.provider", "anthropic", - "--config", config_path1.to_str().unwrap() + "config", + "set", + "llm.provider", + "anthropic", + "--config", + config_path1.to_str().unwrap(), ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "export", - "--config", config_path1.to_str().unwrap(), - "--output", export_path.to_str().unwrap(), - "--password", "secure_password" + "config", + "export", + "--config", + config_path1.to_str().unwrap(), + "--output", + export_path.to_str().unwrap(), + "--password", + "secure_password", ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "import", - "--config", config_path2.to_str().unwrap(), - "--file", export_path.to_str().unwrap(), - "--password", "secure_password" + "config", + "import", + "--config", + config_path2.to_str().unwrap(), + "--file", + export_path.to_str().unwrap(), + "--password", + "secure_password", ]); cmd.assert() .success() .stdout(predicate::str::contains("Configuration imported")); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["config", "get", "llm.provider", "--config", config_path2.to_str().unwrap()]); + cmd.args(&[ + "config", + "get", + "llm.provider", + "--config", + config_path2.to_str().unwrap(), + ]); cmd.assert() .success() .stdout(predicate::str::contains("anthropic")); @@ -203,24 +252,32 @@ keep_changelog_types_english = true let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let export_path = temp_dir.path().join("encrypted.toml"); - + init_quicommit(&config_path); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "export", - "--config", config_path.to_str().unwrap(), - "--output", export_path.to_str().unwrap(), - "--password", "correct_password" + "config", + "export", + "--config", + config_path.to_str().unwrap(), + "--output", + export_path.to_str().unwrap(), + "--password", + "correct_password", ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "import", - "--config", config_path.to_str().unwrap(), - "--file", export_path.to_str().unwrap(), - "--password", "wrong_password" + "config", + "import", + "--config", + config_path.to_str().unwrap(), + "--file", + export_path.to_str().unwrap(), + "--password", + "wrong_password", ]); cmd.assert() .failure() @@ -237,35 +294,52 @@ mod config_export_import_roundtrip { let config_path1 = temp_dir.path().join("config1.toml"); let config_path2 = temp_dir.path().join("config2.toml"); let export_path = temp_dir.path().join("export.toml"); - + init_quicommit(&config_path1); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "set", "llm.model", "gpt-4-turbo", - "--config", config_path1.to_str().unwrap() + "config", + "set", + "llm.model", + "gpt-4-turbo", + "--config", + config_path1.to_str().unwrap(), ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "export", - "--config", config_path1.to_str().unwrap(), - "--output", export_path.to_str().unwrap(), - "--password", "" + "config", + "export", + "--config", + config_path1.to_str().unwrap(), + "--output", + export_path.to_str().unwrap(), + "--password", + "", ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "import", - "--config", config_path2.to_str().unwrap(), - "--file", export_path.to_str().unwrap() + "config", + "import", + "--config", + config_path2.to_str().unwrap(), + "--file", + export_path.to_str().unwrap(), ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["config", "get", "llm.model", "--config", config_path2.to_str().unwrap()]); + cmd.args(&[ + "config", + "get", + "llm.model", + "--config", + config_path2.to_str().unwrap(), + ]); cmd.assert() .success() .stdout(predicate::str::contains("gpt-4-turbo")); @@ -278,29 +352,41 @@ mod config_export_import_roundtrip { let config_path2 = temp_dir.path().join("config2.toml"); let export_path = temp_dir.path().join("encrypted.toml"); let password = "my_secure_password_123"; - + init_quicommit(&config_path1); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "set", "llm.provider", "deepseek", - "--config", config_path1.to_str().unwrap() + "config", + "set", + "llm.provider", + "deepseek", + "--config", + config_path1.to_str().unwrap(), ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "set", "llm.model", "deepseek-chat", - "--config", config_path1.to_str().unwrap() + "config", + "set", + "llm.model", + "deepseek-chat", + "--config", + config_path1.to_str().unwrap(), ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "export", - "--config", config_path1.to_str().unwrap(), - "--output", export_path.to_str().unwrap(), - "--password", password + "config", + "export", + "--config", + config_path1.to_str().unwrap(), + "--output", + export_path.to_str().unwrap(), + "--password", + password, ]); cmd.assert().success(); @@ -310,21 +396,37 @@ mod config_export_import_roundtrip { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(&[ - "config", "import", - "--config", config_path2.to_str().unwrap(), - "--file", export_path.to_str().unwrap(), - "--password", password + "config", + "import", + "--config", + config_path2.to_str().unwrap(), + "--file", + export_path.to_str().unwrap(), + "--password", + password, ]); cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["config", "get", "llm.provider", "--config", config_path2.to_str().unwrap()]); + cmd.args(&[ + "config", + "get", + "llm.provider", + "--config", + config_path2.to_str().unwrap(), + ]); cmd.assert() .success() .stdout(predicate::str::contains("deepseek")); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["config", "get", "llm.model", "--config", config_path2.to_str().unwrap()]); + cmd.args(&[ + "config", + "get", + "llm.model", + "--config", + config_path2.to_str().unwrap(), + ]); cmd.assert() .success() .stdout(predicate::str::contains("deepseek-chat")); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index c13713c..ad9e5fe 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -107,8 +107,14 @@ mod cli_basic { configure_git_user(&repo_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["-vv", "init", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "-vv", + "init", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert().success(); } @@ -169,7 +175,13 @@ mod init_command { cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["init", "--yes", "--reset", "--config", config_path.to_str().unwrap()]); + cmd.args(&[ + "init", + "--yes", + "--reset", + "--config", + config_path.to_str().unwrap(), + ]); cmd.assert() .success() .stdout(predicate::str::contains("initialized successfully")); @@ -261,8 +273,14 @@ mod commit_command { cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(temp_dir.path()); + cmd.args(&[ + "commit", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(temp_dir.path()); cmd.assert() .failure() @@ -279,8 +297,17 @@ mod commit_command { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--manual", "-m", "test: empty", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "commit", + "--manual", + "-m", + "test: empty", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert() .success() @@ -297,8 +324,17 @@ mod commit_command { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--manual", "-m", "test: add test file", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "commit", + "--manual", + "-m", + "test: add test file", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert() .success() @@ -315,8 +351,15 @@ mod commit_command { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--date", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "commit", + "--date", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert() .success() @@ -333,8 +376,18 @@ mod commit_command { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--think", "--manual", "-m", "test: think flag", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "commit", + "--think", + "--manual", + "-m", + "test: think flag", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert().success(); } @@ -353,8 +406,14 @@ mod tag_command { cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["tag", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(temp_dir.path()); + cmd.args(&[ + "tag", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(temp_dir.path()); cmd.assert() .failure() @@ -375,8 +434,16 @@ mod tag_command { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["tag", "--name", "v0.1.0", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "tag", + "--name", + "v0.1.0", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert() .success() @@ -397,8 +464,17 @@ mod tag_command { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["tag", "--think", "--name", "v0.2.0", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "tag", + "--think", + "--name", + "v0.2.0", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert().success(); } @@ -419,8 +495,15 @@ mod changelog_command { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["changelog", "--init", "--output", changelog_path.to_str().unwrap(), "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "changelog", + "--init", + "--output", + changelog_path.to_str().unwrap(), + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert().success(); @@ -441,11 +524,16 @@ mod changelog_command { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["changelog", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "changelog", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); - cmd.assert() - .success(); + cmd.assert().success(); } } @@ -560,8 +648,17 @@ mod validators { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--manual", "-m", "invalid commit message without type", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "commit", + "--manual", + "-m", + "invalid commit message without type", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert() .failure() @@ -578,8 +675,17 @@ mod validators { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--manual", "-m", "feat: add new feature", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "commit", + "--manual", + "-m", + "feat: add new feature", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert() .success() @@ -600,8 +706,17 @@ mod subcommands { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["c", "--manual", "-m", "fix: test", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "c", + "--manual", + "-m", + "fix: test", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert() .success() @@ -648,7 +763,12 @@ mod edge_cases { let non_existent_config = temp_dir.path().join("non_existent_config.toml"); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["config", "show", "--config", non_existent_config.to_str().unwrap()]); + cmd.args(&[ + "config", + "show", + "--config", + non_existent_config.to_str().unwrap(), + ]); cmd.assert() .success() @@ -668,8 +788,14 @@ mod edge_cases { cmd.assert().success(); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "commit", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); cmd.assert() .failure() @@ -686,11 +812,20 @@ mod edge_cases { init_quicommit(&repo_path, &config_path); let mut cmd = cargo_bin_cmd!("quicommit"); - cmd.args(&["commit", "--manual", "-m", "", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) - .current_dir(&repo_path); + cmd.args(&[ + "commit", + "--manual", + "-m", + "", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); - cmd.assert() - .failure() - .stderr(predicate::str::contains("Invalid conventional commit format")); + cmd.assert().failure().stderr(predicate::str::contains( + "Invalid conventional commit format", + )); } }