9 Commits
v0.1.4 ... main

Author SHA1 Message Date
04410ea9e7 📝 docs(CHANGELOG.md):更新变更日志以记录版本0.1.7的发布内容 2026-02-14 15:10:04 +08:00
a514cdc69f ⬆️ chore(Cargo.toml):升级版本号至0.1.7
♻️ refactor(changelog.rs):移除prepend参数,改为自动前置到现有changelog
♻️ refactor(formatter.rs):移除未使用的日期和格式化函数
♻️ refactor(validators.rs):移除未使用的SSH密钥验证功能
2026-02-14 15:00:59 +08:00
e822ba1f54 feat(commands):为所有命令添加config_path参数支持,实现自定义配置文件路径
♻️ refactor(config):重构ConfigManager,添加with_path_fresh方法用于初始化新配置
🔧 fix(git):改进跨平台路径处理,增强git仓库检测的鲁棒性
 test(tests):添加全面的集成测试,覆盖所有命令和跨平台场景
2026-02-14 14:28:11 +08:00
3c925d8268 update version 2026-02-04 11:34:17 +08:00
c9073ff4a7 feat(profile):应用配置文件时自动设置GPG签名和SSH配置
♻️ refactor(generator):移除未使用的导入和剪贴板功能
♻️ refactor(git):清理未使用的导入和优化代码结构
♻️ refactor(i18n):简化翻译模块的导出结构
♻️ refactor(llm):移除未使用的序列化导入
♻️ refactor(openrouter):简化模型验证函数
2026-02-04 10:57:15 +08:00
88324c21c2 fix: 增强 GPG 签名失败时的错误提示 2026-02-02 14:57:54 +08:00
ffc9741d1e refactor(git): 移除重复的 git commit 命令并添加 tempfile 导入 2026-02-02 14:50:12 +08:00
5638315031 feat(config): 为 anthropic、kimi、deepseek 添加 list_models 支持 2026-02-02 06:40:41 +00:00
2e43a5e396 docs: 规范化 changelog 格式并补充 0.1.0 版本记录 2026-02-01 14:25:50 +00:00
26 changed files with 1528 additions and 422 deletions

View File

