From a40c88c768bc90cd7fd33dca33f7a1e82a5788fb Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Fri, 21 Aug 2026 13:57:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(changelog):=20=E6=B7=BB=E5=8A=A0=20`--no-g?= =?UTF-8?q?enerate`=20=E6=A0=87=E5=BF=97=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=BA=AF=E6=A8=A1=E6=9D=BF=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `--no-generate` 参数,强制使用模板生成而非 AI - 调整 `--yes` 参数仅跳过交互提示,不改变生成行为 - 统一使用工具函数替代 println 输出 --- README.md | 26 +- readme_zh.md | 26 +- src/commands/changelog.rs | 39 +- src/commands/commit.rs | 82 +- src/commands/config.rs | 583 +++--- src/commands/credential.rs | 915 ++++----- src/commands/init.rs | 107 +- src/commands/profile.rs | 692 ++++--- src/commands/tag.rs | 38 +- src/config/manager.rs | 27 + src/config/mod.rs | 19 + src/generator/mod.rs | 31 +- src/git/mod.rs | 37 +- src/i18n/messages.rs | 4001 ++++++++++++++++++++++++++++++++++-- src/lib.rs | 2 - src/llm/rig/mod.rs | 144 +- src/llm/thinking.rs | 14 +- src/main.rs | 44 +- src/utils/keyring.rs | 4 +- src/utils/mod.rs | 156 +- tests/gitignore_tests.rs | 6 +- tests/integration_tests.rs | 189 ++ 22 files changed, 5709 insertions(+), 1473 deletions(-) diff --git a/README.md b/README.md index fe54998..2328d30 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,10 @@ quicommit profile token quicommit config set-llm ollama quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2 +> ⚠️ **Security note**: passing your API key as a CLI argument puts it in your shell +> history and process list. Prefer running `quicommit config set-api-key` (or +> `config set-llm`) without the key to enter it via a hidden prompt instead. + # Configure OpenAI quicommit config set-llm openai quicommit config set-api-key YOUR_API_KEY @@ -329,7 +333,7 @@ quicommit config reset --force | `--commitlint` | Use commitlint format | | `--no-verify` | Skip commit message verification | | `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) | -| `-y, --yes` | Skip confirmation | +| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) | | `--push` | Push after committing | | `--remote` | Specify remote repository (default: origin) | @@ -349,7 +353,7 @@ quicommit config reset --force | `-r, --remote` | Specify remote repository (default: origin) | | `--dry-run` | Dry run | | `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) | -| `-y, --yes` | Skip confirmation | +| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) | ### Changelog Options @@ -366,7 +370,8 @@ quicommit config reset --force | `--format` | Format (keep-a-changelog, github-releases) | | `--dry-run` | Dry run (output to stdout) | | `--think` | Enable LLM thinking/reasoning mode (overrides config) | -| `-y, --yes` | Skip confirmation | +| `--no-generate` | Generate deterministically from template (no AI call) | +| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) | ## Configuration File @@ -425,6 +430,9 @@ auto_generate = true path = "CHANGELOG.md" auto_generate = true +[output] +emoji = true + [repo_profiles] "/path/to/work/project" = "work" "/path/to/personal/project" = "personal" @@ -436,7 +444,15 @@ auto_generate = true |----------|-------------| | `QUICOMMIT_CONFIG` | Configuration file path | | `EDITOR` | Default editor | -| `NO_COLOR` | Disable colored output | +| `NO_COLOR` | Disable colored output and emoji decorations (any value) | +| `RUST_LOG` | Overrides `-v` log filtering (e.g. `debug`) | + +## Global Options + +- `-v, --verbose` (repeatable): `-v` shows info logs, `-vv` debug, `-vvv` trace. +- `--no-color`: disable colors **and** emoji decorations (highest priority). +- `--emoji` / `--no-emoji`: force emoji decorations on/off, overriding the `output.emoji` config value. +- Emoji decorations can also be configured via `config set output.emoji true|false`. ## Troubleshooting @@ -453,7 +469,7 @@ quicommit config set llm.provider ollama # Get configuration value quicommit config get llm.provider -# Set API key (stored in system keyring) +# Set API key (stored in system keyring; omit the value for hidden prompt input) quicommit config set-api-key YOUR_API_KEY # Delete API key from keyring diff --git a/readme_zh.md b/readme_zh.md index 5b0b920..92fb2dd 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -230,6 +230,10 @@ quicommit profile token quicommit config set-llm ollama quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2 +> ⚠️ **安全提示**:把 API 密钥作为命令行参数传入会留在 shell history 与进程列表中。 +> 建议直接运行 `quicommit config set-api-key`(或 `config set-llm`)不携带密钥, +> 以隐藏方式输入。 + # 配置OpenAI quicommit config set-llm openai quicommit config set-api-key YOUR_API_KEY @@ -323,7 +327,7 @@ quicommit config reset --force | `--commitlint` | 使用commitlint格式 | | `--no-verify` | 不验证提交信息 | | `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) | -| `-y, --yes` | 跳过确认提示 | +| `-y, --yes` | 仅跳过确认提示(生成行为不变) | | `--push` | 提交后推送到远程 | | `--remote` | 指定远程仓库(默认:origin) | @@ -343,7 +347,7 @@ quicommit config reset --force | `-r, --remote` | 指定远程仓库(默认:origin) | | `--dry-run` | 试运行 | | `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) | -| `-y, --yes` | 跳过确认提示 | +| `-y, --yes` | 仅跳过确认提示(生成行为不变) | ### changelog命令选项 @@ -360,7 +364,8 @@ quicommit config reset --force | `--format` | 格式(keep-a-changelog、github-releases) | | `--dry-run` | 试运行(输出到stdout) | | `--think` | 启用 LLM 思考/推理模式(覆盖配置) | -| `-y, --yes` | 跳过确认提示 | +| `--no-generate` | 使用模板确定性生成(不调用 AI) | +| `-y, --yes` | 仅跳过确认提示(生成行为不变) | ## 配置文件 @@ -419,6 +424,9 @@ auto_generate = true path = "CHANGELOG.md" auto_generate = true +[output] +emoji = true + [repo_profiles] "/path/to/work/project" = "work" "/path/to/personal/project" = "personal" @@ -430,7 +438,15 @@ auto_generate = true |--------|------| | `QUICOMMIT_CONFIG` | 配置文件路径 | | `EDITOR` | 默认编辑器 | -| `NO_COLOR` | 禁用彩色输出 | +| `NO_COLOR` | 禁用彩色与 Emoji 装饰输出(任意值即生效) | +| `RUST_LOG` | 覆盖 `-v` 的日志过滤(例如 `debug`) | + +## 全局选项 + +- `-v, --verbose`(可叠加):`-v` 显示 info 日志、`-vv` debug、`-vvv` trace。 +- `--no-color`:禁用彩色**与** Emoji 装饰(最高优先级)。 +- `--emoji` / `--no-emoji`:强制开启/关闭 Emoji 装饰,覆盖 `output.emoji` 配置。 +- Emoji 装饰也可通过 `quicommit config set output.emoji true|false` 配置。 ## 故障排除 @@ -447,7 +463,7 @@ quicommit config set llm.provider ollama # 获取配置值 quicommit config get llm.provider -# 设置API密钥(存储在系统密钥环中) +# 设置API密钥(存储在系统密钥环中;省略密钥值可通过隐藏提示输入) quicommit config set-api-key YOUR_API_KEY # 从密钥环删除API密钥 diff --git a/src/commands/changelog.rs b/src/commands/changelog.rs index 8595fd6..95bfac8 100644 --- a/src/commands/changelog.rs +++ b/src/commands/changelog.rs @@ -11,6 +11,7 @@ use crate::git::GitRepo; use crate::git::find_repo; use crate::git::{CommitInfo, changelog::*}; use crate::i18n::{Messages, translate_changelog_category}; +use crate::utils::{print_progress, print_success, print_warning}; /// Generate changelog #[derive(Parser)] @@ -60,7 +61,11 @@ pub struct ChangelogCommand { #[arg(long)] think: bool, - /// Skip interactive prompts + /// Generate deterministically from template (no AI call) + #[arg(long, conflicts_with = "generate")] + no_generate: bool, + + /// Skip interactive prompts only (generation behavior unchanged) #[arg(short = 'y', long)] yes: bool, } @@ -85,7 +90,7 @@ impl ChangelogCommand { .unwrap_or_else(|| PathBuf::from(&config.changelog.path)); init_changelog(&path)?; - println!("{}", messages.initialized_changelog(&format!("{:?}", path))); + print_success(&messages.initialized_changelog(&path.display().to_string())); return Ok(()); } @@ -101,10 +106,7 @@ 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!("{}", messages.unknown_changelog_format(f)), }; // Get version @@ -120,7 +122,7 @@ impl ChangelogCommand { }; // Get commits - println!("{}", messages.fetching_commits()); + print_progress(messages.fetching_commits()); // Determine from_tag: use explicit --from, or auto-detect from changelog let from_tag = self.resolve_from_tag(&repo, &output_path, &messages); @@ -130,10 +132,10 @@ impl ChangelogCommand { bail!("{}", messages.no_commits_found()); } - println!("{}", messages.found_commits(commits.len())); + print_success(&messages.found_commits(commits.len())); // Generate changelog - let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) { + let changelog = if self.generate || (config.changelog.auto_generate && !self.no_generate) { self.generate_with_ai(&version, &commits, &messages).await? } else { self.generate_with_template(format, &version, &commits, language)? @@ -161,12 +163,12 @@ impl ChangelogCommand { println!("{}", "─".repeat(60)); let confirm = Confirm::new() - .with_prompt(messages.write_to_file(&format!("{:?}", output_path))) + .with_prompt(messages.write_to_file(&output_path.display().to_string())) .default(true) .interact()?; if !confirm { - println!("{}", messages.cancelled().yellow()); + print_warning(messages.cancelled()); return Ok(()); } } @@ -185,7 +187,11 @@ impl ChangelogCommand { std::fs::write(&output_path, content)?; } - println!("{} {:?}", messages.changelog_written(), output_path); + print_success(&format!( + "{} {}", + messages.changelog_written(), + output_path.display() + )); Ok(()) } @@ -221,12 +227,13 @@ impl ChangelogCommand { let manager = ConfigManager::new()?; let language = manager.get_language().unwrap_or(Language::English); - println!("{}", messages.ai_generating_changelog()); - let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?; - generator + let spinner = crate::utils::Spinner::start(messages.ai_generating_changelog()); + let result = generator .generate_changelog_entry(version, commits, language) - .await + .await; + spinner.finish_clear(); + result } fn generate_with_template( diff --git a/src/commands/commit.rs b/src/commands/commit.rs index 13efc71..ba8397b 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -11,6 +11,7 @@ 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; +use crate::utils::{print_progress, print_success, print_warning}; /// Generate and execute conventional commits #[derive(Parser)] @@ -75,7 +76,7 @@ pub struct CommitCommand { #[arg(short = 't', long)] think: bool, - /// Skip interactive prompts + /// Skip interactive prompts only (generation behavior unchanged) #[arg(short = 'y', long)] yes: bool, @@ -121,13 +122,13 @@ impl CommitCommand { // Auto-add if no files are staged and there are unstaged/untracked changes if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all { println!("{}", messages.auto_stage_changes().yellow()); - let removed = repo.stage_all()?; + let removed = repo.stage_all(&messages)?; println!("{}", messages.staged_all().green()); if !removed.is_empty() { - println!( - "{}", - format!("Removed {} ignored files from staging:", removed.len()).yellow() - ); + print_warning(&format!( + "Removed {} ignored files from staging:", + removed.len() + )); for file in &removed { println!(" • {}", file); } @@ -136,19 +137,19 @@ impl CommitCommand { // Re-check status after staging to ensure changes are detected let new_status = repo.status_summary()?; if new_status.staged == 0 { - bail!("Failed to stage changes. Please try running 'git add -A' manually."); + bail!("{}", messages.failed_to_stage()); } } // Stage all if requested if self.all { - let removed = repo.stage_all()?; + let removed = repo.stage_all(&messages)?; println!("{}", messages.staged_all().green()); if !removed.is_empty() { - println!( - "{}", - format!("Removed {} ignored files from staging:", removed.len()).yellow() - ); + print_warning(&format!( + "Removed {} ignored files from staging:", + removed.len() + )); for file in &removed { println!(" • {}", file); } @@ -194,29 +195,21 @@ impl CommitCommand { .interact()?; if !confirm { - println!("{}", messages.commit_cancelled().yellow()); + print_warning(messages.commit_cancelled()); return Ok(()); } } 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() @@ -226,30 +219,34 @@ impl CommitCommand { }; if let Some(commit_oid) = result { - println!( + print_success(&format!( "{} {}", messages.commit_created().green().bold(), commit_oid.to_string()[..8].to_string().cyan() - ); + )); } else { - println!("{} successfully", messages.commit_amended().green().bold()); + print_success(messages.commit_amended_successfully()); } // Push after commit if requested or ask user - if self.push { - println!("\n{}", messages.pushing_commit(&self.remote)); - repo.push(&self.remote, "HEAD")?; - println!("{}", messages.pushed_commit(&self.remote)); - } else if !self.yes && !self.dry_run { - let should_push = Confirm::new() - .with_prompt(messages.push_after_commit()) - .default(false) - .interact()?; + if self.push || (!self.yes && !self.dry_run) { + let branch = repo + .current_branch() + .unwrap_or_else(|_| "HEAD (detached)".to_string()); + + let should_push = if self.push { + true + } else { + Confirm::new() + .with_prompt(messages.push_after_commit(&branch)) + .default(false) + .interact()? + }; if should_push { - println!("\n{}", messages.pushing_commit(&self.remote)); + print_progress(&messages.pushing_commit(&self.remote, &branch)); repo.push(&self.remote, "HEAD")?; - println!("{}", messages.pushed_commit(&self.remote)); + print_success(&messages.pushed_commit(&self.remote, &branch)); } } @@ -306,21 +303,22 @@ impl CommitCommand { .await .context("Failed to initialize LLM. Use --manual for manual commit.")?; - println!("{}", messages.ai_analyzing()); + let spinner = crate::utils::Spinner::start(messages.ai_analyzing()); let language = manager.get_language().unwrap_or(Language::English); let generated = if self.yes { generator .generate_commit_from_repo(repo, format, language) - .await? + .await } else { generator - .generate_commit_interactive(repo, format, language) - .await? + .generate_commit_interactive(repo, format, language, messages) + .await }; + spinner.finish_clear(); - Ok(generated.to_conventional()) + Ok(generated?.to_conventional()) } async fn create_interactive_commit( diff --git a/src/commands/config.rs b/src/commands/config.rs index 37b9214..dc06052 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -6,22 +6,39 @@ use std::path::PathBuf; use crate::config::CommitFormat; use crate::config::{EncryptedPat, ExportData, Language, manager::ConfigManager}; +use crate::i18n::Messages; use crate::utils::crypto::{decrypt, encrypt}; use crate::utils::keyring::{ get_default_base_url, get_default_model, get_supported_providers, provider_needs_api_key, }; +use crate::utils::{ + eprint_warning, error_prefix, print_error, print_info, print_success, print_warning, + success_prefix, warning_prefix, +}; /// Mask API key with asterisks for security fn mask_api_key(key: Option<&str>) -> String { match key { Some(k) => { - if k.len() <= 8 { - "*".repeat(k.len()) + let chars: Vec = k.chars().collect(); + if chars.len() <= 8 { + "*".repeat(chars.len()) } else { - format!("{}***{}", &k[..4], &k[k.len() - 4..]) + let head: String = chars[..4].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{}***{}", head, tail) } } - None => "✗ not set".red().to_string(), + None => "not set".to_string(), + } +} + +/// Parse a boolean config value, rejecting anything other than true/false +fn parse_bool(value: &str, messages: &Messages) -> Result { + match value.to_lowercase().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => bail!("{}", messages.invalid_value_use(value, "true, false")), } } @@ -68,14 +85,16 @@ enum ConfigSubcommand { #[arg(short, long)] base_url: Option, - /// API key (will be stored in system keyring) + /// API key (stored in system keyring; note: visible in shell history and + /// process list when passed as an argument — prefer omitting it for hidden input) #[arg(short = 'k', long)] api_key: Option, }, /// Set API key for current provider (stored in system keyring) SetApiKey { - /// API key + /// API key (visible in shell history and process list when passed here; + /// omit the value and use interactive hidden input instead) key: String, }, @@ -229,6 +248,11 @@ impl ConfigCommand { } } + fn messages_for(&self, manager: &ConfigManager) -> Messages { + let language = manager.get_language().unwrap_or(Language::English); + Messages::new(language) + } + async fn show_path(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; println!("{}", manager.path().display()); @@ -237,24 +261,28 @@ impl ConfigCommand { async fn check_keyring(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let keyring = manager.keyring(); - println!("{}", "\nKeyring Status".bold()); + println!("\n{}", messages.keyring_status_header().bold()); println!("{}", "─".repeat(40)); if keyring.is_available() { - println!("{} Keyring is available", "✓".green()); + print_success(messages.keyring_available_status()); println!(" {}", keyring.get_status_message()); } else { - println!("{} Keyring is not available", "✗".red()); + print_error(messages.keyring_not_available_status()); println!(" {}", keyring.get_status_message()); } - println!("\n{}", "Environment Variables:".bold()); + println!("\n{}", messages.environment_variables_header().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()); + println!( + " QUICOMMIT_API_KEY: {}", + messages.source_not_set().dimmed() + ); } let provider = manager.llm_provider(); @@ -262,7 +290,7 @@ impl ConfigCommand { if let Ok(key) = std::env::var(&provider_env) { println!(" {}: {}", provider_env, mask_api_key(Some(&key))); } else { - println!(" {}: {}", provider_env, "not set".dimmed()); + println!(" {}: {}", provider_env, messages.source_not_set().dimmed()); } Ok(()) @@ -270,37 +298,68 @@ impl ConfigCommand { async fn show_config(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let config = manager.config(); - println!("{}", "\nQuiCommit Configuration".bold()); + println!("\n{}", messages.quicommit_configuration().bold()); println!("{}", "─".repeat(60)); - println!("\n{}", "General:".bold()); - println!(" Config file: {}", manager.path().display()); + println!("\n{}", messages.general_section().bold()); println!( - " Default profile: {}", - config.default_profile.as_deref().unwrap_or("(none)").cyan() + " {} {}", + messages.config_file_value(), + manager.path().display() ); - println!(" Profiles: {}", config.profiles.len()); + println!( + " {} {}", + messages.default_profile_value(), + config + .default_profile + .as_deref() + .unwrap_or(messages.none_value()) + .cyan() + ); + println!(" {} {}", messages.profiles_value(), config.profiles.len()); - println!("\n{}", "LLM Configuration:".bold()); - println!(" Provider: {}", config.llm.provider.cyan()); - println!(" Model: {}", config.llm.model.cyan()); + println!("\n{}", messages.llm_configuration_section().bold()); + println!( + " {} {}", + messages.provider_value(), + config.llm.provider.cyan() + ); + println!(" {} {}", messages.model_value(), config.llm.model.cyan()); let base_url = manager.llm_base_url(); - println!(" Base URL: {}", base_url); + println!(" {} {}", messages.base_url_value(), 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: {}", + " {} {}", + messages.api_key_value(), + mask_api_key(api_key.as_deref()) + ); + + println!( + " {} {}", + messages.max_tokens_value(), + config.llm.max_tokens + ); + println!( + " {} {}", + messages.temperature_value(), + config.llm.temperature + ); + println!(" {} {}s", messages.timeout_value(), config.llm.timeout); + + println!("\n{}", messages.commit_configuration_section().bold()); + println!( + " {} {}", + messages.format_value(), + config.commit.format.to_string().cyan() + ); + println!( + " {} {}", + messages.auto_generate_value(), if config.commit.auto_generate { "yes".green() } else { @@ -308,10 +367,15 @@ impl ConfigCommand { } ); - println!("\n{}", "Tag Configuration:".bold()); - println!(" Version prefix: '{}'", config.tag.version_prefix); + println!("\n{}", messages.tag_configuration_section().bold()); println!( - " Auto-generate: {}", + " {} '{}'", + messages.version_prefix_value(), + config.tag.version_prefix + ); + println!( + " {} {}", + messages.auto_generate_value(), if config.tag.auto_generate { "yes".green() } else { @@ -319,11 +383,16 @@ impl ConfigCommand { } ); - println!("\n{}", "Language Configuration:".bold()); + println!("\n{}", messages.language_configuration_section().bold()); let language = manager.get_language().unwrap_or(Language::English); - println!(" Output language: {}", language.display_name().cyan()); println!( - " Keep commit types in English: {}", + " {} {}", + messages.output_language_value(), + language.display_name().cyan() + ); + println!( + " {} {}", + messages.keep_types_english_value(), if manager.keep_types_english() { "yes".green() } else { @@ -331,18 +400,29 @@ impl ConfigCommand { } ); println!( - " Keep changelog types in English: {}", + " {} {}", + messages.keep_changelog_types_english_value(), if manager.keep_changelog_types_english() { "yes".green() } else { "no".red() } ); - - println!("\n{}", "Changelog Configuration:".bold()); - println!(" Path: {}", config.changelog.path); println!( - " Auto-generate: {}", + " {} {}", + messages.emoji_decorations_value(), + if config.output.emoji { + "yes".green() + } else { + "no".red() + } + ); + + println!("\n{}", messages.changelog_configuration_section().bold()); + println!(" {} {}", messages.path_value(), config.changelog.path); + println!( + " {} {}", + messages.auto_generate_value(), if config.changelog.auto_generate { "yes".green() } else { @@ -350,14 +430,18 @@ impl ConfigCommand { } ); - println!("\n{}", "Security:".bold()); - println!(" Repository mappings: {} mapping(s)", config.repo_profiles.len()); + println!("\n{}", messages.security_section().bold()); println!( - " Keyring: {}", + " {}", + messages.repository_mappings_value(config.repo_profiles.len()) + ); + println!( + " {} {}", + messages.keyring_value(), if manager.keyring().is_available() { - "available".green() + messages.available_value().green() } else { - "unavailable".red() + messages.unavailable_value().red() } ); @@ -366,13 +450,16 @@ impl ConfigCommand { async fn edit_config(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); crate::utils::editor::edit_file(manager.path())?; - println!("{} Configuration updated", "✓".green()); + print_success(messages.configuration_updated()); Ok(()) } async fn set_value(&self, key: &str, value: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); + let mut echo_value: Option = None; match key { "llm.provider" => manager.set_llm_provider(value.to_string()), @@ -391,7 +478,9 @@ impl ConfigCommand { manager.config_mut().llm.timeout = timeout; } "llm.thinking_enabled" => { - manager.config_mut().llm.thinking_enabled = value == "true"; + let parsed = parse_bool(value, &messages)?; + manager.config_mut().llm.thinking_enabled = parsed; + echo_value = Some(parsed.to_string()); } "llm.thinking_budget_tokens" => { let budget: u32 = value.parse()?; @@ -400,7 +489,10 @@ impl ConfigCommand { "llm.api_key_storage" => { let valid_values = ["keyring", "config", "environment"]; if !valid_values.contains(&value) { - bail!("Invalid value: {}. Use: {}", value, valid_values.join(", ")); + bail!( + "{}", + messages.invalid_value_use(value, &valid_values.join(", ")) + ); } manager.config_mut().llm.api_key_storage = value.to_string(); } @@ -408,41 +500,57 @@ impl ConfigCommand { let format = match value { "conventional" => CommitFormat::Conventional, "commitlint" => CommitFormat::Commitlint, - _ => bail!("Invalid format: {}. Use: conventional, commitlint", value), + _ => bail!("{}", messages.invalid_format(value)), }; manager.set_commit_format(format); } "commit.auto_generate" => { - manager.set_auto_generate_commits(value == "true"); + let parsed = parse_bool(value, &messages)?; + manager.set_auto_generate_commits(parsed); + echo_value = Some(parsed.to_string()); } "tag.version_prefix" => manager.set_version_prefix(value.to_string()), "tag.auto_generate" => { - manager.config_mut().tag.auto_generate = value == "true"; + let parsed = parse_bool(value, &messages)?; + manager.config_mut().tag.auto_generate = parsed; + echo_value = Some(parsed.to_string()); } "changelog.path" => manager.set_changelog_path(value.to_string()), "changelog.auto_generate" => { - manager.config_mut().changelog.auto_generate = value == "true"; + let parsed = parse_bool(value, &messages)?; + manager.config_mut().changelog.auto_generate = parsed; + echo_value = Some(parsed.to_string()); } "language.output_language" => { manager.set_output_language(value.to_string()); } "language.keep_types_english" => { - manager.set_keep_types_english(value == "true"); + let parsed = parse_bool(value, &messages)?; + manager.set_keep_types_english(parsed); + echo_value = Some(parsed.to_string()); } "language.keep_changelog_types_english" => { - manager.set_keep_changelog_types_english(value == "true"); + let parsed = parse_bool(value, &messages)?; + manager.set_keep_changelog_types_english(parsed); + echo_value = Some(parsed.to_string()); } - _ => bail!("Unknown configuration key: {}", key), + "output.emoji" => { + let parsed = parse_bool(value, &messages)?; + manager.set_emoji_enabled(parsed); + echo_value = Some(parsed.to_string()); + } + _ => bail!("{}", messages.unknown_config_key(key)), } manager.save()?; - println!("{} Set {} = {}", "✓".green(), key.cyan(), value); + print_success(&messages.set_value(key, echo_value.as_deref().unwrap_or(value))); Ok(()) } async fn get_value(&self, key: &str, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let config = manager.config(); let value = match key { @@ -470,7 +578,8 @@ impl ConfigCommand { "language.keep_changelog_types_english" => { config.language.keep_changelog_types_english.to_string() } - _ => bail!("Unknown configuration key: {}", key), + "output.emoji" => config.output.emoji.to_string(), + _ => bail!("{}", messages.unknown_config_key(key)), }; println!("{}", value); @@ -486,19 +595,16 @@ impl ConfigCommand { config_path: &Option, ) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); 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!("{}", messages.invalid_provider(p, &providers.join(", "))); } p.to_string() } else { - println!("{}", "Select LLM Provider:".bold()); + println!("{}", messages.select_llm_provider_header().bold()); let provider_display_names = vec![ "Ollama (local)", "OpenAI", @@ -521,11 +627,8 @@ impl ConfigCommand { 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!("{}", keyring.get_status_message().yellow()); + print_warning(messages.keyring_unavailable()); + print_warning(&keyring.get_status_message()); } let selected_model = if let Some(m) = model { @@ -533,7 +636,7 @@ impl ConfigCommand { } else { let default_model = get_default_model(&selected_provider); Input::new() - .with_prompt("Model name") + .with_prompt(messages.model_name()) .default(default_model.to_string()) .interact_text()? }; @@ -542,20 +645,20 @@ impl ConfigCommand { Some(u.to_string()) } else if selected_provider == "ollama" { let url: String = Input::new() - .with_prompt("Ollama server URL") + .with_prompt(messages.ollama_server_url()) .default("http://localhost:11434".to_string()) .interact_text()?; Some(url) } else { let use_custom = Confirm::new() - .with_prompt("Use custom API base URL?") + .with_prompt(messages.use_custom_base_url()) .default(false) .interact()?; if use_custom { let default_url = get_default_base_url(&selected_provider); let url: String = Input::new() - .with_prompt("Base URL") + .with_prompt(messages.base_url_plain()) .default(default_url.to_string()) .interact_text()?; Some(url) @@ -571,25 +674,24 @@ impl ConfigCommand { let existing_key = manager.get_api_key(); if existing_key.is_some() { let overwrite = Confirm::new() - .with_prompt("API key already exists. Update it?") + .with_prompt(messages.api_key_exists_update()) .default(false) .interact()?; if overwrite { - let key: String = Input::new().with_prompt("API key").interact_text()?; + let key: String = + crate::utils::password_input(messages.api_key_prompt_other())?; Some(key) } else { None } } else { - let key: String = Input::new().with_prompt("API key").interact_text()?; + let key: String = + crate::utils::password_input(messages.api_key_prompt_other())?; Some(key) } } else { - println!( - "\n{}", - "Please set the QUICOMMIT_API_KEY environment variable.".yellow() - ); + print_warning(messages.please_set_api_key_env()); None } } else { @@ -602,28 +704,34 @@ impl ConfigCommand { if let Some(key) = selected_api_key { manager.set_api_key(&key)?; - println!( - "\n{} API key stored securely in system keyring", - "✓".green() - ); + print_success(messages.api_key_stored_keyring()); } 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()); + print_success(messages.llm_configuration_updated()); + println!( + " {} {}", + messages.provider_value(), + manager.llm_provider().cyan() + ); + println!( + " {} {}", + messages.model_value(), + manager.llm_model().cyan() + ); + println!(" {} {}", messages.base_url_value(), 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 messages = self.messages_for(&manager); let provider = manager.llm_provider().to_string(); if !provider_needs_api_key(&provider) { - println!("{} {} does not require an API key", "ℹ".blue(), provider); + print_info(&format!("{} does not require an API key", provider)); return Ok(()); } @@ -632,40 +740,27 @@ impl ConfigCommand { 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!("{}", messages.keyring_unavailable_env_hint()); } manager.set_api_key(key)?; - println!( - "{} API key stored securely in system keyring for {}", - "✓".green(), - provider.cyan() - ); + print_success(&messages.api_key_stored_for(&provider)); } "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() - ); + print_success(&messages.api_key_stored_config_for(&provider)); + print_warning(messages.api_key_plain_text_note()); } "environment" => { bail!( - "API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.", - provider.to_uppercase() + "{}", + messages.api_key_env_storage_bail(&provider.to_uppercase()) ); } _ => { - bail!("Invalid API key storage method: {}", storage_method); + bail!("{}", messages.invalid_storage_method(&storage_method)); } } @@ -674,6 +769,7 @@ impl ConfigCommand { async fn delete_api_key(&self, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let provider = manager.llm_provider().to_string(); let storage_method = manager.config().llm.api_key_storage.to_string(); @@ -681,35 +777,23 @@ impl ConfigCommand { match storage_method.as_str() { "keyring" => { if !manager.keyring().is_available() { - bail!("Keyring is not available."); + bail!("{}", messages.keyring_unavailable_plain()); } manager.delete_api_key()?; - println!( - "{} API key deleted from system keyring for {}", - "✓".green(), - provider.cyan() - ); + print_success(&messages.api_key_deleted_keyring_for(&provider)); } "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() - ); + print_success(&messages.api_key_deleted_config_for(&provider)); } "environment" => { - println!( - "{} API key storage set to 'environment'. Please remove QUICOMMIT_{}_API_KEY environment variable manually.", - "ℹ".blue(), - provider.to_uppercase() - ); + print_info(&messages.api_key_env_storage_info(&provider.to_uppercase())); } _ => { - bail!("Invalid API key storage method: {}", storage_method); + bail!("{}", messages.invalid_storage_method(&storage_method)); } } @@ -718,39 +802,38 @@ impl ConfigCommand { async fn set_commit_format(&self, format: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let format = match format { "conventional" => CommitFormat::Conventional, "commitlint" => CommitFormat::Commitlint, - _ => bail!("Invalid format: {}. Use: conventional, commitlint", format), + _ => bail!("{}", messages.invalid_format(format)), }; manager.set_commit_format(format); manager.save()?; - println!( - "{} Commit format set to {}", - "✓".green(), - format.to_string().cyan() - ); + print_success(&messages.commit_format_set(&format.to_string())); Ok(()) } async fn set_version_prefix(&self, prefix: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); manager.set_version_prefix(prefix.to_string()); manager.save()?; - println!("{} Version prefix set to '{}'", "✓".green(), prefix.cyan()); + print_success(&messages.version_prefix_set(prefix)); Ok(()) } async fn set_changelog_path(&self, path: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); manager.set_changelog_path(path.to_string()); manager.save()?; - println!("{} Changelog path set to {}", "✓".green(), path.cyan()); + print_success(&messages.changelog_path_set(path)); Ok(()) } @@ -760,11 +843,12 @@ impl ConfigCommand { config_path: &Option, ) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let lang_code = if let Some(l) = language { l.to_string() } else { - println!("{}", "Select Output Language:".bold()); + println!("{}", messages.select_output_language_header().bold()); let languages = [ ("en", "English"), ("zh", "中文"), @@ -784,11 +868,7 @@ impl ConfigCommand { manager.set_output_language(lang_code.clone()); manager.save()?; - println!( - "{} Output language set to {}", - "✓".green(), - lang_code.cyan() - ); + print_success(&messages.output_language_set(&lang_code)); Ok(()) } @@ -798,14 +878,11 @@ impl ConfigCommand { config_path: &Option, ) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); manager.set_keep_types_english(keep); manager.save()?; - println!( - "{} Keep commit types in English: {}", - "✓".green(), - keep.to_string().cyan() - ); + print_success(&messages.keep_types_english_set(keep)); Ok(()) } @@ -815,26 +892,26 @@ impl ConfigCommand { config_path: &Option, ) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); manager.set_keep_changelog_types_english(keep); manager.save()?; - println!( - "{} Keep changelog types in English: {}", - "✓".green(), - keep.to_string().cyan() - ); + print_success(&messages.keep_changelog_types_english_set(keep)); Ok(()) } async fn reset(&self, force: bool, config_path: &Option) -> Result<()> { + let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); + if !force { let confirm = Confirm::new() - .with_prompt("Reset all configuration to defaults?") + .with_prompt(messages.reset_confirm()) .default(false) .interact()?; if !confirm { - println!("Reset cancelled."); + print_warning(messages.reset_cancelled()); return Ok(()); } } @@ -843,7 +920,7 @@ impl ConfigCommand { manager.reset(); manager.save()?; - println!("{} Configuration reset to defaults", "✓".green()); + print_success(messages.configuration_reset_done()); Ok(()) } @@ -854,27 +931,32 @@ impl ConfigCommand { config_path: &Option, ) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let toml = manager.export()?; + if output.is_none() && password.is_some() { + bail!("{}", messages.password_requires_output()); + } + let export_content = if let Some(_path) = output { let pwd = if let Some(p) = password { p.to_string() } else { let confirm = Confirm::new() - .with_prompt("Encrypt the exported configuration?") + .with_prompt(messages.encrypt_export_confirm()) .default(true) .interact()?; if confirm { let pwd1 = Password::new() - .with_prompt("Enter encryption password") + .with_prompt(messages.enter_encryption_password()) .interact()?; let pwd2 = Password::new() - .with_prompt("Confirm encryption password") + .with_prompt(messages.confirm_encryption_password()) .interact()?; if pwd1 != pwd2 { - bail!("Passwords do not match"); + bail!("{}", messages.passwords_do_not_match()); } pwd1 } else { @@ -894,21 +976,9 @@ 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!(); + print_warning(messages.export_warning_no_encryption()); + print_warning(messages.export_pats_not_included()); + print_warning(messages.export_enable_encryption_hint()); } toml @@ -945,16 +1015,25 @@ impl ConfigCommand { Some(path) => { std::fs::write(path, &export_content)?; if export_content.starts_with("ENCRYPTED:") { - println!( - "{} Configuration encrypted and exported to {}", - "✓".green(), - path - ); + print_success(&messages.configuration_encrypted_exported(path)); } else { - println!("{} Configuration exported to {}", "✓".green(), path); + print_success(&messages.configuration_exported(path)); } } None => { + let has_pats = manager + .config() + .profiles + .iter() + .any(|(profile_name, profile)| { + profile + .tokens + .keys() + .any(|service| manager.has_pat_for_profile(profile_name, service)) + }); + if has_pats { + eprint_warning(messages.export_plain_pat_warning()); + } println!("{}", export_content); } } @@ -968,6 +1047,8 @@ impl ConfigCommand { password: Option<&str>, config_path: &Option, ) -> Result<()> { + let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let content = std::fs::read_to_string(file)?; let (config_content, encrypted_pats, pwd) = if content.starts_with("ENCRYPTED:") { @@ -977,17 +1058,14 @@ impl ConfigCommand { p.to_string() } else { Password::new() - .with_prompt("Enter decryption password") + .with_prompt(messages.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!("{}", messages.failed_decrypt_config(&e.to_string())); } }; @@ -1014,7 +1092,7 @@ impl ConfigCommand { && !pats.is_empty() { println!(); - println!("{}", "Importing Personal Access Tokens...".bold()); + println!("{}", messages.importing_pats_header().bold()); let mut imported_count = 0; let mut failed_count = 0; @@ -1031,19 +1109,23 @@ impl ConfigCommand { ) { Ok(_) => { println!( - " {} Token for {} ({}) imported to keyring", - "✓".green(), - pat.profile_name.cyan(), - pat.service.yellow() + " {} {}", + success_prefix(), + messages.token_imported_to_keyring( + &pat.profile_name, + &pat.service + ) ); imported_count += 1; } Err(e) => { println!( - " {} Failed to store token for {} ({}): {}", - "✗".red(), - pat.profile_name, - pat.service, + " {} {}: {}", + error_prefix(), + messages.failed_store_token( + &pat.profile_name, + &pat.service + ), e ); failed_count += 1; @@ -1051,20 +1133,21 @@ impl ConfigCommand { } } else { println!( - " {} Keyring not available, cannot store token for {} ({})", - "⚠".yellow(), - pat.profile_name, - pat.service + " {} {}", + warning_prefix(), + messages.keyring_unavailable_token_import( + &pat.profile_name, + &pat.service + ) ); failed_count += 1; } } Err(e) => { println!( - " {} Invalid token format for {} ({}): {}", - "✗".red(), - pat.profile_name, - pat.service, + " {} {}: {}", + error_prefix(), + messages.invalid_token_format(&pat.profile_name, &pat.service), e ); failed_count += 1; @@ -1072,10 +1155,9 @@ impl ConfigCommand { }, Err(e) => { println!( - " {} Failed to decrypt token for {} ({}): {}", - "✗".red(), - pat.profile_name, - pat.service, + " {} {}: {}", + error_prefix(), + messages.failed_decrypt_token(&pat.profile_name, &pat.service), e ); failed_count += 1; @@ -1085,49 +1167,51 @@ impl ConfigCommand { println!(); if imported_count > 0 { - println!( - "{} {} token(s) imported to keyring", - "✓".green(), - imported_count - ); + print_success(&messages.tokens_imported_count(imported_count)); } if failed_count > 0 { - println!( - "{} {} token(s) failed to import", - "⚠".yellow(), - failed_count - ); + print_warning(&messages.tokens_failed_count(failed_count)); } } - println!("{} Configuration imported from {}", "✓".green(), file); + print_success(&messages.configuration_imported(file)); Ok(()) } async fn list_models(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let provider = manager.llm_provider(); - println!("{}", "\nAvailable Models".bold()); + println!("\n{}", messages.available_models_header().bold()); println!("{}", "─".repeat(40)); match provider { "ollama" => { - println!("Ollama models (local):"); - println!(" llama2, llama2-uncensored, llama2:13b"); - println!(" codellama, codellama:34b"); - println!(" mistral, mixtral, phi, gemma"); - println!("\nRun 'ollama list' to see installed models"); + let output = std::process::Command::new("ollama").args(["list"]).output(); + match output { + Ok(out) if out.status.success() => { + println!("Ollama models (installed):"); + print!("{}", String::from_utf8_lossy(&out.stdout)); + } + _ => { + println!("Ollama models (examples):"); + println!(" llama2, llama2-uncensored, llama2:13b"); + println!(" codellama, codellama:34b"); + println!(" mistral, mixtral, phi, gemma"); + println!("\nRun 'ollama list' to see installed models"); + } + } } "openai" => { - println!("OpenAI models:"); + println!("OpenAI models (examples):"); println!(" o-series (reasoning): o4-mini, o3, o3-mini, o1, o1-mini, o1-pro"); println!(" GPT-4: gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, gpt-4o, gpt-4o-mini"); println!(" Legacy: gpt-4-turbo, gpt-4, gpt-3.5-turbo"); println!("\nUse --think/-t with o-series models for reasoning mode."); } "anthropic" => { - println!("Anthropic Claude models:"); + println!("Anthropic Claude models (examples):"); println!( " Claude 4 (thinking): claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5" ); @@ -1138,7 +1222,7 @@ impl ConfigCommand { println!("\nUse --think/-t with Claude 4 models for extended thinking."); } "kimi" => { - println!("Kimi (Moonshot AI) models:"); + println!("Kimi (Moonshot AI) models (examples):"); println!( " K2 (thinking): kimi-k2.6, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo" ); @@ -1147,7 +1231,7 @@ impl ConfigCommand { println!("\nUse --think/-t with K2 models for thinking mode."); } "deepseek" => { - println!("DeepSeek models:"); + println!("DeepSeek models (examples):"); println!(" V4: deepseek-v4-flash, deepseek-v4-pro"); println!(" Legacy: deepseek-chat, deepseek-reasoner (deprecated 2026-07-24)"); println!("\nUse --think/-t for reasoning mode."); @@ -1165,43 +1249,50 @@ impl ConfigCommand { } } - println!("\nCurrent model: {}", manager.llm_model().cyan()); + println!( + "\n{} {}", + messages.current_model_value(), + manager.llm_model().cyan() + ); Ok(()) } async fn test_llm(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); - println!("{}", "\nTesting LLM Connection...".bold()); - println!(" Provider: {}", manager.llm_provider().cyan()); - println!(" Model: {}", manager.llm_model().cyan()); - println!(" Base URL: {}", manager.llm_base_url()); + println!("\n{}", messages.testing_llm_header().bold()); + println!( + " {} {}", + messages.provider_value(), + manager.llm_provider().cyan() + ); + println!( + " {} {}", + messages.model_value(), + manager.llm_model().cyan() + ); + println!(" {} {}", messages.base_url_value(), 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() - } - ); + if has_key { + print_success(messages.api_key_configured()); + } else { + print_error(messages.api_key_not_set()); + } } - println!("\n{}", "Sending test request...".dimmed()); + println!("\n{}", messages.sending_test_request().dimmed()); match crate::llm::test_connection(&manager).await { Ok(response) => { - println!("{} Connection successful!", "✓".green()); - println!("\n{}", "Response:".bold()); + print_success(messages.connection_successful()); + println!("\n{}", messages.response_header().bold()); println!(" {}", response); } - Err(e) => { - println!("{} Connection failed: {}", "✗".red(), e); - return Err(e); - } + Err(e) => return Err(e), } Ok(()) diff --git a/src/commands/credential.rs b/src/commands/credential.rs index a418c83..de97a0a 100644 --- a/src/commands/credential.rs +++ b/src/commands/credential.rs @@ -1,458 +1,457 @@ -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use std::io::{self, BufRead, Write}; -use std::path::PathBuf; - -use crate::config::manager::ConfigManager; -use crate::config::{TokenConfig, TokenType}; - -/// Git credential helper command. -/// -/// Implements the git credential helper protocol -/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git -/// via `credential.helper` configuration, not by end users. Hidden from the -/// main help output. -#[derive(Parser)] -#[command(hide = true)] -pub struct CredentialCommand { - #[command(subcommand)] - command: CredentialSubcommand, -} - -#[derive(Subcommand)] -enum CredentialSubcommand { - /// Read attributes on stdin and output credentials on stdout. - #[command(hide = true)] - Get, - /// Read attributes (including password) on stdin and store them. - #[command(hide = true)] - Store, - /// Read attributes on stdin and erase any matching stored credentials. - #[command(hide = true)] - Erase, -} - -impl CredentialCommand { - pub async fn execute(&self, config_path: Option) -> Result<()> { - match &self.command { - CredentialSubcommand::Get => Self::get(config_path), - CredentialSubcommand::Store => Self::store(config_path), - CredentialSubcommand::Erase => Self::erase(config_path), - } - } - - fn get_manager(config_path: &Option) -> Result { - match config_path { - Some(path) => ConfigManager::with_path(path), - None => ConfigManager::new(), - } - } - - /// `git credential get`: look up a PAT for the requested host and emit it - /// on stdout following the git credential helper protocol. - fn get(config_path: Option) -> Result<()> { - let attrs = CredentialAttributes::from_stdin()?; - - let host = match attrs.host.as_deref() { - Some(h) if !h.is_empty() => h, - _ => return Ok(()), - }; - let service = host_to_service(host); - - let manager = match Self::get_manager(&config_path) { - Ok(m) => m, - Err(_) => return Ok(()), - }; - - let (profile_name, pat) = match find_pat_for_service(&manager, &service) { - Some(tuple) => tuple, - None => return Ok(()), - }; - - // Prefer a git-supplied username; fall back to the profile's user_name. - let username = attrs.username.clone().or_else(|| { - manager - .get_profile(&profile_name) - .map(|p| p.user_name.clone()) - }); - - let output = CredentialAttributes { - protocol: attrs.protocol.clone(), - host: attrs.host.clone(), - path: attrs.path.clone(), - username, - password: Some(pat), - }; - output.to_stdout()?; - Ok(()) - } - - /// `git credential store`: persist the PAT provided by git using the - /// existing keyring-backed storage, associated with a matching profile. - fn store(config_path: Option) -> Result<()> { - let attrs = CredentialAttributes::from_stdin()?; - - let host = match attrs.host.as_deref() { - Some(h) if !h.is_empty() => h, - _ => return Ok(()), - }; - let service = host_to_service(host); - - let password = match attrs.password.as_deref() { - Some(p) if !p.is_empty() => p, - _ => return Ok(()), - }; - - let mut manager = Self::get_manager(&config_path)?; - - let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) { - Some(name) => name, - None => return Ok(()), - }; - - // Store PAT in keyring using the existing profile-bound logic. - if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) { - eprintln!("[quicommit credential] failed to store PAT: {}", e); - return Ok(()); - } - - // Register the token in the profile config if not already present. - let already_has = manager - .get_profile(&profile_name) - .map(|p| p.tokens.contains_key(&service)) - .unwrap_or(false); - if !already_has { - let _ = manager.add_token_to_profile( - &profile_name, - service.clone(), - TokenConfig::new(TokenType::Personal), - ); - } - - manager.save()?; - Ok(()) - } - - /// `git credential erase`: remove any PAT stored for the requested host - /// from the keyring and the associated profile config entries. - fn erase(config_path: Option) -> Result<()> { - let attrs = CredentialAttributes::from_stdin()?; - - let host = match attrs.host.as_deref() { - Some(h) if !h.is_empty() => h, - _ => return Ok(()), - }; - let service = host_to_service(host); - - let mut manager = Self::get_manager(&config_path)?; - - let profile_names: Vec = manager - .list_profiles() - .into_iter() - .filter(|name| { - manager - .get_profile(name) - .map(|p| p.tokens.contains_key(&service)) - .unwrap_or(false) - }) - .cloned() - .collect(); - - for name in &profile_names { - if let Err(e) = manager.remove_token_from_profile(name, &service) { - eprintln!( - "[quicommit credential] failed to erase PAT for '{}': {}", - name, e - ); - } - } - - manager.save()?; - Ok(()) - } -} - -/// Credential attributes exchanged with git via stdin/stdout following the -/// git credential helper protocol (`key=value`, one per line, blank line ends). -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct CredentialAttributes { - pub protocol: Option, - pub host: Option, - pub path: Option, - pub username: Option, - pub password: Option, -} - -impl CredentialAttributes { - /// Read attributes from stdin following the git credential helper protocol. - pub fn from_stdin() -> Result { - let stdin = io::stdin(); - let mut attrs = Self::default(); - for line in stdin.lock().lines() { - let line = - line.context("Failed to read credential attributes from stdin")?; - if line.is_empty() { - break; - } - if let Some((key, value)) = line.split_once('=') { - match key { - "protocol" => attrs.protocol = Some(value.to_string()), - "host" => attrs.host = Some(value.to_string()), - "path" => attrs.path = Some(value.to_string()), - "username" => attrs.username = Some(value.to_string()), - "password" => attrs.password = Some(value.to_string()), - _ => {} // ignore unknown keys - } - } - } - Ok(attrs) - } - - /// Write attributes to stdout following the git credential helper protocol. - pub fn to_stdout(&self) -> Result<()> { - let stdout = io::stdout(); - let mut handle = stdout.lock(); - if let Some(ref v) = self.protocol { - writeln!(handle, "protocol={}", v)?; - } - if let Some(ref v) = self.host { - writeln!(handle, "host={}", v)?; - } - if let Some(ref v) = self.path { - writeln!(handle, "path={}", v)?; - } - if let Some(ref v) = self.username { - writeln!(handle, "username={}", v)?; - } - if let Some(ref v) = self.password { - writeln!(handle, "password={}", v)?; - } - writeln!(handle)?; // blank line terminates the attribute list - handle.flush()?; - Ok(()) - } - - /// Parse attributes from a text block following the git credential helper - /// protocol. Intended for testing and non-stdin input handling. - pub fn parse_str(input: &str) -> Self { - let mut attrs = Self::default(); - for line in input.lines() { - if line.is_empty() { - break; - } - if let Some((key, value)) = line.split_once('=') { - match key { - "protocol" => attrs.protocol = Some(value.to_string()), - "host" => attrs.host = Some(value.to_string()), - "path" => attrs.path = Some(value.to_string()), - "username" => attrs.username = Some(value.to_string()), - "password" => attrs.password = Some(value.to_string()), - _ => {} - } - } - } - attrs - } - - /// Serialize attributes to a string following the git credential helper - /// protocol. Intended for testing and non-stdout output handling. - pub fn serialize(&self) -> String { - let mut out = String::new(); - if let Some(ref v) = self.protocol { - out.push_str(&format!("protocol={}\n", v)); - } - if let Some(ref v) = self.host { - out.push_str(&format!("host={}\n", v)); - } - if let Some(ref v) = self.path { - out.push_str(&format!("path={}\n", v)); - } - if let Some(ref v) = self.username { - out.push_str(&format!("username={}\n", v)); - } - if let Some(ref v) = self.password { - out.push_str(&format!("password={}\n", v)); - } - out.push('\n'); - out - } -} - -/// Map a git host to a service name used by the keyring-backed PAT storage. -/// -/// Common git hosting services are mapped to short canonical names. Unknown -/// hosts are used as-is (lowercased, trailing slash trimmed). -pub fn host_to_service(host: &str) -> String { - let host = host.to_lowercase(); - let host = host.trim_end_matches('/'); - match host { - "github.com" | "www.github.com" => "github".to_string(), - "gitlab.com" | "www.gitlab.com" => "gitlab".to_string(), - "bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(), - "codeberg.org" | "www.codeberg.org" => "codeberg".to_string(), - "gitea.com" | "www.gitea.com" => "gitea".to_string(), - "gitee.com" | "www.gitee.com" => "gitee".to_string(), - other => other.to_string(), - } -} - -/// Search all profiles for one that has a PAT stored for the given service. -/// Returns the profile name and the PAT value. -fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> { - for profile_name in manager.list_profiles() { - if manager.has_pat_for_profile(profile_name, service) { - if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) { - return Some((profile_name.clone(), pat)); - } - } - } - None -} - -/// Determine which profile to use when storing a credential. -/// -/// Prefers a profile whose `user_name` or `user_email` matches the -/// git-supplied username; otherwise falls back to the default profile. -fn find_profile_for_store( - manager: &ConfigManager, - username: Option<&str>, -) -> Option { - if let Some(username) = username { - for name in manager.list_profiles() { - if let Some(profile) = manager.get_profile(name) { - if profile.user_name == username || profile.user_email == username { - return Some(name.clone()); - } - } - } - } - manager.default_profile_name().cloned() -} - -/// Extract a PAT for the given host from saved credentials across all profiles. -/// -/// This is intended for use by other parts of the application (e.g. when -/// verifying access to a git hosting service) and searches every configured -/// profile for a stored PAT matching the host. -pub fn get_pat_for_host(host: &str) -> Result> { - let manager = ConfigManager::new()?; - let service = host_to_service(host); - Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_host_to_service_known_hosts() { - assert_eq!(host_to_service("github.com"), "github"); - assert_eq!(host_to_service("www.github.com"), "github"); - assert_eq!(host_to_service("gitlab.com"), "gitlab"); - assert_eq!(host_to_service("bitbucket.org"), "bitbucket"); - assert_eq!(host_to_service("codeberg.org"), "codeberg"); - assert_eq!(host_to_service("gitea.com"), "gitea"); - assert_eq!(host_to_service("gitee.com"), "gitee"); - } - - #[test] - fn test_host_to_service_case_insensitive() { - assert_eq!(host_to_service("GitHub.Com"), "github"); - assert_eq!(host_to_service("GITLAB.COM"), "gitlab"); - } - - #[test] - fn test_host_to_service_trailing_slash() { - assert_eq!(host_to_service("github.com/"), "github"); - assert_eq!(host_to_service("gitlab.com//"), "gitlab"); - } - - #[test] - fn test_host_to_service_unknown_host() { - assert_eq!(host_to_service("example.com"), "example.com"); - assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp"); - } - - #[test] - fn test_credential_attributes_parse_basic() { - let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n"; - let attrs = CredentialAttributes::parse_str(input); - assert_eq!(attrs.protocol.as_deref(), Some("https")); - assert_eq!(attrs.host.as_deref(), Some("github.com")); - assert_eq!(attrs.username.as_deref(), Some("octocat")); - assert_eq!(attrs.password.as_deref(), Some("ghp_token123")); - assert!(attrs.path.is_none()); - } - - #[test] - fn test_credential_attributes_parse_with_path() { - let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n"; - let attrs = CredentialAttributes::parse_str(input); - assert_eq!(attrs.path.as_deref(), Some("owner/repo.git")); - } - - #[test] - fn test_credential_attributes_parse_empty() { - let attrs = CredentialAttributes::parse_str(""); - assert!(attrs.protocol.is_none()); - assert!(attrs.host.is_none()); - assert!(attrs.username.is_none()); - assert!(attrs.password.is_none()); - } - - #[test] - fn test_credential_attributes_parse_ignores_unknown_keys() { - let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n"; - let attrs = CredentialAttributes::parse_str(input); - assert_eq!(attrs.protocol.as_deref(), Some("https")); - assert_eq!(attrs.host.as_deref(), Some("github.com")); - } - - #[test] - fn test_credential_attributes_parse_stops_at_blank_line() { - let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n"; - let attrs = CredentialAttributes::parse_str(input); - assert_eq!(attrs.protocol.as_deref(), Some("https")); - assert_eq!(attrs.host.as_deref(), Some("github.com")); - assert!(attrs.password.is_none()); - } - - #[test] - fn test_credential_attributes_serialize_roundtrip() { - let attrs = CredentialAttributes { - protocol: Some("https".to_string()), - host: Some("github.com".to_string()), - path: None, - username: Some("octocat".to_string()), - password: Some("ghp_token".to_string()), - }; - let serialized = attrs.serialize(); - let reparsed = CredentialAttributes::parse_str(&serialized); - assert_eq!(attrs, reparsed); - } - - #[test] - fn test_credential_attributes_serialize_includes_blank_line() { - let attrs = CredentialAttributes { - protocol: Some("https".to_string()), - host: Some("github.com".to_string()), - path: None, - username: None, - password: None, - }; - let serialized = attrs.serialize(); - assert!(serialized.ends_with("\n\n")); - assert!(serialized.contains("protocol=https\n")); - assert!(serialized.contains("host=github.com\n")); - } - - #[test] - fn test_credential_attributes_default() { - let attrs = CredentialAttributes::default(); - assert!(attrs.protocol.is_none()); - assert!(attrs.host.is_none()); - assert!(attrs.path.is_none()); - assert!(attrs.username.is_none()); - assert!(attrs.password.is_none()); - } -} +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use crate::config::manager::ConfigManager; +use crate::config::{TokenConfig, TokenType}; + +/// Git credential helper command. +/// +/// Implements the git credential helper protocol +/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git +/// via `credential.helper` configuration, not by end users. Hidden from the +/// main help output. +#[derive(Parser)] +#[command(hide = true)] +pub struct CredentialCommand { + #[command(subcommand)] + command: CredentialSubcommand, +} + +#[derive(Subcommand)] +enum CredentialSubcommand { + /// Read attributes on stdin and output credentials on stdout. + #[command(hide = true)] + Get, + /// Read attributes (including password) on stdin and store them. + #[command(hide = true)] + Store, + /// Read attributes on stdin and erase any matching stored credentials. + #[command(hide = true)] + Erase, +} + +impl CredentialCommand { + pub async fn execute(&self, config_path: Option) -> Result<()> { + match &self.command { + CredentialSubcommand::Get => Self::get(config_path), + CredentialSubcommand::Store => Self::store(config_path), + CredentialSubcommand::Erase => Self::erase(config_path), + } + } + + fn get_manager(config_path: &Option) -> Result { + match config_path { + Some(path) => ConfigManager::with_path(path), + None => ConfigManager::new(), + } + } + + /// `git credential get`: look up a PAT for the requested host and emit it + /// on stdout following the git credential helper protocol. + fn get(config_path: Option) -> Result<()> { + let attrs = CredentialAttributes::from_stdin()?; + + let host = match attrs.host.as_deref() { + Some(h) if !h.is_empty() => h, + _ => return Ok(()), + }; + let service = host_to_service(host); + + let manager = match Self::get_manager(&config_path) { + Ok(m) => m, + Err(_) => return Ok(()), + }; + + let (profile_name, pat) = match find_pat_for_service(&manager, &service) { + Some(tuple) => tuple, + None => return Ok(()), + }; + + // Prefer a git-supplied username; fall back to the profile's user_name. + let username = attrs.username.clone().or_else(|| { + manager + .get_profile(&profile_name) + .map(|p| p.user_name.clone()) + }); + + let output = CredentialAttributes { + protocol: attrs.protocol.clone(), + host: attrs.host.clone(), + path: attrs.path.clone(), + username, + password: Some(pat), + }; + output.to_stdout()?; + Ok(()) + } + + /// `git credential store`: persist the PAT provided by git using the + /// existing keyring-backed storage, associated with a matching profile. + fn store(config_path: Option) -> Result<()> { + let attrs = CredentialAttributes::from_stdin()?; + + let host = match attrs.host.as_deref() { + Some(h) if !h.is_empty() => h, + _ => return Ok(()), + }; + let service = host_to_service(host); + + let password = match attrs.password.as_deref() { + Some(p) if !p.is_empty() => p, + _ => return Ok(()), + }; + + let mut manager = Self::get_manager(&config_path)?; + + let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) { + Some(name) => name, + None => return Ok(()), + }; + + // Store PAT in keyring using the existing profile-bound logic. + if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) { + eprintln!("[quicommit credential] failed to store PAT: {}", e); + return Ok(()); + } + + // Register the token in the profile config if not already present. + let already_has = manager + .get_profile(&profile_name) + .map(|p| p.tokens.contains_key(&service)) + .unwrap_or(false); + if !already_has { + let _ = manager.add_token_to_profile( + &profile_name, + service.clone(), + TokenConfig::new(TokenType::Personal), + ); + } + + manager.save()?; + Ok(()) + } + + /// `git credential erase`: remove any PAT stored for the requested host + /// from the keyring and the associated profile config entries. + fn erase(config_path: Option) -> Result<()> { + let attrs = CredentialAttributes::from_stdin()?; + + let host = match attrs.host.as_deref() { + Some(h) if !h.is_empty() => h, + _ => return Ok(()), + }; + let service = host_to_service(host); + + let mut manager = Self::get_manager(&config_path)?; + + let profile_names: Vec = manager.list_profiles().into_iter().cloned().collect(); + + for name in &profile_names { + let registered = manager + .get_profile(name) + .map(|p| p.tokens.contains_key(&service)) + .unwrap_or(false); + + if registered { + if let Err(e) = manager.remove_token_from_profile(name, &service) { + eprintln!( + "[quicommit credential] failed to erase PAT for '{}': {}", + name, e + ); + } + } else if let Ok(true) = manager.delete_orphan_pat(name, &service) { + // Keyring PAT without a config token entry (issue 24). + eprintln!( + "[quicommit credential] erased unregistered PAT for '{}' (service {}) from keyring", + name, service + ); + } + } + + manager.save()?; + Ok(()) + } +} + +/// Credential attributes exchanged with git via stdin/stdout following the +/// git credential helper protocol (`key=value`, one per line, blank line ends). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct CredentialAttributes { + pub protocol: Option, + pub host: Option, + pub path: Option, + pub username: Option, + pub password: Option, +} + +impl CredentialAttributes { + /// Read attributes from stdin following the git credential helper protocol. + pub fn from_stdin() -> Result { + let stdin = io::stdin(); + let mut attrs = Self::default(); + for line in stdin.lock().lines() { + let line = line.context("Failed to read credential attributes from stdin")?; + if line.is_empty() { + break; + } + if let Some((key, value)) = line.split_once('=') { + match key { + "protocol" => attrs.protocol = Some(value.to_string()), + "host" => attrs.host = Some(value.to_string()), + "path" => attrs.path = Some(value.to_string()), + "username" => attrs.username = Some(value.to_string()), + "password" => attrs.password = Some(value.to_string()), + _ => {} // ignore unknown keys + } + } + } + Ok(attrs) + } + + /// Write attributes to stdout following the git credential helper protocol. + pub fn to_stdout(&self) -> Result<()> { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + if let Some(ref v) = self.protocol { + writeln!(handle, "protocol={}", v)?; + } + if let Some(ref v) = self.host { + writeln!(handle, "host={}", v)?; + } + if let Some(ref v) = self.path { + writeln!(handle, "path={}", v)?; + } + if let Some(ref v) = self.username { + writeln!(handle, "username={}", v)?; + } + if let Some(ref v) = self.password { + writeln!(handle, "password={}", v)?; + } + writeln!(handle)?; // blank line terminates the attribute list + handle.flush()?; + Ok(()) + } + + /// Parse attributes from a text block following the git credential helper + /// protocol. Intended for testing and non-stdin input handling. + pub fn parse_str(input: &str) -> Self { + let mut attrs = Self::default(); + for line in input.lines() { + if line.is_empty() { + break; + } + if let Some((key, value)) = line.split_once('=') { + match key { + "protocol" => attrs.protocol = Some(value.to_string()), + "host" => attrs.host = Some(value.to_string()), + "path" => attrs.path = Some(value.to_string()), + "username" => attrs.username = Some(value.to_string()), + "password" => attrs.password = Some(value.to_string()), + _ => {} + } + } + } + attrs + } + + /// Serialize attributes to a string following the git credential helper + /// protocol. Intended for testing and non-stdout output handling. + pub fn serialize(&self) -> String { + let mut out = String::new(); + if let Some(ref v) = self.protocol { + out.push_str(&format!("protocol={}\n", v)); + } + if let Some(ref v) = self.host { + out.push_str(&format!("host={}\n", v)); + } + if let Some(ref v) = self.path { + out.push_str(&format!("path={}\n", v)); + } + if let Some(ref v) = self.username { + out.push_str(&format!("username={}\n", v)); + } + if let Some(ref v) = self.password { + out.push_str(&format!("password={}\n", v)); + } + out.push('\n'); + out + } +} + +/// Map a git host to a service name used by the keyring-backed PAT storage. +/// +/// Common git hosting services are mapped to short canonical names. Unknown +/// hosts are used as-is (lowercased, trailing slash trimmed). +pub fn host_to_service(host: &str) -> String { + let host = host.to_lowercase(); + let host = host.trim_end_matches('/'); + match host { + "github.com" | "www.github.com" => "github".to_string(), + "gitlab.com" | "www.gitlab.com" => "gitlab".to_string(), + "bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(), + "codeberg.org" | "www.codeberg.org" => "codeberg".to_string(), + "gitea.com" | "www.gitea.com" => "gitea".to_string(), + "gitee.com" | "www.gitee.com" => "gitee".to_string(), + other => other.to_string(), + } +} + +/// Search all profiles for one that has a PAT stored for the given service. +/// Returns the profile name and the PAT value. +fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> { + for profile_name in manager.list_profiles() { + if manager.has_pat_for_profile(profile_name, service) { + if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) { + return Some((profile_name.clone(), pat)); + } + } + } + None +} + +/// Determine which profile to use when storing a credential. +/// +/// Prefers a profile whose `user_name` or `user_email` matches the +/// git-supplied username; otherwise falls back to the default profile. +fn find_profile_for_store(manager: &ConfigManager, username: Option<&str>) -> Option { + if let Some(username) = username { + for name in manager.list_profiles() { + if let Some(profile) = manager.get_profile(name) { + if profile.user_name == username || profile.user_email == username { + return Some(name.clone()); + } + } + } + } + manager.default_profile_name().cloned() +} + +/// Extract a PAT for the given host from saved credentials across all profiles. +/// +/// This is intended for use by other parts of the application (e.g. when +/// verifying access to a git hosting service) and searches every configured +/// profile for a stored PAT matching the host. +pub fn get_pat_for_host(host: &str) -> Result> { + let manager = ConfigManager::new()?; + let service = host_to_service(host); + Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_host_to_service_known_hosts() { + assert_eq!(host_to_service("github.com"), "github"); + assert_eq!(host_to_service("www.github.com"), "github"); + assert_eq!(host_to_service("gitlab.com"), "gitlab"); + assert_eq!(host_to_service("bitbucket.org"), "bitbucket"); + assert_eq!(host_to_service("codeberg.org"), "codeberg"); + assert_eq!(host_to_service("gitea.com"), "gitea"); + assert_eq!(host_to_service("gitee.com"), "gitee"); + } + + #[test] + fn test_host_to_service_case_insensitive() { + assert_eq!(host_to_service("GitHub.Com"), "github"); + assert_eq!(host_to_service("GITLAB.COM"), "gitlab"); + } + + #[test] + fn test_host_to_service_trailing_slash() { + assert_eq!(host_to_service("github.com/"), "github"); + assert_eq!(host_to_service("gitlab.com//"), "gitlab"); + } + + #[test] + fn test_host_to_service_unknown_host() { + assert_eq!(host_to_service("example.com"), "example.com"); + assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp"); + } + + #[test] + fn test_credential_attributes_parse_basic() { + let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + assert_eq!(attrs.username.as_deref(), Some("octocat")); + assert_eq!(attrs.password.as_deref(), Some("ghp_token123")); + assert!(attrs.path.is_none()); + } + + #[test] + fn test_credential_attributes_parse_with_path() { + let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.path.as_deref(), Some("owner/repo.git")); + } + + #[test] + fn test_credential_attributes_parse_empty() { + let attrs = CredentialAttributes::parse_str(""); + assert!(attrs.protocol.is_none()); + assert!(attrs.host.is_none()); + assert!(attrs.username.is_none()); + assert!(attrs.password.is_none()); + } + + #[test] + fn test_credential_attributes_parse_ignores_unknown_keys() { + let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + } + + #[test] + fn test_credential_attributes_parse_stops_at_blank_line() { + let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + assert!(attrs.password.is_none()); + } + + #[test] + fn test_credential_attributes_serialize_roundtrip() { + let attrs = CredentialAttributes { + protocol: Some("https".to_string()), + host: Some("github.com".to_string()), + path: None, + username: Some("octocat".to_string()), + password: Some("ghp_token".to_string()), + }; + let serialized = attrs.serialize(); + let reparsed = CredentialAttributes::parse_str(&serialized); + assert_eq!(attrs, reparsed); + } + + #[test] + fn test_credential_attributes_serialize_includes_blank_line() { + let attrs = CredentialAttributes { + protocol: Some("https".to_string()), + host: Some("github.com".to_string()), + path: None, + username: None, + password: None, + }; + let serialized = attrs.serialize(); + assert!(serialized.ends_with("\n\n")); + assert!(serialized.contains("protocol=https\n")); + assert!(serialized.contains("host=github.com\n")); + } + + #[test] + fn test_credential_attributes_default() { + let attrs = CredentialAttributes::default(); + assert!(attrs.protocol.is_none()); + assert!(attrs.host.is_none()); + assert!(attrs.path.is_none()); + assert!(attrs.username.is_none()); + assert!(attrs.password.is_none()); + } +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 68a0c66..ed85156 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -10,6 +10,7 @@ use crate::config::{GitProfile, Language}; use crate::i18n::Messages; use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key}; use crate::utils::validators::validate_email; +use crate::utils::{print_success, print_warning}; /// Initialize quicommit configuration #[derive(Parser)] @@ -34,19 +35,16 @@ impl InitCommand { if config_path.exists() && !self.reset { if !self.yes { let overwrite = Confirm::new() - .with_prompt("Configuration already exists. Overwrite?") + .with_prompt(messages.config_exists_overwrite()) .default(false) .interact()?; if !overwrite { - println!("{}", "Initialization cancelled.".yellow()); + print_warning(messages.init_cancelled()); return Ok(()); } } else { - println!( - "{}", - "Configuration already exists. Use --reset to overwrite.".yellow() - ); + print_warning(messages.config_exists_use_reset()); return Ok(()); } } @@ -59,7 +57,7 @@ impl InitCommand { let mut manager = ConfigManager::with_path_fresh(&config_path)?; if self.yes { - self.quick_setup(&mut manager).await?; + self.quick_setup(&mut manager, &messages).await?; } else { self.interactive_setup(&mut manager).await?; } @@ -69,27 +67,52 @@ impl InitCommand { let language = manager.get_language().unwrap_or(Language::English); let messages = Messages::new(language); - println!("{}", messages.init_success().bold().green()); + print_success(messages.init_success()); println!("\n{}: {}", messages.config_file(), config_path.display()); println!("\n{}:", messages.next_steps()); - println!(" 1. Create a profile: {}", "quicommit profile add".cyan()); - println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan()); - println!(" 3. Start committing: {}", "quicommit commit".cyan()); + println!( + " 1. {}: {}", + messages.next_steps_create_profile(), + "quicommit profile add".cyan() + ); + println!( + " 2. {}: {}", + messages.next_steps_configure_llm(), + "quicommit config set-llm".cyan() + ); + println!( + " 3. {}: {}", + messages.next_steps_start_committing(), + "quicommit commit".cyan() + ); Ok(()) } - async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> { + async fn quick_setup(&self, manager: &mut ConfigManager, messages: &Messages) -> 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 name_result = git_config.get_string("user.name"); + let email_result = git_config.get_string("user.email"); + let name_missing = name_result.is_err(); + let email_missing = email_result.is_err(); + let user_name = name_result.unwrap_or_else(|_| "User".to_string()); + let user_email = email_result.unwrap_or_else(|_| "user@example.com".to_string()); - let profile = GitProfile::new("default".to_string(), user_name, user_email); + let profile = GitProfile::new("default".to_string(), user_name.clone(), user_email.clone()); + + println!("\n{}", messages.quick_setup_summary().bold()); + println!(" {}: {}", messages.profile_label(), "default".cyan()); + println!( + " {}: {} <{}>", + messages.identity_label(), + user_name, + user_email + ); + if name_missing || email_missing { + print_warning(messages.identity_placeholder_warning()); + } + println!(" {}: {}", messages.llm_provider_label(), "ollama".cyan()); manager.add_profile("default".to_string(), profile)?; manager.set_default_profile(Some("default".to_string()))?; @@ -231,11 +254,8 @@ impl InitCommand { let keyring_available = keyring.is_available(); if !keyring_available { - println!( - "\n{}", - "⚠ Keyring is not available on this system.".yellow() - ); - println!("{}", keyring.get_status_message().yellow()); + print_warning(messages.keyring_unavailable()); + print_warning(&keyring.get_status_message()); } let api_key = if provider_needs_api_key(&provider) { @@ -246,11 +266,7 @@ impl InitCommand { .ok(); if let Some(_key) = env_key { - println!( - "\n{} {}", - "✓".green(), - "Found API key in environment variable.".green() - ); + print_success(messages.api_key_found_env()); None } else if keyring_available { let prompt = match provider.as_str() { @@ -259,16 +275,13 @@ impl InitCommand { "kimi" => messages.kimi_api_key(), "deepseek" => messages.deepseek_api_key(), "openrouter" => messages.openrouter_api_key(), - _ => "API Key", + _ => messages.api_key_prompt_other(), }; - let key: String = Input::new().with_prompt(prompt).interact_text()?; + let key: String = crate::utils::password_input(prompt)?; Some(key) } else { - println!( - "\n{}", - "Please set the QUICOMMIT_API_KEY environment variable.".yellow() - ); + print_warning(messages.please_set_api_key_env()); None } } else { @@ -277,24 +290,26 @@ impl InitCommand { let default_model = get_default_model(&provider); let model: String = Input::new() - .with_prompt("Model name") + .with_prompt(messages.model_name()) .default(default_model.to_string()) .interact_text()?; let base_url: Option = if provider == "ollama" { let url: String = Input::new() - .with_prompt("Ollama server URL") + .with_prompt(messages.ollama_server_url()) .default("http://localhost:11434".to_string()) .interact_text()?; Some(url) } else { let use_custom_url = Confirm::new() - .with_prompt("Use custom API base URL?") + .with_prompt(messages.use_custom_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(messages.base_url_plain()) + .interact_text()?; Some(url) } else { None @@ -309,11 +324,7 @@ impl InitCommand { && provider_needs_api_key(&provider) { manager.set_api_key(&key)?; - println!( - "\n{} {}", - "✓".green(), - "API key stored securely in system keyring.".green() - ); + print_success(messages.api_key_stored_keyring()); } Ok(()) @@ -332,7 +343,7 @@ impl InitCommand { .interact_text()?; let pub_key_path: String = Input::new() - .with_prompt("SSH public key path (optional, leave empty to auto-detect)") + .with_prompt(messages.ssh_public_key_path()) .default(ssh_dir.join("id_rsa.pub").display().to_string()) .allow_empty(true) .interact_text()?; @@ -354,12 +365,12 @@ impl InitCommand { }; let agent_forwarding = Confirm::new() - .with_prompt("Enable SSH agent forwarding (-A)?") + .with_prompt(messages.ssh_agent_forwarding()) .default(false) .interact()?; let known_hosts: String = Input::new() - .with_prompt("Custom known_hosts file path (optional)") + .with_prompt(messages.known_hosts_path()) .allow_empty(true) .interact_text()?; let known_hosts_file = if known_hosts.is_empty() { @@ -369,7 +380,7 @@ impl InitCommand { }; let custom_cmd: String = Input::new() - .with_prompt("Custom SSH command (optional, overrides all other SSH settings)") + .with_prompt(messages.custom_ssh_command()) .allow_empty(true) .interact_text()?; let ssh_command = if custom_cmd.is_empty() { diff --git a/src/commands/profile.rs b/src/commands/profile.rs index 41a0d7b..2a610a1 100644 --- a/src/commands/profile.rs +++ b/src/commands/profile.rs @@ -8,7 +8,12 @@ use crate::config::manager::ConfigManager; use crate::config::profile::{GpgConfig, SshConfig}; use crate::config::{GitProfile, TokenConfig, TokenType}; use crate::git::find_repo; +use crate::i18n::Messages; use crate::utils::validators::validate_profile_name; +use crate::utils::{ + error_prefix, info_prefix, print_error, print_success, print_warning, success_prefix, + warning_prefix, +}; /// Manage Git profiles #[derive(Parser)] @@ -167,42 +172,56 @@ impl ProfileCommand { } } + fn messages_for(&self, manager: &ConfigManager) -> Messages { + let language = manager + .get_language() + .unwrap_or(crate::config::Language::English); + Messages::new(language) + } + async fn add_profile(&self, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); - println!("{}", "\nAdd new profile".bold()); + println!("\n{}", messages.add_new_profile().bold()); println!("{}", "─".repeat(40)); let name: String = Input::new() - .with_prompt("Profile name") + .with_prompt(messages.profile_name()) .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); + bail!("{}", messages.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(messages.git_user_name()) + .interact_text()?; let user_email: String = Input::new() - .with_prompt("Git user email") + .with_prompt(messages.git_user_email()) .validate_with(|input: &String| { crate::utils::validators::validate_email(input).map_err(|e| e.to_string()) }) .interact_text()?; let description: String = Input::new() - .with_prompt("Description (optional)") + .with_prompt(messages.description_optional()) .allow_empty(true) .interact_text()?; let is_work = Confirm::new() - .with_prompt("Is this a work profile?") + .with_prompt(messages.is_work_profile()) .default(false) .interact()?; let organization = if is_work { - Some(Input::new().with_prompt("Organization").interact_text()?) + Some( + Input::new() + .with_prompt(messages.organization_prompt()) + .interact_text()?, + ) } else { None }; @@ -216,51 +235,48 @@ impl ProfileCommand { profile.organization = organization; let setup_ssh = Confirm::new() - .with_prompt("Configure SSH key?") + .with_prompt(messages.configure_ssh()) .default(false) .interact()?; if setup_ssh { - profile.ssh = Some(self.setup_ssh_interactive().await?); + profile.ssh = Some(self.setup_ssh_interactive(&messages).await?); } let setup_gpg = Confirm::new() - .with_prompt("Configure GPG signing?") + .with_prompt(messages.configure_gpg()) .default(false) .interact()?; if setup_gpg { - profile.gpg = Some(self.setup_gpg_interactive().await?); + profile.gpg = Some(self.setup_gpg_interactive(&messages).await?); } let setup_token = Confirm::new() - .with_prompt("Add a Personal Access Token?") + .with_prompt(messages.add_pat_confirm()) .default(false) .interact()?; if setup_token { - self.setup_token_interactive(&mut profile, &manager).await?; + self.setup_token_interactive(&mut profile, &manager, &messages) + .await?; } manager.add_profile(name.clone(), profile)?; manager.save()?; - println!( - "{} Profile '{}' added successfully", - "✓".green(), - name.cyan() - ); + print_success(&messages.profile_added(&name)); if manager.default_profile().is_none() { let set_default = Confirm::new() - .with_prompt("Set as default profile?") + .with_prompt(messages.set_default_confirm()) .default(true) .interact()?; if set_default { manager.set_default_profile(Some(name.clone()))?; manager.save()?; - println!("{} Set '{}' as default profile", "✓".green(), name.cyan()); + print_success(&messages.set_default_done(&name)); } } @@ -269,21 +285,19 @@ impl ProfileCommand { async fn remove_profile(&self, name: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); if !manager.has_profile(name) { - bail!("Profile '{}' not found", name); + bail!("{}", messages.profile_not_found(name)); } let confirm = Confirm::new() - .with_prompt(format!( - "Are you sure you want to remove profile '{}'?", - name - )) + .with_prompt(messages.remove_profile_confirm(name)) .default(false) .interact()?; if !confirm { - println!("{}", "Cancelled.".yellow()); + print_warning(messages.cancelled()); return Ok(()); } @@ -292,29 +306,26 @@ impl ProfileCommand { manager.remove_profile(name)?; manager.save()?; - println!( - "{} Profile '{}' removed (including all stored tokens)", - "✓".green(), - name - ); + print_success(&messages.profile_removed(name)); Ok(()) } async fn list_profiles(&self, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let profiles = manager.list_profiles(); if profiles.is_empty() { - println!("{}", "No profiles configured.".yellow()); - println!("Run {} to create one.", "quicommit profile add".cyan()); + print_warning(messages.no_profiles_configured()); + println!("{}", messages.run_to_create_one("quicommit profile add")); return Ok(()); } let default = manager.default_profile_name(); - println!("{}", "\nConfigured profiles:".bold()); + println!("\n{}", messages.configured_profiles().bold()); println!("{}", "─".repeat(60)); for name in profiles { @@ -327,7 +338,7 @@ impl ProfileCommand { "○".dimmed() }; let work_marker = if profile.is_work { - " [work]".yellow() + messages.work_marker().yellow() } else { "".normal() }; @@ -340,32 +351,25 @@ impl ProfileCommand { } if profile.has_ssh() { - println!(" {} SSH configured", "🔑".to_string().dimmed()); + println!(" {}", messages.ssh_configured()); } if profile.has_gpg() { - println!(" {} GPG configured", "🔒".to_string().dimmed()); + println!(" {}", messages.gpg_configured()); } if profile.signing_key().is_some() { println!( - " {} Signing key: {}", - "🔏".to_string().dimmed(), - profile.signing_key().unwrap().dimmed() + " {}", + messages + .signing_key_value(profile.signing_key().unwrap()) + .dimmed() ); } if profile.has_tokens() { - println!( - " {} {} token(s)", - "🔐".to_string().dimmed(), - profile.tokens.len() - ); + println!(" {}", messages.tokens_count(profile.tokens.len())); } if let Some(ref usage) = profile.usage.last_used { - println!( - " {} Last used: {}", - "📊".to_string().dimmed(), - usage.dimmed() - ); + println!(" {}", messages.last_used_value(usage).dimmed()); } println!(); @@ -376,10 +380,14 @@ impl ProfileCommand { async fn show_profile(&self, name: Option<&str>, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); 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, &messages) + .await + } + Err(_) => self.show_global_status(&manager, name, &messages).await, } } @@ -388,28 +396,42 @@ impl ProfileCommand { repo: &crate::git::GitRepo, manager: &ConfigManager, name: Option<&str>, + messages: &Messages, ) -> Result<()> { use crate::git::MergedUserConfig; let merged_config = MergedUserConfig::from_repo(repo.inner())?; let repo_path = repo.path().to_string_lossy().to_string(); - println!("{}", "\n📁 Current Repository Status".bold()); + println!("\n{}", messages.repo_status_header().bold()); println!("{}", "─".repeat(60)); - println!("Repository: {}", repo_path.cyan()); + println!("{}", messages.repository_value(&repo_path)); - println!( - "\n{}", - "Git User Configuration (merged local/global):".bold() + println!("\n{}", messages.git_user_config_header().bold()); + println!("{}", "─".repeat(60)); + + self.print_config_entry(messages.label_user_name(), &merged_config.name, messages); + self.print_config_entry(messages.label_user_email(), &merged_config.email, messages); + self.print_config_entry( + messages.label_signing_key(), + &merged_config.signing_key, + messages, + ); + self.print_config_entry( + messages.label_ssh_command(), + &merged_config.ssh_command, + messages, + ); + self.print_config_entry( + messages.label_commit_gpgsign(), + &merged_config.commit_gpgsign, + messages, + ); + self.print_config_entry( + messages.label_tag_gpgsign(), + &merged_config.tag_gpgsign, + messages, ); - println!("{}", "─".repeat(60)); - - self.print_config_entry("User name", &merged_config.name); - self.print_config_entry("User email", &merged_config.email); - self.print_config_entry("Signing key", &merged_config.signing_key); - self.print_config_entry("SSH command", &merged_config.ssh_command); - self.print_config_entry("Commit GPG sign", &merged_config.commit_gpgsign); - self.print_config_entry("Tag GPG sign", &merged_config.tag_gpgsign); let user_name = merged_config.name.value.clone().unwrap_or_default(); let user_email = merged_config.email.value.clone().unwrap_or_default(); @@ -418,70 +440,72 @@ impl ProfileCommand { let matching_profile = manager.find_matching_profile(&user_name, &user_email, signing_key); let repo_profile_name = manager.get_repo_profile_name(&repo_path); - println!("\n{}", "QuiCommit Profile Status:".bold()); + println!("\n{}", messages.profile_status_header().bold()); println!("{}", "─".repeat(60)); 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() + "{} {}", + success_prefix(), + messages.profile_mapped(&profile.name) ); - println!(" This repository's git config matches the saved profile."); + println!(" {}", messages.config_matches_saved()); } else { println!( - "{} Profile '{}' matches current config", - "✓".green(), - profile.name.cyan() - ); - println!( - " But repository is mapped to different profile: {}", - mapped_name.yellow() + "{} {}", + success_prefix(), + messages.profile_matches(&profile.name) ); + println!(" {}", messages.repo_mapped_different(mapped_name)); } } (Some(profile), None) => { println!( - "{} Profile '{}' matches current config", - "✓".green(), - profile.name.cyan() + "{} {}", + success_prefix(), + messages.profile_matches(&profile.name) ); println!( - " {} This repository is not mapped to any profile.", - "ℹ".yellow() + " {} {}", + info_prefix().yellow(), + messages.repo_not_mapped() ); } (None, Some(mapped_name)) => { println!( - "{} Repository is mapped to profile '{}'", - "⚠".yellow(), - mapped_name.cyan() + "{} {}", + warning_prefix(), + messages.repo_mapped_profile(mapped_name) ); - println!(" But current git config does not match this profile!"); + println!(" {}", messages.config_does_not_match()); if let Some(mapped_profile) = manager.get_profile(mapped_name) { - println!("\n Mapped profile config:"); + println!("\n {}", messages.mapped_profile_config()); println!(" user.name: {}", mapped_profile.user_name); println!(" user.email: {}", mapped_profile.user_email); } } (None, None) => { - println!("{} No matching profile found in QuiCommit", "✗".red()); - println!(" Current git identity is not saved as a QuiCommit profile."); + println!("{} {}", error_prefix(), messages.no_matching_profile()); + println!(" {}", messages.identity_not_saved()); let partial_matches = manager.find_partial_matches(&user_name, &user_email); if !partial_matches.is_empty() { - println!("\n {} Similar profiles exist:", "ℹ".yellow()); + println!( + "\n {} {}", + info_prefix().yellow(), + messages.similar_profiles() + ); for p in partial_matches { let same_name = p.user_name == user_name; let same_email = p.user_email == user_email; let reason = match (same_name, same_email) { - (true, true) => "same name & email", - (true, false) => "same name", - (false, true) => "same email", - (false, false) => "partial match", + (true, true) => messages.same_name_email(), + (true, false) => messages.same_name(), + (false, true) => messages.same_email(), + (false, false) => messages.partial_match(), }; println!(" - {} ({})", p.name.cyan(), reason.dimmed()); } @@ -489,16 +513,17 @@ impl ProfileCommand { if merged_config.is_complete() { println!( - "\n {} Would you like to save this identity as a new profile?", - "💡".yellow() + "\n {} {}", + info_prefix().yellow(), + messages.save_identity_suggestion() ); let save = Confirm::new() - .with_prompt("Save current git identity as new profile?") + .with_prompt(messages.save_identity_confirm()) .default(true) .interact()?; if save { - self.save_current_identity_as_profile(&merged_config, manager) + self.save_current_identity_as_profile(&merged_config, manager, messages) .await?; } } @@ -507,91 +532,113 @@ 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{}", messages.requested_profile(profile_name).bold()); println!("{}", "─".repeat(60)); - self.print_profile_details(profile); + self.print_profile_details(profile, messages); } else { - println!("\n{} Profile '{}' not found", "✗".red(), profile_name); + println!( + "\n{} {}", + error_prefix(), + messages.profile_not_found(profile_name) + ); } } else if let Some(profile) = manager.default_profile() { - println!("\n{}", format!("Default Profile: {}", profile.name).bold()); + println!( + "\n{}", + messages.default_profile_header(&profile.name).bold() + ); println!("{}", "─".repeat(60)); - self.print_profile_details(profile); + self.print_profile_details(profile, messages); } Ok(()) } - async fn show_global_status(&self, manager: &ConfigManager, name: Option<&str>) -> Result<()> { - println!("{}", "\n⚠ Not in a Git Repository".bold().yellow()); + async fn show_global_status( + &self, + manager: &ConfigManager, + name: Option<&str>, + messages: &Messages, + ) -> Result<()> { + print_warning(messages.not_in_repo()); println!("{}", "─".repeat(60)); - println!("Run this command inside a git repository to see local config status."); + println!("{}", messages.run_inside_repo_hint()); println!(); if let Some(profile_name) = name { if let Some(profile) = manager.get_profile(profile_name) { - println!("{}", format!("Profile: {}", profile_name).bold()); + println!("{}", messages.profile_header(profile_name).bold()); println!("{}", "─".repeat(40)); - self.print_profile_details(profile); + self.print_profile_details(profile, messages); } else { - bail!("Profile '{}' not found", profile_name); + bail!("{}", messages.profile_not_found(profile_name)); } } else if let Some(profile) = manager.default_profile() { - println!("{}", format!("Default Profile: {}", profile.name).bold()); + println!("{}", messages.default_profile_header(&profile.name).bold()); println!("{}", "─".repeat(40)); - self.print_profile_details(profile); + self.print_profile_details(profile, messages); } else { - println!("{}", "No default profile set.".yellow()); - println!("Run {} to create one.", "quicommit profile add".cyan()); + print_warning(messages.no_default_profile()); + println!("{}", messages.run_to_create_one("quicommit profile add")); } Ok(()) } - fn print_config_entry(&self, label: &str, entry: &crate::git::ConfigEntry) { + fn print_config_entry( + &self, + label: &str, + entry: &crate::git::ConfigEntry, + messages: &Messages, + ) { use crate::git::ConfigSource; let source_indicator = match entry.source { - ConfigSource::Local => format!("[{}]", "local".green()), - ConfigSource::Global => format!("[{}]", "global".blue()), - ConfigSource::NotSet => format!("[{}]", "not set".dimmed()), + ConfigSource::Local => format!("[{}]", messages.source_local().green()), + ConfigSource::Global => format!("[{}]", messages.source_global().blue()), + ConfigSource::NotSet => format!("[{}]", messages.source_not_set().dimmed()), }; match &entry.value { Some(value) => { println!("{} {}: {}", source_indicator, label, value); - if entry.local_value.is_some() && entry.global_value.is_some() { + if let (Some(local), Some(global)) = (&entry.local_value, &entry.global_value) { println!( - " {} local: {}", + " {} {}: {}", "├".dimmed(), - entry.local_value.as_ref().unwrap() + messages.source_local(), + local ); println!( - " {} global: {}", + " {} {}: {}", "└".dimmed(), - entry.global_value.as_ref().unwrap() + messages.source_global(), + global ); } } None => { - println!("{} {}: {}", source_indicator, label, "".dimmed()); + println!( + "{} {}: {}", + source_indicator, + label, + messages.not_set_value().dimmed() + ); } } } - fn print_profile_details(&self, profile: &GitProfile) { - println!("User name: {}", profile.user_name); - println!("User email: {}", profile.user_email); + fn print_profile_details(&self, profile: &GitProfile, messages: &Messages) { + println!("{}: {}", messages.label_user_name(), profile.user_name); + println!("{}: {}", messages.label_user_email(), profile.user_email); if let Some(ref desc) = profile.description { - println!("Description: {}", desc); + println!("{} {}", messages.label_description(), desc); } println!( - "Work profile: {}", + "{} {}", + messages.label_work_profile(), if profile.is_work { "yes".yellow() } else { @@ -600,17 +647,18 @@ impl ProfileCommand { ); if let Some(ref org) = profile.organization { - println!("Organization: {}", org); + println!("{} {}", messages.label_organization(), org); } if let Some(ref key) = profile.signing_key { - println!("Signing key: {}", key); + println!(" {}", messages.signing_key_value(key)); } // Profile settings - println!("\n{}", "Settings:".bold()); + println!("\n{}", messages.settings_header().bold()); println!( - " Auto-sign commits: {}", + " {} {}", + messages.auto_sign_commits(), if profile.settings.auto_sign_commits { "yes" } else { @@ -618,7 +666,8 @@ impl ProfileCommand { } ); println!( - " Auto-sign tags: {}", + " {} {}", + messages.auto_sign_tags(), if profile.settings.auto_sign_tags { "yes" } else { @@ -626,52 +675,64 @@ impl ProfileCommand { } ); if let Some(ref fmt) = profile.settings.default_commit_format { - println!(" Default commit format: {}", fmt.to_string()); + println!(" {} {}", messages.default_commit_format(), fmt); } if !profile.settings.repo_patterns.is_empty() { - println!(" Repo patterns: {:?}", profile.settings.repo_patterns); + println!( + " {} {:?}", + messages.repo_patterns(), + profile.settings.repo_patterns + ); } if let Some(ref provider) = profile.settings.llm_provider { - println!(" Preferred LLM: {}", provider); + println!(" {} {}", messages.preferred_llm(), provider); } if let Some(ref template) = profile.settings.commit_template { - println!(" Commit template: {}", template); + println!(" {} {}", messages.commit_template(), template); } if let Some(ref ssh) = profile.ssh { - println!("\n{}", "SSH Configuration:".bold()); + println!("\n{}", messages.ssh_config_header().bold()); if let Some(ref path) = ssh.private_key_path { - println!(" Private key: {:?}", path); + println!(" {} {:?}", messages.private_key(), path); } if let Some(ref path) = ssh.public_key_path { - println!(" Public key: {:?}", path); + println!(" {} {:?}", messages.public_key(), path); } else if let Some(ref path) = ssh.effective_public_key_path() { - println!(" Public key: {:?} (auto-detected)", path); + println!( + " {}", + messages.public_key_autodetected(&path.display().to_string()) + ); } println!( - " Agent forwarding: {}", + " {} {}", + messages.agent_forwarding(), if ssh.agent_forwarding { "yes" } else { "no" } ); if let Some(ref cmd) = ssh.ssh_command { - println!(" Custom command: {}", cmd); + println!(" {} {}", messages.custom_command(), cmd); } if let Some(ref kh) = ssh.known_hosts_file { - println!(" Known hosts file: {:?}", kh); + println!(" {} {:?}", messages.known_hosts_file(), kh); } if ssh.passphrase.is_some() { - println!(" Passphrase: [set]"); + println!(" {}", messages.passphrase_set()); } } if let Some(ref gpg) = profile.gpg { - println!("\n{}", "GPG Configuration:".bold()); - println!(" Key ID: {}", gpg.key_id); - println!(" Program: {}", gpg.program); - println!(" Use agent: {}", if gpg.use_agent { "yes" } else { "no" }); + println!("\n{}", messages.gpg_config_header().bold()); + println!(" {} {}", messages.key_id(), gpg.key_id); + println!(" {} {}", messages.program(), gpg.program); + println!( + " {} {}", + messages.use_agent(), + if gpg.use_agent { "yes" } else { "no" } + ); } if !profile.tokens.is_empty() { - println!("\n{}", "Tokens:".bold()); + println!("\n{}", messages.tokens_header().bold()); for (service, token) in &profile.tokens { println!(" {} ({})", service.cyan(), token.token_type); if let Some(ref desc) = token.description { @@ -681,10 +742,10 @@ impl ProfileCommand { } if profile.usage.total_uses > 0 { - println!("\n{}", "Usage Statistics:".bold()); - println!(" Total uses: {}", profile.usage.total_uses); + println!("\n{}", messages.usage_stats_header().bold()); + println!(" {} {}", messages.total_uses(), profile.usage.total_uses); if let Some(ref last_used) = profile.usage.last_used { - println!(" Last used: {}", last_used); + println!(" {}", messages.last_used_value(last_used)); } } } @@ -693,6 +754,7 @@ impl ProfileCommand { &self, merged_config: &crate::git::MergedUserConfig, manager: &ConfigManager, + messages: &Messages, ) -> Result<()> { let config_path = manager.path().to_path_buf(); let mut manager = ConfigManager::with_path(&config_path)?; @@ -700,43 +762,44 @@ impl ProfileCommand { let user_name = merged_config.name.value.clone().unwrap_or_default(); let user_email = merged_config.email.value.clone().unwrap_or_default(); - println!("\n{}", "Save New Profile".bold()); + println!("\n{}", messages.save_new_profile().bold()); println!("{}", "─".repeat(40)); let default_name = user_name.to_lowercase().replace(' ', "-"); let profile_name: String = Input::new() - .with_prompt("Profile name") + .with_prompt(messages.profile_name()) .default(default_name) .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(messages.profile_exists_overwrite(&profile_name)) .default(false) .interact()?; if !overwrite { - println!("{}", "Cancelled.".yellow()); + print_warning(messages.cancelled()); return Ok(()); } } let description: String = Input::new() - .with_prompt("Description (optional)") + .with_prompt(messages.description_optional()) .default("Imported from existing git config".to_string()) .allow_empty(true) .interact_text()?; let is_work = Confirm::new() - .with_prompt("Is this a work profile?") + .with_prompt(messages.is_work_profile()) .default(false) .interact()?; let organization = if is_work { - Some(Input::new().with_prompt("Organization").interact_text()?) + Some( + Input::new() + .with_prompt(messages.organization_prompt()) + .interact_text()?, + ) } else { None }; @@ -752,57 +815,50 @@ impl ProfileCommand { profile.signing_key = Some(key.clone()); let setup_gpg = Confirm::new() - .with_prompt("Configure GPG signing details?") + .with_prompt(messages.configure_gpg_details()) .default(true) .interact()?; if setup_gpg { - profile.gpg = Some(self.setup_gpg_interactive().await?); + profile.gpg = Some(self.setup_gpg_interactive(messages).await?); } } if merged_config.ssh_command.is_set() { let setup_ssh = Confirm::new() - .with_prompt("Configure SSH key details?") + .with_prompt(messages.configure_ssh_details()) .default(false) .interact()?; if setup_ssh { - profile.ssh = Some(self.setup_ssh_interactive().await?); + profile.ssh = Some(self.setup_ssh_interactive(messages).await?); } } let setup_token = Confirm::new() - .with_prompt("Add a Personal Access Token?") + .with_prompt(messages.add_pat_confirm()) .default(false) .interact()?; if setup_token { - self.setup_token_interactive(&mut profile, &manager).await?; + self.setup_token_interactive(&mut profile, &manager, messages) + .await?; } manager.add_profile(profile_name.clone(), profile)?; manager.save()?; - println!( - "{} Profile '{}' saved successfully", - "✓".green(), - profile_name.cyan() - ); + print_success(&messages.profile_saved(&profile_name)); let set_default = Confirm::new() - .with_prompt("Set as default profile?") + .with_prompt(messages.set_default_confirm()) .default(true) .interact()?; if set_default { manager.set_default_profile(Some(profile_name.clone()))?; manager.save()?; - println!( - "{} Set '{}' as default profile", - "✓".green(), - profile_name.cyan() - ); + print_success(&messages.set_default_done(&profile_name)); } Ok(()) @@ -810,22 +866,23 @@ impl ProfileCommand { async fn edit_profile(&self, name: &str, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let mut profile = manager .get_profile(name) - .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))? + .ok_or_else(|| anyhow::anyhow!("{}", messages.profile_not_found(name)))? .clone(); - println!("{}", format!("\nEditing profile: {}", name).bold()); + println!("{}", messages.editing_profile(name).bold()); println!("{}", "─".repeat(40)); let user_name: String = Input::new() - .with_prompt("Git user name") + .with_prompt(messages.git_user_name()) .default(profile.user_name.clone()) .interact_text()?; let user_email: String = Input::new() - .with_prompt("Git user email") + .with_prompt(messages.git_user_email()) .default(profile.user_email.clone()) .validate_with(|input: &String| { crate::utils::validators::validate_email(input).map_err(|e| e.to_string()) @@ -839,14 +896,14 @@ impl ProfileCommand { loop { println!(); let options = vec![ - "Done / Save changes", - "Edit SSH configuration", - "Edit GPG configuration", - "Edit signing preferences", - "Manage tokens", + messages.done_save_changes(), + messages.edit_ssh_config(), + messages.edit_gpg_config(), + messages.edit_signing_prefs(), + messages.manage_tokens(), ]; let selection = Select::new() - .with_prompt("What would you like to edit?") + .with_prompt(messages.what_to_edit()) .items(&options) .default(0) .interact()?; @@ -854,23 +911,24 @@ impl ProfileCommand { match selection { 0 => break, 1 => { - profile.ssh = Some(self.setup_ssh_interactive().await?); + profile.ssh = Some(self.setup_ssh_interactive(&messages).await?); } 2 => { - profile.gpg = Some(self.setup_gpg_interactive().await?); + profile.gpg = Some(self.setup_gpg_interactive(&messages).await?); } 3 => { profile.settings.auto_sign_commits = Confirm::new() - .with_prompt("Auto-sign commits?") + .with_prompt(messages.auto_sign_commits_confirm()) .default(profile.settings.auto_sign_commits) .interact()?; profile.settings.auto_sign_tags = Confirm::new() - .with_prompt("Auto-sign tags?") + .with_prompt(messages.auto_sign_tags_confirm()) .default(profile.settings.auto_sign_tags) .interact()?; } 4 => { - self.edit_tokens_interactive(&mut profile, &manager).await?; + self.edit_tokens_interactive(&mut profile, &manager, &messages) + .await?; } _ => unreachable!(), } @@ -882,7 +940,7 @@ impl ProfileCommand { manager.update_profile(name, profile)?; manager.save()?; - println!("{} Profile '{}' updated", "✓".green(), name); + print_success(&messages.profile_updated(name)); Ok(()) } @@ -891,19 +949,20 @@ impl ProfileCommand { &self, profile: &mut GitProfile, manager: &ConfigManager, + messages: &Messages, ) -> Result<()> { loop { println!(); let mut options: Vec = profile .tokens .keys() - .map(|s| format!("Remove token: {}", s)) + .map(|s| messages.remove_token_option(s)) .collect(); - options.push("Add new token".to_string()); - options.push("Back".to_string()); + options.push(messages.add_new_token().to_string()); + options.push(messages.back().to_string()); let selection = Select::new() - .with_prompt(format!("Manage tokens for '{}'", profile.name)) + .with_prompt(messages.manage_tokens_for(&profile.name)) .items(&options) .default(options.len() - 1) .interact()?; @@ -912,20 +971,12 @@ impl ProfileCommand { break; } if selection < profile.tokens.len() { - let service: String = profile - .tokens - .keys() - .nth(selection) - .unwrap() - .clone(); + let service: String = profile.tokens.keys().nth(selection).unwrap().clone(); profile.remove_token(&service); - println!( - "{} Token '{}' removed", - "✓".green(), - service.cyan() - ); + print_success(&messages.token_removed(&service)); } else { - self.setup_token_interactive(profile, manager).await?; + self.setup_token_interactive(profile, manager, messages) + .await?; } } Ok(()) @@ -933,17 +984,19 @@ impl ProfileCommand { async fn set_default(&self, name: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); manager.set_default_profile(Some(name.to_string()))?; manager.save()?; - println!("{} Set '{}' as default profile", "✓".green(), name.cyan()); + print_success(&messages.set_default_done(name)); Ok(()) } async fn set_repo(&self, name: &str, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let repo = find_repo(std::env::current_dir()?.as_path())?; let repo_path = repo.path().to_string_lossy().to_string(); @@ -953,22 +1006,14 @@ impl ProfileCommand { // Get the profile and apply it to the repository let profile = manager .get_profile(name) - .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?; + .ok_or_else(|| anyhow::anyhow!("{}", messages.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() - ); + print_success(&messages.set_for_current_repo(name)); + print_success(&messages.applied_to_current_repo(name)); Ok(()) } @@ -980,19 +1025,20 @@ impl ProfileCommand { config_path: &Option, ) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let profile_name = if let Some(n) = name { n.to_string() } else { manager .default_profile_name() - .ok_or_else(|| anyhow::anyhow!("No default profile set"))? + .ok_or_else(|| anyhow::anyhow!("{}", messages.no_default_profile()))? .clone() }; let profile = manager .get_profile(&profile_name) - .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))? + .ok_or_else(|| anyhow::anyhow!("{}", messages.profile_not_found(&profile_name)))? .clone(); let repo_path = if global { @@ -1004,19 +1050,11 @@ impl ProfileCommand { if global { profile.apply_global()?; - println!( - "{} Applied profile '{}' globally", - "✓".green(), - profile.name.cyan() - ); + print_success(&messages.applied_globally(&profile.name)); } 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() - ); + print_success(&messages.applied_to_current_repo(&profile.name)); } manager.record_profile_usage(&profile_name, repo_path)?; @@ -1027,6 +1065,7 @@ impl ProfileCommand { async fn switch_profile(&self, config_path: &Option) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let profiles: Vec = manager .list_profiles() @@ -1035,7 +1074,7 @@ impl ProfileCommand { .collect(); if profiles.is_empty() { - bail!("No profiles configured"); + bail!("{}", messages.no_profiles_configured()); } let default = manager.default_profile_name().map(|s| s.as_str()); @@ -1044,7 +1083,7 @@ impl ProfileCommand { .unwrap_or(0); let selection = Select::new() - .with_prompt("Select profile to switch to") + .with_prompt(messages.select_profile_to_switch()) .items(&profiles) .default(default_idx) .interact()?; @@ -1054,11 +1093,11 @@ impl ProfileCommand { manager.set_default_profile(Some(selected.clone()))?; manager.save()?; - println!("{} Switched to profile '{}'", "✓".green(), selected.cyan()); + print_success(&messages.switched_to(selected)); if find_repo(".").is_ok() { let apply = Confirm::new() - .with_prompt("Apply to current repository?") + .with_prompt(messages.apply_to_current_repo()) .default(true) .interact()?; @@ -1078,10 +1117,11 @@ impl ProfileCommand { config_path: &Option, ) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let source = manager .get_profile(from) - .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))? + .ok_or_else(|| anyhow::anyhow!("{}", messages.profile_not_found(from)))? .clone(); validate_profile_name(to)?; @@ -1093,12 +1133,7 @@ impl ProfileCommand { manager.add_profile(to.to_string(), new_profile)?; manager.save()?; - println!( - "{} Copied profile '{}' to '{}'", - "✓".green(), - from, - to.cyan() - ); + print_success(&messages.copied_profile(from, to)); Ok(()) } @@ -1126,30 +1161,24 @@ impl ProfileCommand { config_path: &Option, ) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); if !manager.has_profile(profile_name) { - bail!("Profile '{}' not found", profile_name); + bail!("{}", messages.profile_not_found(profile_name)); } if !manager.keyring().is_available() { - bail!( - "Keyring is not available. Cannot store PAT securely. Please ensure your system keyring is accessible." - ); + bail!("{}", messages.keyring_unavailable_pat()); } - println!( - "{}", - format!("\nAdd token to profile '{}'", profile_name).bold() - ); + println!("\n{}", messages.add_token_header(profile_name).bold()); println!("{}", "─".repeat(40)); - let token_value: String = Input::new() - .with_prompt(format!("Token for {}", service)) - .interact_text()?; + let token_value: String = crate::utils::password_input(&messages.token_prompt(service))?; let token_type_options = vec!["Personal", "OAuth", "Deploy", "App"]; let selection = Select::new() - .with_prompt("Token type") + .with_prompt(messages.token_type_prompt()) .items(&token_type_options) .default(0) .interact()?; @@ -1163,7 +1192,7 @@ impl ProfileCommand { }; let description: String = Input::new() - .with_prompt("Description (optional)") + .with_prompt(messages.description_optional()) .allow_empty(true) .interact_text()?; @@ -1177,12 +1206,7 @@ impl ProfileCommand { 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 - ); + print_success(&messages.token_added(service, profile_name)); Ok(()) } @@ -1194,65 +1218,52 @@ impl ProfileCommand { config_path: &Option, ) -> Result<()> { let mut manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); if !manager.has_profile(profile_name) { - bail!("Profile '{}' not found", profile_name); + bail!("{}", messages.profile_not_found(profile_name)); } let confirm = Confirm::new() - .with_prompt(format!( - "Remove token '{}' from profile '{}'?", - service, profile_name - )) + .with_prompt(messages.remove_token_confirm(service, profile_name)) .default(false) .interact()?; if !confirm { - println!("{}", "Cancelled.".yellow()); + print_warning(messages.cancelled()); return Ok(()); } manager.remove_token_from_profile(profile_name, service)?; manager.save()?; - println!( - "{} Token '{}' removed from profile '{}' (deleted from keyring)", - "✓".green(), - service, - profile_name - ); + print_success(&messages.token_removed_from(service, profile_name)); Ok(()) } async fn list_tokens(&self, profile_name: &str, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let profile = manager .get_profile(profile_name) - .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?; + .ok_or_else(|| anyhow::anyhow!("{}", messages.profile_not_found(profile_name)))?; if profile.tokens.is_empty() { - println!( - "{} No tokens configured for profile '{}'", - "ℹ".yellow(), - profile_name - ); + print_warning(&messages.no_tokens_configured(profile_name)); return Ok(()); } - println!( - "{}", - format!("\nTokens for profile '{}':", profile_name).bold() - ); + println!("\n{}", messages.tokens_for_profile(profile_name).bold()); println!("{}", "─".repeat(40)); for (service, token) in &profile.tokens { let has_token = manager.has_pat_for_profile(profile_name, service); let status = if has_token { - format!("[{}]", "stored".green()) + format!("[{}]", messages.token_stored_marker().green()) } else { - format!("[{}]", "not stored".yellow()) + format!("[{}]", messages.token_not_stored_marker().yellow()) }; println!( @@ -1265,7 +1276,7 @@ impl ProfileCommand { println!(" {}", desc); } if let Some(ref last_used) = token.last_used { - println!(" Last used: {}", last_used); + println!(" {}", messages.last_used_value(last_used)); } } @@ -1274,45 +1285,41 @@ impl ProfileCommand { async fn check_profile(&self, name: Option<&str>, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); let profile_name = if let Some(n) = name { n.to_string() } else { manager .default_profile_name() - .ok_or_else(|| anyhow::anyhow!("No default profile set"))? + .ok_or_else(|| anyhow::anyhow!("{}", messages.no_default_profile()))? .clone() }; 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!("\n{}", messages.checking_profile(&profile_name).bold()); println!("{}", "─".repeat(60)); if comparison.matches { - println!( - "{} Profile configuration matches git settings", - "✓".green().bold() - ); + print_success(messages.config_matches()); } else { - println!( - "{} Profile configuration differs from git settings", - "✗".red().bold() - ); - println!("\n{}", "Differences:".bold()); + print_error(messages.config_differs()); + println!("\n{}", messages.differences_header().bold()); for diff in &comparison.differences { println!("\n {}:", diff.key.cyan()); - println!(" Profile: {}", diff.profile_value.green()); - println!(" Git: {}", diff.git_value.yellow()); + println!( + " {} {}", + messages.profile_diff_label(), + diff.profile_value.green() + ); + println!( + " {} {}", + messages.git_diff_label(), + diff.git_value.yellow() + ); } } @@ -1321,27 +1328,28 @@ impl ProfileCommand { async fn show_stats(&self, name: Option<&str>, config_path: &Option) -> Result<()> { let manager = self.get_manager(config_path)?; + let messages = self.messages_for(&manager); if let Some(n) = name { let profile = manager .get_profile(n) - .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?; + .ok_or_else(|| anyhow::anyhow!("{}", messages.profile_not_found(n)))?; - self.show_single_profile_stats(profile); + self.show_single_profile_stats(profile, &messages); } else { let profiles = manager.list_profiles(); if profiles.is_empty() { - println!("{}", "No profiles configured.".yellow()); + print_warning(messages.no_profiles_configured()); return Ok(()); } - println!("{}", "\nProfile Usage Statistics:".bold()); + println!("\n{}", messages.profile_usage_stats().bold()); println!("{}", "─".repeat(60)); for profile_name in profiles { if let Some(profile) = manager.get_profile(profile_name) { - self.show_single_profile_stats(profile); + self.show_single_profile_stats(profile, &messages); println!(); } } @@ -1350,38 +1358,38 @@ impl ProfileCommand { Ok(()) } - fn show_single_profile_stats(&self, profile: &GitProfile) { + fn show_single_profile_stats(&self, profile: &GitProfile, messages: &Messages) { println!("{}", format!("\n{}", profile.name).bold()); - println!(" Total uses: {}", profile.usage.total_uses); + println!(" {} {}", messages.total_uses(), profile.usage.total_uses); if let Some(ref last_used) = profile.usage.last_used { - println!(" Last used: {}", last_used); + println!(" {}", messages.last_used_value(last_used)); } if !profile.usage.repo_usage.is_empty() { - println!(" Repositories:"); + println!(" {}", messages.repositories_header()); for (repo, count) in &profile.usage.repo_usage { - println!(" {} ({} uses)", repo, count); + println!(" {}", messages.uses_line(repo, *count)); } } } - async fn setup_ssh_interactive(&self) -> Result { + async fn setup_ssh_interactive(&self, messages: &Messages) -> Result { use std::path::PathBuf; let ssh_dir = dirs::home_dir() .map(|h| h.join(".ssh")) .unwrap_or_else(|| PathBuf::from("~/.ssh")); - println!("\n{}", "SSH Configuration".bold()); + println!("\n{}", messages.ssh_setup_header().bold()); let key_path: String = Input::new() - .with_prompt("SSH private key path") + .with_prompt(messages.ssh_private_key_path()) .default(ssh_dir.join("id_rsa").display().to_string()) .interact_text()?; let pub_key_path: String = Input::new() - .with_prompt("SSH public key path (optional, leave empty to auto-detect)") + .with_prompt(messages.ssh_public_key_path()) .default(ssh_dir.join("id_rsa.pub").display().to_string()) .allow_empty(true) .interact_text()?; @@ -1392,12 +1400,12 @@ impl ProfileCommand { }; let agent_forwarding = Confirm::new() - .with_prompt("Enable SSH agent forwarding (-A)?") + .with_prompt(messages.ssh_agent_forwarding()) .default(false) .interact()?; let known_hosts: String = Input::new() - .with_prompt("Custom known_hosts file path (optional)") + .with_prompt(messages.known_hosts_path()) .allow_empty(true) .interact_text()?; let known_hosts_file = if known_hosts.is_empty() { @@ -1407,7 +1415,7 @@ impl ProfileCommand { }; let custom_cmd: String = Input::new() - .with_prompt("Custom SSH command (optional, overrides all other SSH settings)") + .with_prompt(messages.custom_ssh_command()) .allow_empty(true) .interact_text()?; let ssh_command = if custom_cmd.is_empty() { @@ -1426,8 +1434,10 @@ impl ProfileCommand { }) } - async fn setup_gpg_interactive(&self) -> Result { - let key_id: String = Input::new().with_prompt("GPG key ID").interact_text()?; + async fn setup_gpg_interactive(&self, messages: &Messages) -> Result { + let key_id: String = Input::new() + .with_prompt(messages.gpg_key_id()) + .interact_text()?; Ok(GpgConfig { key_id, @@ -1442,14 +1452,12 @@ impl ProfileCommand { &self, profile: &mut GitProfile, manager: &ConfigManager, + messages: &Messages, ) -> Result<()> { if !manager.keyring().is_available() { - println!( - "{} Keyring is not available. Cannot store PAT securely.", - "⚠".yellow() - ); + print_warning(messages.keyring_unavailable_pat()); let continue_anyway = Confirm::new() - .with_prompt("Continue without secure token storage?") + .with_prompt(messages.continue_without_secure()) .default(false) .interact()?; if !continue_anyway { @@ -1458,16 +1466,16 @@ impl ProfileCommand { } let service: String = Input::new() - .with_prompt("Service name (e.g., github, gitlab)") + .with_prompt(messages.service_name_prompt()) .interact_text()?; - let token_value: String = Input::new().with_prompt("Token value").interact_text()?; + let token_value: String = crate::utils::password_input(messages.token_value_prompt())?; 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()); + print_success(messages.token_stored_keyring()); } profile.add_token(service, token); diff --git a/src/commands/tag.rs b/src/commands/tag.rs index a203013..7e20630 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -13,6 +13,7 @@ use crate::git::tag::{ }; use crate::git::{GitRepo, find_repo}; use crate::i18n::Messages; +use crate::utils::{print_progress, print_success, print_warning}; /// Generate and create Git tags #[derive(Parser)] @@ -61,7 +62,7 @@ pub struct TagCommand { #[arg(short = 't', long)] think: bool, - /// Skip interactive prompts + /// Skip interactive prompts only (generation behavior unchanged) #[arg(short = 'y', long)] yes: bool, @@ -115,11 +116,11 @@ impl TagCommand { { let version_str = tag_name.trim_start_matches('v'); if let Err(e) = crate::utils::validators::validate_semver(version_str) { - println!("{}: {}", "Warning".yellow(), e); + print_warning(&format!("{}: {}", messages.warning(), e)); if !self.yes { let proceed = Confirm::new() - .with_prompt("Proceed with this tag name anyway?") + .with_prompt(messages.proceed_invalid_tag_name()) .default(true) .interact()?; @@ -135,7 +136,7 @@ impl TagCommand { None } else if let Some(msg) = &self.message { Some(msg.clone()) - } else if self.generate || (config.tag.auto_generate && !self.yes) { + } else if self.generate || config.tag.auto_generate { Some( self.generate_tag_message(&repo, &tag_name, &messages) .await?, @@ -143,7 +144,7 @@ impl TagCommand { } else if !self.yes { Some(self.input_message_interactive(&tag_name, &messages)?) } else { - Some(format!("Release {}", tag_name)) + Some(messages.release_default(&tag_name)) }; // Show preview @@ -165,13 +166,13 @@ impl TagCommand { .interact()?; if !confirm { - println!("{}", messages.tag_cancelled().yellow()); + print_warning(messages.tag_cancelled()); return Ok(()); } } if self.dry_run { - println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow()); + println!("\n{}", messages.dry_run_tag_not_created().yellow()); return Ok(()); } @@ -184,13 +185,13 @@ impl TagCommand { builder.execute(&repo)?; - println!("{} {}", messages.tag_created().green(), tag_name.cyan()); + print_success(&format!("{} {}", messages.tag_created(), tag_name.cyan())); // Push if requested or ask user if self.push { - println!("{}", messages.pushing_tag(&self.remote)); + print_progress(&messages.pushing_tag(&self.remote)); repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; - println!("{}", messages.pushed_tag(&self.remote)); + print_success(&messages.pushed_tag(&self.remote)); } else if !self.yes && !self.dry_run { let should_push = Confirm::new() .with_prompt(messages.push_after_tag()) @@ -198,9 +199,9 @@ impl TagCommand { .interact()?; if should_push { - println!("{}", messages.pushing_tag(&self.remote)); + print_progress(&messages.pushing_tag(&self.remote)); repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; - println!("{}", messages.pushed_tag(&self.remote)); + print_success(&messages.pushed_tag(&self.remote)); } } @@ -323,15 +324,16 @@ impl TagCommand { }; if commits.is_empty() { - return Ok(format!("Release {}", version)); + return Ok(messages.release_default(version)); } - println!("{}", messages.ai_generating_tag(commits.len())); - let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?; - generator + let spinner = crate::utils::Spinner::start(&messages.ai_generating_tag(commits.len())); + let result = generator .generate_tag_message(version, &commits, language) - .await + .await; + spinner.finish_clear(); + result } async fn auto_detect_version( @@ -418,7 +420,7 @@ impl TagCommand { } fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result { - let default_msg = format!("Release {}", version); + let default_msg = messages.release_default(version); let use_editor = Confirm::new() .with_prompt(messages.open_editor()) diff --git a/src/config/manager.rs b/src/config/manager.rs index 6a80afe..095a86d 100644 --- a/src/config/manager.rs +++ b/src/config/manager.rs @@ -278,6 +278,22 @@ impl ConfigManager { Ok(()) } + /// Delete a PAT from the keyring even when no config token entry exists. + /// Returns true if a keyring entry was deleted (issue 24). + pub fn delete_orphan_pat(&self, profile_name: &str, service: &str) -> Result { + if let Some(profile) = self.get_profile(profile_name) { + let user_email = &profile.user_email; + if self.keyring.has_pat(profile_name, user_email, service) { + self.keyring.delete_pat(profile_name, user_email, service)?; + Ok(true) + } else { + Ok(false) + } + } else { + Ok(false) + } + } + /// Delete all PAT tokens for a profile (used when removing a profile) pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> { if let Some(profile) = self.get_profile(profile_name) { @@ -658,6 +674,17 @@ impl ConfigManager { self.modified = true; } + /// Check if emoji decorations are enabled + pub fn emoji_enabled(&self) -> bool { + self.config.output.emoji + } + + /// Set emoji decorations flag + pub fn set_emoji_enabled(&mut self, enabled: bool) { + self.config.output.emoji = enabled; + self.modified = true; + } + /// Export configuration to TOML string pub fn export(&self) -> Result { toml::to_string_pretty(&self.config).context("Failed to serialize config") diff --git a/src/config/mod.rs b/src/config/mod.rs index d19cc9d..c95ca42 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -47,6 +47,10 @@ pub struct AppConfig { /// Language settings #[serde(default)] pub language: LanguageConfig, + + /// Output settings + #[serde(default)] + pub output: OutputConfig, } impl Default for AppConfig { @@ -61,6 +65,7 @@ impl Default for AppConfig { changelog: ChangelogConfig::default(), repo_profiles: HashMap::new(), language: LanguageConfig::default(), + output: OutputConfig::default(), } } } @@ -236,6 +241,20 @@ impl Default for LanguageConfig { } } +/// Output configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputConfig { + /// Show emoji/symbol decorations (✓/✗/⚠/→) + #[serde(default = "default_true")] + pub emoji: bool, +} + +impl Default for OutputConfig { + fn default() -> Self { + Self { emoji: true } + } +} + /// Supported languages #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Language { diff --git a/src/generator/mod.rs b/src/generator/mod.rs index 26bf274..e34fcb2 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -1,8 +1,10 @@ use crate::config::manager::ConfigManager; use crate::config::{CommitFormat, Language}; use crate::git::{CommitInfo, GitRepo}; +use crate::i18n::Messages; use crate::llm::parsing::GeneratedCommit; use crate::llm::rig::LlmClient; +use crate::utils::{eprint_warning, success_prefix}; use anyhow::{Context, Result}; /// Content generator using LLM @@ -33,11 +35,9 @@ impl ContentGenerator { if thinking_enabled { let provider = manager.llm_provider(); if !Self::supports_thinking(provider) { - eprintln!( - "Warning: Provider '{}' does not support thinking mode. \ - Disabling thinking for this invocation.", - provider - ); + let language = manager.get_language().unwrap_or(Language::English); + let messages = Messages::new(language); + eprint_warning(&messages.thinking_unsupported(provider)); thinking_enabled = false; } } @@ -156,6 +156,7 @@ impl ContentGenerator { repo: &GitRepo, format: CommitFormat, language: Language, + messages: &Messages, ) -> Result { use dialoguer::Select; @@ -167,33 +168,33 @@ impl ContentGenerator { // Show diff summary let files = repo.get_staged_files()?; - println!("\nStaged files ({}):", files.len()); + println!("\n{}", messages.staged_files(files.len())); for file in &files { println!(" • {}", file); } // Generate initial commit - println!("\nGenerating commit message..."); + println!("\n{}", messages.generating_commit_message()); let mut generated = self .generate_commit_message(&diff, format, language) .await?; loop { println!("\n{}", "─".repeat(60)); - println!("Generated commit message:"); + println!("{}", messages.generated_commit_message()); println!("{}", "─".repeat(60)); println!("{}", generated.to_conventional()); println!("{}", "─".repeat(60)); let options = vec![ - "✓ Accept and commit", - "🔄 Regenerate", - "✏️ Edit", - "❌ Cancel", + format!("{} {}", success_prefix(), messages.accept_and_commit()), + messages.regenerate().to_string(), + messages.edit().to_string(), + messages.cancel().to_string(), ]; let selection = Select::new() - .with_prompt("What would you like to do?") + .with_prompt(messages.what_would_you_like_to_do()) .items(&options) .default(0) .interact()?; @@ -201,7 +202,7 @@ impl ContentGenerator { match selection { 0 => return Ok(generated), 1 => { - println!("Regenerating..."); + println!("{}", messages.regenerating()); generated = self .generate_commit_message(&diff, format, language) .await?; @@ -210,7 +211,7 @@ impl ContentGenerator { let edited = crate::utils::editor::edit_content(&generated.to_conventional())?; generated = self.parse_edited_commit(&edited, format)?; } - 3 => anyhow::bail!("Cancelled by user"), + 3 => anyhow::bail!("{}", messages.cancelled_by_user()), _ => {} } } diff --git a/src/git/mod.rs b/src/git/mod.rs index d751dce..36ccfbe 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -76,7 +76,7 @@ fn try_open_repo_with_git2(path: &Path) -> Result { .or_else(|_| Repository::discover(&normalized)) .or_else(|_| Repository::open(&normalized)); - repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e)) + repo.map_err(|e| anyhow::anyhow!("Failed to open repository: {}", e)) } fn try_open_repo_with_git_cli(path: &Path) -> Result { @@ -642,7 +642,7 @@ impl GitRepo { } /// Stage all changes including subdirectories, then remove ignored tracked files - pub fn stage_all(&self) -> Result> { + pub fn stage_all(&self, messages: &crate::i18n::Messages) -> Result> { // Use git command for reliable staging (handles all edge cases) let output = std::process::Command::new("git") .args(["add", "-A"]) @@ -662,7 +662,11 @@ impl GitRepo { match self.remove_ignored_from_index() { Ok(removed) => Ok(removed), Err(e) => { - eprintln!("Warning: failed to clean ignored files from index: {}", e); + crate::utils::eprint_warning(&format!( + "{}: {}", + messages.failed_clean_ignored(), + e + )); Ok(Vec::new()) } } @@ -937,16 +941,6 @@ impl GitRepo { Ok(()) } - /// Create GPG signature for arbitrary content - fn create_gpg_signature_for_content( - &self, - _content: &str, - _gpg_program: &str, - _signing_key: &str, - ) -> Result { - Ok(String::new()) - } - /// Delete a tag pub fn delete_tag(&self, name: &str) -> Result<()> { self.repo.tag_delete(name)?; @@ -1515,7 +1509,14 @@ mod tests { let head = repo.repo.head().unwrap().peel_to_commit().unwrap(); // Create a lightweight tag - repo.repo.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false) + repo.repo + .tag( + "v0.1.0", + head.as_object(), + &repo.repo.signature().unwrap(), + "", + false, + ) .unwrap(); let tags = repo.get_tags().unwrap(); @@ -1541,7 +1542,13 @@ mod tests { // Create lightweight tag repo.repo - .tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false) + .tag( + "v0.1.0", + head.as_object(), + &repo.repo.signature().unwrap(), + "", + false, + ) .unwrap(); let tags = repo.get_tags().unwrap(); diff --git a/src/i18n/messages.rs b/src/i18n/messages.rs index 87714ad..abf5bf7 100644 --- a/src/i18n/messages.rs +++ b/src/i18n/messages.rs @@ -11,25 +11,25 @@ impl Messages { pub fn initializing(&self) -> &str { match self.language { - Language::English => "🚀 Initializing QuiCommit...", - Language::Chinese => "🚀 正在初始化 QuiCommit...", - Language::Japanese => "🚀 QuiCommitを初期化中...", - Language::Korean => "🚀 QuiCommit 초기화 중...", - Language::Spanish => "🚀 Inicializando QuiCommit...", - Language::French => "🚀 Initialisation de QuiCommit...", - Language::German => "🚀 QuiCommit wird initialisiert...", + Language::English => "Initializing QuiCommit...", + Language::Chinese => "正在初始化 QuiCommit...", + Language::Japanese => "QuiCommitを初期化中...", + Language::Korean => "QuiCommit 초기화 중...", + Language::Spanish => "Inicializando QuiCommit...", + Language::French => "Initialisation de QuiCommit...", + Language::German => "QuiCommit wird initialisiert...", } } pub fn init_success(&self) -> &str { match self.language { - Language::English => "✅ QuiCommit initialized successfully!", - Language::Chinese => "✅ QuiCommit 初始化成功!", - Language::Japanese => "✅ QuiCommitの初期化が完了しました!", - Language::Korean => "✅ QuiCommit 초기화 성공!", - Language::Spanish => "✅ ¡QuiCommit inicializado exitosamente!", - Language::French => "✅ QuiCommit initialisé avec succès !", - Language::German => "✅ QuiCommit erfolgreich initialisiert!", + Language::English => "QuiCommit initialized successfully!", + Language::Chinese => "QuiCommit 初始化成功!", + Language::Japanese => "QuiCommitの初期化が完了しました!", + Language::Korean => "QuiCommit 초기화 성공!", + Language::Spanish => "¡QuiCommit inicializado exitosamente!", + Language::French => "QuiCommit initialisé avec succès !", + Language::German => "QuiCommit erfolgreich initialisiert!", } } @@ -153,15 +153,15 @@ impl Messages { } } - pub fn commit_amended(&self) -> &str { + pub fn commit_amended_successfully(&self) -> &str { match self.language { - Language::English => "Amended commit", - Language::Chinese => "已修改提交", - Language::Japanese => "コミットを修正しました", - Language::Korean => "커밋 수정됨", - Language::Spanish => "Commit enmendado", - Language::French => "Commit modifié", - Language::German => "Commit geändert", + Language::English => "Amended commit successfully", + Language::Chinese => "已成功修改提交", + Language::Japanese => "コミットを正常に修正しました", + Language::Korean => "커밋을 성공적으로 수정함", + Language::Spanish => "Commit enmendado exitosamente", + Language::French => "Commit modifié avec succès", + Language::German => "Commit erfolgreich geändert", } } @@ -225,6 +225,30 @@ impl Messages { } } + pub fn warning(&self) -> &str { + match self.language { + Language::English => "Warning", + Language::Chinese => "警告", + Language::Japanese => "警告", + Language::Korean => "경고", + Language::Spanish => "Advertencia", + Language::French => "Avertissement", + Language::German => "Warnung", + } + } + + pub fn proceed_invalid_tag_name(&self) -> &str { + match self.language { + Language::English => "Proceed with this tag name anyway?", + Language::Chinese => "仍然使用此标签名称?", + Language::Japanese => "それでもこのタグ名を使用しますか?", + Language::Korean => "그래도 이 태그 이름을 사용하시겠습니까?", + Language::Spanish => "¿Continuar con este nombre de etiqueta de todos modos?", + Language::French => "Continuer avec ce nom d'étiquette quand même ?", + Language::German => "Diesen Tag-Namen trotzdem verwenden?", + } + } + pub fn changelog_preview(&self) -> &str { match self.language { Language::English => "Changelog preview:", @@ -249,15 +273,39 @@ impl Messages { } } - pub fn dry_run(&self) -> &str { + pub fn dry_run_commit_not_created(&self) -> &str { match self.language { - Language::English => "Dry run", - Language::Chinese => "试运行", - Language::Japanese => "ドライラン", - Language::Korean => "드라이 런", - Language::Spanish => "Ejecución en seco", - Language::French => "Exécution à blanc", - Language::German => "Testlauf", + Language::English => "Dry run - commit not created.", + Language::Chinese => "试运行 - 未创建提交。", + Language::Japanese => "ドライラン - コミットは作成されませんでした。", + Language::Korean => "드라이 런 - 커밋이 생성되지 않음.", + Language::Spanish => "Ejecución en seco - commit no creado.", + Language::French => "Exécution à blanc - commit non créé.", + Language::German => "Testlauf - Commit nicht erstellt.", + } + } + + pub fn dry_run_commit_not_amended(&self) -> &str { + match self.language { + Language::English => "Dry run - commit not amended.", + Language::Chinese => "试运行 - 未修改提交。", + Language::Japanese => "ドライラン - コミットは修正されませんでした。", + Language::Korean => "드라이 런 - 커밋이 수정되지 않음.", + Language::Spanish => "Ejecución en seco - commit no enmendado.", + Language::French => "Exécution à blanc - commit non modifié.", + Language::German => "Testlauf - Commit nicht geändert.", + } + } + + pub fn dry_run_tag_not_created(&self) -> &str { + match self.language { + Language::English => "Dry run - tag not created.", + Language::Chinese => "试运行 - 未创建标签。", + Language::Japanese => "ドライラン - タグは作成されませんでした。", + Language::Korean => "드라이 런 - 태그가 생성되지 않음.", + Language::Spanish => "Ejecución en seco - etiqueta no creada.", + Language::French => "Exécution à blanc - étiquette non créée.", + Language::German => "Testlauf - Tag nicht erstellt.", } } @@ -307,15 +355,25 @@ impl Messages { } } - pub fn push_after_commit(&self) -> &str { + pub fn push_after_commit(&self, branch: &str) -> String { match self.language { - Language::English => "Push changes to remote?", - Language::Chinese => "推送更改到远程仓库?", - Language::Japanese => "リモートに変更をプッシュしますか?", - Language::Korean => "원격으로 변경 사항을 푸시하시겠습니까?", - Language::Spanish => "¿Enviar cambios al remoto?", - Language::French => "Envoyer les modifications au distant ?", - Language::German => "Änderungen an Remote pushen?", + Language::English => format!("Push changes on branch '{}' to remote?", branch), + Language::Chinese => format!("是否将分支 '{}' 的更改推送到远程仓库?", branch), + Language::Japanese => { + format!("ブランチ '{}' の変更をリモートにプッシュしますか?", branch) + } + Language::Korean => format!( + "'{}' 브랜치의 변경 사항을 원격으로 푸시하시겠습니까?", + branch + ), + Language::Spanish => format!("¿Enviar los cambios de la rama '{}' al remoto?", branch), + Language::French => { + format!( + "Envoyer les modifications de la branche '{}' au distant ?", + branch + ) + } + Language::German => format!("Änderungen auf Branch '{}' an Remote pushen?", branch), } } @@ -331,98 +389,98 @@ impl Messages { } } - pub fn pushing_commit(&self, remote: &str) -> String { + pub fn pushing_commit(&self, remote: &str, branch: &str) -> String { match self.language { - Language::English => format!("→ Pushing commit to {}...", remote), - Language::Chinese => format!("→ 正在推送提交到 {}...", remote), - Language::Japanese => format!("→ コミットを{}にプッシュ中...", remote), - Language::Korean => format!("→ 커밋을 {}로 푸시 중...", remote), - Language::Spanish => format!("→ Enviando commit a {}...", remote), - Language::French => format!("→ Envoi du commit vers {}...", remote), - Language::German => format!("→ Commit wird an {} gepusht...", remote), + Language::English => format!("Pushing branch '{}' to {}...", branch, remote), + Language::Chinese => format!("正在推送分支 '{}' 到 {}...", branch, remote), + Language::Japanese => format!("ブランチ '{}' を{}にプッシュ中...", branch, remote), + Language::Korean => format!("'{}' 브랜치를 {}로 푸시 중...", branch, remote), + Language::Spanish => format!("Enviando rama '{}' a {}...", branch, remote), + Language::French => format!("Envoi de la branche '{}' vers {}...", branch, remote), + Language::German => format!("Branch '{}' wird an {} gepusht...", branch, remote), } } - pub fn pushed_commit(&self, remote: &str) -> String { + pub fn pushed_commit(&self, remote: &str, branch: &str) -> String { match self.language { - Language::English => format!("✓ Pushed commit to {}", remote), - Language::Chinese => format!("✓ 已推送提交到 {}", remote), - Language::Japanese => format!("✓ コミットを{}にプッシュしました", remote), - Language::Korean => format!("✓ 커밋을 {}로 푸시함", remote), - Language::Spanish => format!("✓ Commit enviado a {}", remote), - Language::French => format!("✓ Commit envoyé à {}", remote), - Language::German => format!("✓ Commit an {} gepusht", remote), + Language::English => format!("Pushed branch '{}' to {}", branch, remote), + Language::Chinese => format!("已推送分支 '{}' 到 {}", branch, remote), + Language::Japanese => format!("ブランチ '{}' を{}にプッシュしました", branch, remote), + Language::Korean => format!("'{}' 브랜치를 {}로 푸시함", branch, remote), + Language::Spanish => format!("Rama '{}' enviada a {}", branch, remote), + Language::French => format!("Branche '{}' envoyée à {}", branch, remote), + Language::German => format!("Branch '{}' an {} gepusht", branch, remote), } } pub fn ai_analyzing(&self) -> &str { match self.language { - Language::English => "🤖 AI is analyzing your changes...", - Language::Chinese => "🤖 AI 正在分析你的更改...", - Language::Japanese => "🤖 AIが変更を分析しています...", - Language::Korean => "🤖 AI가 변경 사항을 분석 중...", - Language::Spanish => "🤖 La IA está analizando tus cambios...", - Language::French => "🤖 L'IA analyse vos modifications...", - Language::German => "🤖 KI analysiert Ihre Änderungen...", + Language::English => "AI is analyzing your changes...", + Language::Chinese => "AI 正在分析你的更改...", + Language::Japanese => "AIが変更を分析しています...", + Language::Korean => "AI가 변경 사항을 분석 중...", + Language::Spanish => "La IA está analizando tus cambios...", + Language::French => "L'IA analyse vos modifications...", + Language::German => "KI analysiert Ihre Änderungen...", } } pub fn ai_generating_tag(&self, count: usize) -> String { match self.language { Language::English => { - format!("🤖 AI is generating tag message from {} commits...", count) + format!("AI is generating tag message from {} commits...", count) } - Language::Chinese => format!("🤖 AI 正在从 {} 个提交生成标签消息...", count), + Language::Chinese => format!("AI 正在从 {} 个提交生成标签消息...", count), Language::Japanese => format!( - "🤖 AIが{}個のコミットからタグメッセージを生成しています...", + "AIが{}個のコミットからタグメッセージを生成しています...", count ), - Language::Korean => format!("🤖 AI가 {}개의 커밋에서 태그 메시지를 생성 중...", count), + Language::Korean => format!("AI가 {}개의 커밋에서 태그 메시지를 생성 중...", count), Language::Spanish => format!( - "🤖 La IA está generando mensaje de etiqueta desde {} commits...", + "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...", + "L'IA génère le message d'étiquette à partir de {} commits...", count ), - Language::German => format!("🤖 KI generiert Tag-Nachricht aus {} Commits...", count), + Language::German => format!("KI generiert Tag-Nachricht aus {} Commits...", count), } } pub fn ai_generating_changelog(&self) -> &str { match self.language { - Language::English => "🤖 AI is generating changelog...", - Language::Chinese => "🤖 AI 正在生成变更日志...", - Language::Japanese => "🤖 AIが変更ログを生成しています...", - Language::Korean => "🤖 AI가 변경 로그를 생성 중...", - Language::Spanish => "🤖 La IA está generando el changelog...", - Language::French => "🤖 L'IA génère le changelog...", - Language::German => "🤖 KI generiert Changelog...", + Language::English => "AI is generating changelog...", + Language::Chinese => "AI 正在生成变更日志...", + Language::Japanese => "AIが変更ログを生成しています...", + Language::Korean => "AI가 변경 로그를 생성 중...", + Language::Spanish => "La IA está generando el changelog...", + Language::French => "L'IA génère le changelog...", + Language::German => "KI generiert Changelog...", } } pub fn fetching_commits(&self) -> &str { match self.language { - Language::English => "→ Fetching commits...", - Language::Chinese => "→ 正在获取提交...", - Language::Japanese => "→ コミットを取得中...", - Language::Korean => "→ 커밋 가져오는 중...", - Language::Spanish => "→ Obteniendo commits...", - Language::French => "→ Récupération des commits...", - Language::German => "→ Commits werden abgerufen...", + Language::English => "Fetching commits...", + Language::Chinese => "正在获取提交...", + Language::Japanese => "コミットを取得中...", + Language::Korean => "커밋 가져오는 중...", + Language::Spanish => "Obteniendo commits...", + Language::French => "Récupération des commits...", + Language::German => "Commits werden abgerufen...", } } pub fn found_commits(&self, count: usize) -> String { match self.language { - Language::English => format!("✓ Found {} commits", count), - Language::Chinese => format!("✓ 找到 {} 个提交", count), - Language::Japanese => format!("✓ {}個のコミットが見つかりました", count), - Language::Korean => format!("✓ {}개의 커밋을 찾음", count), - Language::Spanish => format!("✓ Se encontraron {} commits", count), - Language::French => format!("✓ {} commits trouvés", count), - Language::German => format!("✓ {} Commits gefunden", count), + Language::English => format!("Found {} commits", count), + Language::Chinese => format!("找到 {} 个提交", count), + Language::Japanese => format!("{}個のコミットが見つかりました", count), + Language::Korean => format!("{}개의 커밋을 찾음", count), + Language::Spanish => format!("Se encontraron {} commits", count), + Language::French => format!("{} commits trouvés", count), + Language::German => format!("{} Commits gefunden", count), } } @@ -488,25 +546,25 @@ impl Messages { pub fn pushing_tag(&self, remote: &str) -> String { match self.language { - Language::English => format!("→ Pushing tag to {}...", remote), - Language::Chinese => format!("→ 正在推送标签到 {}...", remote), - Language::Japanese => format!("→ タグを{}にプッシュ中...", remote), - Language::Korean => format!("→ 태그를 {}로 푸시 중...", remote), - Language::Spanish => format!("→ Enviando etiqueta a {}...", remote), - Language::French => format!("→ Envoi de l'étiquette vers {}...", remote), - Language::German => format!("→ Tag wird an {} gepusht...", remote), + Language::English => format!("Pushing tag to {}...", remote), + Language::Chinese => format!("正在推送标签到 {}...", remote), + Language::Japanese => format!("タグを{}にプッシュ中...", remote), + Language::Korean => format!("태그를 {}로 푸시 중...", remote), + Language::Spanish => format!("Enviando etiqueta a {}...", remote), + Language::French => format!("Envoi de l'étiquette vers {}...", remote), + Language::German => format!("Tag wird an {} gepusht...", remote), } } pub fn pushed_tag(&self, remote: &str) -> String { match self.language { - Language::English => format!("✓ Pushed tag to {}", remote), - Language::Chinese => format!("✓ 已推送标签到 {}", remote), - Language::Japanese => format!("✓ タグを{}にプッシュしました", remote), - Language::Korean => format!("✓ 태그를 {}로 푸시함", remote), - Language::Spanish => format!("✓ Etiqueta enviada a {}", remote), - Language::French => format!("✓ Étiquette envoyée à {}", remote), - Language::German => format!("✓ Tag an {} gepusht", remote), + Language::English => format!("Pushed tag to {}", remote), + Language::Chinese => format!("已推送标签到 {}", remote), + Language::Japanese => format!("タグを{}にプッシュしました", remote), + Language::Korean => format!("태그를 {}로 푸시함", remote), + Language::Spanish => format!("Etiqueta enviada a {}", remote), + Language::French => format!("Étiquette envoyée à {}", remote), + Language::German => format!("Tag an {} gepusht", remote), } } @@ -630,6 +688,18 @@ impl Messages { } } + pub fn release_default(&self, version: &str) -> String { + match self.language { + Language::English => format!("Release {}", version), + Language::Chinese => format!("发布 {}", version), + Language::Japanese => format!("リリース {}", version), + Language::Korean => format!("릴리스 {}", version), + Language::Spanish => format!("Lanzamiento {}", version), + Language::French => format!("Version {}", version), + Language::German => format!("Release {}", version), + } + } + pub fn unreleased(&self) -> &str { match self.language { Language::English => "Unreleased", @@ -656,25 +726,25 @@ impl Messages { pub fn write_to_file(&self, path: &str) -> String { match self.language { - Language::English => format!("Write to {:?}?", path), - Language::Chinese => format!("写入 {:?}?", path), - Language::Japanese => format!("{:?} に書き込みますか?", path), - Language::Korean => format!("{:?}에 쓰시겠습니까?", path), - Language::Spanish => format!("¿Escribir en {:?}?", path), - Language::French => format!("Écrire dans {:?} ?", path), - Language::German => format!("In {:?} schreiben?", path), + Language::English => format!("Write to {}?", path), + Language::Chinese => format!("写入 {}?", path), + Language::Japanese => format!("{} に書き込みますか?", path), + Language::Korean => format!("{}에 쓰시겠습니까?", path), + Language::Spanish => format!("¿Escribir en {}?", path), + Language::French => format!("Écrire dans {} ?", path), + Language::German => format!("In {} schreiben?", path), } } pub fn initialized_changelog(&self, path: &str) -> String { match self.language { - Language::English => format!("✓ Initialized changelog at {:?}", path), - Language::Chinese => format!("✓ 已在 {:?} 初始化变更日志", path), - Language::Japanese => format!("✓ {:?}で変更ログを初期化しました", path), - Language::Korean => format!("✓ {:?}에서 변경 로그 초기화됨", path), - Language::Spanish => format!("✓ Changelog inicializado en {:?}", path), - Language::French => format!("✓ Changelog initialisé à {:?}", path), - Language::German => format!("✓ Changelog bei {:?} initialisiert", path), + Language::English => format!("Initialized changelog at {}", path), + Language::Chinese => format!("已在 {} 初始化变更日志", path), + Language::Japanese => format!("{}で変更ログを初期化しました", path), + Language::Korean => format!("{}에서 변경 로그 초기화됨", path), + Language::Spanish => format!("Changelog inicializado en {}", path), + Language::French => format!("Changelog initialisé à {}", path), + Language::German => format!("Changelog bei {} initialisiert", path), } } @@ -728,61 +798,61 @@ impl Messages { pub fn accept_and_commit(&self) -> &str { match self.language { - Language::English => "✓ Accept and commit", - Language::Chinese => "✓ 接受并提交", - Language::Japanese => "✓ 受け入れてコミット", - Language::Korean => "✓ 수락하고 커밋", - Language::Spanish => "✓ Aceptar y hacer commit", - Language::French => "✓ Accepter et commiter", - Language::German => "✓ Akzeptieren und committen", + Language::English => "Accept and commit", + Language::Chinese => "接受并提交", + Language::Japanese => "受け入れてコミット", + Language::Korean => "수락하고 커밋", + Language::Spanish => "Aceptar y hacer commit", + Language::French => "Accepter et commiter", + Language::German => "Akzeptieren und committen", } } pub fn regenerate(&self) -> &str { match self.language { - Language::English => "🔄 Regenerate", - Language::Chinese => "🔄 重新生成", - Language::Japanese => "🔄 再生成", - Language::Korean => "🔄 재생성", - Language::Spanish => "🔄 Regenerar", - Language::French => "🔄 Régénérer", - Language::German => "🔄 Neu generieren", + Language::English => "Regenerate", + Language::Chinese => "重新生成", + Language::Japanese => "再生成", + Language::Korean => "재생성", + Language::Spanish => "Regenerar", + Language::French => "Régénérer", + Language::German => "Neu generieren", } } pub fn edit(&self) -> &str { match self.language { - Language::English => "✏️ Edit", - Language::Chinese => "✏️ 编辑", - Language::Japanese => "✏️ 編集", - Language::Korean => "✏️ 편집", - Language::Spanish => "✏️ Editar", - Language::French => "✏️ Modifier", - Language::German => "✏️ Bearbeiten", + Language::English => "Edit", + Language::Chinese => "编辑", + Language::Japanese => "編集", + Language::Korean => "편집", + Language::Spanish => "Editar", + Language::French => "Modifier", + Language::German => "Bearbeiten", } } pub fn copy_to_clipboard(&self) -> &str { match self.language { - Language::English => "📋 Copy to clipboard", - Language::Chinese => "📋 复制到剪贴板", - Language::Japanese => "📋 クリップボードにコピー", - Language::Korean => "📋 클립보드에 복사", - Language::Spanish => "📋 Copiar al portapapeles", - Language::French => "📋 Copier dans le presse-papiers", - Language::German => "📋 In die Zwischenablage kopieren", + Language::English => "Copy to clipboard", + Language::Chinese => "复制到剪贴板", + Language::Japanese => "クリップボードにコピー", + Language::Korean => "클립보드에 복사", + Language::Spanish => "Copiar al portapapeles", + Language::French => "Copier dans le presse-papiers", + Language::German => "In die Zwischenablage kopieren", } } pub fn cancel(&self) -> &str { match self.language { - Language::English => "❌ Cancel", - Language::Chinese => "❌ 取消", - Language::Japanese => "❌ キャンセル", - Language::Korean => "❌ 취소", - Language::Spanish => "❌ Cancelar", - Language::French => "❌ Annuler", - Language::German => "❌ Abbrechen", + Language::English => "Cancel", + Language::Chinese => "取消", + Language::Japanese => "キャンセル", + Language::Korean => "취소", + Language::Spanish => "Cancelar", + Language::French => "Annuler", + Language::German => "Abbrechen", } } @@ -1172,25 +1242,25 @@ impl Messages { pub fn configuration_updated(&self) -> &str { match self.language { - Language::English => "✓ Configuration updated", - Language::Chinese => "✓ 配置已更新", - Language::Japanese => "✓ 設定を更新しました", - Language::Korean => "✓ 구성이 업데이트됨", - Language::Spanish => "✓ Configuración actualizada", - Language::French => "✓ Configuration mise à jour", - Language::German => "✓ Konfiguration aktualisiert", + Language::English => "Configuration updated", + Language::Chinese => "配置已更新", + Language::Japanese => "設定を更新しました", + Language::Korean => "구성이 업데이트됨", + Language::Spanish => "Configuración actualizada", + Language::French => "Configuration mise à jour", + Language::German => "Konfiguration aktualisiert", } } pub fn set_value(&self, key: &str, value: &str) -> String { match self.language { - Language::English => format!("✓ Set {} = {}", key, value), - Language::Chinese => format!("✓ 设置 {} = {}", key, value), - Language::Japanese => format!("✓ {} = {} を設定", key, value), - Language::Korean => format!("✓ {} = {} 설정됨", key, value), - Language::Spanish => format!("✓ Establecer {} = {}", key, value), - Language::French => format!("✓ Définir {} = {}", key, value), - Language::German => format!("✓ {} = {} festlegen", key, value), + Language::English => format!("Set {} = {}", key, value), + Language::Chinese => format!("设置 {} = {}", key, value), + Language::Japanese => format!("{} = {} を設定", key, value), + Language::Korean => format!("{} = {} 설정됨", key, value), + Language::Spanish => format!("Establecer {} = {}", key, value), + Language::French => format!("Définir {} = {}", key, value), + Language::German => format!("{} = {} festlegen", key, value), } } @@ -1205,4 +1275,3603 @@ impl Messages { Language::German => "Ausgabesprache auswählen:", } } + + // ---- init command (issue 18) ---- + + pub fn config_exists_overwrite(&self) -> &str { + match self.language { + Language::English => "Configuration already exists. Overwrite?", + Language::Chinese => "配置文件已存在。是否覆盖?", + Language::Japanese => "設定ファイルは既に存在します。上書きしますか?", + Language::Korean => "구성 파일이 이미 존재합니다. 덮어쓰시겠습니까?", + Language::Spanish => "La configuración ya existe. ¿Sobrescribir?", + Language::French => "La configuration existe déjà. Écraser ?", + Language::German => "Konfiguration existiert bereits. Überschreiben?", + } + } + + pub fn init_cancelled(&self) -> &str { + match self.language { + Language::English => "Initialization cancelled.", + Language::Chinese => "初始化已取消。", + Language::Japanese => "初期化がキャンセルされました。", + Language::Korean => "초기화가 취소되었습니다.", + Language::Spanish => "Inicialización cancelada.", + Language::French => "Initialisation annulée.", + Language::German => "Initialisierung abgebrochen.", + } + } + + pub fn config_exists_use_reset(&self) -> &str { + match self.language { + Language::English => "Configuration already exists. Use --reset to overwrite.", + Language::Chinese => "配置文件已存在。使用 --reset 覆盖。", + Language::Japanese => { + "設定ファイルは既に存在します。上書きするには --reset を使用してください。" + } + Language::Korean => "구성 파일이 이미 존재합니다. --reset을 사용하여 덮어쓰세요.", + Language::Spanish => "La configuración ya existe. Use --reset para sobrescribir.", + Language::French => "La configuration existe déjà. Utilisez --reset pour écraser.", + Language::German => { + "Konfiguration existiert bereits. Zum Überschreiben --reset verwenden." + } + } + } + + pub fn next_steps_create_profile(&self) -> &str { + match self.language { + Language::English => "Create a profile", + Language::Chinese => "创建配置文件", + Language::Japanese => "プロファイルを作成", + Language::Korean => "프로필 만들기", + Language::Spanish => "Crear un perfil", + Language::French => "Créer un profil", + Language::German => "Profil erstellen", + } + } + + pub fn next_steps_configure_llm(&self) -> &str { + match self.language { + Language::English => "Configure LLM", + Language::Chinese => "配置 LLM", + Language::Japanese => "LLMを設定", + Language::Korean => "LLM 구성", + Language::Spanish => "Configurar LLM", + Language::French => "Configurer le LLM", + Language::German => "LLM konfigurieren", + } + } + + pub fn next_steps_start_committing(&self) -> &str { + match self.language { + Language::English => "Start committing", + Language::Chinese => "开始提交", + Language::Japanese => "コミットを開始", + Language::Korean => "커밋 시작", + Language::Spanish => "Empezar a hacer commit", + Language::French => "Commencer à commiter", + Language::German => "Mit Committen beginnen", + } + } + + pub fn quick_setup_summary(&self) -> &str { + match self.language { + Language::English => "Quick setup summary:", + Language::Chinese => "快速设置摘要:", + Language::Japanese => "クイックセットアップ概要:", + Language::Korean => "빠른 설정 요약:", + Language::Spanish => "Resumen de configuración rápida:", + Language::French => "Résumé de la configuration rapide :", + Language::German => "Schnelleinrichtungs-Zusammenfassung:", + } + } + + pub fn profile_label(&self) -> &str { + match self.language { + Language::English => "Profile", + Language::Chinese => "配置文件", + Language::Japanese => "プロファイル", + Language::Korean => "프로필", + Language::Spanish => "Perfil", + Language::French => "Profil", + Language::German => "Profil", + } + } + + pub fn identity_label(&self) -> &str { + match self.language { + Language::English => "Identity", + Language::Chinese => "身份", + Language::Japanese => "識別情報", + Language::Korean => "신원", + Language::Spanish => "Identidad", + Language::French => "Identité", + Language::German => "Identität", + } + } + + pub fn identity_placeholder_warning(&self) -> &str { + match self.language { + Language::English => { + "Identity uses placeholder values because git user.name/user.email are not set. \ + Update it with: quicommit profile edit default" + } + Language::Chinese => { + "由于未设置 git user.name/user.email,身份使用了占位值。\ + 请使用 quicommit profile edit default 更新。" + } + Language::Japanese => { + "git user.name/user.email が設定されていないため、識別情報はプレースホルダー値です。\ + quicommit profile edit default で更新してください。" + } + Language::Korean => { + "git user.name/user.email이 설정되지 않아 신원이 자리 표시자 값입니다. \ + quicommit profile edit default로 업데이트하세요." + } + Language::Spanish => { + "La identidad usa valores de marcador porque git user.name/user.email no están \ + configurados. Actualícela con: quicommit profile edit default" + } + Language::French => { + "L'identité utilise des valeurs par défaut car git user.name/user.email ne sont \ + pas définis. Mettez-la à jour avec : quicommit profile edit default" + } + Language::German => { + "Identität verwendet Platzhalterwerte, da git user.name/user.email nicht gesetzt \ + sind. Mit quicommit profile edit default aktualisieren." + } + } + } + + pub fn llm_provider_label(&self) -> &str { + match self.language { + Language::English => "LLM provider", + Language::Chinese => "LLM 提供商", + Language::Japanese => "LLMプロバイダー", + Language::Korean => "LLM 공급자", + Language::Spanish => "Proveedor LLM", + Language::French => "Fournisseur LLM", + Language::German => "LLM-Anbieter", + } + } + + pub fn keyring_unavailable(&self) -> &str { + match self.language { + Language::English => "Keyring is not available on this system.", + Language::Chinese => "此系统上的密钥环不可用。", + Language::Japanese => "このシステムではキーリングが利用できません。", + Language::Korean => "이 시스템에서 키링을 사용할 수 없습니다.", + Language::Spanish => "El keyring no está disponible en este sistema.", + Language::French => "Le trousseau de clés n'est pas disponible sur ce système.", + Language::German => "Der Schlüsselbund ist auf diesem System nicht verfügbar.", + } + } + + pub fn api_key_found_env(&self) -> &str { + match self.language { + Language::English => "Found API key in environment variable.", + Language::Chinese => "在环境变量中找到了 API 密钥。", + Language::Japanese => "環境変数でAPIキーが見つかりました。", + Language::Korean => "환경 변수에서 API 키를 찾았습니다.", + Language::Spanish => "Se encontró la clave API en la variable de entorno.", + Language::French => "Clé API trouvée dans la variable d'environnement.", + Language::German => "API-Schlüssel in Umgebungsvariable gefunden.", + } + } + + pub fn api_key_prompt_other(&self) -> &str { + match self.language { + Language::English => "API Key", + Language::Chinese => "API 密钥", + Language::Japanese => "APIキー", + Language::Korean => "API 키", + Language::Spanish => "Clave API", + Language::French => "Clé API", + Language::German => "API-Schlüssel", + } + } + + pub fn please_set_api_key_env(&self) -> &str { + match self.language { + Language::English => "Please set the QUICOMMIT_API_KEY environment variable.", + Language::Chinese => "请设置 QUICOMMIT_API_KEY 环境变量。", + Language::Japanese => "QUICOMMIT_API_KEY 環境変数を設定してください。", + Language::Korean => "QUICOMMIT_API_KEY 환경 변수를 설정하세요.", + Language::Spanish => "Configure la variable de entorno QUICOMMIT_API_KEY.", + Language::French => "Veuillez définir la variable d'environnement QUICOMMIT_API_KEY.", + Language::German => "Bitte die Umgebungsvariable QUICOMMIT_API_KEY setzen.", + } + } + + pub fn api_key_stored_keyring(&self) -> &str { + match self.language { + Language::English => "API key stored securely in system keyring.", + Language::Chinese => "API 密钥已安全存储在系统密钥环中。", + Language::Japanese => "APIキーはシステムキーリングに安全に保存されました。", + Language::Korean => "API 키가 시스템 키링에 안전하게 저장되었습니다.", + Language::Spanish => "Clave API almacenada de forma segura en el keyring del sistema.", + Language::French => "Clé API stockée en toute sécurité dans le trousseau système.", + Language::German => "API-Schlüssel sicher im System-Schlüsselbund gespeichert.", + } + } + + pub fn model_name(&self) -> &str { + match self.language { + Language::English => "Model name", + Language::Chinese => "模型名称", + Language::Japanese => "モデル名", + Language::Korean => "모델 이름", + Language::Spanish => "Nombre del modelo", + Language::French => "Nom du modèle", + Language::German => "Modellname", + } + } + + pub fn ollama_server_url(&self) -> &str { + match self.language { + Language::English => "Ollama server URL", + Language::Chinese => "Ollama 服务器 URL", + Language::Japanese => "OllamaサーバーURL", + Language::Korean => "Ollama 서버 URL", + Language::Spanish => "URL del servidor Ollama", + Language::French => "URL du serveur Ollama", + Language::German => "Ollama-Server-URL", + } + } + + pub fn use_custom_base_url(&self) -> &str { + match self.language { + Language::English => "Use custom API base URL?", + Language::Chinese => "使用自定义 API 基础 URL?", + Language::Japanese => "カスタムAPIベースURLを使用しますか?", + Language::Korean => "사용자 지정 API 기본 URL을 사용하시겠습니까?", + Language::Spanish => "¿Usar una URL base de API personalizada?", + Language::French => "Utiliser une URL de base d'API personnalisée ?", + Language::German => "Benutzerdefinierte API-Basis-URL verwenden?", + } + } + + pub fn base_url_plain(&self) -> &str { + match self.language { + Language::English => "Base URL", + Language::Chinese => "基础 URL", + Language::Japanese => "ベースURL", + Language::Korean => "기본 URL", + Language::Spanish => "URL base", + Language::French => "URL de base", + Language::German => "Basis-URL", + } + } + + pub fn ssh_public_key_path(&self) -> &str { + match self.language { + Language::English => "SSH public key path (optional, leave empty to auto-detect)", + Language::Chinese => "SSH 公钥路径(可选,留空自动检测)", + Language::Japanese => "SSH公開鍵パス(オプション、空のままにすると自動検出)", + Language::Korean => "SSH 공개 키 경로(선택 사항, 비워 두면 자동 감지)", + Language::Spanish => { + "Ruta de clave pública SSH (opcional, dejar vacío para auto-detectar)" + } + Language::French => { + "Chemin de la clé publique SSH (optionnel, laisser vide pour détection automatique)" + } + Language::German => { + "Pfad zum öffentlichen SSH-Schlüssel (optional, leer lassen für Auto-Erkennung)" + } + } + } + + pub fn ssh_agent_forwarding(&self) -> &str { + match self.language { + Language::English => "Enable SSH agent forwarding (-A)?", + Language::Chinese => "启用 SSH 代理转发(-A)?", + Language::Japanese => "SSHエージェント転送(-A)を有効にしますか?", + Language::Korean => "SSH 에이전트 포워딩(-A)을 활성화하시겠습니까?", + Language::Spanish => "¿Habilitar reenvío del agente SSH (-A)?", + Language::French => "Activer le transfert d'agent SSH (-A) ?", + Language::German => "SSH-Agent-Forwarding aktivieren (-A)?", + } + } + + pub fn known_hosts_path(&self) -> &str { + match self.language { + Language::English => "Custom known_hosts file path (optional)", + Language::Chinese => "自定义 known_hosts 文件路径(可选)", + Language::Japanese => "カスタムknown_hostsファイルパス(オプション)", + Language::Korean => "사용자 지정 known_hosts 파일 경로(선택 사항)", + Language::Spanish => "Ruta personalizada del archivo known_hosts (opcional)", + Language::French => "Chemin personnalisé du fichier known_hosts (optionnel)", + Language::German => "Benutzerdefinierter known_hosts-Dateipfad (optional)", + } + } + + pub fn custom_ssh_command(&self) -> &str { + match self.language { + Language::English => "Custom SSH command (optional, overrides all other SSH settings)", + Language::Chinese => "自定义 SSH 命令(可选,覆盖所有其他 SSH 设置)", + Language::Japanese => "カスタムSSHコマンド(オプション、他のすべてのSSH設定を上書き)", + Language::Korean => "사용자 지정 SSH 명령(선택 사항, 다른 모든 SSH 설정 재정의)", + Language::Spanish => { + "Comando SSH personalizado (opcional, anula el resto de ajustes SSH)" + } + Language::French => { + "Commande SSH personnalisée (optionnel, remplace tous les autres réglages SSH)" + } + Language::German => { + "Benutzerdefinierter SSH-Befehl (optional, überschreibt alle anderen SSH-Einstellungen)" + } + } + } + + // ---- scattered errors (issue 19) ---- + + pub fn failed_to_stage(&self) -> &str { + match self.language { + Language::English => { + "Failed to stage changes. Please try running 'git add -A' manually." + } + Language::Chinese => "暂存更改失败。请尝试手动运行 'git add -A'。", + Language::Japanese => { + "変更のステージに失敗しました。手動で 'git add -A' を実行してみてください。" + } + Language::Korean => "변경 사항 스테이징 실패. 'git add -A'를 수동으로 실행해 보세요.", + Language::Spanish => { + "No se pudieron preparar los cambios. Intente ejecutar 'git add -A' manualmente." + } + Language::French => { + "Échec de l'indexation des modifications. Essayez 'git add -A' manuellement." + } + Language::German => { + "Änderungen konnten nicht bereitgestellt werden. Führen Sie 'git add -A' manuell aus." + } + } + } + + pub fn unknown_changelog_format(&self, format: &str) -> String { + match self.language { + Language::English => { + format!( + "Unknown format: {}. Use: keep-a-changelog, github-releases", + format + ) + } + Language::Chinese => { + format!( + "未知格式:{}。可用:keep-a-changelog、github-releases", + format + ) + } + Language::Japanese => format!( + "不明なフォーマット: {}。使用可能: keep-a-changelog、github-releases", + format + ), + Language::Korean => format!( + "알 수 없는 형식: {}. 사용 가능: keep-a-changelog, github-releases", + format + ), + Language::Spanish => format!( + "Formato desconocido: {}. Use: keep-a-changelog, github-releases", + format + ), + Language::French => format!( + "Format inconnu : {}. Utilisez : keep-a-changelog, github-releases", + format + ), + Language::German => format!( + "Unbekanntes Format: {}. Verwenden Sie: keep-a-changelog, github-releases", + format + ), + } + } + + pub fn thinking_unsupported(&self, provider: &str) -> String { + match self.language { + Language::English => format!( + "Provider '{}' does not support thinking mode. \ + Disabling thinking for this invocation.", + provider + ), + Language::Chinese => { + format!("提供商 '{}' 不支持思考模式。本次调用已禁用思考。", provider) + } + Language::Japanese => format!( + "プロバイダー '{}' は思考モードをサポートしていません。\ + 今回の呼び出しでは思考を無効にします。", + provider + ), + Language::Korean => format!( + "'{}' 공급자는 사고 모드를 지원하지 않습니다. 이 호출에서는 사고 모드를 비활성화합니다.", + provider + ), + Language::Spanish => format!( + "El proveedor '{}' no admite el modo de pensamiento. \ + Se deshabilita para esta invocación.", + provider + ), + Language::French => format!( + "Le fournisseur '{}' ne prend pas en charge le mode de réflexion. \ + Désactivation pour cette invocation.", + provider + ), + Language::German => format!( + "Anbieter '{}' unterstützt den Thinking-Modus nicht. \ + Thinking für diesen Aufruf deaktiviert.", + provider + ), + } + } + + pub fn failed_clean_ignored(&self) -> &str { + match self.language { + Language::English => "Failed to clean ignored files from index", + Language::Chinese => "从索引中清理已忽略文件失败", + Language::Japanese => { + "インデックスから無視されたファイルをクリーンアップできませんでした" + } + Language::Korean => "인덱스에서 무시된 파일을 정리하지 못함", + Language::Spanish => "No se pudieron limpiar los archivos ignorados del índice", + Language::French => "Échec du nettoyage des fichiers ignorés de l'index", + Language::German => "Ignorierte Dateien konnten nicht aus dem Index entfernt werden", + } + } + + // ---- profile command (issue 16) ---- + + pub fn add_new_profile(&self) -> &str { + match self.language { + Language::English => "Add new profile", + Language::Chinese => "添加新配置文件", + Language::Japanese => "新しいプロファイルを追加", + Language::Korean => "새 프로필 추가", + Language::Spanish => "Añadir nuevo perfil", + Language::French => "Ajouter un nouveau profil", + Language::German => "Neues Profil hinzufügen", + } + } + + pub fn add_pat_confirm(&self) -> &str { + match self.language { + Language::English => "Add a Personal Access Token?", + Language::Chinese => "添加个人访问令牌(PAT)?", + Language::Japanese => "個人アクセストークンを追加しますか?", + Language::Korean => "개인 액세스 토큰을 추가하시겠습니까?", + Language::Spanish => "¿Añadir un token de acceso personal?", + Language::French => "Ajouter un jeton d'accès personnel ?", + Language::German => "Persönliches Zugriffstoken hinzufügen?", + } + } + + pub fn set_default_confirm(&self) -> &str { + match self.language { + Language::English => "Set as default profile?", + Language::Chinese => "设为默认配置文件?", + Language::Japanese => "デフォルトプロファイルに設定しますか?", + Language::Korean => "기본 프로필로 설정하시겠습니까?", + Language::Spanish => "¿Establecer como perfil predeterminado?", + Language::French => "Définir comme profil par défaut ?", + Language::German => "Als Standardprofil festlegen?", + } + } + + pub fn profile_added(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile '{}' added successfully", name), + Language::Chinese => format!("配置文件 '{}' 添加成功", name), + Language::Japanese => format!("プロファイル '{}' を追加しました", name), + Language::Korean => format!("프로필 '{}' 추가됨", name), + Language::Spanish => format!("Perfil '{}' añadido exitosamente", name), + Language::French => format!("Profil '{}' ajouté avec succès", name), + Language::German => format!("Profil '{}' erfolgreich hinzugefügt", name), + } + } + + pub fn set_default_done(&self, name: &str) -> String { + match self.language { + Language::English => format!("Set '{}' as default profile", name), + Language::Chinese => format!("已将 '{}' 设为默认配置文件", name), + Language::Japanese => format!("'{}' をデフォルトプロファイルに設定しました", name), + Language::Korean => format!("'{}'을(를) 기본 프로필로 설정함", name), + Language::Spanish => format!("'{}' establecido como perfil predeterminado", name), + Language::French => format!("'{}' défini comme profil par défaut", name), + Language::German => format!("'{}' als Standardprofil festgelegt", name), + } + } + + pub fn profile_not_found(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile '{}' not found", name), + Language::Chinese => format!("未找到配置文件 '{}'", name), + Language::Japanese => format!("プロファイル '{}' が見つかりません", name), + Language::Korean => format!("프로필 '{}'을(를) 찾을 수 없음", name), + Language::Spanish => format!("Perfil '{}' no encontrado", name), + Language::French => format!("Profil '{}' introuvable", name), + Language::German => format!("Profil '{}' nicht gefunden", name), + } + } + + pub fn profile_already_exists(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile '{}' already exists", name), + Language::Chinese => format!("配置文件 '{}' 已存在", name), + Language::Japanese => format!("プロファイル '{}' は既に存在します", name), + Language::Korean => format!("프로필 '{}'이(가) 이미 존재합니다", name), + Language::Spanish => format!("El perfil '{}' ya existe", name), + Language::French => format!("Le profil '{}' existe déjà", name), + Language::German => format!("Profil '{}' existiert bereits", name), + } + } + + pub fn remove_profile_confirm(&self, name: &str) -> String { + match self.language { + Language::English => format!("Are you sure you want to remove profile '{}'?", name), + Language::Chinese => format!("确定要删除配置文件 '{}' 吗?", name), + Language::Japanese => format!("プロファイル '{}' を削除してもよろしいですか?", name), + Language::Korean => format!("프로필 '{}'을(를) 삭제하시겠습니까?", name), + Language::Spanish => format!("¿Seguro que quiere eliminar el perfil '{}'?", name), + Language::French => format!("Voulez-vous vraiment supprimer le profil '{}' ?", name), + Language::German => format!("Profil '{}' wirklich entfernen?", name), + } + } + + pub fn profile_removed(&self, name: &str) -> String { + match self.language { + Language::English => { + format!("Profile '{}' removed (including all stored tokens)", name) + } + Language::Chinese => format!("配置文件 '{}' 已删除(包括所有已存储的令牌)", name), + Language::Japanese => format!( + "プロファイル '{}' を削除しました(保存済みトークンを含む)", + name + ), + Language::Korean => format!("프로필 '{}' 삭제됨(저장된 모든 토큰 포함)", name), + Language::Spanish => { + format!( + "Perfil '{}' eliminado (incluidos todos los tokens almacenados)", + name + ) + } + Language::French => { + format!( + "Profil '{}' supprimé (y compris tous les jetons stockés)", + name + ) + } + Language::German => { + format!( + "Profil '{}' entfernt (inklusive aller gespeicherten Tokens)", + name + ) + } + } + } + + pub fn no_profiles_configured(&self) -> &str { + match self.language { + Language::English => "No profiles configured.", + Language::Chinese => "未配置任何配置文件。", + Language::Japanese => "プロファイルが設定されていません。", + Language::Korean => "구성된 프로필이 없습니다.", + Language::Spanish => "No hay perfiles configurados.", + Language::French => "Aucun profil configuré.", + Language::German => "Keine Profile konfiguriert.", + } + } + + pub fn run_to_create_one(&self, cmd: &str) -> String { + match self.language { + Language::English => format!("Run {} to create one.", cmd), + Language::Chinese => format!("运行 {} 来创建一个。", cmd), + Language::Japanese => format!("{} を実行して作成してください。", cmd), + Language::Korean => format!("{}을(를) 실행하여 만드세요.", cmd), + Language::Spanish => format!("Ejecute {} para crear uno.", cmd), + Language::French => format!("Exécutez {} pour en créer un.", cmd), + Language::German => format!("Führen Sie {} aus, um eines zu erstellen.", cmd), + } + } + + pub fn configured_profiles(&self) -> &str { + match self.language { + Language::English => "Configured profiles:", + Language::Chinese => "已配置的配置文件:", + Language::Japanese => "設定済みプロファイル:", + Language::Korean => "구성된 프로필:", + Language::Spanish => "Perfiles configurados:", + Language::French => "Profils configurés :", + Language::German => "Konfigurierte Profile:", + } + } + + pub fn work_marker(&self) -> &str { + match self.language { + Language::English => " [work]", + Language::Chinese => " [工作]", + Language::Japanese => " [仕事]", + Language::Korean => " [업무]", + Language::Spanish => " [trabajo]", + Language::French => " [travail]", + Language::German => " [Arbeit]", + } + } + + pub fn ssh_configured(&self) -> &str { + match self.language { + Language::English => "SSH configured", + Language::Chinese => "已配置 SSH", + Language::Japanese => "SSH設定済み", + Language::Korean => "SSH 구성됨", + Language::Spanish => "SSH configurado", + Language::French => "SSH configuré", + Language::German => "SSH konfiguriert", + } + } + + pub fn gpg_configured(&self) -> &str { + match self.language { + Language::English => "GPG configured", + Language::Chinese => "已配置 GPG", + Language::Japanese => "GPG設定済み", + Language::Korean => "GPG 구성됨", + Language::Spanish => "GPG configurado", + Language::French => "GPG configuré", + Language::German => "GPG konfiguriert", + } + } + + pub fn signing_key_value(&self, key: &str) -> String { + match self.language { + Language::English => format!("Signing key: {}", key), + Language::Chinese => format!("签名密钥:{}", key), + Language::Japanese => format!("署名キー: {}", key), + Language::Korean => format!("서명 키: {}", key), + Language::Spanish => format!("Clave de firma: {}", key), + Language::French => format!("Clé de signature : {}", key), + Language::German => format!("Signaturschlüssel: {}", key), + } + } + + pub fn tokens_count(&self, count: usize) -> String { + match self.language { + Language::English => format!("{} token(s)", count), + Language::Chinese => format!("{} 个令牌", count), + Language::Japanese => format!("{} 個のトークン", count), + Language::Korean => format!("토큰 {}개", count), + Language::Spanish => format!("{} token(s)", count), + Language::French => format!("{} jeton(s)", count), + Language::German => format!("{} Token(s)", count), + } + } + + pub fn last_used_value(&self, value: &str) -> String { + match self.language { + Language::English => format!("Last used: {}", value), + Language::Chinese => format!("最近使用:{}", value), + Language::Japanese => format!("最終使用: {}", value), + Language::Korean => format!("마지막 사용: {}", value), + Language::Spanish => format!("Último uso: {}", value), + Language::French => format!("Dernière utilisation : {}", value), + Language::German => format!("Zuletzt verwendet: {}", value), + } + } + + pub fn repo_status_header(&self) -> &str { + match self.language { + Language::English => "Current Repository Status", + Language::Chinese => "当前仓库状态", + Language::Japanese => "現在のリポジトリステータス", + Language::Korean => "현재 저장소 상태", + Language::Spanish => "Estado actual del repositorio", + Language::French => "État actuel du dépôt", + Language::German => "Aktueller Repository-Status", + } + } + + pub fn repository_value(&self, path: &str) -> String { + match self.language { + Language::English => format!("Repository: {}", path), + Language::Chinese => format!("仓库:{}", path), + Language::Japanese => format!("リポジトリ: {}", path), + Language::Korean => format!("저장소: {}", path), + Language::Spanish => format!("Repositorio: {}", path), + Language::French => format!("Dépôt : {}", path), + Language::German => format!("Repository: {}", path), + } + } + + pub fn git_user_config_header(&self) -> &str { + match self.language { + Language::English => "Git User Configuration (merged local/global):", + Language::Chinese => "Git 用户配置(合并本地/全局):", + Language::Japanese => "Gitユーザー設定(ローカル/グローバル統合):", + Language::Korean => "Git 사용자 구성(로컬/전역 병합):", + Language::Spanish => "Configuración de usuario de Git (local/global combinada):", + Language::French => "Configuration utilisateur Git (locale/globale fusionnée) :", + Language::German => "Git-Benutzerkonfiguration (lokal/global zusammengeführt):", + } + } + + pub fn label_user_name(&self) -> &str { + match self.language { + Language::English => "User name", + Language::Chinese => "用户名", + Language::Japanese => "ユーザー名", + Language::Korean => "사용자 이름", + Language::Spanish => "Nombre de usuario", + Language::French => "Nom d'utilisateur", + Language::German => "Benutzername", + } + } + + pub fn label_user_email(&self) -> &str { + match self.language { + Language::English => "User email", + Language::Chinese => "用户邮箱", + Language::Japanese => "ユーザーメール", + Language::Korean => "사용자 이메일", + Language::Spanish => "Correo de usuario", + Language::French => "E-mail utilisateur", + Language::German => "Benutzer-E-Mail", + } + } + + pub fn label_signing_key(&self) -> &str { + match self.language { + Language::English => "Signing key", + Language::Chinese => "签名密钥", + Language::Japanese => "署名キー", + Language::Korean => "서명 키", + Language::Spanish => "Clave de firma", + Language::French => "Clé de signature", + Language::German => "Signaturschlüssel", + } + } + + pub fn label_ssh_command(&self) -> &str { + match self.language { + Language::English => "SSH command", + Language::Chinese => "SSH 命令", + Language::Japanese => "SSHコマンド", + Language::Korean => "SSH 명령", + Language::Spanish => "Comando SSH", + Language::French => "Commande SSH", + Language::German => "SSH-Befehl", + } + } + + pub fn label_commit_gpgsign(&self) -> &str { + match self.language { + Language::English => "Commit GPG sign", + Language::Chinese => "提交 GPG 签名", + Language::Japanese => "コミットGPG署名", + Language::Korean => "커밋 GPG 서명", + Language::Spanish => "Firma GPG de commit", + Language::French => "Signature GPG de commit", + Language::German => "Commit-GPG-Signatur", + } + } + + pub fn label_tag_gpgsign(&self) -> &str { + match self.language { + Language::English => "Tag GPG sign", + Language::Chinese => "标签 GPG 签名", + Language::Japanese => "タグGPG署名", + Language::Korean => "태그 GPG 서명", + Language::Spanish => "Firma GPG de etiqueta", + Language::French => "Signature GPG d'étiquette", + Language::German => "Tag-GPG-Signatur", + } + } + + pub fn source_local(&self) -> &str { + match self.language { + Language::English => "local", + Language::Chinese => "本地", + Language::Japanese => "ローカル", + Language::Korean => "로컬", + Language::Spanish => "local", + Language::French => "local", + Language::German => "lokal", + } + } + + pub fn source_global(&self) -> &str { + match self.language { + Language::English => "global", + Language::Chinese => "全局", + Language::Japanese => "グローバル", + Language::Korean => "전역", + Language::Spanish => "global", + Language::French => "global", + Language::German => "global", + } + } + + pub fn source_not_set(&self) -> &str { + match self.language { + Language::English => "not set", + Language::Chinese => "未设置", + Language::Japanese => "未設定", + Language::Korean => "설정 안 됨", + Language::Spanish => "no establecido", + Language::French => "non défini", + Language::German => "nicht gesetzt", + } + } + + pub fn not_set_value(&self) -> &str { + match self.language { + Language::English => "", + Language::Chinese => "<未设置>", + Language::Japanese => "<未設定>", + Language::Korean => "<설정 안 됨>", + Language::Spanish => "", + Language::French => "", + Language::German => "", + } + } + + pub fn profile_status_header(&self) -> &str { + match self.language { + Language::English => "QuiCommit Profile Status:", + Language::Chinese => "QuiCommit 配置文件状态:", + Language::Japanese => "QuiCommitプロファイルステータス:", + Language::Korean => "QuiCommit 프로필 상태:", + Language::Spanish => "Estado del perfil de QuiCommit:", + Language::French => "État du profil QuiCommit :", + Language::German => "QuiCommit-Profilstatus:", + } + } + + pub fn profile_mapped(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile '{}' is mapped to this repository", name), + Language::Chinese => format!("配置文件 '{}' 已映射到此仓库", name), + Language::Japanese => format!( + "プロファイル '{}' はこのリポジトリにマップされています", + name + ), + Language::Korean => format!("프로필 '{}'이(가) 이 저장소에 매핑됨", name), + Language::Spanish => format!("El perfil '{}' está asignado a este repositorio", name), + Language::French => format!("Le profil '{}' est associé à ce dépôt", name), + Language::German => format!("Profil '{}' ist diesem Repository zugeordnet", name), + } + } + + pub fn config_matches_saved(&self) -> &str { + match self.language { + Language::English => "This repository's git config matches the saved profile.", + Language::Chinese => "此仓库的 git 配置与保存的配置文件一致。", + Language::Japanese => "このリポジトリのgit設定は保存済みプロファイルと一致します。", + Language::Korean => "이 저장소의 git 구성이 저장된 프로필과 일치합니다.", + Language::Spanish => { + "La configuración git de este repositorio coincide con el perfil guardado." + } + Language::French => "La configuration git de ce dépôt correspond au profil enregistré.", + Language::German => { + "Die Git-Konfiguration dieses Repositories entspricht dem gespeicherten Profil." + } + } + } + + pub fn profile_matches(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile '{}' matches current config", name), + Language::Chinese => format!("配置文件 '{}' 与当前配置一致", name), + Language::Japanese => format!("プロファイル '{}' は現在の設定と一致します", name), + Language::Korean => format!("프로필 '{}'이(가) 현재 구성과 일치함", name), + Language::Spanish => { + format!("El perfil '{}' coincide con la configuración actual", name) + } + Language::French => format!( + "Le profil '{}' correspond à la configuration actuelle", + name + ), + Language::German => format!("Profil '{}' entspricht der aktuellen Konfiguration", name), + } + } + + pub fn repo_mapped_different(&self, name: &str) -> String { + match self.language { + Language::English => format!("But repository is mapped to different profile: {}", name), + Language::Chinese => format!("但仓库映射到了不同的配置文件:{}", name), + Language::Japanese => format!( + "ただし、リポジトリは別のプロファイルにマップされています: {}", + name + ), + Language::Korean => { + format!("하지만 저장소가 다른 프로필에 매핑되어 있습니다: {}", name) + } + Language::Spanish => { + format!( + "Pero el repositorio está asignado a un perfil diferente: {}", + name + ) + } + Language::French => { + format!("Mais le dépôt est associé à un profil différent : {}", name) + } + Language::German => { + format!( + "Aber das Repository ist einem anderen Profil zugeordnet: {}", + name + ) + } + } + } + + pub fn repo_not_mapped(&self) -> &str { + match self.language { + Language::English => "This repository is not mapped to any profile.", + Language::Chinese => "此仓库未映射到任何配置文件。", + Language::Japanese => "このリポジトリはどのプロファイルにもマップされていません。", + Language::Korean => "이 저장소는 어떤 프로필에도 매핑되지 않았습니다.", + Language::Spanish => "Este repositorio no está asignado a ningún perfil.", + Language::French => "Ce dépôt n'est associé à aucun profil.", + Language::German => "Dieses Repository ist keinem Profil zugeordnet.", + } + } + + pub fn repo_mapped_profile(&self, name: &str) -> String { + match self.language { + Language::English => format!("Repository is mapped to profile '{}'", name), + Language::Chinese => format!("仓库映射到配置文件 '{}'", name), + Language::Japanese => { + format!("リポジトリはプロファイル '{}' にマップされています", name) + } + Language::Korean => format!("저장소가 프로필 '{}'에 매핑됨", name), + Language::Spanish => format!("El repositorio está asignado al perfil '{}'", name), + Language::French => format!("Le dépôt est associé au profil '{}'", name), + Language::German => format!("Repository ist dem Profil '{}' zugeordnet", name), + } + } + + pub fn config_does_not_match(&self) -> &str { + match self.language { + Language::English => "But current git config does not match this profile!", + Language::Chinese => "但当前 git 配置与此配置文件不一致!", + Language::Japanese => "ただし、現在のgit設定はこのプロファイルと一致しません!", + Language::Korean => "하지만 현재 git 구성이 이 프로필과 일치하지 않습니다!", + Language::Spanish => "¡Pero la configuración git actual no coincide con este perfil!", + Language::French => { + "Mais la configuration git actuelle ne correspond pas à ce profil !" + } + Language::German => { + "Aber die aktuelle Git-Konfiguration entspricht nicht diesem Profil!" + } + } + } + + pub fn mapped_profile_config(&self) -> &str { + match self.language { + Language::English => "Mapped profile config:", + Language::Chinese => "映射的配置文件配置:", + Language::Japanese => "マップされたプロファイル設定:", + Language::Korean => "매핑된 프로필 구성:", + Language::Spanish => "Configuración del perfil asignado:", + Language::French => "Configuration du profil associé :", + Language::German => "Konfiguration des zugeordneten Profils:", + } + } + + pub fn no_matching_profile(&self) -> &str { + match self.language { + Language::English => "No matching profile found in QuiCommit", + Language::Chinese => "在 QuiCommit 中未找到匹配的配置文件", + Language::Japanese => "QuiCommitに一致するプロファイルが見つかりません", + Language::Korean => "QuiCommit에서 일치하는 프로필을 찾을 수 없음", + Language::Spanish => "No se encontró un perfil coincidente en QuiCommit", + Language::French => "Aucun profil correspondant trouvé dans QuiCommit", + Language::German => "Kein passendes Profil in QuiCommit gefunden", + } + } + + pub fn identity_not_saved(&self) -> &str { + match self.language { + Language::English => "Current git identity is not saved as a QuiCommit profile.", + Language::Chinese => "当前 git 身份未保存为 QuiCommit 配置文件。", + Language::Japanese => { + "現在のgit識別情報はQuiCommitプロファイルとして保存されていません。" + } + Language::Korean => "현재 git 신원이 QuiCommit 프로필로 저장되지 않았습니다.", + Language::Spanish => { + "La identidad git actual no está guardada como perfil de QuiCommit." + } + Language::French => { + "L'identité git actuelle n'est pas enregistrée comme profil QuiCommit." + } + Language::German => { + "Die aktuelle Git-Identität ist nicht als QuiCommit-Profil gespeichert." + } + } + } + + pub fn similar_profiles(&self) -> &str { + match self.language { + Language::English => "Similar profiles exist:", + Language::Chinese => "存在相似的配置文件:", + Language::Japanese => "類似のプロファイルが存在します:", + Language::Korean => "유사한 프로필이 존재합니다:", + Language::Spanish => "Existen perfiles similares:", + Language::French => "Des profils similaires existent :", + Language::German => "Ähnliche Profile vorhanden:", + } + } + + pub fn same_name_email(&self) -> &str { + match self.language { + Language::English => "same name & email", + Language::Chinese => "名称与邮箱相同", + Language::Japanese => "名前とメールが同じ", + Language::Korean => "이름과 이메일 동일", + Language::Spanish => "mismo nombre y correo", + Language::French => "même nom et e-mail", + Language::German => "gleicher Name & E-Mail", + } + } + + pub fn same_name(&self) -> &str { + match self.language { + Language::English => "same name", + Language::Chinese => "名称相同", + Language::Japanese => "名前が同じ", + Language::Korean => "이름 동일", + Language::Spanish => "mismo nombre", + Language::French => "même nom", + Language::German => "gleicher Name", + } + } + + pub fn same_email(&self) -> &str { + match self.language { + Language::English => "same email", + Language::Chinese => "邮箱相同", + Language::Japanese => "メールが同じ", + Language::Korean => "이메일 동일", + Language::Spanish => "mismo correo", + Language::French => "même e-mail", + Language::German => "gleiche E-Mail", + } + } + + pub fn partial_match(&self) -> &str { + match self.language { + Language::English => "partial match", + Language::Chinese => "部分匹配", + Language::Japanese => "部分一致", + Language::Korean => "부분 일치", + Language::Spanish => "coincidencia parcial", + Language::French => "correspondance partielle", + Language::German => "teilweise Übereinstimmung", + } + } + + pub fn save_identity_suggestion(&self) -> &str { + match self.language { + Language::English => "Would you like to save this identity as a new profile?", + Language::Chinese => "是否将此身份保存为新配置文件?", + Language::Japanese => "この識別情報を新しいプロファイルとして保存しますか?", + Language::Korean => "이 신원을 새 프로필로 저장하시겠습니까?", + Language::Spanish => "¿Desea guardar esta identidad como un nuevo perfil?", + Language::French => "Voulez-vous enregistrer cette identité comme nouveau profil ?", + Language::German => "Möchten Sie diese Identität als neues Profil speichern?", + } + } + + pub fn save_identity_confirm(&self) -> &str { + match self.language { + Language::English => "Save current git identity as new profile?", + Language::Chinese => "将当前 git 身份保存为新配置文件?", + Language::Japanese => "現在のgit識別情報を新しいプロファイルとして保存しますか?", + Language::Korean => "현재 git 신원을 새 프로필로 저장하시겠습니까?", + Language::Spanish => "¿Guardar la identidad git actual como nuevo perfil?", + Language::French => "Enregistrer l'identité git actuelle comme nouveau profil ?", + Language::German => "Aktuelle Git-Identität als neues Profil speichern?", + } + } + + pub fn requested_profile(&self, name: &str) -> String { + match self.language { + Language::English => format!("Requested Profile: {}", name), + Language::Chinese => format!("请求的配置文件:{}", name), + Language::Japanese => format!("リクエストされたプロファイル: {}", name), + Language::Korean => format!("요청된 프로필: {}", name), + Language::Spanish => format!("Perfil solicitado: {}", name), + Language::French => format!("Profil demandé : {}", name), + Language::German => format!("Angefordertes Profil: {}", name), + } + } + + pub fn default_profile_header(&self, name: &str) -> String { + match self.language { + Language::English => format!("Default Profile: {}", name), + Language::Chinese => format!("默认配置文件:{}", name), + Language::Japanese => format!("デフォルトプロファイル: {}", name), + Language::Korean => format!("기본 프로필: {}", name), + Language::Spanish => format!("Perfil predeterminado: {}", name), + Language::French => format!("Profil par défaut : {}", name), + Language::German => format!("Standardprofil: {}", name), + } + } + + pub fn not_in_repo(&self) -> &str { + match self.language { + Language::English => "Not in a Git Repository", + Language::Chinese => "不在 Git 仓库中", + Language::Japanese => "Gitリポジトリ内ではありません", + Language::Korean => "Git 저장소에 있지 않음", + Language::Spanish => "No está en un repositorio Git", + Language::French => "Pas dans un dépôt Git", + Language::German => "Nicht in einem Git-Repository", + } + } + + pub fn run_inside_repo_hint(&self) -> &str { + match self.language { + Language::English => { + "Run this command inside a git repository to see local config status." + } + Language::Chinese => "在 git 仓库内运行此命令可查看本地配置状态。", + Language::Japanese => { + "ローカル設定の状態を確認するには、gitリポジトリ内でこのコマンドを実行してください。" + } + Language::Korean => "로컬 구성 상태를 보려면 git 저장소 내에서 이 명령을 실행하세요.", + Language::Spanish => { + "Ejecute este comando dentro de un repositorio git para ver el estado de configuración local." + } + Language::French => { + "Exécutez cette commande dans un dépôt git pour voir l'état de la configuration locale." + } + Language::German => { + "Führen Sie diesen Befehl in einem Git-Repository aus, um den lokalen Konfigurationsstatus zu sehen." + } + } + } + + pub fn profile_header(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile: {}", name), + Language::Chinese => format!("配置文件:{}", name), + Language::Japanese => format!("プロファイル: {}", name), + Language::Korean => format!("프로필: {}", name), + Language::Spanish => format!("Perfil: {}", name), + Language::French => format!("Profil : {}", name), + Language::German => format!("Profil: {}", name), + } + } + + pub fn no_default_profile(&self) -> &str { + match self.language { + Language::English => "No default profile set.", + Language::Chinese => "未设置默认配置文件。", + Language::Japanese => "デフォルトプロファイルが設定されていません。", + Language::Korean => "기본 프로필이 설정되지 않았습니다.", + Language::Spanish => "No hay perfil predeterminado.", + Language::French => "Aucun profil par défaut défini.", + Language::German => "Kein Standardprofil festgelegt.", + } + } + + pub fn label_work_profile(&self) -> &str { + match self.language { + Language::English => "Work profile:", + Language::Chinese => "工作配置文件:", + Language::Japanese => "ワークプロファイル:", + Language::Korean => "업무 프로필:", + Language::Spanish => "Perfil de trabajo:", + Language::French => "Profil de travail :", + Language::German => "Arbeitsprofil:", + } + } + + pub fn label_organization(&self) -> &str { + match self.language { + Language::English => "Organization:", + Language::Chinese => "组织:", + Language::Japanese => "組織:", + Language::Korean => "조직:", + Language::Spanish => "Organización:", + Language::French => "Organisation :", + Language::German => "Organisation:", + } + } + + pub fn label_description(&self) -> &str { + match self.language { + Language::English => "Description:", + Language::Chinese => "描述:", + Language::Japanese => "説明:", + Language::Korean => "설명:", + Language::Spanish => "Descripción:", + Language::French => "Description :", + Language::German => "Beschreibung:", + } + } + + pub fn settings_header(&self) -> &str { + match self.language { + Language::English => "Settings:", + Language::Chinese => "设置:", + Language::Japanese => "設定:", + Language::Korean => "설정:", + Language::Spanish => "Ajustes:", + Language::French => "Paramètres :", + Language::German => "Einstellungen:", + } + } + + pub fn auto_sign_commits(&self) -> &str { + match self.language { + Language::English => "Auto-sign commits:", + Language::Chinese => "自动签名提交:", + Language::Japanese => "コミットの自動署名:", + Language::Korean => "커밋 자동 서명:", + Language::Spanish => "Firmar commits automáticamente:", + Language::French => "Signature automatique des commits :", + Language::German => "Commits automatisch signieren:", + } + } + + pub fn auto_sign_tags(&self) -> &str { + match self.language { + Language::English => "Auto-sign tags:", + Language::Chinese => "自动签名标签:", + Language::Japanese => "タグの自動署名:", + Language::Korean => "태그 자동 서명:", + Language::Spanish => "Firmar etiquetas automáticamente:", + Language::French => "Signature automatique des étiquettes :", + Language::German => "Tags automatisch signieren:", + } + } + + pub fn default_commit_format(&self) -> &str { + match self.language { + Language::English => "Default commit format:", + Language::Chinese => "默认提交格式:", + Language::Japanese => "デフォルトコミットフォーマット:", + Language::Korean => "기본 커밋 형식:", + Language::Spanish => "Formato de commit predeterminado:", + Language::French => "Format de commit par défaut :", + Language::German => "Standard-Commit-Format:", + } + } + + pub fn repo_patterns(&self) -> &str { + match self.language { + Language::English => "Repo patterns:", + Language::Chinese => "仓库模式:", + Language::Japanese => "リポジトリパターン:", + Language::Korean => "저장소 패턴:", + Language::Spanish => "Patrones de repositorio:", + Language::French => "Motifs de dépôt :", + Language::German => "Repository-Muster:", + } + } + + pub fn preferred_llm(&self) -> &str { + match self.language { + Language::English => "Preferred LLM:", + Language::Chinese => "首选 LLM:", + Language::Japanese => "優先LLM:", + Language::Korean => "선호 LLM:", + Language::Spanish => "LLM preferido:", + Language::French => "LLM préféré :", + Language::German => "Bevorzugtes LLM:", + } + } + + pub fn commit_template(&self) -> &str { + match self.language { + Language::English => "Commit template:", + Language::Chinese => "提交模板:", + Language::Japanese => "コミットテンプレート:", + Language::Korean => "커밋 템플릿:", + Language::Spanish => "Plantilla de commit:", + Language::French => "Modèle de commit :", + Language::German => "Commit-Vorlage:", + } + } + + pub fn ssh_config_header(&self) -> &str { + match self.language { + Language::English => "SSH Configuration:", + Language::Chinese => "SSH 配置:", + Language::Japanese => "SSH設定:", + Language::Korean => "SSH 구성:", + Language::Spanish => "Configuración SSH:", + Language::French => "Configuration SSH :", + Language::German => "SSH-Konfiguration:", + } + } + + pub fn private_key(&self) -> &str { + match self.language { + Language::English => "Private key:", + Language::Chinese => "私钥:", + Language::Japanese => "秘密鍵:", + Language::Korean => "개인 키:", + Language::Spanish => "Clave privada:", + Language::French => "Clé privée :", + Language::German => "Privater Schlüssel:", + } + } + + pub fn public_key(&self) -> &str { + match self.language { + Language::English => "Public key:", + Language::Chinese => "公钥:", + Language::Japanese => "公開鍵:", + Language::Korean => "공개 키:", + Language::Spanish => "Clave pública:", + Language::French => "Clé publique :", + Language::German => "Öffentlicher Schlüssel:", + } + } + + pub fn public_key_autodetected(&self, path: &str) -> String { + match self.language { + Language::English => format!("Public key: {} (auto-detected)", path), + Language::Chinese => format!("公钥:{}(自动检测)", path), + Language::Japanese => format!("公開鍵: {}(自動検出)", path), + Language::Korean => format!("공개 키: {}(자동 감지)", path), + Language::Spanish => format!("Clave pública: {} (auto-detectada)", path), + Language::French => format!("Clé publique : {} (détection automatique)", path), + Language::German => format!("Öffentlicher Schlüssel: {} (automatisch erkannt)", path), + } + } + + pub fn agent_forwarding(&self) -> &str { + match self.language { + Language::English => "Agent forwarding:", + Language::Chinese => "代理转发:", + Language::Japanese => "エージェント転送:", + Language::Korean => "에이전트 포워딩:", + Language::Spanish => "Reenvío de agente:", + Language::French => "Transfert d'agent :", + Language::German => "Agent-Forwarding:", + } + } + + pub fn custom_command(&self) -> &str { + match self.language { + Language::English => "Custom command:", + Language::Chinese => "自定义命令:", + Language::Japanese => "カスタムコマンド:", + Language::Korean => "사용자 지정 명령:", + Language::Spanish => "Comando personalizado:", + Language::French => "Commande personnalisée :", + Language::German => "Benutzerdefinierter Befehl:", + } + } + + pub fn known_hosts_file(&self) -> &str { + match self.language { + Language::English => "Known hosts file:", + Language::Chinese => "known_hosts 文件:", + Language::Japanese => "known_hostsファイル:", + Language::Korean => "known_hosts 파일:", + Language::Spanish => "Archivo known_hosts:", + Language::French => "Fichier known_hosts :", + Language::German => "known_hosts-Datei:", + } + } + + pub fn passphrase_set(&self) -> &str { + match self.language { + Language::English => "Passphrase: [set]", + Language::Chinese => "密码短语:[已设置]", + Language::Japanese => "パスフレーズ: [設定済み]", + Language::Korean => "암호: [설정됨]", + Language::Spanish => "Frase de paso: [establecida]", + Language::French => "Phrase de passe : [définie]", + Language::German => "Passphrase: [gesetzt]", + } + } + + pub fn gpg_config_header(&self) -> &str { + match self.language { + Language::English => "GPG Configuration:", + Language::Chinese => "GPG 配置:", + Language::Japanese => "GPG設定:", + Language::Korean => "GPG 구성:", + Language::Spanish => "Configuración GPG:", + Language::French => "Configuration GPG :", + Language::German => "GPG-Konfiguration:", + } + } + + pub fn key_id(&self) -> &str { + match self.language { + Language::English => "Key ID:", + Language::Chinese => "密钥 ID:", + Language::Japanese => "キーID:", + Language::Korean => "키 ID:", + Language::Spanish => "ID de clave:", + Language::French => "ID de clé :", + Language::German => "Schlüssel-ID:", + } + } + + pub fn program(&self) -> &str { + match self.language { + Language::English => "Program:", + Language::Chinese => "程序:", + Language::Japanese => "プログラム:", + Language::Korean => "프로그램:", + Language::Spanish => "Programa:", + Language::French => "Programme :", + Language::German => "Programm:", + } + } + + pub fn use_agent(&self) -> &str { + match self.language { + Language::English => "Use agent:", + Language::Chinese => "使用代理:", + Language::Japanese => "エージェントを使用:", + Language::Korean => "에이전트 사용:", + Language::Spanish => "Usar agente:", + Language::French => "Utiliser l'agent :", + Language::German => "Agent verwenden:", + } + } + + pub fn tokens_header(&self) -> &str { + match self.language { + Language::English => "Tokens:", + Language::Chinese => "令牌:", + Language::Japanese => "トークン:", + Language::Korean => "토큰:", + Language::Spanish => "Tokens:", + Language::French => "Jetons :", + Language::German => "Tokens:", + } + } + + pub fn usage_stats_header(&self) -> &str { + match self.language { + Language::English => "Usage Statistics:", + Language::Chinese => "使用统计:", + Language::Japanese => "使用統計:", + Language::Korean => "사용 통계:", + Language::Spanish => "Estadísticas de uso:", + Language::French => "Statistiques d'utilisation :", + Language::German => "Nutzungsstatistik:", + } + } + + pub fn total_uses(&self) -> &str { + match self.language { + Language::English => "Total uses:", + Language::Chinese => "总使用次数:", + Language::Japanese => "総使用回数:", + Language::Korean => "총 사용 횟수:", + Language::Spanish => "Usos totales:", + Language::French => "Utilisations totales :", + Language::German => "Gesamtnutzungen:", + } + } + + pub fn save_new_profile(&self) -> &str { + match self.language { + Language::English => "Save New Profile", + Language::Chinese => "保存新配置文件", + Language::Japanese => "新しいプロファイルを保存", + Language::Korean => "새 프로필 저장", + Language::Spanish => "Guardar nuevo perfil", + Language::French => "Enregistrer le nouveau profil", + Language::German => "Neues Profil speichern", + } + } + + pub fn profile_exists_overwrite(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile '{}' already exists. Overwrite?", name), + Language::Chinese => format!("配置文件 '{}' 已存在。是否覆盖?", name), + Language::Japanese => { + format!("プロファイル '{}' は既に存在します。上書きしますか?", name) + } + Language::Korean => { + format!("프로필 '{}'이(가) 이미 존재합니다. 덮어쓰시겠습니까?", name) + } + Language::Spanish => format!("El perfil '{}' ya existe. ¿Sobrescribir?", name), + Language::French => format!("Le profil '{}' existe déjà. Écraser ?", name), + Language::German => format!("Profil '{}' existiert bereits. Überschreiben?", name), + } + } + + pub fn configure_gpg_details(&self) -> &str { + match self.language { + Language::English => "Configure GPG signing details?", + Language::Chinese => "配置 GPG 签名详情?", + Language::Japanese => "GPG署名の詳細を設定しますか?", + Language::Korean => "GPG 서명 세부 정보를 구성하시겠습니까?", + Language::Spanish => "¿Configurar detalles de firma GPG?", + Language::French => "Configurer les détails de signature GPG ?", + Language::German => "GPG-Signaturdetails konfigurieren?", + } + } + + pub fn configure_ssh_details(&self) -> &str { + match self.language { + Language::English => "Configure SSH key details?", + Language::Chinese => "配置 SSH 密钥详情?", + Language::Japanese => "SSHキーの詳細を設定しますか?", + Language::Korean => "SSH 키 세부 정보를 구성하시겠습니까?", + Language::Spanish => "¿Configurar detalles de la clave SSH?", + Language::French => "Configurer les détails de la clé SSH ?", + Language::German => "SSH-Schlüsseldetails konfigurieren?", + } + } + + pub fn profile_saved(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile '{}' saved successfully", name), + Language::Chinese => format!("配置文件 '{}' 保存成功", name), + Language::Japanese => format!("プロファイル '{}' を保存しました", name), + Language::Korean => format!("프로필 '{}' 저장됨", name), + Language::Spanish => format!("Perfil '{}' guardado exitosamente", name), + Language::French => format!("Profil '{}' enregistré avec succès", name), + Language::German => format!("Profil '{}' erfolgreich gespeichert", name), + } + } + + pub fn editing_profile(&self, name: &str) -> String { + match self.language { + Language::English => format!("Editing profile: {}", name), + Language::Chinese => format!("正在编辑配置文件:{}", name), + Language::Japanese => format!("プロファイルを編集中: {}", name), + Language::Korean => format!("프로필 편집 중: {}", name), + Language::Spanish => format!("Editando perfil: {}", name), + Language::French => format!("Modification du profil : {}", name), + Language::German => format!("Profil wird bearbeitet: {}", name), + } + } + + pub fn done_save_changes(&self) -> &str { + match self.language { + Language::English => "Done / Save changes", + Language::Chinese => "完成 / 保存更改", + Language::Japanese => "完了 / 変更を保存", + Language::Korean => "완료 / 변경 사항 저장", + Language::Spanish => "Hecho / Guardar cambios", + Language::French => "Terminé / Enregistrer les modifications", + Language::German => "Fertig / Änderungen speichern", + } + } + + pub fn edit_ssh_config(&self) -> &str { + match self.language { + Language::English => "Edit SSH configuration", + Language::Chinese => "编辑 SSH 配置", + Language::Japanese => "SSH設定を編集", + Language::Korean => "SSH 구성 편집", + Language::Spanish => "Editar configuración SSH", + Language::French => "Modifier la configuration SSH", + Language::German => "SSH-Konfiguration bearbeiten", + } + } + + pub fn edit_gpg_config(&self) -> &str { + match self.language { + Language::English => "Edit GPG configuration", + Language::Chinese => "编辑 GPG 配置", + Language::Japanese => "GPG設定を編集", + Language::Korean => "GPG 구성 편집", + Language::Spanish => "Editar configuración GPG", + Language::French => "Modifier la configuration GPG", + Language::German => "GPG-Konfiguration bearbeiten", + } + } + + pub fn edit_signing_prefs(&self) -> &str { + match self.language { + Language::English => "Edit signing preferences", + Language::Chinese => "编辑签名偏好", + Language::Japanese => "署名設定を編集", + Language::Korean => "서명 기본 설정 편집", + Language::Spanish => "Editar preferencias de firma", + Language::French => "Modifier les préférences de signature", + Language::German => "Signatur-Einstellungen bearbeiten", + } + } + + pub fn manage_tokens(&self) -> &str { + match self.language { + Language::English => "Manage tokens", + Language::Chinese => "管理令牌", + Language::Japanese => "トークンを管理", + Language::Korean => "토큰 관리", + Language::Spanish => "Gestionar tokens", + Language::French => "Gérer les jetons", + Language::German => "Tokens verwalten", + } + } + + pub fn what_to_edit(&self) -> &str { + match self.language { + Language::English => "What would you like to edit?", + Language::Chinese => "你想编辑什么?", + Language::Japanese => "何を編集しますか?", + Language::Korean => "무엇을 편집하시겠습니까?", + Language::Spanish => "¿Qué desea editar?", + Language::French => "Que souhaitez-vous modifier ?", + Language::German => "Was möchten Sie bearbeiten?", + } + } + + pub fn auto_sign_commits_confirm(&self) -> &str { + match self.language { + Language::English => "Auto-sign commits?", + Language::Chinese => "自动签名提交?", + Language::Japanese => "コミットを自動署名しますか?", + Language::Korean => "커밋을 자동 서명하시겠습니까?", + Language::Spanish => "¿Firmar commits automáticamente?", + Language::French => "Signer automatiquement les commits ?", + Language::German => "Commits automatisch signieren?", + } + } + + pub fn auto_sign_tags_confirm(&self) -> &str { + match self.language { + Language::English => "Auto-sign tags?", + Language::Chinese => "自动签名标签?", + Language::Japanese => "タグを自動署名しますか?", + Language::Korean => "태그를 자동 서명하시겠습니까?", + Language::Spanish => "¿Firmar etiquetas automáticamente?", + Language::French => "Signer automatiquement les étiquettes ?", + Language::German => "Tags automatisch signieren?", + } + } + + pub fn profile_updated(&self, name: &str) -> String { + match self.language { + Language::English => format!("Profile '{}' updated", name), + Language::Chinese => format!("配置文件 '{}' 已更新", name), + Language::Japanese => format!("プロファイル '{}' を更新しました", name), + Language::Korean => format!("프로필 '{}' 업데이트됨", name), + Language::Spanish => format!("Perfil '{}' actualizado", name), + Language::French => format!("Profil '{}' mis à jour", name), + Language::German => format!("Profil '{}' aktualisiert", name), + } + } + + pub fn remove_token_option(&self, service: &str) -> String { + match self.language { + Language::English => format!("Remove token: {}", service), + Language::Chinese => format!("删除令牌:{}", service), + Language::Japanese => format!("トークンを削除: {}", service), + Language::Korean => format!("토큰 제거: {}", service), + Language::Spanish => format!("Eliminar token: {}", service), + Language::French => format!("Supprimer le jeton : {}", service), + Language::German => format!("Token entfernen: {}", service), + } + } + + pub fn add_new_token(&self) -> &str { + match self.language { + Language::English => "Add new token", + Language::Chinese => "添加新令牌", + Language::Japanese => "新しいトークンを追加", + Language::Korean => "새 토큰 추가", + Language::Spanish => "Añadir nuevo token", + Language::French => "Ajouter un nouveau jeton", + Language::German => "Neues Token hinzufügen", + } + } + + pub fn back(&self) -> &str { + match self.language { + Language::English => "Back", + Language::Chinese => "返回", + Language::Japanese => "戻る", + Language::Korean => "뒤로", + Language::Spanish => "Atrás", + Language::French => "Retour", + Language::German => "Zurück", + } + } + + pub fn manage_tokens_for(&self, name: &str) -> String { + match self.language { + Language::English => format!("Manage tokens for '{}'", name), + Language::Chinese => format!("管理 '{}' 的令牌", name), + Language::Japanese => format!("'{}' のトークンを管理", name), + Language::Korean => format!("'{}'의 토큰 관리", name), + Language::Spanish => format!("Gestionar tokens de '{}'", name), + Language::French => format!("Gérer les jetons de '{}'", name), + Language::German => format!("Tokens für '{}' verwalten", name), + } + } + + pub fn token_removed(&self, service: &str) -> String { + match self.language { + Language::English => format!("Token '{}' removed", service), + Language::Chinese => format!("令牌 '{}' 已删除", service), + Language::Japanese => format!("トークン '{}' を削除しました", service), + Language::Korean => format!("토큰 '{}' 제거됨", service), + Language::Spanish => format!("Token '{}' eliminado", service), + Language::French => format!("Jeton '{}' supprimé", service), + Language::German => format!("Token '{}' entfernt", service), + } + } + + pub fn set_for_current_repo(&self, name: &str) -> String { + match self.language { + Language::English => format!("Set '{}' for current repository", name), + Language::Chinese => format!("已为当前仓库设置 '{}'", name), + Language::Japanese => format!("'{}' を現在のリポジトリに設定しました", name), + Language::Korean => format!("'{}'을(를) 현재 저장소에 설정함", name), + Language::Spanish => format!("'{}' establecido para el repositorio actual", name), + Language::French => format!("'{}' défini pour le dépôt actuel", name), + Language::German => format!("'{}' für das aktuelle Repository festgelegt", name), + } + } + + pub fn applied_to_current_repo(&self, name: &str) -> String { + match self.language { + Language::English => format!("Applied profile '{}' to current repository", name), + Language::Chinese => format!("已将配置文件 '{}' 应用到当前仓库", name), + Language::Japanese => { + format!("プロファイル '{}' を現在のリポジトリに適用しました", name) + } + Language::Korean => format!("프로필 '{}'을(를) 현재 저장소에 적용함", name), + Language::Spanish => format!("Perfil '{}' aplicado al repositorio actual", name), + Language::French => format!("Profil '{}' appliqué au dépôt actuel", name), + Language::German => format!("Profil '{}' auf das aktuelle Repository angewendet", name), + } + } + + pub fn applied_globally(&self, name: &str) -> String { + match self.language { + Language::English => format!("Applied profile '{}' globally", name), + Language::Chinese => format!("已全局应用配置文件 '{}'", name), + Language::Japanese => format!("プロファイル '{}' をグローバルに適用しました", name), + Language::Korean => format!("프로필 '{}'을(를) 전역으로 적용함", name), + Language::Spanish => format!("Perfil '{}' aplicado globalmente", name), + Language::French => format!("Profil '{}' appliqué globalement", name), + Language::German => format!("Profil '{}' global angewendet", name), + } + } + + pub fn select_profile_to_switch(&self) -> &str { + match self.language { + Language::English => "Select profile to switch to", + Language::Chinese => "选择要切换到的配置文件", + Language::Japanese => "切り替えるプロファイルを選択", + Language::Korean => "전환할 프로필 선택", + Language::Spanish => "Seleccione el perfil al que cambiar", + Language::French => "Sélectionnez le profil vers lequel basculer", + Language::German => "Profil zum Wechseln auswählen", + } + } + + pub fn switched_to(&self, name: &str) -> String { + match self.language { + Language::English => format!("Switched to profile '{}'", name), + Language::Chinese => format!("已切换到配置文件 '{}'", name), + Language::Japanese => format!("プロファイル '{}' に切り替えました", name), + Language::Korean => format!("프로필 '{}'로 전환됨", name), + Language::Spanish => format!("Cambiado al perfil '{}'", name), + Language::French => format!("Basculé vers le profil '{}'", name), + Language::German => format!("Zum Profil '{}' gewechselt", name), + } + } + + pub fn apply_to_current_repo(&self) -> &str { + match self.language { + Language::English => "Apply to current repository?", + Language::Chinese => "应用到当前仓库?", + Language::Japanese => "現在のリポジトリに適用しますか?", + Language::Korean => "현재 저장소에 적용하시겠습니까?", + Language::Spanish => "¿Aplicar al repositorio actual?", + Language::French => "Appliquer au dépôt actuel ?", + Language::German => "Auf das aktuelle Repository anwenden?", + } + } + + pub fn copied_profile(&self, from: &str, to: &str) -> String { + match self.language { + Language::English => format!("Copied profile '{}' to '{}'", from, to), + Language::Chinese => format!("已将配置文件 '{}' 复制为 '{}'", from, to), + Language::Japanese => format!("プロファイル '{}' を '{}' にコピーしました", from, to), + Language::Korean => format!("프로필 '{}'을(를) '{}'에 복사함", from, to), + Language::Spanish => format!("Perfil '{}' copiado a '{}'", from, to), + Language::French => format!("Profil '{}' copié vers '{}'", from, to), + Language::German => format!("Profil '{}' nach '{}' kopiert", from, to), + } + } + + pub fn add_token_header(&self, name: &str) -> String { + match self.language { + Language::English => format!("Add token to profile '{}'", name), + Language::Chinese => format!("向配置文件 '{}' 添加令牌", name), + Language::Japanese => format!("プロファイル '{}' にトークンを追加", name), + Language::Korean => format!("프로필 '{}'에 토큰 추가", name), + Language::Spanish => format!("Añadir token al perfil '{}'", name), + Language::French => format!("Ajouter un jeton au profil '{}'", name), + Language::German => format!("Token zum Profil '{}' hinzufügen", name), + } + } + + pub fn token_prompt(&self, service: &str) -> String { + match self.language { + Language::English => format!("Token for {}", service), + Language::Chinese => format!("{} 的令牌", service), + Language::Japanese => format!("{} のトークン", service), + Language::Korean => format!("{}용 토큰", service), + Language::Spanish => format!("Token para {}", service), + Language::French => format!("Jeton pour {}", service), + Language::German => format!("Token für {}", service), + } + } + + pub fn token_type_prompt(&self) -> &str { + match self.language { + Language::English => "Token type", + Language::Chinese => "令牌类型", + Language::Japanese => "トークンタイプ", + Language::Korean => "토큰 유형", + Language::Spanish => "Tipo de token", + Language::French => "Type de jeton", + Language::German => "Token-Typ", + } + } + + pub fn token_added(&self, service: &str, profile: &str) -> String { + match self.language { + Language::English => { + format!( + "Token for '{}' added to profile '{}' (stored securely in keyring)", + service, profile + ) + } + Language::Chinese => format!( + "'{}' 的令牌已添加到配置文件 '{}'(安全存储在密钥环中)", + service, profile + ), + Language::Japanese => format!( + "'{}' のトークンをプロファイル '{}' に追加しました(キーリングに安全に保存)", + service, profile + ), + Language::Korean => format!( + "'{}'용 토큰이 프로필 '{}'에 추가됨(키링에 안전하게 저장)", + service, profile + ), + Language::Spanish => format!( + "Token de '{}' añadido al perfil '{}' (almacenado de forma segura en el keyring)", + service, profile + ), + Language::French => format!( + "Jeton de '{}' ajouté au profil '{}' (stocké en toute sécurité dans le trousseau)", + service, profile + ), + Language::German => format!( + "Token für '{}' zum Profil '{}' hinzugefügt (sicher im Schlüsselbund gespeichert)", + service, profile + ), + } + } + + pub fn remove_token_confirm(&self, service: &str, profile: &str) -> String { + match self.language { + Language::English => { + format!("Remove token '{}' from profile '{}'?", service, profile) + } + Language::Chinese => format!("从配置文件 '{}' 删除令牌 '{}'?", profile, service), + Language::Japanese => format!( + "プロファイル '{}' からトークン '{}' を削除しますか?", + profile, service + ), + Language::Korean => format!( + "프로필 '{}'에서 토큰 '{}'을(를) 제거하시겠습니까?", + profile, service + ), + Language::Spanish => { + format!("¿Eliminar el token '{}' del perfil '{}'?", service, profile) + } + Language::French => { + format!("Supprimer le jeton '{}' du profil '{}' ?", service, profile) + } + Language::German => { + format!("Token '{}' aus Profil '{}' entfernen?", service, profile) + } + } + } + + pub fn token_removed_from(&self, service: &str, profile: &str) -> String { + match self.language { + Language::English => { + format!( + "Token '{}' removed from profile '{}' (deleted from keyring)", + service, profile + ) + } + Language::Chinese => format!( + "令牌 '{}' 已从配置文件 '{}' 删除(已从密钥环中删除)", + service, profile + ), + Language::Japanese => format!( + "トークン '{}' をプロファイル '{}' から削除しました(キーリングから削除済み)", + service, profile + ), + Language::Korean => format!( + "토큰 '{}'이(가) 프로필 '{}'에서 제거됨(키링에서 삭제됨)", + service, profile + ), + Language::Spanish => format!( + "Token '{}' eliminado del perfil '{}' (borrado del keyring)", + service, profile + ), + Language::French => format!( + "Jeton '{}' supprimé du profil '{}' (effacé du trousseau)", + service, profile + ), + Language::German => format!( + "Token '{}' aus Profil '{}' entfernt (aus dem Schlüsselbund gelöscht)", + service, profile + ), + } + } + + pub fn no_tokens_configured(&self, name: &str) -> String { + match self.language { + Language::English => format!("No tokens configured for profile '{}'", name), + Language::Chinese => format!("配置文件 '{}' 未配置令牌", name), + Language::Japanese => format!("プロファイル '{}' にトークンが設定されていません", name), + Language::Korean => format!("프로필 '{}'에 구성된 토큰이 없습니다", name), + Language::Spanish => format!("No hay tokens configurados para el perfil '{}'", name), + Language::French => format!("Aucun jeton configuré pour le profil '{}'", name), + Language::German => format!("Keine Tokens für Profil '{}' konfiguriert", name), + } + } + + pub fn tokens_for_profile(&self, name: &str) -> String { + match self.language { + Language::English => format!("Tokens for profile '{}':", name), + Language::Chinese => format!("配置文件 '{}' 的令牌:", name), + Language::Japanese => format!("プロファイル '{}' のトークン:", name), + Language::Korean => format!("프로필 '{}'의 토큰:", name), + Language::Spanish => format!("Tokens del perfil '{}':", name), + Language::French => format!("Jetons du profil '{}' :", name), + Language::German => format!("Tokens für Profil '{}':", name), + } + } + + pub fn token_stored_marker(&self) -> &str { + match self.language { + Language::English => "stored", + Language::Chinese => "已存储", + Language::Japanese => "保存済み", + Language::Korean => "저장됨", + Language::Spanish => "almacenado", + Language::French => "stocké", + Language::German => "gespeichert", + } + } + + pub fn token_not_stored_marker(&self) -> &str { + match self.language { + Language::English => "not stored", + Language::Chinese => "未存储", + Language::Japanese => "未保存", + Language::Korean => "저장 안 됨", + Language::Spanish => "no almacenado", + Language::French => "non stocké", + Language::German => "nicht gespeichert", + } + } + + pub fn checking_profile(&self, name: &str) -> String { + match self.language { + Language::English => format!("Checking profile '{}' against git configuration", name), + Language::Chinese => format!("正在将配置文件 '{}' 与 git 配置进行核对", name), + Language::Japanese => format!("プロファイル '{}' をgit設定と照合しています", name), + Language::Korean => format!("프로필 '{}'을(를) git 구성과 대조하는 중", name), + Language::Spanish => { + format!( + "Comprobando el perfil '{}' contra la configuración git", + name + ) + } + Language::French => { + format!( + "Vérification du profil '{}' par rapport à la configuration git", + name + ) + } + Language::German => { + format!( + "Profil '{}' wird mit der Git-Konfiguration abgeglichen", + name + ) + } + } + } + + pub fn config_matches(&self) -> &str { + match self.language { + Language::English => "Profile configuration matches git settings", + Language::Chinese => "配置文件配置与 git 设置一致", + Language::Japanese => "プロファイル設定はgit設定と一致します", + Language::Korean => "프로필 구성이 git 설정과 일치함", + Language::Spanish => "La configuración del perfil coincide con los ajustes de git", + Language::French => "La configuration du profil correspond aux paramètres git", + Language::German => "Profilkonfiguration stimmt mit den Git-Einstellungen überein", + } + } + + pub fn config_differs(&self) -> &str { + match self.language { + Language::English => "Profile configuration differs from git settings", + Language::Chinese => "配置文件配置与 git 设置不一致", + Language::Japanese => "プロファイル設定はgit設定と異なります", + Language::Korean => "프로필 구성이 git 설정과 다름", + Language::Spanish => "La configuración del perfil difiere de los ajustes de git", + Language::French => "La configuration du profil diffère des paramètres git", + Language::German => "Profilkonfiguration weicht von den Git-Einstellungen ab", + } + } + + pub fn differences_header(&self) -> &str { + match self.language { + Language::English => "Differences:", + Language::Chinese => "差异:", + Language::Japanese => "相違点:", + Language::Korean => "차이점:", + Language::Spanish => "Diferencias:", + Language::French => "Différences :", + Language::German => "Unterschiede:", + } + } + + pub fn profile_diff_label(&self) -> &str { + match self.language { + Language::English => "Profile:", + Language::Chinese => "配置文件:", + Language::Japanese => "プロファイル:", + Language::Korean => "프로필:", + Language::Spanish => "Perfil:", + Language::French => "Profil :", + Language::German => "Profil:", + } + } + + pub fn git_diff_label(&self) -> &str { + match self.language { + Language::English => "Git:", + Language::Chinese => "Git:", + Language::Japanese => "Git:", + Language::Korean => "Git:", + Language::Spanish => "Git:", + Language::French => "Git :", + Language::German => "Git:", + } + } + + pub fn profile_usage_stats(&self) -> &str { + match self.language { + Language::English => "Profile Usage Statistics:", + Language::Chinese => "配置文件使用统计:", + Language::Japanese => "プロファイル使用統計:", + Language::Korean => "프로필 사용 통계:", + Language::Spanish => "Estadísticas de uso del perfil:", + Language::French => "Statistiques d'utilisation du profil :", + Language::German => "Profil-Nutzungsstatistik:", + } + } + + pub fn repositories_header(&self) -> &str { + match self.language { + Language::English => "Repositories:", + Language::Chinese => "仓库:", + Language::Japanese => "リポジトリ:", + Language::Korean => "저장소:", + Language::Spanish => "Repositorios:", + Language::French => "Dépôts :", + Language::German => "Repositories:", + } + } + + pub fn uses_line(&self, repo: &str, count: u64) -> String { + match self.language { + Language::English => format!("{} ({} uses)", repo, count), + Language::Chinese => format!("{}({} 次使用)", repo, count), + Language::Japanese => format!("{}({} 回使用)", repo, count), + Language::Korean => format!("{}({}회 사용)", repo, count), + Language::Spanish => format!("{} ({} usos)", repo, count), + Language::French => format!("{} ({} utilisations)", repo, count), + Language::German => format!("{} ({} Nutzungen)", repo, count), + } + } + + pub fn ssh_setup_header(&self) -> &str { + match self.language { + Language::English => "SSH Configuration", + Language::Chinese => "SSH 配置", + Language::Japanese => "SSH設定", + Language::Korean => "SSH 구성", + Language::Spanish => "Configuración SSH", + Language::French => "Configuration SSH", + Language::German => "SSH-Konfiguration", + } + } + + pub fn keyring_unavailable_pat(&self) -> &str { + match self.language { + Language::English => "Keyring is not available. Cannot store PAT securely.", + Language::Chinese => "密钥环不可用。无法安全存储 PAT。", + Language::Japanese => "キーリングが利用できません。PATを安全に保存できません。", + Language::Korean => "키링을 사용할 수 없습니다. PAT을 안전하게 저장할 수 없습니다.", + Language::Spanish => { + "El keyring no está disponible. No se puede almacenar el PAT de forma segura." + } + Language::French => { + "Le trousseau n'est pas disponible. Impossible de stocker le PAT en toute sécurité." + } + Language::German => { + "Schlüsselbund nicht verfügbar. PAT kann nicht sicher gespeichert werden." + } + } + } + + pub fn continue_without_secure(&self) -> &str { + match self.language { + Language::English => "Continue without secure token storage?", + Language::Chinese => "在没有安全令牌存储的情况下继续?", + Language::Japanese => "安全なトークン保存なしで続行しますか?", + Language::Korean => "안전한 토큰 저장 없이 계속하시겠습니까?", + Language::Spanish => "¿Continuar sin almacenamiento seguro de tokens?", + Language::French => "Continuer sans stockage sécurisé des jetons ?", + Language::German => "Ohne sichere Token-Speicherung fortfahren?", + } + } + + pub fn service_name_prompt(&self) -> &str { + match self.language { + Language::English => "Service name (e.g., github, gitlab)", + Language::Chinese => "服务名称(例如:github、gitlab)", + Language::Japanese => "サービス名(例:github、gitlab)", + Language::Korean => "서비스 이름(예: github, gitlab)", + Language::Spanish => "Nombre del servicio (p. ej., github, gitlab)", + Language::French => "Nom du service (ex. : github, gitlab)", + Language::German => "Dienstname (z.B. github, gitlab)", + } + } + + pub fn token_value_prompt(&self) -> &str { + match self.language { + Language::English => "Token value", + Language::Chinese => "令牌值", + Language::Japanese => "トークン値", + Language::Korean => "토큰 값", + Language::Spanish => "Valor del token", + Language::French => "Valeur du jeton", + Language::German => "Token-Wert", + } + } + + pub fn token_stored_keyring(&self) -> &str { + match self.language { + Language::English => "Token stored securely in keyring", + Language::Chinese => "令牌已安全存储在密钥环中", + Language::Japanese => "トークンをキーリングに安全に保存しました", + Language::Korean => "토큰이 키링에 안전하게 저장됨", + Language::Spanish => "Token almacenado de forma segura en el keyring", + Language::French => "Jeton stocké en toute sécurité dans le trousseau", + Language::German => "Token sicher im Schlüsselbund gespeichert", + } + } + + pub fn description_optional(&self) -> &str { + match self.language { + Language::English => "Description (optional)", + Language::Chinese => "描述(可选)", + Language::Japanese => "説明(オプション)", + Language::Korean => "설명(선택 사항)", + Language::Spanish => "Descripción (opcional)", + Language::French => "Description (optionnel)", + Language::German => "Beschreibung (optional)", + } + } + + pub fn organization_prompt(&self) -> &str { + match self.language { + Language::English => "Organization", + Language::Chinese => "组织", + Language::Japanese => "組織", + Language::Korean => "조직", + Language::Spanish => "Organización", + Language::French => "Organisation", + Language::German => "Organisation", + } + } + + // ---- config command (issue 17) ---- + + pub fn keyring_status_header(&self) -> &str { + match self.language { + Language::English => "Keyring Status", + Language::Chinese => "密钥环状态", + Language::Japanese => "キーリングステータス", + Language::Korean => "키링 상태", + Language::Spanish => "Estado del keyring", + Language::French => "État du trousseau", + Language::German => "Schlüsselbund-Status", + } + } + + pub fn environment_variables_header(&self) -> &str { + match self.language { + Language::English => "Environment Variables:", + Language::Chinese => "环境变量:", + Language::Japanese => "環境変数:", + Language::Korean => "환경 변수:", + Language::Spanish => "Variables de entorno:", + Language::French => "Variables d'environnement :", + Language::German => "Umgebungsvariablen:", + } + } + + pub fn keyring_available_status(&self) -> &str { + match self.language { + Language::English => "Keyring is available", + Language::Chinese => "密钥环可用", + Language::Japanese => "キーリングは利用可能です", + Language::Korean => "키링 사용 가능", + Language::Spanish => "El keyring está disponible", + Language::French => "Le trousseau est disponible", + Language::German => "Schlüsselbund ist verfügbar", + } + } + + pub fn keyring_not_available_status(&self) -> &str { + match self.language { + Language::English => "Keyring is not available", + Language::Chinese => "密钥环不可用", + Language::Japanese => "キーリングは利用できません", + Language::Korean => "키링 사용 불가", + Language::Spanish => "El keyring no está disponible", + Language::French => "Le trousseau n'est pas disponible", + Language::German => "Schlüsselbund ist nicht verfügbar", + } + } + + pub fn quicommit_configuration(&self) -> &str { + match self.language { + Language::English => "QuiCommit Configuration", + Language::Chinese => "QuiCommit 配置", + Language::Japanese => "QuiCommit設定", + Language::Korean => "QuiCommit 구성", + Language::Spanish => "Configuración de QuiCommit", + Language::French => "Configuration QuiCommit", + Language::German => "QuiCommit-Konfiguration", + } + } + + pub fn general_section(&self) -> &str { + match self.language { + Language::English => "General:", + Language::Chinese => "常规:", + Language::Japanese => "一般:", + Language::Korean => "일반:", + Language::Spanish => "General:", + Language::French => "Général :", + Language::German => "Allgemein:", + } + } + + pub fn config_file_value(&self) -> &str { + match self.language { + Language::English => "Config file:", + Language::Chinese => "配置文件:", + Language::Japanese => "設定ファイル:", + Language::Korean => "구성 파일:", + Language::Spanish => "Archivo de configuración:", + Language::French => "Fichier de configuration :", + Language::German => "Konfigurationsdatei:", + } + } + + pub fn default_profile_value(&self) -> &str { + match self.language { + Language::English => "Default profile:", + Language::Chinese => "默认配置文件:", + Language::Japanese => "デフォルトプロファイル:", + Language::Korean => "기본 프로필:", + Language::Spanish => "Perfil predeterminado:", + Language::French => "Profil par défaut :", + Language::German => "Standardprofil:", + } + } + + pub fn profiles_value(&self) -> &str { + match self.language { + Language::English => "Profiles:", + Language::Chinese => "配置文件:", + Language::Japanese => "プロファイル:", + Language::Korean => "프로필:", + Language::Spanish => "Perfiles:", + Language::French => "Profils :", + Language::German => "Profile:", + } + } + + pub fn none_value(&self) -> &str { + match self.language { + Language::English => "(none)", + Language::Chinese => "(无)", + Language::Japanese => "(なし)", + Language::Korean => "(없음)", + Language::Spanish => "(ninguno)", + Language::French => "(aucun)", + Language::German => "(keine)", + } + } + + pub fn llm_configuration_section(&self) -> &str { + match self.language { + Language::English => "LLM Configuration:", + Language::Chinese => "LLM 配置:", + Language::Japanese => "LLM設定:", + Language::Korean => "LLM 구성:", + Language::Spanish => "Configuración LLM:", + Language::French => "Configuration LLM :", + Language::German => "LLM-Konfiguration:", + } + } + + pub fn provider_value(&self) -> &str { + match self.language { + Language::English => "Provider:", + Language::Chinese => "提供商:", + Language::Japanese => "プロバイダー:", + Language::Korean => "공급자:", + Language::Spanish => "Proveedor:", + Language::French => "Fournisseur :", + Language::German => "Anbieter:", + } + } + + pub fn model_value(&self) -> &str { + match self.language { + Language::English => "Model:", + Language::Chinese => "模型:", + Language::Japanese => "モデル:", + Language::Korean => "모델:", + Language::Spanish => "Modelo:", + Language::French => "Modèle :", + Language::German => "Modell:", + } + } + + pub fn base_url_value(&self) -> &str { + match self.language { + Language::English => "Base URL:", + Language::Chinese => "基础 URL:", + Language::Japanese => "ベースURL:", + Language::Korean => "기본 URL:", + Language::Spanish => "URL base:", + Language::French => "URL de base :", + Language::German => "Basis-URL:", + } + } + + pub fn api_key_value(&self) -> &str { + match self.language { + Language::English => "API key:", + Language::Chinese => "API 密钥:", + Language::Japanese => "APIキー:", + Language::Korean => "API 키:", + Language::Spanish => "Clave API:", + Language::French => "Clé API :", + Language::German => "API-Schlüssel:", + } + } + + pub fn max_tokens_value(&self) -> &str { + match self.language { + Language::English => "Max tokens:", + Language::Chinese => "最大令牌数:", + Language::Japanese => "最大トークン数:", + Language::Korean => "최대 토큰:", + Language::Spanish => "Tokens máximos:", + Language::French => "Jetons maximum :", + Language::German => "Maximale Tokens:", + } + } + + pub fn temperature_value(&self) -> &str { + match self.language { + Language::English => "Temperature:", + Language::Chinese => "温度:", + Language::Japanese => "温度:", + Language::Korean => "온도:", + Language::Spanish => "Temperatura:", + Language::French => "Température :", + Language::German => "Temperatur:", + } + } + + pub fn timeout_value(&self) -> &str { + match self.language { + Language::English => "Timeout:", + Language::Chinese => "超时:", + Language::Japanese => "タイムアウト:", + Language::Korean => "시간 초과:", + Language::Spanish => "Tiempo de espera:", + Language::French => "Délai d'attente :", + Language::German => "Zeitlimit:", + } + } + + pub fn commit_configuration_section(&self) -> &str { + match self.language { + Language::English => "Commit Configuration:", + Language::Chinese => "提交配置:", + Language::Japanese => "コミット設定:", + Language::Korean => "커밋 구성:", + Language::Spanish => "Configuración de commit:", + Language::French => "Configuration de commit :", + Language::German => "Commit-Konfiguration:", + } + } + + pub fn format_value(&self) -> &str { + match self.language { + Language::English => "Format:", + Language::Chinese => "格式:", + Language::Japanese => "フォーマット:", + Language::Korean => "형식:", + Language::Spanish => "Formato:", + Language::French => "Format :", + Language::German => "Format:", + } + } + + pub fn auto_generate_value(&self) -> &str { + match self.language { + Language::English => "Auto-generate:", + Language::Chinese => "自动生成:", + Language::Japanese => "自動生成:", + Language::Korean => "자동 생성:", + Language::Spanish => "Generación automática:", + Language::French => "Génération automatique :", + Language::German => "Automatisch generieren:", + } + } + + pub fn tag_configuration_section(&self) -> &str { + match self.language { + Language::English => "Tag Configuration:", + Language::Chinese => "标签配置:", + Language::Japanese => "タグ設定:", + Language::Korean => "태그 구성:", + Language::Spanish => "Configuración de etiquetas:", + Language::French => "Configuration des étiquettes :", + Language::German => "Tag-Konfiguration:", + } + } + + pub fn version_prefix_value(&self) -> &str { + match self.language { + Language::English => "Version prefix:", + Language::Chinese => "版本前缀:", + Language::Japanese => "バージョンプレフィックス:", + Language::Korean => "버전 접두사:", + Language::Spanish => "Prefijo de versión:", + Language::French => "Préfixe de version :", + Language::German => "Versionspräfix:", + } + } + + pub fn language_configuration_section(&self) -> &str { + match self.language { + Language::English => "Language Configuration:", + Language::Chinese => "语言配置:", + Language::Japanese => "言語設定:", + Language::Korean => "언어 구성:", + Language::Spanish => "Configuración de idioma:", + Language::French => "Configuration de langue :", + Language::German => "Sprachkonfiguration:", + } + } + + pub fn output_language_value(&self) -> &str { + match self.language { + Language::English => "Output language:", + Language::Chinese => "输出语言:", + Language::Japanese => "出力言語:", + Language::Korean => "출력 언어:", + Language::Spanish => "Idioma de salida:", + Language::French => "Langue de sortie :", + Language::German => "Ausgabesprache:", + } + } + + pub fn keep_types_english_value(&self) -> &str { + match self.language { + Language::English => "Keep commit types in English:", + Language::Chinese => "保持提交类型为英文:", + Language::Japanese => "コミットタイプを英語のままにする:", + Language::Korean => "커밋 유형을 영어로 유지:", + Language::Spanish => "Mantener tipos de commit en inglés:", + Language::French => "Conserver les types de commit en anglais :", + Language::German => "Commit-Typen auf Englisch belassen:", + } + } + + pub fn keep_changelog_types_english_value(&self) -> &str { + match self.language { + Language::English => "Keep changelog types in English:", + Language::Chinese => "保持变更日志类型为英文:", + Language::Japanese => "変更ログタイプを英語のままにする:", + Language::Korean => "변경 로그 유형을 영어로 유지:", + Language::Spanish => "Mantener tipos de changelog en inglés:", + Language::French => "Conserver les types de changelog en anglais :", + Language::German => "Changelog-Typen auf Englisch belassen:", + } + } + + pub fn emoji_decorations_value(&self) -> &str { + match self.language { + Language::English => "Emoji decorations:", + Language::Chinese => "Emoji 装饰:", + Language::Japanese => "絵文字装飾:", + Language::Korean => "이모지 장식:", + Language::Spanish => "Decoraciones emoji:", + Language::French => "Décorations emoji :", + Language::German => "Emoji-Dekorationen:", + } + } + + pub fn changelog_configuration_section(&self) -> &str { + match self.language { + Language::English => "Changelog Configuration:", + Language::Chinese => "变更日志配置:", + Language::Japanese => "変更ログ設定:", + Language::Korean => "변경 로그 구성:", + Language::Spanish => "Configuración del changelog:", + Language::French => "Configuration du changelog :", + Language::German => "Changelog-Konfiguration:", + } + } + + pub fn path_value(&self) -> &str { + match self.language { + Language::English => "Path:", + Language::Chinese => "路径:", + Language::Japanese => "パス:", + Language::Korean => "경로:", + Language::Spanish => "Ruta:", + Language::French => "Chemin :", + Language::German => "Pfad:", + } + } + + pub fn security_section(&self) -> &str { + match self.language { + Language::English => "Security:", + Language::Chinese => "安全:", + Language::Japanese => "セキュリティ:", + Language::Korean => "보안:", + Language::Spanish => "Seguridad:", + Language::French => "Sécurité :", + Language::German => "Sicherheit:", + } + } + + pub fn repository_mappings_value(&self, count: usize) -> String { + match self.language { + Language::English => format!("Repository mappings: {} mapping(s)", count), + Language::Chinese => format!("仓库映射:{} 个", count), + Language::Japanese => format!("リポジトリマッピング: {} 件", count), + Language::Korean => format!("저장소 매핑: {}개", count), + Language::Spanish => format!("Asignaciones de repositorio: {} asignación(es)", count), + Language::French => format!("Associations de dépôt : {} association(s)", count), + Language::German => format!("Repository-Zuordnungen: {} Zuordnung(en)", count), + } + } + + pub fn keyring_value(&self) -> &str { + match self.language { + Language::English => "Keyring:", + Language::Chinese => "密钥环:", + Language::Japanese => "キーリング:", + Language::Korean => "키링:", + Language::Spanish => "Keyring:", + Language::French => "Trousseau :", + Language::German => "Schlüsselbund:", + } + } + + pub fn available_value(&self) -> &str { + match self.language { + Language::English => "available", + Language::Chinese => "可用", + Language::Japanese => "利用可能", + Language::Korean => "사용 가능", + Language::Spanish => "disponible", + Language::French => "disponible", + Language::German => "verfügbar", + } + } + + pub fn unavailable_value(&self) -> &str { + match self.language { + Language::English => "unavailable", + Language::Chinese => "不可用", + Language::Japanese => "利用不可", + Language::Korean => "사용 불가", + Language::Spanish => "no disponible", + Language::French => "indisponible", + Language::German => "nicht verfügbar", + } + } + + pub fn select_llm_provider_header(&self) -> &str { + match self.language { + Language::English => "Select LLM Provider:", + Language::Chinese => "选择 LLM 提供商:", + Language::Japanese => "LLMプロバイダーを選択:", + Language::Korean => "LLM 공급자 선택:", + Language::Spanish => "Seleccione el proveedor LLM:", + Language::French => "Sélectionnez le fournisseur LLM :", + Language::German => "LLM-Anbieter auswählen:", + } + } + + pub fn api_key_exists_update(&self) -> &str { + match self.language { + Language::English => "API key already exists. Update it?", + Language::Chinese => "API 密钥已存在。是否更新?", + Language::Japanese => "APIキーは既に存在します。更新しますか?", + Language::Korean => "API 키가 이미 존재합니다. 업데이트하시겠습니까?", + Language::Spanish => "La clave API ya existe. ¿Actualizarla?", + Language::French => "La clé API existe déjà. La mettre à jour ?", + Language::German => "API-Schlüssel existiert bereits. Aktualisieren?", + } + } + + pub fn reset_confirm(&self) -> &str { + match self.language { + Language::English => "Reset all configuration to defaults?", + Language::Chinese => "将所有配置重置为默认值?", + Language::Japanese => "すべての設定をデフォルトにリセットしますか?", + Language::Korean => "모든 구성을 기본값으로 재설정하시겠습니까?", + Language::Spanish => { + "¿Restablecer toda la configuración a los valores predeterminados?" + } + Language::French => "Réinitialiser toute la configuration aux valeurs par défaut ?", + Language::German => "Gesamte Konfiguration auf Standardwerte zurücksetzen?", + } + } + + pub fn reset_cancelled(&self) -> &str { + match self.language { + Language::English => "Reset cancelled.", + Language::Chinese => "已取消重置。", + Language::Japanese => "リセットがキャンセルされました。", + Language::Korean => "재설정이 취소되었습니다.", + Language::Spanish => "Restablecimiento cancelado.", + Language::French => "Réinitialisation annulée.", + Language::German => "Zurücksetzen abgebrochen.", + } + } + + pub fn encrypt_export_confirm(&self) -> &str { + match self.language { + Language::English => "Encrypt the exported configuration?", + Language::Chinese => "加密导出的配置?", + Language::Japanese => "エクスポートする設定を暗号化しますか?", + Language::Korean => "내보낸 구성을 암호화하시겠습니까?", + Language::Spanish => "¿Cifrar la configuración exportada?", + Language::French => "Chiffrer la configuration exportée ?", + Language::German => "Exportierte Konfiguration verschlüsseln?", + } + } + + pub fn enter_encryption_password(&self) -> &str { + match self.language { + Language::English => "Enter encryption password", + Language::Chinese => "输入加密密码", + Language::Japanese => "暗号化パスワードを入力", + Language::Korean => "암호화 비밀번호 입력", + Language::Spanish => "Introduzca la contraseña de cifrado", + Language::French => "Saisissez le mot de passe de chiffrement", + Language::German => "Verschlüsselungspasswort eingeben", + } + } + + pub fn confirm_encryption_password(&self) -> &str { + match self.language { + Language::English => "Confirm encryption password", + Language::Chinese => "确认加密密码", + Language::Japanese => "暗号化パスワードを確認", + Language::Korean => "암호화 비밀번호 확인", + Language::Spanish => "Confirme la contraseña de cifrado", + Language::French => "Confirmez le mot de passe de chiffrement", + Language::German => "Verschlüsselungspasswort bestätigen", + } + } + + pub fn enter_decryption_password(&self) -> &str { + match self.language { + Language::English => "Enter decryption password", + Language::Chinese => "输入解密密码", + Language::Japanese => "復号化パスワードを入力", + Language::Korean => "복호화 비밀번호 입력", + Language::Spanish => "Introduzca la contraseña de descifrado", + Language::French => "Saisissez le mot de passe de déchiffrement", + Language::German => "Entschlüsselungspasswort eingeben", + } + } + + pub fn importing_pats_header(&self) -> &str { + match self.language { + Language::English => "Importing Personal Access Tokens...", + Language::Chinese => "正在导入个人访问令牌...", + Language::Japanese => "個人アクセストークンをインポート中...", + Language::Korean => "개인 액세스 토큰 가져오는 중...", + Language::Spanish => "Importando tokens de acceso personal...", + Language::French => "Importation des jetons d'accès personnel...", + Language::German => "Persönliche Zugriffstokens werden importiert...", + } + } + + pub fn available_models_header(&self) -> &str { + match self.language { + Language::English => "Available Models", + Language::Chinese => "可用模型", + Language::Japanese => "利用可能なモデル", + Language::Korean => "사용 가능한 모델", + Language::Spanish => "Modelos disponibles", + Language::French => "Modèles disponibles", + Language::German => "Verfügbare Modelle", + } + } + + pub fn current_model_value(&self) -> &str { + match self.language { + Language::English => "Current model:", + Language::Chinese => "当前模型:", + Language::Japanese => "現在のモデル:", + Language::Korean => "현재 모델:", + Language::Spanish => "Modelo actual:", + Language::French => "Modèle actuel :", + Language::German => "Aktuelles Modell:", + } + } + + pub fn testing_llm_header(&self) -> &str { + match self.language { + Language::English => "Testing LLM Connection...", + Language::Chinese => "正在测试 LLM 连接...", + Language::Japanese => "LLM接続をテスト中...", + Language::Korean => "LLM 연결 테스트 중...", + Language::Spanish => "Probando la conexión LLM...", + Language::French => "Test de la connexion LLM...", + Language::German => "LLM-Verbindung wird getestet...", + } + } + + pub fn sending_test_request(&self) -> &str { + match self.language { + Language::English => "Sending test request...", + Language::Chinese => "正在发送测试请求...", + Language::Japanese => "テストリクエストを送信中...", + Language::Korean => "테스트 요청 보내는 중...", + Language::Spanish => "Enviando solicitud de prueba...", + Language::French => "Envoi de la requête de test...", + Language::German => "Testanfrage wird gesendet...", + } + } + + pub fn response_header(&self) -> &str { + match self.language { + Language::English => "Response:", + Language::Chinese => "响应:", + Language::Japanese => "応答:", + Language::Korean => "응답:", + Language::Spanish => "Respuesta:", + Language::French => "Réponse :", + Language::German => "Antwort:", + } + } + + pub fn select_output_language_header(&self) -> &str { + match self.language { + Language::English => "Select Output Language:", + Language::Chinese => "选择输出语言:", + Language::Japanese => "出力言語を選択:", + Language::Korean => "출력 언어 선택:", + Language::Spanish => "Seleccione el idioma de salida:", + Language::French => "Sélectionnez la langue de sortie :", + Language::German => "Ausgabesprache auswählen:", + } + } + + pub fn invalid_value_use(&self, value: &str, allowed: &str) -> String { + match self.language { + Language::English => format!("Invalid value: '{}'. Use: {}", value, allowed), + Language::Chinese => format!("无效值:'{}'。可用:{}", value, allowed), + Language::Japanese => format!("無効な値: '{}'。使用可能: {}", value, allowed), + Language::Korean => format!("잘못된 값: '{}'. 사용 가능: {}", value, allowed), + Language::Spanish => format!("Valor no válido: '{}'. Use: {}", value, allowed), + Language::French => format!("Valeur non valide : '{}'. Utilisez : {}", value, allowed), + Language::German => format!("Ungültiger Wert: '{}'. Verwenden Sie: {}", value, allowed), + } + } + + pub fn invalid_format(&self, value: &str) -> String { + match self.language { + Language::English => { + format!("Invalid format: {}. Use: conventional, commitlint", value) + } + Language::Chinese => format!("无效格式:{}。可用:conventional、commitlint", value), + Language::Japanese => format!( + "無効なフォーマット: {}。使用可能: conventional、commitlint", + value + ), + Language::Korean => format!( + "잘못된 형식: {}. 사용 가능: conventional, commitlint", + value + ), + Language::Spanish => format!( + "Formato no válido: {}. Use: conventional, commitlint", + value + ), + Language::French => format!( + "Format non valide : {}. Utilisez : conventional, commitlint", + value + ), + Language::German => format!( + "Ungültiges Format: {}. Verwenden Sie: conventional, commitlint", + value + ), + } + } + + pub fn unknown_config_key(&self, key: &str) -> String { + match self.language { + Language::English => format!("Unknown configuration key: {}", key), + Language::Chinese => format!("未知配置键:{}", key), + Language::Japanese => format!("不明な設定キー: {}", key), + Language::Korean => format!("알 수 없는 구성 키: {}", key), + Language::Spanish => format!("Clave de configuración desconocida: {}", key), + Language::French => format!("Clé de configuration inconnue : {}", key), + Language::German => format!("Unbekannter Konfigurationsschlüssel: {}", key), + } + } + + pub fn invalid_provider(&self, provider: &str, options: &str) -> String { + match self.language { + Language::English => { + format!("Invalid provider: {}. Valid options: {}", provider, options) + } + Language::Chinese => format!("无效提供商:{}。可用选项:{}", provider, options), + Language::Japanese => format!( + "無効なプロバイダー: {}。有効なオプション: {}", + provider, options + ), + Language::Korean => format!("잘못된 공급자: {}. 유효한 옵션: {}", provider, options), + Language::Spanish => format!( + "Proveedor no válido: {}. Opciones válidas: {}", + provider, options + ), + Language::French => format!( + "Fournisseur non valide : {}. Options valides : {}", + provider, options + ), + Language::German => format!( + "Ungültiger Anbieter: {}. Gültige Optionen: {}", + provider, options + ), + } + } + + pub fn passwords_do_not_match(&self) -> &str { + match self.language { + Language::English => "Passwords do not match", + Language::Chinese => "两次输入的密码不一致", + Language::Japanese => "パスワードが一致しません", + Language::Korean => "비밀번호가 일치하지 않습니다", + Language::Spanish => "Las contraseñas no coinciden", + Language::French => "Les mots de passe ne correspondent pas", + Language::German => "Passwörter stimmen nicht überein", + } + } + + pub fn password_requires_output(&self) -> &str { + match self.language { + Language::English => "--password requires --output", + Language::Chinese => "--password 需要 --output", + Language::Japanese => "--password には --output が必要です", + Language::Korean => "--password에는 --output이 필요합니다", + Language::Spanish => "--password requiere --output", + Language::French => "--password nécessite --output", + Language::German => "--password erfordert --output", + } + } + + pub fn keyring_unavailable_env_hint(&self) -> &str { + match self.language { + Language::English => { + "Keyring is not available. Set QUICOMMIT_API_KEY environment variable instead or change api_key_storage to 'config'." + } + Language::Chinese => { + "密钥环不可用。请改用 QUICOMMIT_API_KEY 环境变量,或将 api_key_storage 改为 'config'。" + } + Language::Japanese => { + "キーリングが利用できません。代わりに QUICOMMIT_API_KEY 環境変数を設定するか、api_key_storage を 'config' に変更してください。" + } + Language::Korean => { + "키링을 사용할 수 없습니다. 대신 QUICOMMIT_API_KEY 환경 변수를 설정하거나 api_key_storage를 'config'로 변경하세요." + } + Language::Spanish => { + "El keyring no está disponible. Configure la variable QUICOMMIT_API_KEY o cambie api_key_storage a 'config'." + } + Language::French => { + "Le trousseau n'est pas disponible. Définissez la variable QUICOMMIT_API_KEY ou passez api_key_storage à 'config'." + } + Language::German => { + "Schlüsselbund nicht verfügbar. Setzen Sie die Umgebungsvariable QUICOMMIT_API_KEY oder ändern Sie api_key_storage auf 'config'." + } + } + } + + pub fn keyring_unavailable_plain(&self) -> &str { + match self.language { + Language::English => "Keyring is not available.", + Language::Chinese => "密钥环不可用。", + Language::Japanese => "キーリングは利用できません。", + Language::Korean => "키링을 사용할 수 없습니다.", + Language::Spanish => "El keyring no está disponible.", + Language::French => "Le trousseau n'est pas disponible.", + Language::German => "Schlüsselbund ist nicht verfügbar.", + } + } + + pub fn api_key_env_storage_bail(&self, provider: &str) -> String { + match self.language { + Language::English => { + format!( + "API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.", + provider + ) + } + Language::Chinese => { + format!( + "API 密钥存储方式为 'environment'。请设置 QUICOMMIT_{}_API_KEY 环境变量。", + provider + ) + } + Language::Japanese => { + format!( + "APIキー保存方法が 'environment' です。QUICOMMIT_{}_API_KEY 環境変数を設定してください。", + provider + ) + } + Language::Korean => { + format!( + "API 키 저장 방식이 'environment'입니다. QUICOMMIT_{}_API_KEY 환경 변수를 설정하세요.", + provider + ) + } + Language::Spanish => { + format!( + "El almacenamiento de clave API está en 'environment'. Configure QUICOMMIT_{}_API_KEY.", + provider + ) + } + Language::French => { + format!( + "Stockage de clé API défini sur 'environment'. Définissez QUICOMMIT_{}_API_KEY.", + provider + ) + } + Language::German => { + format!( + "API-Schlüssel-Speicherung auf 'environment' gesetzt. Setzen Sie QUICOMMIT_{}_API_KEY.", + provider + ) + } + } + } + + pub fn api_key_env_storage_info(&self, provider: &str) -> String { + match self.language { + Language::English => { + format!( + "API key storage set to 'environment'. Please remove QUICOMMIT_{}_API_KEY environment variable manually.", + provider + ) + } + Language::Chinese => { + format!( + "API 密钥存储方式为 'environment'。请手动移除 QUICOMMIT_{}_API_KEY 环境变量。", + provider + ) + } + Language::Japanese => { + format!( + "APIキー保存方法が 'environment' です。QUICOMMIT_{}_API_KEY 環境変数を手動で削除してください。", + provider + ) + } + Language::Korean => { + format!( + "API 키 저장 방식이 'environment'입니다. QUICOMMIT_{}_API_KEY 환경 변수를 수동으로 제거하세요.", + provider + ) + } + Language::Spanish => { + format!( + "El almacenamiento está en 'environment'. Elimine QUICOMMIT_{}_API_KEY manualmente.", + provider + ) + } + Language::French => { + format!( + "Stockage défini sur 'environment'. Supprimez QUICOMMIT_{}_API_KEY manuellement.", + provider + ) + } + Language::German => { + format!( + "Speicherung auf 'environment' gesetzt. Entfernen Sie QUICOMMIT_{}_API_KEY manuell.", + provider + ) + } + } + } + + pub fn invalid_storage_method(&self, method: &str) -> String { + match self.language { + Language::English => format!("Invalid API key storage method: {}", method), + Language::Chinese => format!("无效的 API 密钥存储方式:{}", method), + Language::Japanese => format!("無効なAPIキー保存方法: {}", method), + Language::Korean => format!("잘못된 API 키 저장 방식: {}", method), + Language::Spanish => format!( + "Método de almacenamiento de clave API no válido: {}", + method + ), + Language::French => format!("Méthode de stockage de clé API non valide : {}", method), + Language::German => format!("Ungültige API-Schlüssel-Speichermethode: {}", method), + } + } + + pub fn api_key_configured(&self) -> &str { + match self.language { + Language::English => "API Key: configured", + Language::Chinese => "API 密钥:已配置", + Language::Japanese => "APIキー: 設定済み", + Language::Korean => "API 키: 구성됨", + Language::Spanish => "Clave API: configurada", + Language::French => "Clé API : configurée", + Language::German => "API-Schlüssel: konfiguriert", + } + } + + pub fn api_key_not_set(&self) -> &str { + match self.language { + Language::English => "API Key: not set", + Language::Chinese => "API 密钥:未设置", + Language::Japanese => "APIキー: 未設定", + Language::Korean => "API 키: 설정 안 됨", + Language::Spanish => "Clave API: no establecida", + Language::French => "Clé API : non définie", + Language::German => "API-Schlüssel: nicht gesetzt", + } + } + + pub fn connection_successful(&self) -> &str { + match self.language { + Language::English => "Connection successful!", + Language::Chinese => "连接成功!", + Language::Japanese => "接続に成功しました!", + Language::Korean => "연결 성공!", + Language::Spanish => "¡Conexión exitosa!", + Language::French => "Connexion réussie !", + Language::German => "Verbindung erfolgreich!", + } + } + + pub fn llm_configuration_updated(&self) -> &str { + match self.language { + Language::English => "LLM configuration updated", + Language::Chinese => "LLM 配置已更新", + Language::Japanese => "LLM設定を更新しました", + Language::Korean => "LLM 구성 업데이트됨", + Language::Spanish => "Configuración LLM actualizada", + Language::French => "Configuration LLM mise à jour", + Language::German => "LLM-Konfiguration aktualisiert", + } + } + + pub fn api_key_stored_for(&self, provider: &str) -> String { + match self.language { + Language::English => { + format!("API key stored securely in system keyring for {}", provider) + } + Language::Chinese => format!("API 密钥已安全存储在系统密钥环中({})", provider), + Language::Japanese => format!( + "APIキーをシステムキーリングに安全に保存しました({})", + provider + ), + Language::Korean => format!("API 키가 시스템 키링에 안전하게 저장됨({})", provider), + Language::Spanish => format!("Clave API almacenada de forma segura para {}", provider), + Language::French => format!("Clé API stockée en toute sécurité pour {}", provider), + Language::German => format!("API-Schlüssel sicher gespeichert für {}", provider), + } + } + + pub fn api_key_stored_config_for(&self, provider: &str) -> String { + match self.language { + Language::English => format!("API key stored in configuration file for {}", provider), + Language::Chinese => format!("API 密钥已存储到配置文件中({})", provider), + Language::Japanese => format!("APIキーを設定ファイルに保存しました({})", provider), + Language::Korean => format!("API 키가 구성 파일에 저장됨({})", provider), + Language::Spanish => format!( + "Clave API almacenada en el archivo de configuración para {}", + provider + ), + Language::French => format!( + "Clé API stockée dans le fichier de configuration pour {}", + provider + ), + Language::German => format!( + "API-Schlüssel in Konfigurationsdatei gespeichert für {}", + provider + ), + } + } + + pub fn api_key_plain_text_note(&self) -> &str { + match self.language { + Language::English => { + "Note: API key is stored in plain text. Consider using 'keyring' storage for better security." + } + Language::Chinese => { + "注意:API 密钥以明文存储。为更安全考虑,建议使用 'keyring' 存储。" + } + Language::Japanese => { + "注意:APIキーは平文で保存されています。より安全な 'keyring' 保存を検討してください。" + } + Language::Korean => { + "참고: API 키가 평문으로 저장됩니다. 더 나은 보안을 위해 'keyring' 저장을 고려하세요." + } + Language::Spanish => { + "Nota: la clave API se almacena en texto plano. Considere usar 'keyring' para mayor seguridad." + } + Language::French => { + "Note : la clé API est stockée en clair. Envisagez le stockage 'keyring' pour plus de sécurité." + } + Language::German => { + "Hinweis: API-Schlüssel wird im Klartext gespeichert. Für mehr Sicherheit 'keyring' verwenden." + } + } + } + + pub fn api_key_deleted_keyring_for(&self, provider: &str) -> String { + match self.language { + Language::English => format!("API key deleted from system keyring for {}", provider), + Language::Chinese => format!("已从系统密钥环删除 API 密钥({})", provider), + Language::Japanese => format!( + "APIキーをシステムキーリングから削除しました({})", + provider + ), + Language::Korean => format!("API 키가 시스템 키링에서 삭제됨({})", provider), + Language::Spanish => format!( + "Clave API eliminada del keyring del sistema para {}", + provider + ), + Language::French => format!("Clé API supprimée du trousseau système pour {}", provider), + Language::German => format!( + "API-Schlüssel aus dem Schlüsselbund gelöscht für {}", + provider + ), + } + } + + pub fn api_key_deleted_config_for(&self, provider: &str) -> String { + match self.language { + Language::English => { + format!("API key deleted from configuration file for {}", provider) + } + Language::Chinese => format!("已从配置文件删除 API 密钥({})", provider), + Language::Japanese => format!("APIキーを設定ファイルから削除しました({})", provider), + Language::Korean => format!("API 키가 구성 파일에서 삭제됨({})", provider), + Language::Spanish => format!( + "Clave API eliminada del archivo de configuración para {}", + provider + ), + Language::French => format!( + "Clé API supprimée du fichier de configuration pour {}", + provider + ), + Language::German => format!( + "API-Schlüssel aus Konfigurationsdatei gelöscht für {}", + provider + ), + } + } + + pub fn commit_format_set(&self, format: &str) -> String { + match self.language { + Language::English => format!("Commit format set to {}", format), + Language::Chinese => format!("提交格式已设置为 {}", format), + Language::Japanese => format!("コミットフォーマットを {} に設定しました", format), + Language::Korean => format!("커밋 형식이 {}로 설정됨", format), + Language::Spanish => format!("Formato de commit establecido en {}", format), + Language::French => format!("Format de commit défini sur {}", format), + Language::German => format!("Commit-Format auf {} gesetzt", format), + } + } + + pub fn version_prefix_set(&self, prefix: &str) -> String { + match self.language { + Language::English => format!("Version prefix set to '{}'", prefix), + Language::Chinese => format!("版本前缀已设置为 '{}'", prefix), + Language::Japanese => format!("バージョンプレフィックスを '{}' に設定しました", prefix), + Language::Korean => format!("버전 접두사가 '{}'(으)로 설정됨", prefix), + Language::Spanish => format!("Prefijo de versión establecido en '{}'", prefix), + Language::French => format!("Préfixe de version défini sur '{}'", prefix), + Language::German => format!("Versionspräfix auf '{}' gesetzt", prefix), + } + } + + pub fn changelog_path_set(&self, path: &str) -> String { + match self.language { + Language::English => format!("Changelog path set to {}", path), + Language::Chinese => format!("变更日志路径已设置为 {}", path), + Language::Japanese => format!("変更ログパスを {} に設定しました", path), + Language::Korean => format!("변경 로그 경로가 {}로 설정됨", path), + Language::Spanish => format!("Ruta del changelog establecida en {}", path), + Language::French => format!("Chemin du changelog défini sur {}", path), + Language::German => format!("Changelog-Pfad auf {} gesetzt", path), + } + } + + pub fn output_language_set(&self, code: &str) -> String { + match self.language { + Language::English => format!("Output language set to {}", code), + Language::Chinese => format!("输出语言已设置为 {}", code), + Language::Japanese => format!("出力言語を {} に設定しました", code), + Language::Korean => format!("출력 언어가 {}로 설정됨", code), + Language::Spanish => format!("Idioma de salida establecido en {}", code), + Language::French => format!("Langue de sortie définie sur {}", code), + Language::German => format!("Ausgabesprache auf {} gesetzt", code), + } + } + + pub fn keep_types_english_set(&self, keep: bool) -> String { + match self.language { + Language::English => format!("Keep commit types in English: {}", keep), + Language::Chinese => format!("保持提交类型为英文:{}", keep), + Language::Japanese => format!("コミットタイプを英語のままにする: {}", keep), + Language::Korean => format!("커밋 유형을 영어로 유지: {}", keep), + Language::Spanish => format!("Mantener tipos de commit en inglés: {}", keep), + Language::French => format!("Conserver les types de commit en anglais : {}", keep), + Language::German => format!("Commit-Typen auf Englisch belassen: {}", keep), + } + } + + pub fn keep_changelog_types_english_set(&self, keep: bool) -> String { + match self.language { + Language::English => format!("Keep changelog types in English: {}", keep), + Language::Chinese => format!("保持变更日志类型为英文:{}", keep), + Language::Japanese => format!("変更ログタイプを英語のままにする: {}", keep), + Language::Korean => format!("변경 로그 유형을 영어로 유지: {}", keep), + Language::Spanish => format!("Mantener tipos de changelog en inglés: {}", keep), + Language::French => format!("Conserver les types de changelog en anglais : {}", keep), + Language::German => format!("Changelog-Typen auf Englisch belassen: {}", keep), + } + } + + pub fn configuration_reset_done(&self) -> &str { + match self.language { + Language::English => "Configuration reset to defaults", + Language::Chinese => "配置已重置为默认值", + Language::Japanese => "設定をデフォルトにリセットしました", + Language::Korean => "구성이 기본값으로 재설정됨", + Language::Spanish => "Configuración restablecida a los valores predeterminados", + Language::French => "Configuration réinitialisée aux valeurs par défaut", + Language::German => "Konfiguration auf Standardwerte zurückgesetzt", + } + } + + pub fn export_warning_no_encryption(&self) -> &str { + match self.language { + Language::English => "WARNING: Exporting without encryption.", + Language::Chinese => "警告:正在以未加密方式导出。", + Language::Japanese => "警告:暗号化なしでエクスポートしています。", + Language::Korean => "경고: 암호화 없이 내보내는 중입니다.", + Language::Spanish => "ADVERTENCIA: Exportando sin cifrado.", + Language::French => "AVERTISSEMENT : exportation sans chiffrement.", + Language::German => "WARNUNG: Export ohne Verschlüsselung.", + } + } + + pub fn export_pats_not_included(&self) -> &str { + match self.language { + Language::English => { + "Personal Access Tokens (PATs) stored in keyring will NOT be exported." + } + Language::Chinese => "存储在密钥环中的个人访问令牌(PAT)将不会被导出。", + Language::Japanese => { + "キーリングに保存された個人アクセストークン(PAT)はエクスポートされません。" + } + Language::Korean => "키링에 저장된 개인 액세스 토큰(PAT)은 내보내지지 않습니다.", + Language::Spanish => { + "Los tokens de acceso personal (PAT) almacenados en el keyring NO se exportarán." + } + Language::French => { + "Les jetons d'accès personnel (PAT) stockés dans le trousseau ne seront PAS exportés." + } + Language::German => { + "Im Schlüsselbund gespeicherte persönliche Zugriffstokens (PATs) werden NICHT exportiert." + } + } + } + + pub fn export_enable_encryption_hint(&self) -> &str { + match self.language { + Language::English => "To export PATs securely, please enable encryption.", + Language::Chinese => "如需安全导出 PAT,请启用加密。", + Language::Japanese => "PATを安全にエクスポートするには、暗号化を有効にしてください。", + Language::Korean => "PAT을 안전하게 내보내려면 암호화를 활성화하세요.", + Language::Spanish => "Para exportar PAT de forma segura, habilite el cifrado.", + Language::French => "Pour exporter les PAT en toute sécurité, activez le chiffrement.", + Language::German => { + "Um PATs sicher zu exportieren, aktivieren Sie die Verschlüsselung." + } + } + } + + pub fn export_plain_pat_warning(&self) -> &str { + match self.language { + Language::English => { + "WARNING: Plain export does not include PATs stored in keyring. \ + Use --output with --password to export them encrypted." + } + Language::Chinese => { + "警告:明文导出不包含密钥环中存储的 PAT。\ + 使用 --output 与 --password 加密导出。" + } + Language::Japanese => { + "警告:平文エクスポートにはキーリングのPATは含まれません。\ + 暗号化してエクスポートするには --output と --password を使用してください。" + } + Language::Korean => { + "경고: 평문 내보내기에는 키링에 저장된 PAT이 포함되지 않습니다. \ + 암호화하여 내보내려면 --output과 --password를 사용하세요." + } + Language::Spanish => { + "ADVERTENCIA: la exportación en texto plano no incluye PAT del keyring. \ + Use --output con --password para exportarlos cifrados." + } + Language::French => { + "AVERTISSEMENT : l'export en clair n'inclut pas les PAT du trousseau. \ + Utilisez --output avec --password pour les exporter chiffrés." + } + Language::German => { + "WARNUNG: Klartext-Export enthält keine PATs aus dem Schlüsselbund. \ + Verwenden Sie --output mit --password für verschlüsselten Export." + } + } + } + + pub fn configuration_encrypted_exported(&self, path: &str) -> String { + match self.language { + Language::English => format!("Configuration encrypted and exported to {}", path), + Language::Chinese => format!("配置已加密并导出到 {}", path), + Language::Japanese => format!("設定を暗号化して {} にエクスポートしました", path), + Language::Korean => format!("구성이 암호화되어 {}로 내보내짐", path), + Language::Spanish => format!("Configuración cifrada y exportada a {}", path), + Language::French => format!("Configuration chiffrée et exportée vers {}", path), + Language::German => format!("Konfiguration verschlüsselt und exportiert nach {}", path), + } + } + + pub fn configuration_exported(&self, path: &str) -> String { + match self.language { + Language::English => format!("Configuration exported to {}", path), + Language::Chinese => format!("配置已导出到 {}", path), + Language::Japanese => format!("設定を {} にエクスポートしました", path), + Language::Korean => format!("구성이 {}로 내보내짐", path), + Language::Spanish => format!("Configuración exportada a {}", path), + Language::French => format!("Configuration exportée vers {}", path), + Language::German => format!("Konfiguration exportiert nach {}", path), + } + } + + pub fn configuration_imported(&self, file: &str) -> String { + match self.language { + Language::English => format!("Configuration imported from {}", file), + Language::Chinese => format!("已从 {} 导入配置", file), + Language::Japanese => format!("{} から設定をインポートしました", file), + Language::Korean => format!("{}에서 구성 가져옴", file), + Language::Spanish => format!("Configuración importada desde {}", file), + Language::French => format!("Configuration importée depuis {}", file), + Language::German => format!("Konfiguration importiert aus {}", file), + } + } + + pub fn token_imported_to_keyring(&self, profile: &str, service: &str) -> String { + match self.language { + Language::English => { + format!("Token for {} ({}) imported to keyring", profile, service) + } + Language::Chinese => format!("{}({})的令牌已导入密钥环", profile, service), + Language::Japanese => format!( + "{}({})のトークンをキーリングにインポートしました", + profile, service + ), + Language::Korean => format!("{}({})용 토큰이 키링으로 가져와짐", profile, service), + Language::Spanish => format!("Token de {} ({}) importado al keyring", profile, service), + Language::French => format!( + "Jeton de {} ({}) importé dans le trousseau", + profile, service + ), + Language::German => format!( + "Token für {} ({}) in den Schlüsselbund importiert", + profile, service + ), + } + } + + pub fn failed_store_token(&self, profile: &str, service: &str) -> String { + match self.language { + Language::English => format!("Failed to store token for {} ({})", profile, service), + Language::Chinese => format!("存储 {}({})的令牌失败", profile, service), + Language::Japanese => { + format!("{}({})のトークンの保存に失敗しました", profile, service) + } + Language::Korean => format!("{}({})용 토큰 저장 실패", profile, service), + Language::Spanish => { + format!("No se pudo almacenar el token de {} ({})", profile, service) + } + Language::French => format!("Échec du stockage du jeton de {} ({})", profile, service), + Language::German => format!( + "Token für {} ({}) konnte nicht gespeichert werden", + profile, service + ), + } + } + + pub fn keyring_unavailable_token_import(&self, profile: &str, service: &str) -> String { + match self.language { + Language::English => { + format!( + "Keyring not available, cannot store token for {} ({})", + profile, service + ) + } + Language::Chinese => format!("密钥环不可用,无法存储 {}({})的令牌", profile, service), + Language::Japanese => format!( + "キーリングが利用できないため、{}({})のトークンを保存できません", + profile, service + ), + Language::Korean => format!( + "키링을 사용할 수 없어 {}({})용 토큰을 저장할 수 없습니다", + profile, service + ), + Language::Spanish => { + format!( + "Keyring no disponible, no se puede almacenar el token de {} ({})", + profile, service + ) + } + Language::French => { + format!( + "Trousseau indisponible, impossible de stocker le jeton de {} ({})", + profile, service + ) + } + Language::German => { + format!( + "Schlüsselbund nicht verfügbar, Token für {} ({}) kann nicht gespeichert werden", + profile, service + ) + } + } + } + + pub fn invalid_token_format(&self, profile: &str, service: &str) -> String { + match self.language { + Language::English => format!("Invalid token format for {} ({})", profile, service), + Language::Chinese => format!("{}({})的令牌格式无效", profile, service), + Language::Japanese => format!("{}({})のトークン形式が無効です", profile, service), + Language::Korean => format!("{}({})의 토큰 형식이 잘못됨", profile, service), + Language::Spanish => { + format!("Formato de token no válido para {} ({})", profile, service) + } + Language::French => { + format!("Format de jeton non valide pour {} ({})", profile, service) + } + Language::German => format!("Ungültiges Token-Format für {} ({})", profile, service), + } + } + + pub fn failed_decrypt_token(&self, profile: &str, service: &str) -> String { + match self.language { + Language::English => format!("Failed to decrypt token for {} ({})", profile, service), + Language::Chinese => format!("解密 {}({})的令牌失败", profile, service), + Language::Japanese => { + format!("{}({})のトークンの復号に失敗しました", profile, service) + } + Language::Korean => format!("{}({})용 토큰 복호화 실패", profile, service), + Language::Spanish => { + format!("No se pudo descifrar el token de {} ({})", profile, service) + } + Language::French => format!( + "Échec du déchiffrement du jeton de {} ({})", + profile, service + ), + Language::German => format!( + "Token für {} ({}) konnte nicht entschlüsselt werden", + profile, service + ), + } + } + + pub fn tokens_imported_count(&self, count: usize) -> String { + match self.language { + Language::English => format!("{} token(s) imported to keyring", count), + Language::Chinese => format!("已将 {} 个令牌导入密钥环", count), + Language::Japanese => format!("{} 個のトークンをキーリングにインポートしました", count), + Language::Korean => format!("토큰 {}개를 키링으로 가져옴", count), + Language::Spanish => format!("{} token(s) importados al keyring", count), + Language::French => format!("{} jeton(s) importés dans le trousseau", count), + Language::German => format!("{} Token(s) in den Schlüsselbund importiert", count), + } + } + + pub fn tokens_failed_count(&self, count: usize) -> String { + match self.language { + Language::English => format!("{} token(s) failed to import", count), + Language::Chinese => format!("{} 个令牌导入失败", count), + Language::Japanese => format!("{} 個のトークンのインポートに失敗しました", count), + Language::Korean => format!("토큰 {}개 가져오기 실패", count), + Language::Spanish => format!("{} token(s) no se pudieron importar", count), + Language::French => format!("{} jeton(s) n'ont pas pu être importés", count), + Language::German => format!("{} Token(s) konnten nicht importiert werden", count), + } + } + + pub fn failed_decrypt_config(&self, error: &str) -> String { + match self.language { + Language::English => { + format!( + "Failed to decrypt configuration: {}. Please check your password.", + error + ) + } + Language::Chinese => format!("配置解密失败:{}。请检查你的密码。", error), + Language::Japanese => { + format!( + "設定の復号に失敗しました: {}。パスワードを確認してください。", + error + ) + } + Language::Korean => format!("구성 복호화 실패: {}. 비밀번호를 확인하세요.", error), + Language::Spanish => { + format!( + "No se pudo descifrar la configuración: {}. Compruebe su contraseña.", + error + ) + } + Language::French => { + format!( + "Échec du déchiffrement de la configuration : {}. Vérifiez votre mot de passe.", + error + ) + } + Language::German => { + format!( + "Konfiguration konnte nicht entschlüsselt werden: {}. Bitte Passwort prüfen.", + error + ) + } + } + } } diff --git a/src/lib.rs b/src/lib.rs index 6a53cc9..7dff402 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - pub mod commands; pub mod config; pub mod generator; diff --git a/src/llm/rig/mod.rs b/src/llm/rig/mod.rs index 187daac..3de3cab 100644 --- a/src/llm/rig/mod.rs +++ b/src/llm/rig/mod.rs @@ -4,7 +4,7 @@ //! 全部 provider 接入并切换后,旧实现将被删除。 use crate::config::manager::ConfigManager; -use crate::llm::thinking::{create_console_thinking_state, ThinkingStateManager}; +use crate::llm::thinking::{ThinkingStateManager, create_console_thinking_state}; use anyhow::{Context, Result, bail}; use rig_core::{ client::{CompletionClient, Nothing, VerifyClient}, @@ -154,7 +154,8 @@ fn required_api_key(provider: &str, key: Option) -> Result { } impl LlmClient { - /// 内部与测试构造入口。 + /// 内部与测试构造入口(仅测试使用)。 + #[cfg(test)] pub(crate) fn new( backend: Backend, model: impl Into, @@ -332,7 +333,9 @@ where let language_instruction = match language { crate::config::Language::Chinese => "\n\n请用中文生成提交消息。", - crate::config::Language::Japanese => "\n\n日本語でコミットメッセージを生成してください。", + crate::config::Language::Japanese => { + "\n\n日本語でコミットメッセージを生成してください。" + } crate::config::Language::Korean => "\n\n한국어로 커밋 메시지를 생성하세요.", crate::config::Language::Spanish => { "\n\nPor favor, genera el mensaje de commit en español." @@ -465,7 +468,8 @@ where let event = event.map_err(|e| map_completion_error(&self.provider, e))?; match event { StreamedAssistantContent::Text(chunk) => { - if has_reasoning && !has_content + if has_reasoning + && !has_content && let Some(state) = state { state.end_thinking(); @@ -474,7 +478,9 @@ where text.push_str(&chunk.text); } StreamedAssistantContent::Reasoning(_) - | StreamedAssistantContent::ReasoningDelta { .. } if !has_reasoning => { + | StreamedAssistantContent::ReasoningDelta { .. } + if !has_reasoning => + { has_reasoning = true; if let Some(state) = state { state.start_thinking(); @@ -519,7 +525,11 @@ fn build_ollama_client(base_url: &str, http: ReqwestClient) -> Result Result { +fn build_deepseek_client( + key: &str, + base_url: &str, + http: ReqwestClient, +) -> Result { deepseek::Client::builder() .api_key(key) .base_url(base_url) @@ -539,7 +549,11 @@ fn build_kimi_client(key: &str, base_url: &str, http: ReqwestClient) -> Result Result { +fn build_anthropic_client( + key: &str, + base_url: &str, + http: ReqwestClient, +) -> Result { anthropic::Client::builder() .api_key(key) .base_url(base_url) @@ -564,7 +578,11 @@ fn build_openai_client( } /// 构建 OpenRouter 客户端(携带 QuiCommit 应用标识头)。 -fn build_openrouter_client(key: &str, base_url: &str, http: ReqwestClient) -> Result { +fn build_openrouter_client( + key: &str, + base_url: &str, + http: ReqwestClient, +) -> Result { openrouter::Client::builder() .api_key(key) .base_url(base_url) @@ -713,8 +731,14 @@ mod tests { .build() .expect("build deepseek client with mock backend"), ); - let client = - LlmClient::new(backend, "deepseek-v4-flash", "deepseek", test_config(), thinking, None); + let client = LlmClient::new( + backend, + "deepseek-v4-flash", + "deepseek", + test_config(), + thinking, + None, + ); (client, recorder) } @@ -817,7 +841,10 @@ mod tests { assert_eq!(text, "hello from deepseek"); let captured = recorder.requests(); - assert_eq!(captured[0].uri, "https://api.deepseek.com/v1/chat/completions"); + assert_eq!( + captured[0].uri, + "https://api.deepseek.com/v1/chat/completions" + ); let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); assert_eq!(body["model"], "deepseek-v4-flash"); // 非流式请求省略 stream 字段(API 默认 false) @@ -843,7 +870,10 @@ mod tests { client.generate(None, "hi").await.unwrap(); let captured = recorder.requests(); - assert_eq!(captured[0].uri, "https://api.moonshot.cn/v1/chat/completions"); + assert_eq!( + captured[0].uri, + "https://api.moonshot.cn/v1/chat/completions" + ); let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); assert_eq!(body["temperature"], 0.6); assert_eq!(body["thinking"]["type"], "disabled"); @@ -882,7 +912,9 @@ mod tests { deepseek::Client::builder() .api_key("sk-test") .base_url("https://api.deepseek.com/v1") - .http_client(MockStreamingClient { sse_bytes: sse.to_string().into() }) + .http_client(MockStreamingClient { + sse_bytes: sse.to_string().into(), + }) .build() .expect("build deepseek client with streaming mock"), ); @@ -962,9 +994,10 @@ mod tests { #[test] fn map_error_without_body_keeps_provider_prefix() { let err = map_completion_error("DeepSeek", CompletionError::ProviderError("boom".into())); - assert!(err - .to_string() - .contains("DeepSeek API request failed: ProviderError: boom")); + assert!( + err.to_string() + .contains("DeepSeek API request failed: ProviderError: boom") + ); } #[test] @@ -980,10 +1013,16 @@ mod tests { #[test] fn supports_thinking_whitelist_matches_legacy() { for provider in ["deepseek", "kimi", "anthropic", "openai"] { - assert!(supports_thinking(provider), "{provider} should support thinking"); + assert!( + supports_thinking(provider), + "{provider} should support thinking" + ); } for provider in ["ollama", "openrouter"] { - assert!(!supports_thinking(provider), "{provider} should not support thinking"); + assert!( + !supports_thinking(provider), + "{provider} should not support thinking" + ); } } @@ -1015,7 +1054,14 @@ mod tests { ); let mut config = test_config(); config.thinking_budget_tokens = budget; - let client = LlmClient::new(backend, "claude-sonnet-4-6", "anthropic", config, thinking, None); + let client = LlmClient::new( + backend, + "claude-sonnet-4-6", + "anthropic", + config, + thinking, + None, + ); (client, recorder) } @@ -1111,7 +1157,9 @@ mod tests { anthropic::Client::builder() .api_key("sk-ant-test") .base_url("https://api.anthropic.com/v1") - .http_client(MockStreamingClient { sse_bytes: sse.to_string().into() }) + .http_client(MockStreamingClient { + sse_bytes: sse.to_string().into(), + }) .build() .expect("build anthropic client with streaming mock"), ); @@ -1186,12 +1234,18 @@ mod tests { assert_eq!(text, "hello from openai"); let captured = recorder.requests(); - assert_eq!(captured[0].uri, "https://api.openai.com/v1/chat/completions"); + assert_eq!( + captured[0].uri, + "https://api.openai.com/v1/chat/completions" + ); let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); assert_eq!(body["model"], "gpt-4o"); assert_eq!(body["temperature"], 0.5); assert_eq!(body["max_tokens"], 123); - assert!(body.get("reasoning_effort").is_none(), "非 o 系列不应传 reasoning_effort"); + assert!( + body.get("reasoning_effort").is_none(), + "非 o 系列不应传 reasoning_effort" + ); } #[test] @@ -1200,7 +1254,9 @@ mod tests { let (client, _) = openai_client(recorder, "o3", false); let options = client.request_options(); - let params = options.additional_params.expect("o 系列应传 reasoning_effort"); + let params = options + .additional_params + .expect("o 系列应传 reasoning_effort"); assert_eq!(params["reasoning_effort"], "none"); assert!(!options.stream); } @@ -1328,7 +1384,10 @@ mod tests { let captured = recorder.requests(); let body: serde_json::Value = serde_json::from_slice(&captured[0].body).unwrap(); let system = body["messages"][0]["content"].as_str().unwrap(); - assert!(system.contains("Conventional Commits"), "system prompt 应包含规范说明"); + assert!( + system.contains("Conventional Commits"), + "system prompt 应包含规范说明" + ); let user = body["messages"][1]["content"].as_str().unwrap(); assert!(user.contains("diff --git"), "user prompt 应包含 diff"); assert!(user.contains("请用中文生成提交消息"), "应包含语言指令"); @@ -1336,12 +1395,15 @@ mod tests { // ---- 实网冒烟(#[ignore],需真实服务/API key)---- - fn smoke_client( - backend: Backend, - model: &str, - provider: &str, - ) -> LlmClient { - LlmClient::new(backend, model, provider, LlmClientConfig::default(), false, None) + fn smoke_client(backend: Backend, model: &str, provider: &str) -> LlmClient { + LlmClient::new( + backend, + model, + provider, + LlmClientConfig::default(), + false, + None, + ) } fn smoke_http() -> ReqwestClient { @@ -1370,7 +1432,9 @@ mod tests { #[ignore = "requires QUICOMMIT_API_KEY with a valid DeepSeek key"] #[tokio::test] async fn live_smoke_deepseek() { - let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { + return; + }; let backend = Backend::DeepSeek( deepseek::Client::builder() .api_key(&key) @@ -1387,7 +1451,9 @@ mod tests { #[ignore = "requires QUICOMMIT_API_KEY with a valid Moonshot key"] #[tokio::test] async fn live_smoke_kimi() { - let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { + return; + }; let backend = Backend::Kimi( moonshot::Client::builder() .api_key(&key) @@ -1404,7 +1470,9 @@ mod tests { #[ignore = "requires QUICOMMIT_API_KEY with a valid Anthropic key"] #[tokio::test] async fn live_smoke_anthropic() { - let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { + return; + }; let backend = Backend::Anthropic( anthropic::Client::builder() .api_key(&key) @@ -1421,7 +1489,9 @@ mod tests { #[ignore = "requires QUICOMMIT_API_KEY with a valid OpenAI key"] #[tokio::test] async fn live_smoke_openai() { - let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { + return; + }; let backend = Backend::OpenAi( openai::Client::builder() .api_key(&key) @@ -1439,7 +1509,9 @@ mod tests { #[ignore = "requires QUICOMMIT_API_KEY with a valid OpenRouter key"] #[tokio::test] async fn live_smoke_openrouter() { - let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { return }; + let Ok(key) = std::env::var("QUICOMMIT_API_KEY") else { + return; + }; let backend = Backend::OpenRouter( openrouter::Client::builder() .api_key(&key) @@ -1453,4 +1525,4 @@ mod tests { let text = client.generate(None, "Say 'Hello, World!'").await.unwrap(); assert!(!text.is_empty()); } -} \ No newline at end of file +} diff --git a/src/llm/thinking.rs b/src/llm/thinking.rs index 861a916..93a162e 100644 --- a/src/llm/thinking.rs +++ b/src/llm/thinking.rs @@ -64,18 +64,10 @@ impl Default for ThinkingStateManager { /// 线程安全的思考状态管理器引用 pub type SharedThinkingState = Arc; -/// 创建带有默认控制台输出的思考状态管理器 -/// 在思考开始时打印 "thinking...",在思考结束时清除该标识 +/// 创建 LLM 流式使用的共享思考状态。 +/// 进度显示由 AI 生成 spinner 负责(issue 21),此处不再附加控制台输出。 pub fn create_console_thinking_state() -> SharedThinkingState { - Arc::new( - ThinkingStateManager::new() - .on_thinking_start(|| { - eprint!("\rthinking..."); - }) - .on_thinking_end(|| { - eprint!("\r \r"); - }), - ) + Arc::new(ThinkingStateManager::new()) } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 4dc366c..af5907a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use anyhow::Result; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -22,7 +20,7 @@ use quicommit::commands::{ #[command(propagate_version = true)] #[command(arg_required_else_help = true)] struct Cli { - /// Enable verbose output + /// Increase verbosity (-v: info, -vv: debug, -vvv: trace) #[arg(short, long, global = true, action = clap::ArgAction::Count)] verbose: u8, @@ -31,9 +29,17 @@ struct Cli { config: Option, /// Disable colored output - #[arg(long, global = true, env = "NO_COLOR")] + #[arg(long, global = true)] no_color: bool, + /// Force emoji decorations on (overrides config) + #[arg(long, global = true, conflicts_with = "no_emoji")] + emoji: bool, + + /// Disable emoji/symbol decorations + #[arg(long, global = true)] + no_emoji: bool, + #[command(subcommand)] command: Commands, } @@ -73,6 +79,31 @@ enum Commands { async fn main() -> Result<()> { let cli = Cli::parse(); + // Apply the global color decision before any output is produced. + // --no-color disables colors; NO_COLOR follows the no-color.org spec + // (any presence, regardless of value, disables color). + let no_color = cli.no_color || std::env::var_os("NO_COLOR").is_some(); + colored::control::set_override(!no_color); + + // Resolve the decoration switch (issue 14): explicit flag > config > + // default; --no-color disables decorations too (ADR-0003). + let emoji_from_config = cli + .config + .as_deref() + .map(PathBuf::from) + .or_else(|| quicommit::config::AppConfig::default_path().ok()) + .and_then(|path| quicommit::config::AppConfig::load(&path).ok()) + .map(|config| config.output.emoji) + .unwrap_or(true); + let emoji_enabled = if no_color || cli.no_emoji { + false + } else if cli.emoji { + true + } else { + emoji_from_config + }; + quicommit::utils::set_emoji_enabled(emoji_enabled); + let log_level = match cli.verbose { 0 => "warn", 1 => "info", @@ -80,8 +111,11 @@ async fn main() -> Result<()> { _ => "trace", }; + // RUST_LOG takes precedence when set; otherwise -v decides (issue 23). + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level)); tracing_subscriber::fmt() - .with_env_filter(log_level) + .with_env_filter(filter) .with_target(false) .init(); diff --git a/src/utils/keyring.rs b/src/utils/keyring.rs index e5246e8..7357fa9 100644 --- a/src/utils/keyring.rs +++ b/src/utils/keyring.rs @@ -232,9 +232,7 @@ impl KeyringManager { "Keyring is available".to_string() } } - KeyringStatus::Unavailable => { - "Keyring is not available. Set QUICOMMIT_API_KEY environment variable.".to_string() - } + KeyringStatus::Unavailable => "Keyring is not available on this system.".to_string(), } } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 301456a..cca53d4 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -6,48 +6,144 @@ pub mod validators; use anyhow::{Context, Result}; use colored::Colorize; -use std::io::{self, Write}; +use std::io; +use std::sync::OnceLock; +use std::time::Duration; + +/// Process-global decoration switch (issue 14). +static EMOJI_ENABLED: OnceLock = OnceLock::new(); + +/// Set whether emoji/symbol decorations are shown. Called once from `main` +/// after resolving config + flags. +pub fn set_emoji_enabled(enabled: bool) { + let _ = EMOJI_ENABLED.set(enabled); +} + +/// Whether emoji/symbol decorations are shown. +pub fn emoji_enabled() -> bool { + *EMOJI_ENABLED.get_or_init(|| true) +} + +/// Styled decoration prefix for status lines; empty when decorations are off +/// (ADR-0003: message text never embeds decorations). +pub fn success_prefix() -> colored::ColoredString { + if emoji_enabled() { + "✓".green().bold() + } else { + "".normal() + } +} + +pub fn error_prefix() -> colored::ColoredString { + if emoji_enabled() { + "✗".red().bold() + } else { + "".normal() + } +} + +pub fn warning_prefix() -> colored::ColoredString { + if emoji_enabled() { + "⚠".yellow().bold() + } else { + "".normal() + } +} + +pub fn info_prefix() -> colored::ColoredString { + if emoji_enabled() { + "ℹ".blue().bold() + } else { + "".normal() + } +} + +pub fn progress_prefix() -> colored::ColoredString { + if emoji_enabled() { + "→".cyan() + } else { + "".normal() + } +} + +fn print_decorated(prefix: colored::ColoredString, msg: &str, to_stderr: bool) { + if emoji_enabled() { + if to_stderr { + eprintln!("{} {}", prefix, msg); + } else { + println!("{} {}", prefix, msg); + } + } else if to_stderr { + eprintln!("{}", msg); + } else { + println!("{}", msg); + } +} /// Print success message pub fn print_success(msg: &str) { - println!("{} {}", "✓".green().bold(), msg); + print_decorated(success_prefix(), msg, false); } -/// Print error message +/// Print error message to stderr pub fn print_error(msg: &str) { - eprintln!("{} {}", "✗".red().bold(), msg); + print_decorated(error_prefix(), msg, true); } /// Print warning message pub fn print_warning(msg: &str) { - println!("{} {}", "⚠".yellow().bold(), msg); + print_decorated(warning_prefix(), msg, false); +} + +/// Print warning message to stderr (stdout must stay clean, e.g. exports) +pub fn eprint_warning(msg: &str) { + print_decorated(warning_prefix(), msg, true); } /// Print info message pub fn print_info(msg: &str) { - println!("{} {}", "ℹ".blue().bold(), msg); + print_decorated(info_prefix(), msg, false); } -/// Confirm action with user -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')) +/// Print progress/status message (→ prefix when decorations are on) +pub fn print_progress(msg: &str) { + print_decorated(progress_prefix(), msg, false); } -/// Get user input -pub fn input(prompt: &str) -> Result { - print!("{}: ", prompt); - io::stdout().flush()?; +/// Progress spinner for long-running operations (issue 21). +/// Degrades to a static line when stdout is not a terminal or when +/// decorations are disabled. +pub struct Spinner { + bar: Option, +} - let mut input = String::new(); - io::stdin().read_line(&mut input)?; +impl Spinner { + pub fn start(msg: &str) -> Self { + if emoji_enabled() && std::io::IsTerminal::is_terminal(&io::stdout()) { + let bar = indicatif::ProgressBar::new_spinner(); + bar.set_message(msg.to_string()); + bar.enable_steady_tick(Duration::from_millis(100)); + Self { bar: Some(bar) } + } else { + println!("{}", msg); + Self { bar: None } + } + } - Ok(input.trim().to_string()) + /// Stop the spinner, erasing it from the terminal (no completion line). + pub fn finish_clear(&self) { + if let Some(bar) = &self.bar { + bar.finish_and_clear(); + } + } + + /// Stop the spinner and print a completion line. + pub fn finish_with(&self, msg: &str) { + if let Some(bar) = &self.bar { + bar.finish_and_clear(); + } + println!("{}", msg); + } } /// Get password input (hidden) @@ -59,19 +155,3 @@ pub fn password_input(prompt: &str) -> Result { .interact() .context("Failed to read password") } - -/// Check if running in a terminal -pub fn is_terminal() -> bool { - std::io::IsTerminal::is_terminal(&io::stdout()) -} - -/// Format duration in human-readable format -pub fn format_duration(secs: u64) -> String { - if secs < 60 { - format!("{}s", secs) - } else if secs < 3600 { - format!("{}m {}s", secs / 60, secs % 60) - } else { - format!("{}h {}m", secs / 3600, (secs % 3600) / 60) - } -} diff --git a/tests/gitignore_tests.rs b/tests/gitignore_tests.rs index a792d91..d766a67 100644 --- a/tests/gitignore_tests.rs +++ b/tests/gitignore_tests.rs @@ -115,7 +115,8 @@ fn test_stage_all_removes_ignored_tracked_files() { write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode"); let repo = GitRepo::open(repo_path).expect("Failed to open repo"); - let removed = repo.stage_all().expect("stage_all should succeed"); + let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English); + let removed = repo.stage_all(&messages).expect("stage_all should succeed"); assert!( removed.iter().any(|f| f == "__pycache__/foo.pyc"), @@ -142,7 +143,8 @@ fn test_stage_all_no_ignored_files_returns_empty() { write_file(repo_path, "src/main.rs", "fn main() {}"); let repo = GitRepo::open(repo_path).expect("Failed to open repo"); - let removed = repo.stage_all().expect("stage_all should succeed"); + let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English); + let removed = repo.stage_all(&messages).expect("stage_all should succeed"); assert!( removed.is_empty(), diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index ad9e5fe..da55e10 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -258,6 +258,190 @@ mod config_command { .success() .stdout(predicate::str::contains("config.toml")); } + + #[test] + fn test_no_color_disables_ansi() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.args(&[ + "config", + "show", + "--no-color", + "--config", + config_path.to_str().unwrap(), + ]); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.contains("\x1b["), + "ANSI color codes present despite --no-color" + ); + } + + #[test] + fn test_emoji_switch_precedence() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let set_format = |extra: &[&str], cfg: &std::path::Path| { + let mut c = cargo_bin_cmd!("quicommit"); + c.env_remove("NO_COLOR"); + c.args(&["config", "set", "commit.format", "conventional"]) + .args(extra) + .args(&["--config", cfg.to_str().unwrap()]); + c.output().unwrap() + }; + + // Default (config true): decorations shown + let out = set_format(&[], &config_path); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains("✓")); + + // Config output.emoji=false: no decorations + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.args(&[ + "config", + "set", + "output.emoji", + "false", + "--config", + config_path.to_str().unwrap(), + ]); + cmd.assert().success(); + + let out = set_format(&[], &config_path); + assert!(out.status.success()); + assert!(!String::from_utf8_lossy(&out.stdout).contains("✓")); + + // Explicit --emoji overrides config false + let out = set_format(&["--emoji"], &config_path); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains("✓")); + + // Config back to true, then --no-emoji overrides + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.args(&[ + "config", + "set", + "output.emoji", + "true", + "--config", + config_path.to_str().unwrap(), + ]); + cmd.assert().success(); + + let out = set_format(&["--no-emoji"], &config_path); + assert!(out.status.success()); + assert!(!String::from_utf8_lossy(&out.stdout).contains("✓")); + } + + #[test] + fn test_chinese_output_smoke() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + setup_test_repo_with_file(&repo_path, "test.txt", "content"); + + let config_path = repo_path.join("config.toml"); + init_quicommit(&repo_path, &config_path); + + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.args(&[ + "config", + "set", + "language.output_language", + "zh", + "--config", + config_path.to_str().unwrap(), + ]); + cmd.assert().success(); + + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.args(&[ + "commit", + "--manual", + "-m", + "feat: 中文冒烟", + "--dry-run", + "--yes", + "--config", + config_path.to_str().unwrap(), + ]) + .current_dir(&repo_path); + + cmd.assert() + .success() + .stdout(predicate::str::contains("试运行")); + } + + #[test] + fn test_rust_log_env() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.env("RUST_LOG", "debug"); + cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Starting quicommit")); + } + + #[test] + fn test_auto_generate_defaults_true() { + // Documents the unified --yes semantics (issue 05): with default + // config, --yes still AI-generates; deterministic output needs + // --no-generate (changelog) or -m (tag). + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]); + cmd.assert().success(); + + for key in [ + "commit.auto_generate", + "tag.auto_generate", + "changelog.auto_generate", + ] { + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.env_remove("NO_COLOR"); + cmd.args(&[ + "config", + "get", + key, + "--config", + config_path.to_str().unwrap(), + ]); + cmd.assert() + .success() + .stdout(predicate::str::contains("true")); + } + } } mod commit_command { @@ -438,6 +622,8 @@ mod tag_command { "tag", "--name", "v0.1.0", + "-m", + "Release v0.1.0", "--dry-run", "--yes", "--config", @@ -469,6 +655,8 @@ mod tag_command { "--think", "--name", "v0.2.0", + "-m", + "Release v0.2.0", "--dry-run", "--yes", "--config", @@ -528,6 +716,7 @@ mod changelog_command { "changelog", "--dry-run", "--yes", + "--no-generate", "--config", config_path.to_str().unwrap(), ])