diff --git a/src/commands/changelog.rs b/src/commands/changelog.rs index c483a0f..130bd19 100644 --- a/src/commands/changelog.rs +++ b/src/commands/changelog.rs @@ -13,13 +13,14 @@ use crate::i18n::{Messages, translate_changelog_category}; /// Generate changelog #[derive(Parser)] +#[command(disable_version_flag = true, disable_help_flag = false)] pub struct ChangelogCommand { /// Output file path #[arg(short, long)] output: Option, /// Version to generate changelog for - #[arg(short, long)] + #[arg(long)] version: Option, /// Generate from specific tag @@ -51,7 +52,7 @@ pub struct ChangelogCommand { include_authors: bool, /// Format (keep-a-changelog, github-releases) - #[arg(short, long)] + #[arg(long)] format: Option, /// Dry run (output to stdout) @@ -64,9 +65,13 @@ pub struct ChangelogCommand { } impl ChangelogCommand { - pub async fn execute(&self) -> Result<()> { + pub async fn execute(&self, config_path: Option) -> Result<()> { let repo = find_repo(std::env::current_dir()?.as_path())?; - let manager = ConfigManager::new()?; + let manager = if let Some(ref path) = config_path { + ConfigManager::with_path(path)? + } else { + ConfigManager::new()? + }; let config = manager.config(); let language = manager.get_language().unwrap_or(Language::English); let messages = Messages::new(language); diff --git a/src/commands/commit.rs b/src/commands/commit.rs index 95e2dc4..3a1ea29 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -2,6 +2,7 @@ use anyhow::{bail, Context, Result}; 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; @@ -84,12 +85,16 @@ pub struct CommitCommand { } impl CommitCommand { - pub async fn execute(&self) -> Result<()> { + 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 = ConfigManager::new()?; + let manager = if let Some(ref path) = config_path { + ConfigManager::with_path(path)? + } else { + ConfigManager::new()? + }; let config = manager.config(); let language = manager.get_language().unwrap_or(Language::English); let messages = Messages::new(language); diff --git a/src/commands/config.rs b/src/commands/config.rs index dda6071..c8ae70d 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -2,6 +2,7 @@ use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; +use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; use crate::config::CommitFormat; @@ -191,43 +192,60 @@ enum ConfigSubcommand { /// Test LLM connection TestLlm, + + /// Show config file path + Path, } impl ConfigCommand { - pub async fn execute(&self) -> Result<()> { + pub async fn execute(&self, config_path: Option) -> Result<()> { match &self.command { - Some(ConfigSubcommand::Show) => self.show_config().await, - Some(ConfigSubcommand::List) => self.list_config().await, - Some(ConfigSubcommand::Edit) => self.edit_config().await, - Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value).await, - Some(ConfigSubcommand::Get { key }) => self.get_value(key).await, - Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref()).await, - Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key).await, - Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key).await, - Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key).await, - Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key).await, - Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key).await, - Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref()).await, - Some(ConfigSubcommand::SetKimi { base_url, model }) => self.set_kimi(base_url.as_deref(), model.as_deref()).await, - Some(ConfigSubcommand::SetDeepSeek { base_url, model }) => self.set_deepseek(base_url.as_deref(), model.as_deref()).await, - Some(ConfigSubcommand::SetOpenRouter { base_url, model }) => self.set_openrouter(base_url.as_deref(), model.as_deref()).await, - Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format).await, - Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await, - Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await, - Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref()).await, - Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep).await, - Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep).await, - Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await, - Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await, - Some(ConfigSubcommand::Import { file }) => self.import_config(file).await, - Some(ConfigSubcommand::ListModels) => self.list_models().await, - Some(ConfigSubcommand::TestLlm) => self.test_llm().await, - None => self.show_config().await, + 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::Get { key }) => self.get_value(key, &config_path).await, + Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref(), &config_path).await, + Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key, &config_path).await, + Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key, &config_path).await, + Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key, &config_path).await, + Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key, &config_path).await, + Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key, &config_path).await, + Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref(), &config_path).await, + Some(ConfigSubcommand::SetKimi { base_url, model }) => self.set_kimi(base_url.as_deref(), model.as_deref(), &config_path).await, + Some(ConfigSubcommand::SetDeepSeek { base_url, model }) => self.set_deepseek(base_url.as_deref(), model.as_deref(), &config_path).await, + Some(ConfigSubcommand::SetOpenRouter { base_url, model }) => self.set_openrouter(base_url.as_deref(), model.as_deref(), &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 }) => self.export_config(output.as_deref(), &config_path).await, + Some(ConfigSubcommand::Import { file }) => self.import_config(file, &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, + None => self.show_config(&config_path).await, } } - async fn show_config(&self) -> Result<()> { - let manager = ConfigManager::new()?; + fn get_manager(&self, config_path: &Option) -> Result { + match config_path { + Some(path) => ConfigManager::with_path(path), + None => ConfigManager::new(), + } + } + + async fn show_path(&self, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; + println!("{}", manager.path().display()); + Ok(()) + } + + async fn show_config(&self, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; let config = manager.config(); println!("{}", "\nQuiCommit Configuration".bold()); @@ -306,8 +324,8 @@ impl ConfigCommand { } /// List all configuration information with masked API keys - async fn list_config(&self) -> Result<()> { - let manager = ConfigManager::new()?; + async fn list_config(&self, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; let config = manager.config(); println!("{}", "\nQuiCommit Configuration".bold()); @@ -404,15 +422,15 @@ impl ConfigCommand { Ok(()) } - async fn edit_config(&self) -> Result<()> { - let manager = ConfigManager::new()?; + async fn edit_config(&self, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; crate::utils::editor::edit_file(manager.path())?; println!("{} Configuration updated", "✓".green()); Ok(()) } - async fn set_value(&self, key: &str, value: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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()), @@ -450,8 +468,8 @@ impl ConfigCommand { Ok(()) } - async fn get_value(&self, key: &str) -> Result<()> { - let manager = ConfigManager::new()?; + 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 { @@ -469,8 +487,8 @@ impl ConfigCommand { Ok(()) } - async fn set_llm(&self, provider: Option<&str>) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_llm(&self, provider: Option<&str>, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; let provider = if let Some(p) = provider { p.to_string() @@ -602,48 +620,48 @@ impl ConfigCommand { Ok(()) } - async fn set_openai_key(&self, key: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_openai_key(&self, key: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; manager.set_openai_api_key(key.to_string()); manager.save()?; println!("{} OpenAI API key set", "✓".green()); Ok(()) } - async fn set_anthropic_key(&self, key: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_anthropic_key(&self, key: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; manager.set_anthropic_api_key(key.to_string()); manager.save()?; println!("{} Anthropic API key set", "✓".green()); Ok(()) } - async fn set_kimi_key(&self, key: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_kimi_key(&self, key: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; manager.set_kimi_api_key(key.to_string()); manager.save()?; println!("{} Kimi API key set", "✓".green()); Ok(()) } - async fn set_deepseek_key(&self, key: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_deepseek_key(&self, key: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; manager.set_deepseek_api_key(key.to_string()); manager.save()?; println!("{} DeepSeek API key set", "✓".green()); Ok(()) } - async fn set_openrouter_key(&self, key: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_openrouter_key(&self, key: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; manager.set_openrouter_api_key(key.to_string()); manager.save()?; println!("{} OpenRouter API key set", "✓".green()); Ok(()) } - async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; if let Some(url) = base_url { manager.set_kimi_base_url(url.to_string()); @@ -657,8 +675,8 @@ impl ConfigCommand { Ok(()) } - async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; if let Some(url) = base_url { manager.set_deepseek_base_url(url.to_string()); @@ -672,8 +690,8 @@ impl ConfigCommand { Ok(()) } - async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; if let Some(url) = base_url { manager.set_openrouter_base_url(url.to_string()); @@ -687,8 +705,8 @@ impl ConfigCommand { Ok(()) } - async fn set_ollama(&self, url: Option<&str>, model: Option<&str>) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_ollama(&self, url: Option<&str>, model: Option<&str>, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; if let Some(u) = url { manager.config_mut().llm.ollama.url = u.to_string(); @@ -702,8 +720,8 @@ impl ConfigCommand { Ok(()) } - async fn set_commit_format(&self, format: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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, @@ -717,24 +735,24 @@ impl ConfigCommand { Ok(()) } - async fn set_version_prefix(&self, prefix: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_version_prefix(&self, prefix: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; manager.set_version_prefix(prefix.to_string()); manager.save()?; println!("{} Set version prefix to '{}'", "✓".green(), prefix); Ok(()) } - async fn set_changelog_path(&self, path: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_changelog_path(&self, path: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; manager.set_changelog_path(path.to_string()); manager.save()?; println!("{} Set changelog path to {}", "✓".green(), path); Ok(()) } - async fn set_language(&self, language: Option<&str>) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_language(&self, language: Option<&str>, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; let language_code = if let Some(lang) = language { lang.to_string() @@ -763,8 +781,8 @@ impl ConfigCommand { Ok(()) } - async fn set_keep_types_english(&self, keep: bool) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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()?; let status = if keep { "enabled" } else { "disabled" }; @@ -772,8 +790,8 @@ impl ConfigCommand { Ok(()) } - async fn set_keep_changelog_types_english(&self, keep: bool) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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()?; let status = if keep { "enabled" } else { "disabled" }; @@ -781,7 +799,7 @@ impl ConfigCommand { Ok(()) } - async fn reset(&self, force: bool) -> Result<()> { + async fn reset(&self, force: bool, config_path: &Option) -> Result<()> { if !force { let confirm = Confirm::new() .with_prompt("Are you sure you want to reset all configuration?") @@ -794,7 +812,7 @@ impl ConfigCommand { } } - let mut manager = ConfigManager::new()?; + let mut manager = self.get_manager(config_path)?; manager.reset(); manager.save()?; @@ -802,8 +820,8 @@ impl ConfigCommand { Ok(()) } - async fn export_config(&self, output: Option<&str>) -> Result<()> { - let manager = ConfigManager::new()?; + async fn export_config(&self, output: Option<&str>, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; let toml = manager.export()?; if let Some(path) = output { @@ -816,10 +834,10 @@ impl ConfigCommand { Ok(()) } - async fn import_config(&self, file: &str) -> Result<()> { + async fn import_config(&self, file: &str, config_path: &Option) -> Result<()> { let toml = std::fs::read_to_string(file)?; - let mut manager = ConfigManager::new()?; + let mut manager = self.get_manager(config_path)?; manager.import(&toml)?; manager.save()?; @@ -827,8 +845,8 @@ impl ConfigCommand { Ok(()) } - async fn list_models(&self) -> Result<()> { - let manager = ConfigManager::new()?; + async fn list_models(&self, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; let config = manager.config(); match config.llm.provider.as_str() { @@ -984,8 +1002,8 @@ impl ConfigCommand { Ok(()) } - async fn test_llm(&self) -> Result<()> { - let manager = ConfigManager::new()?; + async fn test_llm(&self, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; let config = manager.config(); println!("Testing LLM connection ({})...", config.llm.provider.cyan()); diff --git a/src/commands/init.rs b/src/commands/init.rs index 1771ba3..e2ff7cd 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::Parser; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; +use std::path::PathBuf; use crate::config::{GitProfile, Language}; use crate::config::manager::ConfigManager; @@ -22,12 +23,13 @@ pub struct InitCommand { } impl InitCommand { - pub async fn execute(&self) -> Result<()> { - // Start with English messages for initialization + pub async fn execute(&self, config_path: Option) -> Result<()> { let messages = Messages::new(Language::English); println!("{}", messages.initializing().bold().cyan()); - let config_path = crate::config::AppConfig::default_path()?; + let config_path = config_path.unwrap_or_else(|| { + crate::config::AppConfig::default_path().unwrap() + }); // Check if config already exists if config_path.exists() && !self.reset { @@ -41,20 +43,24 @@ impl InitCommand { println!("{}", "Initialization cancelled.".yellow()); return Ok(()); } + } else { + println!("{}", "Configuration already exists. Use --reset to overwrite.".yellow()); + return Ok(()); } } - let mut manager = if self.reset { - ConfigManager::new()? - } else { - ConfigManager::new().or_else(|_| Ok::<_, anyhow::Error>(ConfigManager::default()))? - }; + // Create parent directory if needed + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?; + } + + // Create new config manager with fresh config + let mut manager = ConfigManager::with_path_fresh(&config_path)?; if self.yes { - // Quick setup with defaults self.quick_setup(&mut manager).await?; } else { - // Interactive setup self.interactive_setup(&mut manager).await?; } diff --git a/src/commands/profile.rs b/src/commands/profile.rs index 7d7523d..1700812 100644 --- a/src/commands/profile.rs +++ b/src/commands/profile.rs @@ -2,6 +2,7 @@ use anyhow::{bail, Result}; 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}; @@ -123,27 +124,34 @@ enum TokenSubcommand { } impl ProfileCommand { - pub async fn execute(&self) -> Result<()> { + pub async fn execute(&self, config_path: Option) -> Result<()> { match &self.command { - Some(ProfileSubcommand::Add) => self.add_profile().await, - Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name).await, - Some(ProfileSubcommand::List) => self.list_profiles().await, - Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref()).await, - Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name).await, - Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name).await, - Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name).await, - Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global).await, - Some(ProfileSubcommand::Switch) => self.switch_profile().await, - Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).await, - Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command).await, - Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref()).await, - Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref()).await, - None => self.list_profiles().await, + Some(ProfileSubcommand::Add) => self.add_profile(&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::Edit { name }) => self.edit_profile(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::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, + None => self.list_profiles(&config_path).await, } } - async fn add_profile(&self) -> Result<()> { - let mut manager = ConfigManager::new()?; + fn get_manager(&self, config_path: &Option) -> Result { + match config_path { + Some(path) => ConfigManager::with_path(path), + None => ConfigManager::new(), + } + } + + async fn add_profile(&self, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; println!("{}", "\nAdd new profile".bold()); println!("{}", "─".repeat(40)); @@ -244,8 +252,8 @@ impl ProfileCommand { Ok(()) } - async fn remove_profile(&self, name: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn remove_profile(&self, name: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; if !manager.has_profile(name) { bail!("Profile '{}' not found", name); @@ -269,8 +277,8 @@ impl ProfileCommand { Ok(()) } - async fn list_profiles(&self) -> Result<()> { - let manager = ConfigManager::new()?; + async fn list_profiles(&self, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; let profiles = manager.list_profiles(); @@ -319,8 +327,8 @@ impl ProfileCommand { Ok(()) } - async fn show_profile(&self, name: Option<&str>) -> Result<()> { - let manager = ConfigManager::new()?; + async fn show_profile(&self, name: Option<&str>, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; let profile = if let Some(n) = name { manager.get_profile(n) @@ -380,8 +388,8 @@ impl ProfileCommand { Ok(()) } - async fn edit_profile(&self, name: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))? @@ -420,8 +428,8 @@ impl ProfileCommand { Ok(()) } - async fn set_default(&self, name: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_default(&self, name: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; manager.set_default_profile(Some(name.to_string()))?; manager.save()?; @@ -431,8 +439,8 @@ impl ProfileCommand { Ok(()) } - async fn set_repo(&self, name: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn set_repo(&self, name: &str, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; let repo = find_repo(std::env::current_dir()?.as_path())?; let repo_path = repo.path().to_string_lossy().to_string(); @@ -453,8 +461,8 @@ impl ProfileCommand { Ok(()) } - async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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() @@ -490,8 +498,8 @@ impl ProfileCommand { Ok(()) } - async fn switch_profile(&self) -> Result<()> { - let mut manager = ConfigManager::new()?; + async fn switch_profile(&self, config_path: &Option) -> Result<()> { + let mut manager = self.get_manager(config_path)?; let profiles: Vec = manager.list_profiles() .into_iter() @@ -527,15 +535,15 @@ impl ProfileCommand { .interact()?; if apply { - self.apply_profile(Some(selected), false).await?; + self.apply_profile(Some(selected), false, config_path).await?; } } Ok(()) } - async fn copy_profile(&self, from: &str, to: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))? @@ -555,16 +563,16 @@ impl ProfileCommand { Ok(()) } - async fn handle_token_command(&self, cmd: &TokenSubcommand) -> Result<()> { + async fn handle_token_command(&self, cmd: &TokenSubcommand, config_path: &Option) -> Result<()> { match cmd { - TokenSubcommand::Add { profile, service } => self.add_token(profile, service).await, - TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service).await, - TokenSubcommand::List { profile } => self.list_tokens(profile).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) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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) { bail!("Profile '{}' not found", profile_name); @@ -610,8 +618,8 @@ impl ProfileCommand { Ok(()) } - async fn remove_token(&self, profile_name: &str, service: &str) -> Result<()> { - let mut manager = ConfigManager::new()?; + 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) { bail!("Profile '{}' not found", profile_name); @@ -635,8 +643,8 @@ impl ProfileCommand { Ok(()) } - async fn list_tokens(&self, profile_name: &str) -> Result<()> { - let manager = ConfigManager::new()?; + 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) .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; @@ -662,8 +670,8 @@ impl ProfileCommand { Ok(()) } - async fn check_profile(&self, name: Option<&str>) -> Result<()> { - let manager = ConfigManager::new()?; + async fn check_profile(&self, name: Option<&str>, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; let profile_name = if let Some(n) = name { n.to_string() @@ -695,8 +703,8 @@ impl ProfileCommand { Ok(()) } - async fn show_stats(&self, name: Option<&str>) -> Result<()> { - let manager = ConfigManager::new()?; + async fn show_stats(&self, name: Option<&str>, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; if let Some(n) = name { let profile = manager.get_profile(n) diff --git a/src/commands/tag.rs b/src/commands/tag.rs index 2e55f34..cb16f53 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -3,6 +3,7 @@ use clap::Parser; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; use semver::Version; +use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; use crate::git::{find_repo, GitRepo}; @@ -61,9 +62,13 @@ pub struct TagCommand { } impl TagCommand { - pub async fn execute(&self) -> Result<()> { + pub async fn execute(&self, config_path: Option) -> Result<()> { let repo = find_repo(std::env::current_dir()?.as_path())?; - let manager = ConfigManager::new()?; + let manager = if let Some(ref path) = config_path { + ConfigManager::with_path(path)? + } else { + ConfigManager::new()? + }; let config = manager.config(); let language = manager.get_language().unwrap_or(Language::English); let messages = Messages::new(language); diff --git a/src/config/manager.rs b/src/config/manager.rs index 95e958e..57863f3 100644 --- a/src/config/manager.rs +++ b/src/config/manager.rs @@ -19,7 +19,11 @@ impl ConfigManager { /// Create config manager with specific path pub fn with_path(path: &Path) -> Result { - let config = AppConfig::load(path)?; + let config = if path.exists() { + AppConfig::load(path)? + } else { + AppConfig::default() + }; Ok(Self { config, config_path: path.to_path_buf(), @@ -27,6 +31,15 @@ impl ConfigManager { }) } + /// Create config manager with fresh config (ignoring existing) + pub fn with_path_fresh(path: &Path) -> Result { + Ok(Self { + config: AppConfig::default(), + config_path: path.to_path_buf(), + modified: true, + }) + } + /// Get configuration reference pub fn config(&self) -> &AppConfig { &self.config diff --git a/src/config/profile.rs b/src/config/profile.rs index bb8c1d5..900b71f 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -177,8 +177,17 @@ impl GitProfile { if let Some(ref ssh) = self.ssh { if let Some(ref key_path) = ssh.private_key_path { - config.set_str("core.sshCommand", - &format!("ssh -i {}", key_path.display()))?; + 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))?; + } } } @@ -206,8 +215,17 @@ impl GitProfile { if let Some(ref ssh) = self.ssh { if let Some(ref key_path) = ssh.private_key_path { - config.set_str("core.sshCommand", - &format!("ssh -i {}", key_path.display()))?; + 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))?; + } } } @@ -351,7 +369,15 @@ impl SshConfig { if let Some(ref cmd) = self.ssh_command { Some(cmd.clone()) } else if let Some(ref key_path) = self.private_key_path { - Some(format!("ssh -i '{}'", key_path.display())) + let path_str = key_path.display().to_string(); + #[cfg(target_os = "windows")] + { + Some(format!("ssh -i \"{}\"", path_str.replace('\\', "/"))) + } + #[cfg(not(target_os = "windows"))] + { + Some(format!("ssh -i '{}'", path_str)) + } } else { None } @@ -511,7 +537,11 @@ pub struct ConfigDifference { } fn default_gpg_program() -> String { - "gpg".to_string() + if cfg!(target_os = "windows") { + "gpg.exe".to_string() + } else { + "gpg".to_string() + } } fn default_true() -> bool { diff --git a/src/git/commit.rs b/src/git/commit.rs index dbfaa26..0d5bfc3 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -47,6 +47,12 @@ impl CommitBuilder { self } + /// Set scope (optional) + pub fn scope_opt(mut self, scope: Option) -> Self { + self.scope = scope; + self + } + /// Set description pub fn description(mut self, description: impl Into) -> Self { self.description = Some(description.into()); @@ -59,6 +65,12 @@ impl CommitBuilder { self } + /// Set body (optional) + pub fn body_opt(mut self, body: Option) -> Self { + self.body = body; + self + } + /// Set footer pub fn footer(mut self, footer: impl Into) -> Self { self.footer = Some(footer.into()); diff --git a/src/git/mod.rs b/src/git/mod.rs index fc754e4..5a98475 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -1,6 +1,6 @@ use anyhow::{bail, Context, Result}; use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType}; -use std::path::{Path, PathBuf}; +use std::path::{Path, PathBuf, Component}; use std::collections::HashMap; use tempfile; @@ -8,7 +8,166 @@ pub mod changelog; pub mod commit; pub mod tag; -/// Git repository wrapper with enhanced cross-platform support +#[cfg(target_os = "windows")] +use std::os::windows::ffi::OsStringExt; + +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"\\?\") { + if let Some(stripped) = path_str.strip_prefix(r"\\?\") { + normalized = PathBuf::from(stripped); + } + } + if path_str.starts_with(r"\\?\UNC\") { + if 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 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 => { + if !components.is_empty() && components.last() != Some(&Component::ParentDir) { + components.pop(); + } else { + components.push(component); + } + } + Component::CurDir => {} + _ => 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)) +} + +fn try_open_repo_with_git_cli(path: &Path) -> Result { + let output = std::process::Command::new("git") + .args(&["rev-parse", "--show-toplevel"]) + .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() { + issues.push("Found .git directory".to_string()); + let config_file = git_dir.join("config"); + if config_file.exists() { + issues.push("Git config file exists".to_string()); + } else { + issues.push("WARNING: Git config file missing".to_string()); + } + } else { + issues.push("Found .git file (submodule or worktree)".to_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() { + depth += 1; + if depth > 20 { + break; + } + let parent_git = parent.join(".git"); + if parent_git.exists() { + issues.push(format!("Found .git in parent directory: {:?}", parent)); + break; + } + current = parent; + } + } + + #[cfg(target_os = "windows")] + { + let path_str = path.to_string_lossy(); + if path_str.starts_with(r"\\?\") { + issues.push("Path has Windows extended-length prefix (\\\\?\\)".to_string()); + } + if path_str.contains('\\') && path_str.contains('/') { + 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 ") +} + pub struct GitRepo { repo: Repository, path: PathBuf, @@ -16,54 +175,45 @@ pub struct GitRepo { } impl GitRepo { - /// Open a git repository pub fn open>(path: P) -> Result { let path = path.as_ref(); - // Enhanced cross-platform path handling - let absolute_path = if let Ok(canonical) = path.canonicalize() { - canonical - } else { - // Fallback: convert to absolute path without canonicalization - if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir()?.join(path) - } - }; - - // Try multiple git repository discovery strategies for cross-platform compatibility - let repo = Repository::discover(&absolute_path) - .or_else(|discover_err| { - // Try direct open as fallback - Repository::open(&absolute_path).map_err(|open_err| { - // Provide detailed error information for debugging - anyhow::anyhow!( - "Git repository discovery failed:\n\ - Discovery error: {}\n\ - Direct open error: {}\n\ - Path attempted: {:?}\n\ - Current directory: {:?}", - discover_err, open_err, absolute_path, std::env::current_dir() - ) - }) - }) - .with_context(|| { - format!( - "Failed to open git repository at '{:?}'. Please ensure:\n\ - 1. The directory contains a valid '.git' folder\n\ - 2. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\ - 3. You have proper permissions to access the repository", - absolute_path, - absolute_path.display() - ) + 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\ + \n\ + === git2 Error ===\n {}\n\ + \n\ + === git CLI Error ===\n {}\n\ + \n\ + === Diagnosis ===\n {}\n\ + \n\ + === Suggestions ===\n\ + 1. Ensure you are inside a git repository\n\ + 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() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| resolved_path.clone()); + let config = repo.config().ok(); - + Ok(Self { repo, - path: absolute_path, + path: normalize_path_for_git2(&repo_path), config, }) } @@ -718,20 +868,28 @@ impl StatusSummary { } } -/// Find git repository starting from path and walking up pub fn find_repo>(start_path: P) -> Result { let start_path = start_path.as_ref(); - // Try the starting path first - if let Ok(repo) = GitRepo::open(start_path) { + 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); } - // Walk up the directory tree to find a git repository - let mut current = start_path; + 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) { @@ -740,18 +898,44 @@ pub fn find_repo>(start_path: P) -> Result { current = parent; } - // Provide detailed error information for debugging + if let Ok(output) = std::process::Command::new("git") + .args(&["rev-parse", "--show-toplevel"]) + .current_dir(&resolved_start) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let git_root = stdout.trim(); + if !git_root.is_empty() { + if let Ok(repo) = GitRepo::open(git_root) { + return Ok(repo); + } + } + } + } + + let diagnosis = diagnose_repo_issue(&resolved_start); + bail!( - "No git repository found starting from {:?}.\n\ - Paths attempted:\n {}\n\ - Current directory: {:?}\n\ - Please ensure:\n\ - 1. You are in a git repository or its subdirectory\n\ - 2. The repository has a valid .git folder\n\ - 3. You have proper permissions to access the repository", - start_path, + "No git repository found.\n\ + \n\ + === Starting Path ===\n {:?}\n\ + \n\ + === Paths Attempted ===\n {}\n\ + \n\ + === Current Directory ===\n {:?}\n\ + \n\ + === Diagnosis ===\n {}\n\ + \n\ + === Suggestions ===\n\ + 1. Ensure you are inside a git repository (run: git status)\n\ + 2. Initialize a new repo: git init\n\ + 3. Clone an existing repo: git clone \n\ + 4. Check if .git directory exists and is accessible", + resolved_start, attempted_paths.join("\n "), - std::env::current_dir() + std::env::current_dir().unwrap_or_default(), + diagnosis ) } diff --git a/src/git/tag.rs b/src/git/tag.rs index 93c4edc..bab8d5a 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -281,8 +281,9 @@ pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<() if let Some(remote) = remote { use std::process::Command; + let refspec = format!(":refs/tags/{}", name); let output = Command::new("git") - .args(&["push", remote, ":refs/tags/{}"]) + .args(&["push", remote, &refspec]) .current_dir(repo.path()) .output()?; diff --git a/src/main.rs b/src/main.rs index bf6881c..ffdf7e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::{Parser, Subcommand}; +use std::path::PathBuf; use tracing::debug; mod commands; @@ -74,7 +75,6 @@ enum Commands { async fn main() -> Result<()> { let cli = Cli::parse(); - // Initialize logging let log_level = match cli.verbose { 0 => "warn", 1 => "info", @@ -89,13 +89,14 @@ async fn main() -> Result<()> { debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION")); - // Execute command + let config_path: Option = cli.config.map(PathBuf::from); + match cli.command { - Commands::Init(cmd) => cmd.execute().await, - Commands::Commit(cmd) => cmd.execute().await, - Commands::Tag(cmd) => cmd.execute().await, - Commands::Changelog(cmd) => cmd.execute().await, - Commands::Profile(cmd) => cmd.execute().await, - Commands::Config(cmd) => cmd.execute().await, + Commands::Init(cmd) => cmd.execute(config_path).await, + Commands::Commit(cmd) => cmd.execute(config_path).await, + Commands::Tag(cmd) => cmd.execute(config_path).await, + Commands::Changelog(cmd) => cmd.execute(config_path).await, + Commands::Profile(cmd) => cmd.execute(config_path).await, + Commands::Config(cmd) => cmd.execute(config_path).await, } } diff --git a/src/utils/editor.rs b/src/utils/editor.rs index 7009696..9bfcef4 100644 --- a/src/utils/editor.rs +++ b/src/utils/editor.rs @@ -41,8 +41,22 @@ pub fn get_editor() -> String { .or_else(|_| std::env::var("VISUAL")) .unwrap_or_else(|_| { if cfg!(target_os = "windows") { + if let Ok(code) = which::which("code") { + return "code --wait".to_string(); + } + if let Ok(notepad) = which::which("notepad") { + return "notepad".to_string(); + } "notepad".to_string() + } else if cfg!(target_os = "macos") { + if which::which("code").is_ok() { + return "code --wait".to_string(); + } + "vi".to_string() } else { + if which::which("nano").is_ok() { + return "nano".to_string(); + } "vi".to_string() } }) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 1f554a3..8f29c2b 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,64 +1,642 @@ use assert_cmd::Command; use predicates::prelude::*; use std::fs; +use std::path::PathBuf; use tempfile::TempDir; -#[test] -fn test_cli_help() { - let mut cmd = Command::cargo_bin("quicommit").unwrap(); - cmd.arg("--help"); - cmd.assert() - .success() - .stdout(predicate::str::contains("QuiCommit")); +fn create_git_repo(dir: &PathBuf) -> std::process::Output { + std::process::Command::new("git") + .args(&["init"]) + .current_dir(dir) + .output() + .expect("Failed to init git repo") } -#[test] -fn test_version() { - let mut cmd = Command::cargo_bin("quicommit").unwrap(); - cmd.arg("--version"); - cmd.assert() - .success() - .stdout(predicate::str::contains("0.1.0")); +fn configure_git_user(dir: &PathBuf) { + std::process::Command::new("git") + .args(&["config", "user.name", "Test User"]) + .current_dir(dir) + .output() + .expect("Failed to configure git user name"); + + std::process::Command::new("git") + .args(&["config", "user.email", "test@example.com"]) + .current_dir(dir) + .output() + .expect("Failed to configure git user email"); } -#[test] -fn test_config_show() { - let temp_dir = TempDir::new().unwrap(); - let config_dir = temp_dir.path().join("config"); - fs::create_dir(&config_dir).unwrap(); - - let mut cmd = Command::cargo_bin("quicommit").unwrap(); - cmd.env("QUICOMMIT_CONFIG", config_dir.join("config.toml")) - .arg("config") - .arg("show"); - - cmd.assert().success(); +fn create_test_file(dir: &PathBuf, name: &str, content: &str) { + let file_path = dir.join(name); + fs::write(&file_path, content).expect("Failed to create test file"); } -#[test] -fn test_profile_list_empty() { - let temp_dir = TempDir::new().unwrap(); - - let mut cmd = Command::cargo_bin("quicommit").unwrap(); - cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml")) - .arg("profile") - .arg("list"); - - cmd.assert() - .success() - .stdout(predicate::str::contains("No profiles configured")); +fn stage_file(dir: &PathBuf, name: &str) { + std::process::Command::new("git") + .args(&["add", name]) + .current_dir(dir) + .output() + .expect("Failed to stage file"); } -#[test] -fn test_init_quick() { - let temp_dir = TempDir::new().unwrap(); - - let mut cmd = Command::cargo_bin("quicommit").unwrap(); - cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml")) - .arg("init") - .arg("--yes"); - - cmd.assert() - .success() - .stdout(predicate::str::contains("initialized successfully")); +fn create_commit(dir: &PathBuf, message: &str) { + std::process::Command::new("git") + .args(&["commit", "-m", message]) + .current_dir(dir) + .output() + .expect("Failed to create commit"); +} + +mod cli_basic { + use super::*; + + #[test] + fn test_help() { + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.arg("--help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("QuiCommit")) + .stdout(predicate::str::contains("AI-powered Git assistant")); + } + + #[test] + fn test_version() { + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.arg("--version"); + cmd.assert() + .success() + .stdout(predicate::str::contains("quicommit")); + } + + #[test] + fn test_no_args_shows_help() { + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Usage:")); + } + + #[test] + fn test_verbose_flag() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + let config_path = repo_path.join("config.toml"); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["-vv", "init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + + cmd.assert().success(); + } +} + +mod init_command { + use super::*; + + #[test] + fn test_init_quick() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("initialized successfully")); + } + + #[test] + fn test_init_creates_config_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + + cmd.assert().success(); + + assert!(config_path.exists(), "Config file should be created"); + } + + #[test] + fn test_init_in_git_repo() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + let config_path = repo_path.join("test_config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + + cmd.assert().success(); + } + + #[test] + fn test_init_reset() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--reset", "--config", config_path.to_str().unwrap()]); + cmd.assert() + .success() + .stdout(predicate::str::contains("initialized successfully")); + } +} + +mod profile_command { + use super::*; + + #[test] + fn test_profile_list_empty() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("No profiles")); + } + + #[test] + fn test_profile_list_with_profile() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("default")); + } +} + +mod config_command { + use super::*; + + #[test] + fn test_config_show() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["config", "show", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Configuration")); + } + + #[test] + fn test_config_path() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("config.toml")); + } +} + +mod commit_command { + use super::*; + + #[test] + fn test_commit_no_repo() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["commit", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(temp_dir.path()); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("git").or(predicate::str::contains("repository"))); + } + + #[test] + fn test_commit_no_changes() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + let config_path = repo_path.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["commit", "--manual", "-m", "test: empty", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Dry run")); + } + + #[test] + fn test_commit_with_staged_changes() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + create_test_file(&repo_path, "test.txt", "Hello, World!"); + stage_file(&repo_path, "test.txt"); + + let config_path = repo_path.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + 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() + .stdout(predicate::str::contains("Dry run")); + } + + #[test] + fn test_commit_date_mode() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + create_test_file(&repo_path, "daily.txt", "Daily update"); + stage_file(&repo_path, "daily.txt"); + + let config_path = repo_path.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["commit", "--date", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Dry run")); + } +} + +mod tag_command { + use super::*; + + #[test] + fn test_tag_no_repo() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["tag", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(temp_dir.path()); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("git").or(predicate::str::contains("repository"))); + } + + #[test] + fn test_tag_list_empty() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + create_test_file(&repo_path, "test.txt", "content"); + stage_file(&repo_path, "test.txt"); + create_commit(&repo_path, "feat: initial commit"); + + let config_path = repo_path.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["tag", "--name", "v0.1.0", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + + cmd.assert() + .success() + .stdout(predicate::str::contains("v0.1.0")); + } +} + +mod changelog_command { + use super::*; + + #[test] + fn test_changelog_init() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + let config_path = repo_path.join("config.toml"); + let changelog_path = repo_path.join("CHANGELOG.md"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["changelog", "--init", "--output", changelog_path.to_str().unwrap(), "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + + cmd.assert().success(); + + assert!(changelog_path.exists(), "Changelog file should be created"); + } + + #[test] + fn test_changelog_dry_run() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + create_test_file(&repo_path, "test.txt", "content"); + stage_file(&repo_path, "test.txt"); + create_commit(&repo_path, "feat: add feature"); + + let config_path = repo_path.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["changelog", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + + cmd.assert() + .success(); + } +} + +mod cross_platform { + use super::*; + + #[test] + fn test_path_handling_windows_style() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("subdir").join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + + cmd.assert().success(); + assert!(config_path.exists()); + } + + #[test] + fn test_config_with_spaces_in_path() { + let temp_dir = TempDir::new().unwrap(); + let space_dir = temp_dir.path().join("path with spaces"); + fs::create_dir_all(&space_dir).unwrap(); + let config_path = space_dir.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + + cmd.assert().success(); + assert!(config_path.exists()); + } + + #[test] + fn test_config_with_unicode_path() { + let temp_dir = TempDir::new().unwrap(); + let unicode_dir = temp_dir.path().join("路径测试"); + fs::create_dir_all(&unicode_dir).unwrap(); + let config_path = unicode_dir.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + + cmd.assert().success(); + assert!(config_path.exists()); + } +} + +mod git_operations { + use super::*; + + #[test] + fn test_git_repo_detection() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + let git_dir = repo_path.join(".git"); + assert!(git_dir.exists(), ".git directory should exist"); + } + + #[test] + fn test_git_status_clean() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + let output = std::process::Command::new("git") + .args(&["status", "--porcelain"]) + .current_dir(&repo_path) + .output() + .expect("Failed to run git status"); + + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).is_empty()); + } + + #[test] + fn test_git_commit_creation() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + create_test_file(&repo_path, "test.txt", "content"); + stage_file(&repo_path, "test.txt"); + create_commit(&repo_path, "feat: initial commit"); + + let output = std::process::Command::new("git") + .args(&["log", "--oneline"]) + .current_dir(&repo_path) + .output() + .expect("Failed to run git log"); + + assert!(output.status.success()); + let log = String::from_utf8_lossy(&output.stdout); + assert!(log.contains("initial commit")); + } +} + +mod validators { + use super::*; + + #[test] + fn test_commit_message_validation() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + create_test_file(&repo_path, "test.txt", "content"); + stage_file(&repo_path, "test.txt"); + + let config_path = repo_path.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + 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() + .stderr(predicate::str::contains("Invalid").or(predicate::str::contains("format"))); + } + + #[test] + fn test_valid_conventional_commit() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + create_test_file(&repo_path, "test.txt", "content"); + stage_file(&repo_path, "test.txt"); + + let config_path = repo_path.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + 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() + .stdout(predicate::str::contains("Dry run")); + } +} + +mod subcommands { + use super::*; + + #[test] + fn test_commit_alias() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + create_git_repo(&repo_path); + configure_git_user(&repo_path); + + create_test_file(&repo_path, "test.txt", "content"); + stage_file(&repo_path, "test.txt"); + + let config_path = repo_path.join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["c", "--manual", "-m", "fix: test", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()]) + .current_dir(&repo_path); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Dry run")); + } + + #[test] + fn test_init_alias() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["i", "--yes", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("initialized successfully")); + } + + #[test] + fn test_profile_alias() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.args(&["p", "list", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("default")); + } }