@@ -1,4 +1,21 @@
## v1.0.1 更新日志
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.7] - 2026-02-14
### 🐞 错误修复
- 修复 `changelog` 命令默认覆盖文件的问题,现改为智能追加新版本条目到头部之后
### 🔧 其他变更
- 清理 `formatter.rs` 中未使用的函数(`format_commit_date``format_changelog_date``format_tag_name``truncate``format_markdown_list``format_changelog_section``format_git_config_key`
- 清理 `validators.rs` 中未使用的函数(`validate_ssh_key`
- 移除 `changelog` 命令的 `--prepend` 参数(默认行为已改为追加)
## [0.1.4] - 2026-02-01
### ✨ 新功能
- 新增 `test3.txt`,支持中文输出测试
@@ -19,4 +36,21 @@
### 🔧 其他变更
- 新增个人访问令牌、使用统计与配置校验功能
- 添加 `test2.txt` 占位文件
- 添加 `test2.txt` 占位文件
## [0.1.0] - 2026-01-30
### Added
- Initial project structure
- Core functionality for git operations
- LLM integration
- Configuration management
- CLI interface
### Features
- **Commit Generation**: Automatically generate conventional commit messages from git diffs
- **Profile Management**: Switch between multiple Git identities for different contexts
- **Tag Management**: Create annotated tags with AI-generated release notes
- **Changelog**: Generate and maintain changelog in Keep a Changelog format
- **Security**: Encrypt SSH passphrases and API keys
- **Interactive UI**: Beautiful CLI with prompts and previews

View File

@@ -1,6 +1,6 @@
[package]
name = "quicommit"
version = "0.1.4"
version = "0.1.7"
edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"

View File

@@ -13,13 +13,14 @@ use crate::i18n::{Messages, translate_changelog_category};
/// Generate changelog
#[derive(Parser)]
#[command(disable_version_flag = true, disable_help_flag = false)]
pub struct ChangelogCommand {
/// Output file path
#[arg(short, long)]
output: Option<PathBuf>,
/// Version to generate changelog for
#[arg(short, long)]
#[arg(long)]
version: Option<String>,
/// Generate from specific tag
@@ -38,10 +39,6 @@ pub struct ChangelogCommand {
#[arg(short, long)]
generate: bool,
/// Prepend to existing changelog
#[arg(short, long)]
prepend: bool,
/// Include commit hashes
#[arg(long)]
include_hashes: bool,
@@ -51,7 +48,7 @@ pub struct ChangelogCommand {
include_authors: bool,
/// Format (keep-a-changelog, github-releases)
#[arg(short, long)]
#[arg(long)]
format: Option<String>,
/// Dry run (output to stdout)
@@ -64,9 +61,13 @@ pub struct ChangelogCommand {
}
impl ChangelogCommand {
pub async fn execute(&self) -> Result<()> {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
let repo = find_repo(std::env::current_dir()?.as_path())?;
let manager = ConfigManager::new()?;
let manager = if let Some(ref path) = config_path {
ConfigManager::with_path(path)?
} else {
ConfigManager::new()?
};
let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
@@ -157,13 +158,34 @@ impl ChangelogCommand {
}
}
// Write to file
if self.prepend && output_path.exists() {
// Write to file (always prepend to preserve history)
if output_path.exists() {
let existing = std::fs::read_to_string(&output_path)?;
let new_content = format!("{}\n{}", changelog, existing);
let new_content = if existing.is_empty() {
format!("# Changelog\n\n{}", changelog)
} else {
let lines: Vec<&str> = existing.lines().collect();
let mut header_end = 0;
for (i, line) in lines.iter().enumerate() {
if i == 0 && line.starts_with('#') {
header_end = i + 1;
} else if line.trim().is_empty() {
header_end = i + 1;
} else {
break;
}
}
let header = lines[..header_end].join("\n");
let rest = lines[header_end..].join("\n");
format!("{}\n{}\n{}", header, changelog, rest)
};
std::fs::write(&output_path, new_content)?;
} else {
std::fs::write(&output_path, changelog)?;
let content = format!("# Changelog\n\n{}", changelog);
std::fs::write(&output_path, content)?;
}
println!("{} {:?}", messages.changelog_written(), output_path);

View File

@@ -2,6 +2,7 @@ use anyhow::{bail, Context, Result};
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat;
@@ -84,12 +85,16 @@ pub struct CommitCommand {
}
impl CommitCommand {
pub async fn execute(&self) -> Result<()> {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
// Find git repository
let repo = find_repo(std::env::current_dir()?.as_path())?;
// Load configuration
let manager = ConfigManager::new()?;
let manager = if let Some(ref path) = config_path {
ConfigManager::with_path(path)?
} else {
ConfigManager::new()?
};
let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
@@ -350,8 +355,23 @@ impl CommitCommand {
.output()?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to amend commit: {}", stderr);
let error_msg = if stderr.is_empty() {
if stdout.is_empty() {
"GPG signing failed. Please check:\n\
1. GPG signing key is configured (git config --get user.signingkey)\n\
2. GPG agent is running\n\
3. You can sign commits manually (try: git commit --amend -S)".to_string()
} else {
stdout.to_string()
}
} else {
stderr.to_string()
};
bail!("Failed to amend commit: {}", error_msg);
}
Ok(())

View File

@@ -2,6 +2,7 @@ use anyhow::{bail, Result};
use clap::{Parser, Subcommand};
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat;
@@ -191,43 +192,60 @@ enum ConfigSubcommand {
/// Test LLM connection
TestLlm,
/// Show config file path
Path,
}
impl ConfigCommand {
pub async fn execute(&self) -> Result<()> {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
Some(ConfigSubcommand::Show) => self.show_config().await,
Some(ConfigSubcommand::List) => self.list_config().await,
Some(ConfigSubcommand::Edit) => self.edit_config().await,
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value).await,
Some(ConfigSubcommand::Get { key }) => self.get_value(key).await,
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref()).await,
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key).await,
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key).await,
Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key).await,
Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key).await,
Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key).await,
Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref()).await,
Some(ConfigSubcommand::SetKimi { base_url, model }) => self.set_kimi(base_url.as_deref(), model.as_deref()).await,
Some(ConfigSubcommand::SetDeepSeek { base_url, model }) => self.set_deepseek(base_url.as_deref(), model.as_deref()).await,
Some(ConfigSubcommand::SetOpenRouter { base_url, model }) => self.set_openrouter(base_url.as_deref(), model.as_deref()).await,
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format).await,
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await,
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await,
Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref()).await,
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep).await,
Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep).await,
Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await,
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await,
Some(ConfigSubcommand::Import { file }) => self.import_config(file).await,
Some(ConfigSubcommand::ListModels) => self.list_models().await,
Some(ConfigSubcommand::TestLlm) => self.test_llm().await,
None => self.show_config().await,
Some(ConfigSubcommand::Show) => self.show_config(&config_path).await,
Some(ConfigSubcommand::List) => self.list_config(&config_path).await,
Some(ConfigSubcommand::Edit) => self.edit_config(&config_path).await,
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value, &config_path).await,
Some(ConfigSubcommand::Get { key }) => self.get_value(key, &config_path).await,
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key, &config_path).await,
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key, &config_path).await,
Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key, &config_path).await,
Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key, &config_path).await,
Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key, &config_path).await,
Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetKimi { base_url, model }) => self.set_kimi(base_url.as_deref(), model.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetDeepSeek { base_url, model }) => self.set_deepseek(base_url.as_deref(), model.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetOpenRouter { base_url, model }) => self.set_openrouter(base_url.as_deref(), model.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format, &config_path).await,
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix, &config_path).await,
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path, &config_path).await,
Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep, &config_path).await,
Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await,
Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await,
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref(), &config_path).await,
Some(ConfigSubcommand::Import { file }) => self.import_config(file, &config_path).await,
Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await,
Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await,
Some(ConfigSubcommand::Path) => self.show_path(&config_path).await,
None => self.show_config(&config_path).await,
}
}
async fn show_config(&self) -> Result<()> {
let manager = ConfigManager::new()?;
fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
async fn show_path(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
println!("{}", manager.path().display());
Ok(())
}
async fn show_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let config = manager.config();
println!("{}", "\nQuiCommit Configuration".bold());
@@ -306,8 +324,8 @@ impl ConfigCommand {
}
/// List all configuration information with masked API keys
async fn list_config(&self) -> Result<()> {
let manager = ConfigManager::new()?;
async fn list_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let config = manager.config();
println!("{}", "\nQuiCommit Configuration".bold());
@@ -404,15 +422,15 @@ impl ConfigCommand {
Ok(())
}
async fn edit_config(&self) -> Result<()> {
let manager = ConfigManager::new()?;
async fn edit_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
crate::utils::editor::edit_file(manager.path())?;
println!("{} Configuration updated", "".green());
Ok(())
}
async fn set_value(&self, key: &str, value: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_value(&self, key: &str, value: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
match key {
"llm.provider" => manager.set_llm_provider(value.to_string()),
@@ -450,8 +468,8 @@ impl ConfigCommand {
Ok(())
}
async fn get_value(&self, key: &str) -> Result<()> {
let manager = ConfigManager::new()?;
async fn get_value(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let config = manager.config();
let value = match key {
@@ -469,8 +487,8 @@ impl ConfigCommand {
Ok(())
}
async fn set_llm(&self, provider: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_llm(&self, provider: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let provider = if let Some(p) = provider {
p.to_string()
@@ -602,48 +620,48 @@ impl ConfigCommand {
Ok(())
}
async fn set_openai_key(&self, key: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_openai_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_openai_api_key(key.to_string());
manager.save()?;
println!("{} OpenAI API key set", "".green());
Ok(())
}
async fn set_anthropic_key(&self, key: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_anthropic_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_anthropic_api_key(key.to_string());
manager.save()?;
println!("{} Anthropic API key set", "".green());
Ok(())
}
async fn set_kimi_key(&self, key: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_kimi_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_kimi_api_key(key.to_string());
manager.save()?;
println!("{} Kimi API key set", "".green());
Ok(())
}
async fn set_deepseek_key(&self, key: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_deepseek_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_deepseek_api_key(key.to_string());
manager.save()?;
println!("{} DeepSeek API key set", "".green());
Ok(())
}
async fn set_openrouter_key(&self, key: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_openrouter_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_openrouter_api_key(key.to_string());
manager.save()?;
println!("{} OpenRouter API key set", "".green());
Ok(())
}
async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url {
manager.set_kimi_base_url(url.to_string());
@@ -657,8 +675,8 @@ impl ConfigCommand {
Ok(())
}
async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url {
manager.set_deepseek_base_url(url.to_string());
@@ -672,8 +690,8 @@ impl ConfigCommand {
Ok(())
}
async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url {
manager.set_openrouter_base_url(url.to_string());
@@ -687,8 +705,8 @@ impl ConfigCommand {
Ok(())
}
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if let Some(u) = url {
manager.config_mut().llm.ollama.url = u.to_string();
@@ -702,8 +720,8 @@ impl ConfigCommand {
Ok(())
}
async fn set_commit_format(&self, format: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_commit_format(&self, format: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let format = match format {
"conventional" => CommitFormat::Conventional,
@@ -717,24 +735,24 @@ impl ConfigCommand {
Ok(())
}
async fn set_version_prefix(&self, prefix: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_version_prefix(&self, prefix: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_version_prefix(prefix.to_string());
manager.save()?;
println!("{} Set version prefix to '{}'", "".green(), prefix);
Ok(())
}
async fn set_changelog_path(&self, path: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_changelog_path(&self, path: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_changelog_path(path.to_string());
manager.save()?;
println!("{} Set changelog path to {}", "".green(), path);
Ok(())
}
async fn set_language(&self, language: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_language(&self, language: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let language_code = if let Some(lang) = language {
lang.to_string()
@@ -763,8 +781,8 @@ impl ConfigCommand {
Ok(())
}
async fn set_keep_types_english(&self, keep: bool) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_keep_types_english(&self, keep: bool, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_keep_types_english(keep);
manager.save()?;
let status = if keep { "enabled" } else { "disabled" };
@@ -772,8 +790,8 @@ impl ConfigCommand {
Ok(())
}
async fn set_keep_changelog_types_english(&self, keep: bool) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_keep_changelog_types_english(&self, keep: bool, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_keep_changelog_types_english(keep);
manager.save()?;
let status = if keep { "enabled" } else { "disabled" };
@@ -781,7 +799,7 @@ impl ConfigCommand {
Ok(())
}
async fn reset(&self, force: bool) -> Result<()> {
async fn reset(&self, force: bool, config_path: &Option<PathBuf>) -> Result<()> {
if !force {
let confirm = Confirm::new()
.with_prompt("Are you sure you want to reset all configuration?")
@@ -794,7 +812,7 @@ impl ConfigCommand {
}
}
let mut manager = ConfigManager::new()?;
let mut manager = self.get_manager(config_path)?;
manager.reset();
manager.save()?;
@@ -802,8 +820,8 @@ impl ConfigCommand {
Ok(())
}
async fn export_config(&self, output: Option<&str>) -> Result<()> {
let manager = ConfigManager::new()?;
async fn export_config(&self, output: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let toml = manager.export()?;
if let Some(path) = output {
@@ -816,10 +834,10 @@ impl ConfigCommand {
Ok(())
}
async fn import_config(&self, file: &str) -> Result<()> {
async fn import_config(&self, file: &str, config_path: &Option<PathBuf>) -> Result<()> {
let toml = std::fs::read_to_string(file)?;
let mut manager = ConfigManager::new()?;
let mut manager = self.get_manager(config_path)?;
manager.import(&toml)?;
manager.save()?;
@@ -827,17 +845,17 @@ impl ConfigCommand {
Ok(())
}
async fn list_models(&self) -> Result<()> {
let manager = ConfigManager::new()?;
async fn list_models(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let config = manager.config();
match config.llm.provider.as_str() {
"ollama" => {
let client = crate::llm::OllamaClient::new(
&config.llm.ollama.url,
&config.llm.ollama.model,
);
println!("Fetching available models from Ollama...");
match client.list_models().await {
Ok(models) => {
@@ -859,7 +877,7 @@ impl ConfigCommand {
key,
&config.llm.openai.model,
)?;
println!("Fetching available models from OpenAI...");
match client.list_models().await {
Ok(models) => {
@@ -877,16 +895,115 @@ impl ConfigCommand {
bail!("OpenAI API key not configured");
}
}
"anthropic" => {
if let Some(ref key) = config.llm.anthropic.api_key {
let client = crate::llm::AnthropicClient::new(
key,
&config.llm.anthropic.model,
)?;
println!("Fetching available models from Anthropic...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.anthropic.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("Anthropic API key not configured");
}
}
"kimi" => {
if let Some(ref key) = config.llm.kimi.api_key {
let client = crate::llm::KimiClient::with_base_url(
key,
&config.llm.kimi.model,
&config.llm.kimi.base_url,
)?;
println!("Fetching available models from Kimi...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.kimi.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("Kimi API key not configured");
}
}
"deepseek" => {
if let Some(ref key) = config.llm.deepseek.api_key {
let client = crate::llm::DeepSeekClient::with_base_url(
key,
&config.llm.deepseek.model,
&config.llm.deepseek.base_url,
)?;
println!("Fetching available models from DeepSeek...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.deepseek.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("DeepSeek API key not configured");
}
}
"openrouter" => {
if let Some(ref key) = config.llm.openrouter.api_key {
let client = crate::llm::OpenRouterClient::with_base_url(
key,
&config.llm.openrouter.model,
&config.llm.openrouter.base_url,
)?;
println!("Fetching available models from OpenRouter...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.openrouter.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("OpenRouter API key not configured");
}
}
provider => {
println!("Listing models not supported for provider: {}", provider);
}
}
Ok(())
}
async fn test_llm(&self) -> Result<()> {
let manager = ConfigManager::new()?;
async fn test_llm(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let config = manager.config();
println!("Testing LLM connection ({})...", config.llm.provider.cyan());

View File

@@ -2,6 +2,7 @@ use anyhow::Result;
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{GitProfile, Language};
use crate::config::manager::ConfigManager;
@@ -22,12 +23,13 @@ pub struct InitCommand {
}
impl InitCommand {
pub async fn execute(&self) -> Result<()> {
// Start with English messages for initialization
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
let messages = Messages::new(Language::English);
println!("{}", messages.initializing().bold().cyan());
let config_path = crate::config::AppConfig::default_path()?;
let config_path = config_path.unwrap_or_else(|| {
crate::config::AppConfig::default_path().unwrap()
});
// Check if config already exists
if config_path.exists() && !self.reset {
@@ -41,20 +43,24 @@ impl InitCommand {
println!("{}", "Initialization cancelled.".yellow());
return Ok(());
}
} else {
println!("{}", "Configuration already exists. Use --reset to overwrite.".yellow());
return Ok(());
}
}
let mut manager = if self.reset {
ConfigManager::new()?
} else {
ConfigManager::new().or_else(|_| Ok::<_, anyhow::Error>(ConfigManager::default()))?
};
// Create parent directory if needed
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
}
// Create new config manager with fresh config
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
if self.yes {
// Quick setup with defaults
self.quick_setup(&mut manager).await?;
} else {
// Interactive setup
self.interactive_setup(&mut manager).await?;
}

View File

@@ -2,6 +2,7 @@ use anyhow::{bail, Result};
use clap::{Parser, Subcommand};
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::config::{GitProfile, TokenConfig, TokenType};
@@ -123,27 +124,34 @@ enum TokenSubcommand {
}
impl ProfileCommand {
pub async fn execute(&self) -> Result<()> {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
Some(ProfileSubcommand::Add) => self.add_profile().await,
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name).await,
Some(ProfileSubcommand::List) => self.list_profiles().await,
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref()).await,
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name).await,
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name).await,
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name).await,
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global).await,
Some(ProfileSubcommand::Switch) => self.switch_profile().await,
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).await,
Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command).await,
Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref()).await,
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref()).await,
None => self.list_profiles().await,
Some(ProfileSubcommand::Add) => self.add_profile(&config_path).await,
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name, &config_path).await,
Some(ProfileSubcommand::List) => self.list_profiles(&config_path).await,
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref(), &config_path).await,
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name, &config_path).await,
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name, &config_path).await,
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name, &config_path).await,
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global, &config_path).await,
Some(ProfileSubcommand::Switch) => self.switch_profile(&config_path).await,
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to, &config_path).await,
Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command, &config_path).await,
Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref(), &config_path).await,
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref(), &config_path).await,
None => self.list_profiles(&config_path).await,
}
}
async fn add_profile(&self) -> Result<()> {
let mut manager = ConfigManager::new()?;
fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
async fn add_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
println!("{}", "\nAdd new profile".bold());
println!("{}", "".repeat(40));
@@ -244,8 +252,8 @@ impl ProfileCommand {
Ok(())
}
async fn remove_profile(&self, name: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn remove_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(name) {
bail!("Profile '{}' not found", name);
@@ -269,8 +277,8 @@ impl ProfileCommand {
Ok(())
}
async fn list_profiles(&self) -> Result<()> {
let manager = ConfigManager::new()?;
async fn list_profiles(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let profiles = manager.list_profiles();
@@ -319,8 +327,8 @@ impl ProfileCommand {
Ok(())
}
async fn show_profile(&self, name: Option<&str>) -> Result<()> {
let manager = ConfigManager::new()?;
async fn show_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let profile = if let Some(n) = name {
manager.get_profile(n)
@@ -380,8 +388,8 @@ impl ProfileCommand {
Ok(())
}
async fn edit_profile(&self, name: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn edit_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let profile = manager.get_profile(name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?
@@ -420,8 +428,8 @@ impl ProfileCommand {
Ok(())
}
async fn set_default(&self, name: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_default(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
manager.set_default_profile(Some(name.to_string()))?;
manager.save()?;
@@ -431,22 +439,30 @@ impl ProfileCommand {
Ok(())
}
async fn set_repo(&self, name: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn set_repo(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let repo = find_repo(std::env::current_dir()?.as_path())?;
let repo_path = repo.path().to_string_lossy().to_string();
manager.set_repo_profile(repo_path, name.to_string())?;
manager.set_repo_profile(repo_path.clone(), name.to_string())?;
// Get the profile and apply it to the repository
let profile = manager.get_profile(name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?;
profile.apply_to_repo(repo.inner())?;
manager.record_profile_usage(name, Some(repo_path))?;
manager.save()?;
println!("{} Set '{}' for current repository", "".green(), name.cyan());
println!("{} Applied profile '{}' to current repository", "".green(), name.cyan());
Ok(())
}
async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn apply_profile(&self, name: Option<&str>, global: bool, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let profile_name = if let Some(n) = name {
n.to_string()
@@ -482,8 +498,8 @@ impl ProfileCommand {
Ok(())
}
async fn switch_profile(&self) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn switch_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let profiles: Vec<String> = manager.list_profiles()
.into_iter()
@@ -519,15 +535,15 @@ impl ProfileCommand {
.interact()?;
if apply {
self.apply_profile(Some(selected), false).await?;
self.apply_profile(Some(selected), false, config_path).await?;
}
}
Ok(())
}
async fn copy_profile(&self, from: &str, to: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn copy_profile(&self, from: &str, to: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
let source = manager.get_profile(from)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))?
@@ -547,16 +563,16 @@ impl ProfileCommand {
Ok(())
}
async fn handle_token_command(&self, cmd: &TokenSubcommand) -> Result<()> {
async fn handle_token_command(&self, cmd: &TokenSubcommand, config_path: &Option<PathBuf>) -> Result<()> {
match cmd {
TokenSubcommand::Add { profile, service } => self.add_token(profile, service).await,
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service).await,
TokenSubcommand::List { profile } => self.list_tokens(profile).await,
TokenSubcommand::Add { profile, service } => self.add_token(profile, service, config_path).await,
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service, config_path).await,
TokenSubcommand::List { profile } => self.list_tokens(profile, config_path).await,
}
}
async fn add_token(&self, profile_name: &str, service: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn add_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(profile_name) {
bail!("Profile '{}' not found", profile_name);
@@ -602,8 +618,8 @@ impl ProfileCommand {
Ok(())
}
async fn remove_token(&self, profile_name: &str, service: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
async fn remove_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = self.get_manager(config_path)?;
if !manager.has_profile(profile_name) {
bail!("Profile '{}' not found", profile_name);
@@ -627,8 +643,8 @@ impl ProfileCommand {
Ok(())
}
async fn list_tokens(&self, profile_name: &str) -> Result<()> {
let manager = ConfigManager::new()?;
async fn list_tokens(&self, profile_name: &str, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let profile = manager.get_profile(profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
@@ -654,8 +670,8 @@ impl ProfileCommand {
Ok(())
}
async fn check_profile(&self, name: Option<&str>) -> Result<()> {
let manager = ConfigManager::new()?;
async fn check_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
let profile_name = if let Some(n) = name {
n.to_string()
@@ -687,8 +703,8 @@ impl ProfileCommand {
Ok(())
}
async fn show_stats(&self, name: Option<&str>) -> Result<()> {
let manager = ConfigManager::new()?;
async fn show_stats(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?;
if let Some(n) = name {
let profile = manager.get_profile(n)

View File

@@ -3,6 +3,7 @@ use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use semver::Version;
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::git::{find_repo, GitRepo};
@@ -61,9 +62,13 @@ pub struct TagCommand {
}
impl TagCommand {
pub async fn execute(&self) -> Result<()> {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
let repo = find_repo(std::env::current_dir()?.as_path())?;
let manager = ConfigManager::new()?;
let manager = if let Some(ref path) = config_path {
ConfigManager::with_path(path)?
} else {
ConfigManager::new()?
};
let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);

View File

@@ -19,7 +19,11 @@ impl ConfigManager {
/// Create config manager with specific path
pub fn with_path(path: &Path) -> Result<Self> {
let config = AppConfig::load(path)?;
let config = if path.exists() {
AppConfig::load(path)?
} else {
AppConfig::default()
};
Ok(Self {
config,
config_path: path.to_path_buf(),
@@ -27,6 +31,15 @@ impl ConfigManager {
})
}
/// Create config manager with fresh config (ignoring existing)
pub fn with_path_fresh(path: &Path) -> Result<Self> {
Ok(Self {
config: AppConfig::default(),
config_path: path.to_path_buf(),
modified: true,
})
}
/// Get configuration reference
pub fn config(&self) -> &AppConfig {
&self.config

View File

@@ -177,8 +177,17 @@ impl GitProfile {
if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path {
config.set_str("core.sshCommand",
&format!("ssh -i {}", key_path.display()))?;
let path_str = key_path.display().to_string();
#[cfg(target_os = "windows")]
{
config.set_str("core.sshCommand",
&format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?;
}
#[cfg(not(target_os = "windows"))]
{
config.set_str("core.sshCommand",
&format!("ssh -i '{}'", path_str))?;
}
}
}
@@ -194,6 +203,30 @@ impl GitProfile {
if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", key)?;
if self.settings.auto_sign_commits {
config.set_bool("commit.gpgsign", true)?;
}
if self.settings.auto_sign_tags {
config.set_bool("tag.gpgsign", true)?;
}
}
if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path {
let path_str = key_path.display().to_string();
#[cfg(target_os = "windows")]
{
config.set_str("core.sshCommand",
&format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?;
}
#[cfg(not(target_os = "windows"))]
{
config.set_str("core.sshCommand",
&format!("ssh -i '{}'", path_str))?;
}
}
}
Ok(())
@@ -336,7 +369,15 @@ impl SshConfig {
if let Some(ref cmd) = self.ssh_command {
Some(cmd.clone())
} else if let Some(ref key_path) = self.private_key_path {
Some(format!("ssh -i '{}'", key_path.display()))
let path_str = key_path.display().to_string();
#[cfg(target_os = "windows")]
{
Some(format!("ssh -i \"{}\"", path_str.replace('\\', "/")))
}
#[cfg(not(target_os = "windows"))]
{
Some(format!("ssh -i '{}'", path_str))
}
} else {
None
}
@@ -496,7 +537,11 @@ pub struct ConfigDifference {
}
fn default_gpg_program() -> String {
"gpg".to_string()
if cfg!(target_os = "windows") {
"gpg.exe".to_string()
} else {
"gpg".to_string()
}
}
fn default_true() -> bool {

View File

@@ -2,7 +2,6 @@ use crate::config::{CommitFormat, LlmConfig, Language};
use crate::git::{CommitInfo, GitRepo};
use crate::llm::{GeneratedCommit, LlmClient};
use anyhow::{Context, Result};
use chrono::Utc;
/// Content generator using LLM
pub struct ContentGenerator {
@@ -114,8 +113,7 @@ impl ContentGenerator {
format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> {
use dialoguer::{Confirm, Select};
use console::Term;
use dialoguer::Select;
let diff = repo.get_staged_diff()?;
@@ -145,7 +143,6 @@ impl ContentGenerator {
"✓ Accept and commit",
"🔄 Regenerate",
"✏️ Edit",
"📋 Copy to clipboard",
"❌ Cancel",
];
@@ -165,24 +162,13 @@ impl ContentGenerator {
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
generated = self.parse_edited_commit(&edited, format)?;
}
3 => {
#[cfg(feature = "clipboard")]
{
arboard::Clipboard::new()?.set_text(generated.to_conventional())?;
println!("Copied to clipboard!");
}
#[cfg(not(feature = "clipboard"))]
{
println!("Clipboard feature not enabled");
}
}
4 => anyhow::bail!("Cancelled by user"),
3 => anyhow::bail!("Cancelled by user"),
_ => {}
}
}
}
fn parse_edited_commit(&self, edited: &str, format: CommitFormat) -> Result<GeneratedCommit> {
fn parse_edited_commit(&self, edited: &str, _format: CommitFormat) -> Result<GeneratedCommit> {
let parsed = crate::git::commit::parse_commit_message(edited);
Ok(GeneratedCommit {

View File

@@ -1,6 +1,6 @@
use super::{CommitInfo, GitRepo};
use anyhow::{Context, Result};
use chrono::{DateTime, TimeZone, Utc};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
@@ -232,8 +232,6 @@ impl ChangelogGenerator {
let mut breaking = vec![];
for commit in commits {
let msg = commit.subject();
if commit.message.contains("BREAKING CHANGE") {
breaking.push(commit);
}

View File

@@ -1,5 +1,5 @@
use super::GitRepo;
use anyhow::{bail, Context, Result};
use anyhow::{bail, Result};
use chrono::Local;
/// Commit builder for creating commits
@@ -47,6 +47,12 @@ impl CommitBuilder {
self
}
/// Set scope (optional)
pub fn scope_opt(mut self, scope: Option<String>) -> Self {
self.scope = scope;
self
}
/// Set description
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
@@ -59,6 +65,12 @@ impl CommitBuilder {
self
}
/// Set body (optional)
pub fn body_opt(mut self, body: Option<String>) -> Self {
self.body = body;
self
}
/// Set footer
pub fn footer(mut self, footer: impl Into<String>) -> Self {
self.footer = Some(footer.into());
@@ -174,8 +186,23 @@ impl CommitBuilder {
.output()?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to amend commit: {}", stderr);
let error_msg = if stderr.is_empty() {
if stdout.is_empty() {
"GPG signing failed. Please check:\n\
1. GPG signing key is configured (git config --get user.signingkey)\n\
2. GPG agent is running\n\
3. You can sign commits manually (try: git commit --amend -S)".to_string()
} else {
stdout.to_string()
}
} else {
stderr.to_string()
};
bail!("Failed to amend commit: {}", error_msg);
}
Ok(())

View File

@@ -1,17 +1,173 @@
use anyhow::{bail, Context, Result};
use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType};
use std::path::{Path, PathBuf};
use std::path::{Path, PathBuf, Component};
use std::collections::HashMap;
use tempfile;
pub mod changelog;
pub mod commit;
pub mod tag;
pub use changelog::ChangelogGenerator;
pub use commit::CommitBuilder;
pub use tag::TagBuilder;
#[cfg(target_os = "windows")]
use std::os::windows::ffi::OsStringExt;
fn normalize_path_for_git2(path: &Path) -> PathBuf {
let mut normalized = path.to_path_buf();
#[cfg(target_os = "windows")]
{
let path_str = path.to_string_lossy();
if path_str.starts_with(r"\\?\") {
if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
normalized = PathBuf::from(stripped);
}
}
if path_str.starts_with(r"\\?\UNC\") {
if let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
normalized = PathBuf::from(format!(r"\\{}", stripped));
}
}
}
normalized
}
fn get_absolute_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = path.as_ref();
if path.is_absolute() {
return Ok(normalize_path_for_git2(path));
}
let current_dir = std::env::current_dir()
.with_context(|| "Failed to get current directory")?;
let absolute = current_dir.join(path);
Ok(normalize_path_for_git2(&absolute))
}
fn resolve_path_without_canonicalize(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if !components.is_empty() && components.last() != Some(&Component::ParentDir) {
components.pop();
} else {
components.push(component);
}
}
Component::CurDir => {}
_ => components.push(component),
}
}
let mut result = PathBuf::new();
for component in components {
result.push(component.as_os_str());
}
normalize_path_for_git2(&result)
}
fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
let normalized = normalize_path_for_git2(path);
let discover_opts = git2::RepositoryOpenFlags::empty();
let ceiling_dirs: [&str; 0] = [];
let repo = Repository::open_ext(&normalized, discover_opts, &ceiling_dirs)
.or_else(|_| Repository::discover(&normalized))
.or_else(|_| Repository::open(&normalized));
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e))
}
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
let output = std::process::Command::new("git")
.args(&["rev-parse", "--show-toplevel"])
.current_dir(path)
.output()
.context("Failed to execute git command")?;
if !output.status.success() {
bail!("git CLI failed to find repository");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let git_root = stdout.trim();
if git_root.is_empty() {
bail!("git CLI returned empty path");
}
let git_root_path = PathBuf::from(git_root);
let normalized = normalize_path_for_git2(&git_root_path);
Repository::open(&normalized)
.with_context(|| format!("Failed to open repo from git CLI path: {:?}", normalized))
}
fn diagnose_repo_issue(path: &Path) -> String {
let mut issues = Vec::new();
if !path.exists() {
issues.push(format!("Path does not exist: {:?}", path));
} else if !path.is_dir() {
issues.push(format!("Path is not a directory: {:?}", path));
}
let git_dir = path.join(".git");
if git_dir.exists() {
if git_dir.is_dir() {
issues.push("Found .git directory".to_string());
let config_file = git_dir.join("config");
if config_file.exists() {
issues.push("Git config file exists".to_string());
} else {
issues.push("WARNING: Git config file missing".to_string());
}
} else {
issues.push("Found .git file (submodule or worktree)".to_string());
}
} else {
issues.push("No .git found in current directory".to_string());
let mut current = path;
let mut depth = 0;
while let Some(parent) = current.parent() {
depth += 1;
if depth > 20 {
break;
}
let parent_git = parent.join(".git");
if parent_git.exists() {
issues.push(format!("Found .git in parent directory: {:?}", parent));
break;
}
current = parent;
}
}
#[cfg(target_os = "windows")]
{
let path_str = path.to_string_lossy();
if path_str.starts_with(r"\\?\") {
issues.push("Path has Windows extended-length prefix (\\\\?\\)".to_string());
}
if path_str.contains('\\') && path_str.contains('/') {
issues.push("WARNING: Path has mixed path separators".to_string());
}
}
if let Ok(current_dir) = std::env::current_dir() {
issues.push(format!("Current working directory: {:?}", current_dir));
}
issues.join("\n ")
}
/// Git repository wrapper with enhanced cross-platform support
pub struct GitRepo {
repo: Repository,
path: PathBuf,
@@ -19,28 +175,45 @@ pub struct GitRepo {
}
impl GitRepo {
/// Open a git repository
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let absolute_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let repo = Repository::discover(&absolute_path)
.or_else(|_| Repository::open(&absolute_path))
.with_context(|| {
format!(
"Failed to open git repository at '{:?}'. Please ensure:\n\
1. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\
2. The path is correct and contains a valid '.git' folder.",
absolute_path,
absolute_path.display()
)
let absolute_path = get_absolute_path(path)?;
let resolved_path = resolve_path_without_canonicalize(&absolute_path);
let repo = try_open_repo_with_git2(&resolved_path)
.or_else(|git2_err| {
try_open_repo_with_git_cli(&resolved_path)
.map_err(|cli_err| {
let diagnosis = diagnose_repo_issue(&resolved_path);
anyhow::anyhow!(
"Failed to open git repository:\n\
\n\
=== git2 Error ===\n {}\n\
\n\
=== git CLI Error ===\n {}\n\
\n\
=== Diagnosis ===\n {}\n\
\n\
=== Suggestions ===\n\
1. Ensure you are inside a git repository\n\
2. Run: git status (to verify git works)\n\
3. Run: git config --global --add safe.directory \"*\"\n\
4. Check file permissions",
git2_err, cli_err, diagnosis
)
})
})?;
let repo_path = repo.workdir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| resolved_path.clone());
let config = repo.config().ok();
Ok(Self {
repo,
path: absolute_path,
path: normalize_path_for_git2(&repo_path),
config,
})
}
@@ -341,19 +514,29 @@ impl GitRepo {
let temp_file = tempfile::NamedTempFile::new()?;
std::fs::write(temp_file.path(), message)?;
let mut cmd = std::process::Command::new("git");
cmd.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
.current_dir(&self.path)
.output()?;
let output = std::process::Command::new("git")
.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
.current_dir(&self.path)
.output()?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create signed commit: {}", stderr);
let error_msg = if stderr.is_empty() {
if stdout.is_empty() {
"GPG signing failed. Please check:\n\
1. GPG signing key is configured (git config --get user.signingkey)\n\
2. GPG agent is running\n\
3. You can sign commits manually (try: git commit -S -m 'test')".to_string()
} else {
stdout.to_string()
}
} else {
stderr.to_string()
};
bail!("Failed to create signed commit: {}", error_msg);
}
let head = self.repo.head()?;
@@ -685,23 +868,75 @@ impl StatusSummary {
}
}
/// Find git repository starting from path and walking up
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
let start_path = start_path.as_ref();
if let Ok(repo) = GitRepo::open(start_path) {
let absolute_start = get_absolute_path(start_path)?;
let resolved_start = resolve_path_without_canonicalize(&absolute_start);
if let Ok(repo) = GitRepo::open(&resolved_start) {
return Ok(repo);
}
let mut current = start_path;
let mut current = resolved_start.as_path();
let mut attempted_paths = vec![current.to_string_lossy().to_string()];
let max_depth = 50;
let mut depth = 0;
while let Some(parent) = current.parent() {
depth += 1;
if depth > max_depth {
break;
}
attempted_paths.push(parent.to_string_lossy().to_string());
if let Ok(repo) = GitRepo::open(parent) {
return Ok(repo);
}
current = parent;
}
bail!("No git repository found starting from {:?}", start_path)
if let Ok(output) = std::process::Command::new("git")
.args(&["rev-parse", "--show-toplevel"])
.current_dir(&resolved_start)
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let git_root = stdout.trim();
if !git_root.is_empty() {
if let Ok(repo) = GitRepo::open(git_root) {
return Ok(repo);
}
}
}
}
let diagnosis = diagnose_repo_issue(&resolved_start);
bail!(
"No git repository found.\n\
\n\
=== Starting Path ===\n {:?}\n\
\n\
=== Paths Attempted ===\n {}\n\
\n\
=== Current Directory ===\n {:?}\n\
\n\
=== Diagnosis ===\n {}\n\
\n\
=== Suggestions ===\n\
1. Ensure you are inside a git repository (run: git status)\n\
2. Initialize a new repo: git init\n\
3. Clone an existing repo: git clone <url>\n\
4. Check if .git directory exists and is accessible",
resolved_start,
attempted_paths.join("\n "),
std::env::current_dir().unwrap_or_default(),
diagnosis
)
}
/// Check if path is inside a git repository

View File

@@ -1,5 +1,5 @@
use super::GitRepo;
use anyhow::{bail, Context, Result};
use anyhow::{bail, Result};
use semver::Version;
/// Tag builder for creating tags
@@ -281,8 +281,9 @@ pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()
if let Some(remote) = remote {
use std::process::Command;
let refspec = format!(":refs/tags/{}", name);
let output = Command::new("git")
.args(&["push", remote, ":refs/tags/{}"])
.args(&["push", remote, &refspec])
.current_dir(repo.path())
.output()?;

View File

@@ -2,6 +2,4 @@ pub mod messages;
pub mod translator;
pub use messages::Messages;
pub use translator::Translator;
pub use translator::translate_commit_type;
pub use translator::translate_changelog_category;

View File

@@ -70,10 +70,16 @@ impl AnthropicClient {
Ok(self)
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
// Anthropic doesn't have a models API endpoint, return predefined list
Ok(ANTHROPIC_MODELS.iter().map(|&m| m.to_string()).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
let url = "https://api.anthropic.com/v1/messages";
let request = MessagesRequest {
model: self.model.clone(),
max_tokens: 5,
@@ -84,7 +90,7 @@ impl AnthropicClient {
}],
system: None,
};
let response = self.client
.post(url)
.header("x-api-key", &self.api_key)
@@ -93,7 +99,7 @@ impl AnthropicClient {
.json(&request)
.send()
.await;
match response {
Ok(resp) => {
if resp.status().is_success() {

View File

@@ -82,25 +82,53 @@ impl DeepSeekClient {
Ok(self)
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to validate DeepSeek API key")?;
if response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
.context("Failed to list DeepSeek models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("DeepSeek API error: {} - {}", status, text)
bail!("DeepSeek API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse DeepSeek response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
}
}
}

View File

@@ -82,25 +82,53 @@ impl KimiClient {
Ok(self)
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to validate Kimi API key")?;
if response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
.context("Failed to list Kimi models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("Kimi API error: {} - {}", status, text)
bail!("Kimi API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse Kimi response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
}
}
}

View File

@@ -1,6 +1,5 @@
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::config::Language;

View File

@@ -82,10 +82,10 @@ impl OpenRouterClient {
Ok(self)
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
@@ -93,16 +93,44 @@ impl OpenRouterClient {
.header("X-Title", "QuiCommit")
.send()
.await
.context("Failed to validate OpenRouter API key")?;
if response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
.context("Failed to list OpenRouter models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("OpenRouter API error: {} - {}", status, text)
bail!("OpenRouter API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse OpenRouter response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
}
}
}
@@ -211,7 +239,7 @@ pub const OPENROUTER_MODELS: &[&str] = &[
];
/// Check if a model name is valid
pub fn is_valid_model(model: &str) -> bool {
pub fn is_valid_model(_model: &str) -> bool {
// Since OpenRouter supports many models, we'll allow any model name
// but provide some popular ones as suggestions
true

View File

@@ -1,5 +1,6 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing::debug;
mod commands;
@@ -74,7 +75,6 @@ enum Commands {
async fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize logging
let log_level = match cli.verbose {
0 => "warn",
1 => "info",
@@ -89,13 +89,14 @@ async fn main() -> Result<()> {
debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION"));
// Execute command
let config_path: Option<PathBuf> = cli.config.map(PathBuf::from);
match cli.command {
Commands::Init(cmd) => cmd.execute().await,
Commands::Commit(cmd) => cmd.execute().await,
Commands::Tag(cmd) => cmd.execute().await,
Commands::Changelog(cmd) => cmd.execute().await,
Commands::Profile(cmd) => cmd.execute().await,
Commands::Config(cmd) => cmd.execute().await,
Commands::Init(cmd) => cmd.execute(config_path).await,
Commands::Commit(cmd) => cmd.execute(config_path).await,
Commands::Tag(cmd) => cmd.execute(config_path).await,
Commands::Changelog(cmd) => cmd.execute(config_path).await,
Commands::Profile(cmd) => cmd.execute(config_path).await,
Commands::Config(cmd) => cmd.execute(config_path).await,
}
}

View File

@@ -41,8 +41,22 @@ pub fn get_editor() -> String {
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| {
if cfg!(target_os = "windows") {
if let Ok(code) = which::which("code") {
return "code --wait".to_string();
}
if let Ok(notepad) = which::which("notepad") {
return "notepad".to_string();
}
"notepad".to_string()
} else if cfg!(target_os = "macos") {
if which::which("code").is_ok() {
return "code --wait".to_string();
}
"vi".to_string()
} else {
if which::which("nano").is_ok() {
return "nano".to_string();
}
"vi".to_string()
}
})

View File

@@ -1,4 +1,3 @@
use chrono::{DateTime, Local, Utc};
use regex::Regex;
/// Format commit message with conventional commit format
@@ -12,7 +11,6 @@ pub fn format_conventional_commit(
) -> String {
let mut message = String::new();
// Type and scope
message.push_str(commit_type);
if let Some(s) = scope {
message.push_str(&format!("({})", s));
@@ -22,12 +20,10 @@ pub fn format_conventional_commit(
}
message.push_str(&format!(": {}", description));
// Body
if let Some(b) = body {
message.push_str(&format!("\n\n{}", b));
}
// Footer
if let Some(f) = footer {
message.push_str(&format!("\n\n{}", f));
}
@@ -46,26 +42,22 @@ pub fn format_commitlint_commit(
) -> String {
let mut message = String::new();
// Header
message.push_str(commit_type);
if let Some(s) = scope {
message.push_str(&format!("({})", s));
}
message.push_str(&format!(": {}", subject));
// References
if let Some(refs) = references {
for reference in refs {
message.push_str(&format!(" #{}", reference));
}
}
// Body
if let Some(b) = body {
message.push_str(&format!("\n\n{}", b));
}
// Footer
if let Some(f) = footer {
message.push_str(&format!("\n\n{}", f));
}
@@ -73,38 +65,11 @@ pub fn format_commitlint_commit(
message
}
/// Format date for commit message
pub fn format_commit_date(date: &DateTime<Local>) -> String {
date.format("%Y-%m-%d %H:%M:%S").to_string()
}
/// Format date for changelog
pub fn format_changelog_date(date: &DateTime<Utc>) -> String {
date.format("%Y-%m-%d").to_string()
}
/// Format tag name with version
pub fn format_tag_name(version: &str, prefix: Option<&str>) -> String {
match prefix {
Some(p) => format!("{}{}", p, version),
None => version.to_string(),
}
}
/// Wrap text at specified width
pub fn wrap_text(text: &str, width: usize) -> String {
textwrap::fill(text, width)
}
/// Truncate text with ellipsis
pub fn truncate(text: &str, max_len: usize) -> String {
if text.len() <= max_len {
text.to_string()
} else {
format!("{}...", &text[..max_len.saturating_sub(3)])
}
}
/// Clean commit message (remove comments, extra whitespace)
pub fn clean_message(message: &str) -> String {
let comment_regex = Regex::new(r"^#.*$").unwrap();
@@ -118,44 +83,6 @@ pub fn clean_message(message: &str) -> String {
.to_string()
}
/// Format list as markdown bullet points
pub fn format_markdown_list(items: &[String]) -> String {
items
.iter()
.map(|item| format!("- {}", item))
.collect::<Vec<_>>()
.join("\n")
}
/// Format changelog section
pub fn format_changelog_section(
version: &str,
date: &str,
changes: &[(String, Vec<String>)],
) -> String {
let mut section = format!("## [{}] - {}\n\n", version, date);
for (category, items) in changes {
if !items.is_empty() {
section.push_str(&format!("### {}\n\n", category));
for item in items {
section.push_str(&format!("- {}\n", item));
}
section.push('\n');
}
}
section
}
/// Format git config key
pub fn format_git_config_key(section: &str, subsection: Option<&str>, key: &str) -> String {
match subsection {
Some(sub) => format!("{}.{}.{}", section, sub, key),
None => format!("{}.{}", section, key),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -189,10 +116,4 @@ mod tests {
assert!(msg.starts_with("feat!: change API response format"));
}
#[test]
fn test_truncate() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 8), "hello...");
}
}

View File

@@ -58,11 +58,6 @@ lazy_static! {
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
).unwrap();
/// Regex for SSH key validation (basic)
static ref SSH_KEY_REGEX: Regex = Regex::new(
r"^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/]+={0,2}\s+.*$"
).unwrap();
/// Regex for GPG key ID validation
static ref GPG_KEY_ID_REGEX: Regex = Regex::new(
r"^[A-F0-9]{16,40}$"
@@ -81,7 +76,6 @@ pub fn validate_conventional_commit(message: &str) -> Result<()> {
);
}
// Check description length (max 100 chars for first line)
if first_line.len() > 100 {
bail!("Commit subject too long (max 100 characters)");
}
@@ -93,7 +87,6 @@ pub fn validate_conventional_commit(message: &str) -> Result<()> {
pub fn validate_commitlint_commit(message: &str) -> Result<()> {
let first_line = message.lines().next().unwrap_or("");
// Commitlint is more lenient but still requires type prefix
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
bail!("Invalid commit format. Expected: <type>[optional scope]: <subject>");
@@ -102,7 +95,6 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
let type_part = parts[0];
let subject = parts[1].trim();
// Extract type (handle scope and breaking indicator)
let commit_type = type_part
.split('(')
.next()
@@ -117,7 +109,6 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
);
}
// Validate subject
if subject.is_empty() {
bail!("Commit subject cannot be empty");
}
@@ -130,12 +121,10 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
bail!("Commit subject too long (max 100 characters)");
}
// Subject should not start with uppercase
if subject.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
bail!("Commit subject should not start with uppercase letter");
}
// Subject should not end with period
if subject.ends_with('.') {
bail!("Commit subject should not end with a period");
}
@@ -179,15 +168,6 @@ pub fn validate_email(email: &str) -> Result<()> {
Ok(())
}
/// Validate SSH key format
pub fn validate_ssh_key(key: &str) -> Result<()> {
if !SSH_KEY_REGEX.is_match(key.trim()) {
bail!("Invalid SSH public key format");
}
Ok(())
}
/// Validate GPG key ID
pub fn validate_gpg_key_id(key_id: &str) -> Result<()> {
if !GPG_KEY_ID_REGEX.is_match(key_id) {

View File

@@ -1,64 +1,642 @@
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_cli_help() {
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.arg("--help");
cmd.assert()
.success()
.stdout(predicate::str::contains("QuiCommit"));
fn create_git_repo(dir: &PathBuf) -> std::process::Output {
std::process::Command::new("git")
.args(&["init"])
.current_dir(dir)
.output()
.expect("Failed to init git repo")
}
#[test]
fn test_version() {
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.arg("--version");
cmd.assert()
.success()
.stdout(predicate::str::contains("0.1.0"));
fn configure_git_user(dir: &PathBuf) {
std::process::Command::new("git")
.args(&["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.expect("Failed to configure git user name");
std::process::Command::new("git")
.args(&["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.expect("Failed to configure git user email");
}
#[test]
fn test_config_show() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join("config");
fs::create_dir(&config_dir).unwrap();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.env("QUICOMMIT_CONFIG", config_dir.join("config.toml"))
.arg("config")
.arg("show");
cmd.assert().success();
fn create_test_file(dir: &PathBuf, name: &str, content: &str) {
let file_path = dir.join(name);
fs::write(&file_path, content).expect("Failed to create test file");
}
#[test]
fn test_profile_list_empty() {
let temp_dir = TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml"))
.arg("profile")
.arg("list");
cmd.assert()
.success()
.stdout(predicate::str::contains("No profiles configured"));
fn stage_file(dir: &PathBuf, name: &str) {
std::process::Command::new("git")
.args(&["add", name])
.current_dir(dir)
.output()
.expect("Failed to stage file");
}
#[test]
fn test_init_quick() {
let temp_dir = TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml"))
.arg("init")
.arg("--yes");
cmd.assert()
.success()
.stdout(predicate::str::contains("initialized successfully"));
fn create_commit(dir: &PathBuf, message: &str) {
std::process::Command::new("git")
.args(&["commit", "-m", message])
.current_dir(dir)
.output()
.expect("Failed to create commit");
}
mod cli_basic {
use super::*;
#[test]
fn test_help() {
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.arg("--help");
cmd.assert()
.success()
.stdout(predicate::str::contains("QuiCommit"))
.stdout(predicate::str::contains("AI-powered Git assistant"));
}
#[test]
fn test_version() {
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.arg("--version");
cmd.assert()
.success()
.stdout(predicate::str::contains("quicommit"));
}
#[test]
fn test_no_args_shows_help() {
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.assert()
.failure()
.stderr(predicate::str::contains("Usage:"));
}
#[test]
fn test_verbose_flag() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let config_path = repo_path.join("config.toml");
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["-vv", "init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
}
}
mod init_command {
use super::*;
#[test]
fn test_init_quick() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("initialized successfully"));
}
#[test]
fn test_init_creates_config_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
assert!(config_path.exists(), "Config file should be created");
}
#[test]
fn test_init_in_git_repo() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let config_path = repo_path.join("test_config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
}
#[test]
fn test_init_reset() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--reset", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("initialized successfully"));
}
}
mod profile_command {
use super::*;
#[test]
fn test_profile_list_empty() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("No profiles"));
}
#[test]
fn test_profile_list_with_profile() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("default"));
}
}
mod config_command {
use super::*;
#[test]
fn test_config_show() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "show", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Configuration"));
}
#[test]
fn test_config_path() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("config.toml"));
}
}
mod commit_command {
use super::*;
#[test]
fn test_commit_no_repo() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["commit", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(temp_dir.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
}
#[test]
fn test_commit_no_changes() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let config_path = repo_path.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["commit", "--manual", "-m", "test: empty", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
#[test]
fn test_commit_with_staged_changes() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "test.txt", "Hello, World!");
stage_file(&repo_path, "test.txt");
let config_path = repo_path.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["commit", "--manual", "-m", "test: add test file", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
#[test]
fn test_commit_date_mode() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "daily.txt", "Daily update");
stage_file(&repo_path, "daily.txt");
let config_path = repo_path.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["commit", "--date", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
}
mod tag_command {
use super::*;
#[test]
fn test_tag_no_repo() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["tag", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(temp_dir.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
}
#[test]
fn test_tag_list_empty() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
let config_path = repo_path.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["tag", "--name", "v0.1.0", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("v0.1.0"));
}
}
mod changelog_command {
use super::*;
#[test]
fn test_changelog_init() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let config_path = repo_path.join("config.toml");
let changelog_path = repo_path.join("CHANGELOG.md");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["changelog", "--init", "--output", changelog_path.to_str().unwrap(), "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
assert!(changelog_path.exists(), "Changelog file should be created");
}
#[test]
fn test_changelog_dry_run() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: add feature");
let config_path = repo_path.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["changelog", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert()
.success();
}
}
mod cross_platform {
use super::*;
#[test]
fn test_path_handling_windows_style() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("subdir").join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
assert!(config_path.exists());
}
#[test]
fn test_config_with_spaces_in_path() {
let temp_dir = TempDir::new().unwrap();
let space_dir = temp_dir.path().join("path with spaces");
fs::create_dir_all(&space_dir).unwrap();
let config_path = space_dir.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
assert!(config_path.exists());
}
#[test]
fn test_config_with_unicode_path() {
let temp_dir = TempDir::new().unwrap();
let unicode_dir = temp_dir.path().join("路径测试");
fs::create_dir_all(&unicode_dir).unwrap();
let config_path = unicode_dir.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
assert!(config_path.exists());
}
}
mod git_operations {
use super::*;
#[test]
fn test_git_repo_detection() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let git_dir = repo_path.join(".git");
assert!(git_dir.exists(), ".git directory should exist");
}
#[test]
fn test_git_status_clean() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let output = std::process::Command::new("git")
.args(&["status", "--porcelain"])
.current_dir(&repo_path)
.output()
.expect("Failed to run git status");
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).is_empty());
}
#[test]
fn test_git_commit_creation() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
let output = std::process::Command::new("git")
.args(&["log", "--oneline"])
.current_dir(&repo_path)
.output()
.expect("Failed to run git log");
assert!(output.status.success());
let log = String::from_utf8_lossy(&output.stdout);
assert!(log.contains("initial commit"));
}
}
mod validators {
use super::*;
#[test]
fn test_commit_message_validation() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
let config_path = repo_path.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["commit", "--manual", "-m", "invalid commit message without type", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Invalid").or(predicate::str::contains("format")));
}
#[test]
fn test_valid_conventional_commit() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
let config_path = repo_path.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["commit", "--manual", "-m", "feat: add new feature", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
}
mod subcommands {
use super::*;
#[test]
fn test_commit_alias() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
let config_path = repo_path.join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["c", "--manual", "-m", "fix: test", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
#[test]
fn test_init_alias() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["i", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("initialized successfully"));
}
#[test]
fn test_profile_alias() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["p", "list", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("default"));
}
}