17 Commits

Author SHA1 Message Date
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
baaefa2909 chore(i18n): 统一多语言输出与新增三家 LLM 提供商支持 2026-02-01 14:10:29 +00:00
cf268ebe0f feat: 添加 test3.txt 测试中文输出 2026-02-01 14:07:57 +00:00
e571293f40 chore: add test2.txt with placeholder text 2026-02-01 14:04:20 +00:00
fa92d90ff4 fix(git/commit.rs): 错误修复 2026-02-01 14:01:12 +00:00
33aaa020c4 fix(git/commit.rs): commit错误信息。 2026-02-01 14:00:13 +00:00
bfc1812ebf docs: update readme with new installation methods and cli options 2026-02-01 13:50:09 +00:00
dba6d94eab feat(generator): add language parameter to commit generation methods 2026-02-01 13:03:57 +00:00
09d2b6db8c feat: add auto-push functionality to commit and tag commands 2026-02-01 12:35:26 +00:00
0cbd975748 feat: feat: add multilingual output support for commit, tag, and changelog commands 2026-02-01 12:06:12 +00:00
31 changed files with 4443 additions and 667 deletions

View File

@@ -5,35 +5,42 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [0.1.4] - 2026-02-01
### Added ### ✨ 新功能
- 新增 `test3.txt`,支持中文输出测试
- `generator` 模块新增 `language` 参数,可指定提交信息语言
- `commit``tag` 命令新增自动 push 功能
- 提交、标签及变更日志命令现支持多语言输出
- 新增 Kimi、DeepSeek、OpenRouter 三家 LLM 提供商支持
- 首次创建仓库,完成 0.1.0 版本基础功能
- Initial release of QuiCommit ### 🐞 错误修复
- AI-powered commit message generation using LLM APIs (OpenAI, Anthropic) or local Ollama - 修复 `git/commit.rs` 中的提交错误信息问题
- Support for Conventional Commits and @commitlint formats - 修复 Git2 错误处理逻辑(仓库打开功能暂不可用)
- Multiple Git profile management with SSH and GPG support - 统一代码风格(`rustfmt` 修正)
- Smart tag generation with semantic version bumping
- Automatic changelog generation
- Interactive CLI with beautiful prompts and previews
- Encrypted storage for sensitive data
- Cross-platform support (Linux, macOS, Windows)
### Features ### 📚 文档
- 更新 README补充新的安装方式与 CLI 选项说明
- 优化 README 内容,新增 LLM 提供商介绍
- **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 - 添加 `test2.txt` 占位文件
- **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
## [0.1.0] - 2026-01-30 ## [0.1.0] - 2026-01-30
### Added ### Added
- Initial project structure - Initial project structure
- Core functionality for git operations - Core functionality for git operations
- LLM integration - LLM integration
- Configuration management - Configuration management
- CLI interface - 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] [package]
name = "quicommit" name = "quicommit"
version = "0.1.2" version = "0.1.7"
edition = "2024" edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"] authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)" description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"

234
README.md
View File

@@ -4,6 +4,8 @@ English | [中文文档](README_zh.md)
A powerful AI-powered Git assistant for generating conventional commits, tags, and changelogs. Manage multiple Git profiles for different work contexts. A powerful AI-powered Git assistant for generating conventional commits, tags, and changelogs. Manage multiple Git profiles for different work contexts.
[Still in early development, some features may not be complete. Feedback and contributions are welcome.]
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-blue.svg) ![License](https://img.shields.io/badge/license-MIT-blue.svg)
@@ -19,9 +21,19 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
## Installation ## Installation
### Cargo Install
The cargo-installed version may temporarily lag behind the source code progress.
```bash ```bash
git clone https://github.com/yourusername/quicommit.git cargo install quicommit
cd quicommit ```
### Install from Source
```bash
git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
cd QuiCommit
cargo build --release cargo build --release
cargo install --path . cargo install --path .
``` ```
@@ -50,6 +62,12 @@ quicommit commit -a
# Skip confirmation # Skip confirmation
quicommit commit --yes quicommit commit --yes
# Use date-based commit message
quicommit commit --date
# Push after committing
quicommit commit --push
``` ```
### Create Tag ### Create Tag
@@ -63,6 +81,12 @@ quicommit tag --bump minor
# Custom tag name # Custom tag name
quicommit tag -n v1.0.0 quicommit tag -n v1.0.0
# AI-generate tag message
quicommit tag --generate
# Create tag and push to remote
quicommit tag --push
``` ```
### Generate Changelog ### Generate Changelog
@@ -73,6 +97,15 @@ quicommit changelog
# Generate for specific version # Generate for specific version
quicommit changelog -v 1.0.0 quicommit changelog -v 1.0.0
# AI-generate changelog
quicommit changelog --generate
# Initialize new changelog file
quicommit changelog --init
# Specify output file
quicommit changelog -o RELEASE_NOTES.md
``` ```
### Manage Profiles ### Manage Profiles
@@ -84,11 +117,41 @@ quicommit profile add
# List profiles # List profiles
quicommit profile list quicommit profile list
# Show profile details
quicommit profile show
# Switch profile # Switch profile
quicommit profile switch quicommit profile switch
# Set default profile
quicommit profile set-default personal
# Set profile for current repo # Set profile for current repo
quicommit profile set-repo personal quicommit profile set-repo personal
# Apply profile to current repo
quicommit profile apply
# Apply profile globally
quicommit profile apply --global
# Copy profile
quicommit profile copy personal work
# Edit profile
quicommit profile edit personal
# Remove profile
quicommit profile remove old-profile
# Check profile
quicommit profile check
# View usage statistics
quicommit profile stats
# Manage profile tokens
quicommit profile token
``` ```
### Configure LLM ### Configure LLM
@@ -109,17 +172,41 @@ quicommit config set-anthropic-key YOUR_API_KEY
# Configure Kimi (Moonshot AI) # Configure Kimi (Moonshot AI)
quicommit config set-llm kimi quicommit config set-llm kimi
quicommit config set-kimi-key YOUR_API_KEY quicommit config set-kimi-key YOUR_API_KEY
quicommit config set-kimi --base-url https://api.moonshot.cn/v1 --model moonshot-v1-8k
# Configure DeepSeek # Configure DeepSeek
quicommit config set-llm deepseek quicommit config set-llm deepseek
quicommit config set-deepseek-key YOUR_API_KEY quicommit config set-deepseek-key YOUR_API_KEY
quicommit config set-deepseek --base-url https://api.deepseek.com/v1 --model deepseek-chat
# Configure OpenRouter # Configure OpenRouter
quicommit config set-llm openrouter quicommit config set-llm openrouter
quicommit config set-openrouter-key YOUR_API_KEY quicommit config set-openrouter-key YOUR_API_KEY
quicommit config set-openrouter --base-url https://openrouter.ai/api/v1 --model openai/gpt-4
# Set commit format
quicommit config set-commit-format conventional
# Set version prefix
quicommit config set-version-prefix v
# Set changelog path
quicommit config set-changelog-path CHANGELOG.md
# Set output language
quicommit config set-language en
# Set keep commit types in English
quicommit config set-keep-types-english true
# Set keep changelog types in English
quicommit config set-keep-changelog-types-english true
# Test LLM connection # Test LLM connection
quicommit config test-llm quicommit config test-llm
# Reset configuration to defaults
quicommit config reset
``` ```
## Command Reference ## Command Reference
@@ -137,17 +224,23 @@ quicommit config test-llm
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| `-t, --commit-type` | Commit type (feat, fix, etc.) | | `--commit-type` | Commit type (feat, fix, etc.) |
| `-s, --scope` | Commit scope | | `-s, --scope` | Commit scope |
| `-m, --message` | Commit description | | `-m, --message` | Commit description |
| `--body` | Commit body | | `--body` | Commit body |
| `--breaking` | Mark as breaking change | | `-b, --breaking` | Mark as breaking change |
| `-d, --date` | Use date-based commit message |
| `--manual` | Manual input, skip AI | | `--manual` | Manual input, skip AI |
| `-a, --all` | Stage all changes | | `-a, --all` | Stage all changes |
| `-S, --sign` | GPG sign commit | | `-S, --sign` | GPG sign commit |
| `--amend` | Amend previous commit | | `--amend` | Amend previous commit |
| `--dry-run` | Show without committing | | `--dry-run` | Show without committing |
| `--conventional` | Use Conventional Commits format |
| `--commitlint` | Use commitlint format |
| `--no-verify` | Skip commit message verification |
| `-y, --yes` | Skip confirmation | | `-y, --yes` | Skip confirmation |
| `--push` | Push after committing |
| `--remote` | Specify remote repository (default: origin) |
### Tag Options ### Tag Options
@@ -158,14 +251,35 @@ quicommit config test-llm
| `-m, --message` | Tag message | | `-m, --message` | Tag message |
| `-g, --generate` | AI-generate message | | `-g, --generate` | AI-generate message |
| `-S, --sign` | GPG sign tag | | `-S, --sign` | GPG sign tag |
| `--lightweight` | Create lightweight tag | | `-l, --lightweight` | Create lightweight tag |
| `--push` | Push to remote | | `-f, --force` | Force overwrite existing tag |
| `-p, --push` | Push to remote |
| `-r, --remote` | Specify remote repository (default: origin) |
| `--dry-run` | Dry run |
| `-y, --yes` | Skip confirmation |
### Changelog Options
| Option | Description |
|--------|-------------|
| `-o, --output` | Output file path |
| `-v, --version` | Generate for specific version |
| `-f, --from` | Generate from specific tag |
| `-t, --to` | Generate to specific ref (default: HEAD) |
| `-i, --init` | Initialize new changelog file |
| `-g, --generate` | AI-generate changelog |
| `--prepend` | Prepend to existing changelog |
| `--include-hashes` | Include commit hashes |
| `--include-authors` | Include authors |
| `--format` | Format (keep-a-changelog, github-releases) |
| `--dry-run` | Dry run (output to stdout) |
| `-y, --yes` | Skip confirmation | | `-y, --yes` | Skip confirmation |
## Configuration File ## Configuration File
Location: Location:
- Linux/macOS: `~/.config/quicommit/config.toml` - Linux: `~/.config/quicommit/config.toml`
- macOS: `~/Library/Application Support/quicommit/config.toml`
- Windows: `%APPDATA%\quicommit\config.toml` - Windows: `%APPDATA%\quicommit\config.toml`
```toml ```toml
@@ -174,15 +288,27 @@ default_profile = "personal"
[profiles.personal] [profiles.personal]
name = "personal" name = "personal"
user_name = "John Doe" user_name = "Your Name"
user_email = "john@example.com" user_email = "your.email@example.com"
description = "Personal projects"
is_work = false
[profiles.work] [profiles.work]
name = "work" name = "work"
user_name = "John Doe" user_name = "Your Name"
user_email = "john@company.com" user_email = "your.name@company.com"
description = "Work projects"
is_work = true is_work = true
organization = "Acme Corp" organization = "Your Company"
[profiles.work.ssh]
private_key_path = "/home/user/.ssh/id_rsa_work"
agent_forwarding = true
[profiles.work.gpg]
key_id = "YOUR_GPG_KEY_ID"
program = "gpg"
use_agent = true
[llm] [llm]
provider = "ollama" provider = "ollama"
@@ -198,19 +324,50 @@ model = "llama2"
model = "gpt-4" model = "gpt-4"
base_url = "https://api.openai.com/v1" base_url = "https://api.openai.com/v1"
[llm.anthropic]
model = "claude-3-sonnet-20240229"
[llm.kimi]
model = "moonshot-v1-8k"
[llm.deepseek]
model = "deepseek-chat"
[llm.openrouter]
model = "openai/gpt-4"
[commit] [commit]
format = "conventional" format = "conventional"
auto_generate = true auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100 max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
[tag] [tag]
version_prefix = "v" version_prefix = "v"
auto_generate = true auto_generate = true
gpg_sign = false
include_changelog = true
[changelog] [changelog]
path = "CHANGELOG.md" path = "CHANGELOG.md"
auto_generate = true auto_generate = true
format = "keep-a-changelog"
include_hashes = false
include_authors = false
group_by_type = true group_by_type = true
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
[repo_profiles]
"/path/to/work/project" = "work"
"/path/to/personal/project" = "personal"
``` ```
## Environment Variables ## Environment Variables
@@ -227,14 +384,32 @@ group_by_type = true
# View current configuration # View current configuration
quicommit config list quicommit config list
# Show configuration details
quicommit config show
# Edit configuration file
quicommit config edit
# Set configuration value
quicommit config set llm.provider ollama
# Get configuration value
quicommit config get llm.provider
# Test LLM connection # Test LLM connection
quicommit config test-llm quicommit config test-llm
# List available models # List available models
quicommit config list-models quicommit config list-models
# Edit configuration # Export configuration
quicommit config edit quicommit config export -o config-backup.toml
# Import configuration
quicommit config import -i config-backup.toml
# Reset configuration
quicommit config reset --force
``` ```
## Contributing ## Contributing
@@ -253,8 +428,8 @@ Contributions are welcome! Please follow these steps:
```bash ```bash
# Clone repository # Clone repository
git clone https://github.com/YOUR_USERNAME/quicommit.git git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
cd quicommit cd QuiCommit
# Fetch dependencies # Fetch dependencies
cargo fetch cargo fetch
@@ -282,11 +457,36 @@ cargo fmt --check
``` ```
src/ src/
├── commands/ # CLI command implementations ├── commands/ # CLI command implementations
│ ├── commit.rs
│ ├── tag.rs
│ ├── changelog.rs
│ ├── profile.rs
│ ├── config.rs
│ └── init.rs
├── config/ # Configuration management ├── config/ # Configuration management
│ ├── manager.rs
│ └── profile.rs
├── generator/ # AI content generation ├── generator/ # AI content generation
├── git/ # Git operations ├── git/ # Git operations
│ ├── commit.rs
│ ├── tag.rs
│ └── changelog.rs
├── llm/ # LLM provider implementations ├── llm/ # LLM provider implementations
└── utils/ # Utility functions │ ├── ollama.rs
│ ├── openai.rs
│ ├── anthropic.rs
│ ├── kimi.rs
│ ├── deepseek.rs
│ └── openrouter.rs
├── i18n/ # Internationalization support
│ ├── messages.rs
│ └── translator.rs
├── utils/ # Utility functions
│ ├── validators.rs
│ ├── formatter.rs
│ ├── crypto.rs
│ └── editor.rs
└── main.rs # Program entry point
``` ```
## License ## License

View File

@@ -4,8 +4,10 @@
一款强大的AI驱动的Git助手用于生成规范化的提交信息、标签和变更日志并支持管理多个Git配置。 一款强大的AI驱动的Git助手用于生成规范化的提交信息、标签和变更日志并支持管理多个Git配置。
![Rust](https://img.shields.io/badge/rust-%23000000.svg?logo=rust&logoColor=white) 【目前还处在早期开发阶段,依然有一些功能未完善,欢迎反馈和贡献。】
![LICENSE](https://img.shields.io/badge/license-MIT-blue.svg)
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-blue.svg)
## 主要功能 ## 主要功能
@@ -19,11 +21,19 @@
## 安装 ## 安装
目前,整体工具还在开发,并不保证各项功能准确达到既定目标。但依然十分欢迎参与贡献、反馈问题和建议。 ### cargo安装
cargo安装版本可能暂时不如源码进展快速。
```bash ```bash
git clone https://github.com/yourusername/quicommit.git cargo install quicommit
cd quicommit ```
### 从源代码安装
```bash
git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
cd QuiCommit
cargo build --release cargo build --release
cargo install --path . cargo install --path .
``` ```
@@ -52,6 +62,12 @@ quicommit commit -a
# 跳过确认直接提交 # 跳过确认直接提交
quicommit commit --yes quicommit commit --yes
# 使用日期格式的提交信息
quicommit commit --date
# 提交后推送到远程
quicommit commit --push
``` ```
### 创建标签 ### 创建标签
@@ -65,6 +81,12 @@ quicommit tag --bump minor
# 自定义标签名 # 自定义标签名
quicommit tag -n v1.0.0 quicommit tag -n v1.0.0
# AI生成标签信息
quicommit tag --generate
# 创建标签并推送到远程
quicommit tag --push
``` ```
### 生成变更日志 ### 生成变更日志
@@ -75,6 +97,15 @@ quicommit changelog
# 为特定版本生成 # 为特定版本生成
quicommit changelog -v 1.0.0 quicommit changelog -v 1.0.0
# AI生成变更日志
quicommit changelog --generate
# 初始化新的变更日志文件
quicommit changelog --init
# 指定输出文件
quicommit changelog -o RELEASE_NOTES.md
``` ```
### 配置管理 ### 配置管理
@@ -86,11 +117,41 @@ quicommit profile add
# 查看配置列表 # 查看配置列表
quicommit profile list quicommit profile list
# 显示配置详情
quicommit profile show
# 切换配置 # 切换配置
quicommit profile switch quicommit profile switch
# 设置默认配置
quicommit profile set-default personal
# 设置当前仓库的配置 # 设置当前仓库的配置
quicommit profile set-repo personal quicommit profile set-repo personal
# 应用配置到当前仓库
quicommit profile apply
# 全局应用配置
quicommit profile apply --global
# 复制配置
quicommit profile copy personal work
# 编辑配置
quicommit profile edit personal
# 删除配置
quicommit profile remove old-profile
# 检查配置
quicommit profile check
# 查看使用统计
quicommit profile stats
# 管理配置的令牌
quicommit profile token
``` ```
### LLM配置 ### LLM配置
@@ -111,17 +172,41 @@ quicommit config set-anthropic-key YOUR_API_KEY
# 配置Kimi # 配置Kimi
quicommit config set-llm kimi quicommit config set-llm kimi
quicommit config set-kimi-key YOUR_API_KEY quicommit config set-kimi-key YOUR_API_KEY
quicommit config set-kimi --base-url https://api.moonshot.cn/v1 --model moonshot-v1-8k
# 配置DeepSeek # 配置DeepSeek
quicommit config set-llm deepseek quicommit config set-llm deepseek
quicommit config set-deepseek-key YOUR_API_KEY quicommit config set-deepseek-key YOUR_API_KEY
quicommit config set-deepseek --base-url https://api.deepseek.com/v1 --model deepseek-chat
# 配置OpenRouter # 配置OpenRouter
quicommit config set-llm openrouter quicommit config set-llm openrouter
quicommit config set-openrouter-key YOUR_API_KEY quicommit config set-openrouter-key YOUR_API_KEY
quicommit config set-openrouter --base-url https://openrouter.ai/api/v1 --model openai/gpt-4
# 设置提交格式
quicommit config set-commit-format conventional
# 设置版本前缀
quicommit config set-version-prefix v
# 设置变更日志路径
quicommit config set-changelog-path CHANGELOG.md
# 设置输出语言
quicommit config set-language zh
# 设置保持提交类型为英文
quicommit config set-keep-types-english true
# 设置保持变更日志类型为英文
quicommit config set-keep-changelog-types-english true
# 测试LLM连接 # 测试LLM连接
quicommit config test-llm quicommit config test-llm
# 重置配置为默认值
quicommit config reset
``` ```
## 命令参考 ## 命令参考
@@ -139,17 +224,23 @@ quicommit config test-llm
| 选项 | 说明 | | 选项 | 说明 |
|------|------| |------|------|
| `-t, --commit-type` | 提交类型feat、fix等 | | `--commit-type` | 提交类型feat、fix等 |
| `-s, --scope` | 提交范围 | | `-s, --scope` | 提交范围 |
| `-m, --message` | 提交描述 | | `-m, --message` | 提交描述 |
| `--body` | 提交正文 | | `--body` | 提交正文 |
| `--breaking` | 标记为破坏性变更 | | `-b, --breaking` | 标记为破坏性变更 |
| `-d, --date` | 使用日期格式的提交信息 |
| `--manual` | 手动输入跳过AI生成 | | `--manual` | 手动输入跳过AI生成 |
| `-a, --all` | 暂存所有更改 | | `-a, --all` | 暂存所有更改 |
| `-S, --sign` | GPG签名提交 | | `-S, --sign` | GPG签名提交 |
| `--amend` | 修改上一次提交 | | `--amend` | 修改上一次提交 |
| `--dry-run` | 试运行,不实际提交 | | `--dry-run` | 试运行,不实际提交 |
| `--conventional` | 使用Conventional Commits格式 |
| `--commitlint` | 使用commitlint格式 |
| `--no-verify` | 不验证提交信息 |
| `-y, --yes` | 跳过确认提示 | | `-y, --yes` | 跳过确认提示 |
| `--push` | 提交后推送到远程 |
| `--remote` | 指定远程仓库默认origin |
### tag命令选项 ### tag命令选项
@@ -160,15 +251,36 @@ quicommit config test-llm
| `-m, --message` | 标签信息 | | `-m, --message` | 标签信息 |
| `-g, --generate` | AI生成标签信息 | | `-g, --generate` | AI生成标签信息 |
| `-S, --sign` | GPG签名标签 | | `-S, --sign` | GPG签名标签 |
| `--lightweight` | 创建轻量标签 | | `-l, --lightweight` | 创建轻量标签 |
| `--push` | 推送到远程 | | `-f, --force` | 强制覆盖已存在的标签 |
| `-p, --push` | 推送到远程 |
| `-r, --remote` | 指定远程仓库默认origin |
| `--dry-run` | 试运行 |
| `-y, --yes` | 跳过确认提示 |
### changelog命令选项
| 选项 | 说明 |
|------|------|
| `-o, --output` | 输出文件路径 |
| `-v, --version` | 为特定版本生成 |
| `-f, --from` | 从指定标签生成 |
| `-t, --to` | 生成到指定引用默认HEAD |
| `-i, --init` | 初始化新的变更日志文件 |
| `-g, --generate` | AI生成变更日志 |
| `--prepend` | 添加到现有变更日志开头 |
| `--include-hashes` | 包含提交哈希 |
| `--include-authors` | 包含作者信息 |
| `--format` | 格式keep-a-changelog、github-releases |
| `--dry-run` | 试运行输出到stdout |
| `-y, --yes` | 跳过确认提示 | | `-y, --yes` | 跳过确认提示 |
## 配置文件 ## 配置文件
配置文件位置: 配置文件位置:
- Linux/macOS: `~/.config/quicommit/config.toml` - Linux: `~/.config/quicommit/config.toml`
- Windows: `%APPDATA%\quicommit\config.toml` - macOS: `~/Library/Application Support/quicommit/config.toml`
- Windows: `%APPDATA%\quicommit/config.toml`
```toml ```toml
version = "1" version = "1"
@@ -176,15 +288,27 @@ default_profile = "personal"
[profiles.personal] [profiles.personal]
name = "personal" name = "personal"
user_name = "John Doe" user_name = "Your Name"
user_email = "john@example.com" user_email = "your.email@example.com"
description = "个人项目"
is_work = false
[profiles.work] [profiles.work]
name = "work" name = "work"
user_name = "John Doe" user_name = "Your Name"
user_email = "john@company.com" user_email = "your.name@company.com"
description = "工作项目"
is_work = true is_work = true
organization = "Acme Corp" organization = "Your Company"
[profiles.work.ssh]
private_key_path = "/home/user/.ssh/id_rsa_work"
agent_forwarding = true
[profiles.work.gpg]
key_id = "YOUR_GPG_KEY_ID"
program = "gpg"
use_agent = true
[llm] [llm]
provider = "ollama" provider = "ollama"
@@ -200,19 +324,50 @@ model = "llama2"
model = "gpt-4" model = "gpt-4"
base_url = "https://api.openai.com/v1" base_url = "https://api.openai.com/v1"
[llm.anthropic]
model = "claude-3-sonnet-20240229"
[llm.kimi]
model = "moonshot-v1-8k"
[llm.deepseek]
model = "deepseek-chat"
[llm.openrouter]
model = "openai/gpt-4"
[commit] [commit]
format = "conventional" format = "conventional"
auto_generate = true auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100 max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
[tag] [tag]
version_prefix = "v" version_prefix = "v"
auto_generate = true auto_generate = true
gpg_sign = false
include_changelog = true
[changelog] [changelog]
path = "CHANGELOG.md" path = "CHANGELOG.md"
auto_generate = true auto_generate = true
format = "keep-a-changelog"
include_hashes = false
include_authors = false
group_by_type = true group_by_type = true
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
[repo_profiles]
"/path/to/work/project" = "work"
"/path/to/personal/project" = "personal"
``` ```
## 环境变量 ## 环境变量
@@ -229,14 +384,32 @@ group_by_type = true
# 查看当前配置 # 查看当前配置
quicommit config list quicommit config list
# 显示配置详情
quicommit config show
# 编辑配置文件
quicommit config edit
# 设置配置值
quicommit config set llm.provider ollama
# 获取配置值
quicommit config get llm.provider
# 测试LLM连接 # 测试LLM连接
quicommit config test-llm quicommit config test-llm
# 列出可用模型 # 列出可用模型
quicommit config list-models quicommit config list-models
# 编辑配置文件 # 导出配置
quicommit config edit quicommit config export -o config-backup.toml
# 导入配置
quicommit config import -i config-backup.toml
# 重置配置
quicommit config reset --force
``` ```
## 贡献 ## 贡献
@@ -255,8 +428,8 @@ quicommit config edit
```bash ```bash
# 克隆仓库 # 克隆仓库
git clone https://github.com/YOUR_USERNAME/quicommit.git git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
cd quicommit cd QuiCommit
# 安装依赖 # 安装依赖
cargo fetch cargo fetch
@@ -284,11 +457,36 @@ cargo fmt --check
``` ```
src/ src/
├── commands/ # CLI命令实现 ├── commands/ # CLI命令实现
│ ├── commit.rs
│ ├── tag.rs
│ ├── changelog.rs
│ ├── profile.rs
│ ├── config.rs
│ └── init.rs
├── config/ # 配置管理 ├── config/ # 配置管理
│ ├── manager.rs
│ └── profile.rs
├── generator/ # AI内容生成 ├── generator/ # AI内容生成
├── git/ # Git操作封装 ├── git/ # Git操作封装
│ ├── commit.rs
│ ├── tag.rs
│ └── changelog.rs
├── llm/ # LLM提供商实现 ├── llm/ # LLM提供商实现
└── utils/ # 工具函数 │ ├── ollama.rs
│ ├── openai.rs
│ ├── anthropic.rs
│ ├── kimi.rs
│ ├── deepseek.rs
│ └── openrouter.rs
├── i18n/ # 国际化支持
│ ├── messages.rs
│ └── translator.rs
├── utils/ # 工具函数
│ ├── validators.rs
│ ├── formatter.rs
│ ├── crypto.rs
│ └── editor.rs
└── main.rs # 程序入口
``` ```
## 许可证 ## 许可证

View File

@@ -5,20 +5,22 @@ use colored::Colorize;
use dialoguer::{Confirm, Input}; use dialoguer::{Confirm, Input};
use std::path::PathBuf; use std::path::PathBuf;
use crate::config::manager::ConfigManager; use crate::config::{Language, manager::ConfigManager};
use crate::generator::ContentGenerator; use crate::generator::ContentGenerator;
use crate::git::find_repo; use crate::git::find_repo;
use crate::git::{changelog::*, CommitInfo, GitRepo}; use crate::git::{changelog::*, CommitInfo};
use crate::i18n::{Messages, translate_changelog_category};
/// Generate changelog /// Generate changelog
#[derive(Parser)] #[derive(Parser)]
#[command(disable_version_flag = true, disable_help_flag = false)]
pub struct ChangelogCommand { pub struct ChangelogCommand {
/// Output file path /// Output file path
#[arg(short, long)] #[arg(short, long)]
output: Option<PathBuf>, output: Option<PathBuf>,
/// Version to generate changelog for /// Version to generate changelog for
#[arg(short, long)] #[arg(long)]
version: Option<String>, version: Option<String>,
/// Generate from specific tag /// Generate from specific tag
@@ -37,10 +39,6 @@ pub struct ChangelogCommand {
#[arg(short, long)] #[arg(short, long)]
generate: bool, generate: bool,
/// Prepend to existing changelog
#[arg(short, long)]
prepend: bool,
/// Include commit hashes /// Include commit hashes
#[arg(long)] #[arg(long)]
include_hashes: bool, include_hashes: bool,
@@ -50,7 +48,7 @@ pub struct ChangelogCommand {
include_authors: bool, include_authors: bool,
/// Format (keep-a-changelog, github-releases) /// Format (keep-a-changelog, github-releases)
#[arg(short, long)] #[arg(long)]
format: Option<String>, format: Option<String>,
/// Dry run (output to stdout) /// Dry run (output to stdout)
@@ -63,10 +61,16 @@ pub struct ChangelogCommand {
} }
impl 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 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 config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
// Initialize changelog if requested // Initialize changelog if requested
if self.init { if self.init {
@@ -75,7 +79,7 @@ impl ChangelogCommand {
.unwrap_or_else(|| PathBuf::from(&config.changelog.path)); .unwrap_or_else(|| PathBuf::from(&config.changelog.path));
init_changelog(&path)?; init_changelog(&path)?;
println!("{} Initialized changelog at {:?}", "".green(), path); println!("{}", messages.initialized_changelog(&format!("{:?}", path)));
return Ok(()); return Ok(());
} }
@@ -98,28 +102,28 @@ impl ChangelogCommand {
v.clone() v.clone()
} else if !self.yes { } else if !self.yes {
Input::new() Input::new()
.with_prompt("Version") .with_prompt(messages.version())
.default("Unreleased".to_string()) .default(messages.unreleased().to_string())
.interact_text()? .interact_text()?
} else { } else {
"Unreleased".to_string() messages.unreleased().to_string()
}; };
// Get commits // Get commits
println!("{} Fetching commits...", "".blue()); println!("{}", messages.fetching_commits());
let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?; let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?;
if commits.is_empty() { if commits.is_empty() {
bail!("No commits found in the specified range"); bail!("{}", messages.no_commits_found());
} }
println!("{} Found {} commits", "".green(), commits.len()); println!("{}", messages.found_commits(commits.len()));
// Generate changelog // Generate changelog
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) { let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) {
self.generate_with_ai(&repo, &version, &commits).await? self.generate_with_ai(&version, &commits, &messages).await?
} else { } else {
self.generate_with_template(format, &version, &commits)? self.generate_with_template(format, &version, &commits, language)?
}; };
// Output or write // Output or write
@@ -133,7 +137,7 @@ impl ChangelogCommand {
// Preview // Preview
if !self.yes { if !self.yes {
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
println!("{}", "Changelog preview:".bold()); println!("{}", messages.changelog_preview().bold());
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
// Show first 20 lines // Show first 20 lines
let preview: String = changelog.lines().take(20).collect::<Vec<_>>().join("\n"); let preview: String = changelog.lines().take(20).collect::<Vec<_>>().join("\n");
@@ -144,43 +148,65 @@ impl ChangelogCommand {
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt(&format!("Write to {:?}?", output_path)) .with_prompt(&messages.write_to_file(&format!("{:?}", output_path)))
.default(true) .default(true)
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", "Cancelled.".yellow()); println!("{}", messages.cancelled().yellow());
return Ok(()); return Ok(());
} }
} }
// Write to file // Write to file (always prepend to preserve history)
if self.prepend && output_path.exists() { if output_path.exists() {
let existing = std::fs::read_to_string(&output_path)?; 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)?; std::fs::write(&output_path, new_content)?;
} else { } else {
std::fs::write(&output_path, changelog)?; let content = format!("# Changelog\n\n{}", changelog);
std::fs::write(&output_path, content)?;
} }
println!("{} Changelog written to {:?}", "".green(), output_path); println!("{} {:?}", messages.changelog_written(), output_path);
Ok(()) Ok(())
} }
async fn generate_with_ai( async fn generate_with_ai(
&self, &self,
repo: &GitRepo,
version: &str, version: &str,
commits: &[CommitInfo], commits: &[CommitInfo],
messages: &Messages,
) -> Result<String> { ) -> Result<String> {
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
println!("{} AI is generating changelog...", "🤖"); println!("{}", messages.ai_generating_changelog());
let generator = ContentGenerator::new(&config.llm).await?; let generator = ContentGenerator::new(&config.llm).await?;
generator.generate_changelog_entry(version, commits).await generator.generate_changelog_entry(version, commits, language).await
} }
fn generate_with_template( fn generate_with_template(
@@ -188,12 +214,43 @@ impl ChangelogCommand {
format: ChangelogFormat, format: ChangelogFormat,
version: &str, version: &str,
commits: &[CommitInfo], commits: &[CommitInfo],
language: Language,
) -> Result<String> { ) -> Result<String> {
let manager = ConfigManager::new()?;
let generator = ChangelogGenerator::new() let generator = ChangelogGenerator::new()
.format(format) .format(format)
.include_hashes(self.include_hashes) .include_hashes(self.include_hashes)
.include_authors(self.include_authors); .include_authors(self.include_authors);
generator.generate(version, Utc::now(), commits) let changelog = generator.generate(version, Utc::now(), commits)?;
// Translate changelog categories if configured
if !manager.keep_changelog_types_english() {
Ok(self.translate_changelog_categories(&changelog, language))
} else {
Ok(changelog)
}
}
fn translate_changelog_categories(&self, changelog: &str, language: Language) -> String {
let translated = changelog
.lines()
.map(|line| {
if line.starts_with("## ") || line.starts_with("### ") {
let category = line.trim_start_matches("## ").trim_start_matches("### ");
let translated_category = translate_changelog_category(category, language, false);
if line.starts_with("## ") {
format!("## {}", translated_category)
} else {
format!("### {}", translated_category)
}
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
translated
} }
} }

View File

@@ -2,19 +2,21 @@ use anyhow::{bail, Context, Result};
use clap::Parser; use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::manager::ConfigManager; use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat; use crate::config::CommitFormat;
use crate::generator::ContentGenerator; use crate::generator::ContentGenerator;
use crate::git::{find_repo, GitRepo}; use crate::git::{find_repo, GitRepo};
use crate::git::commit::{CommitBuilder, create_date_commit_message}; use crate::git::commit::{CommitBuilder, create_date_commit_message};
use crate::i18n::Messages;
use crate::utils::validators::get_commit_types; use crate::utils::validators::get_commit_types;
/// Generate and execute conventional commits /// Generate and execute conventional commits
#[derive(Parser)] #[derive(Parser)]
pub struct CommitCommand { pub struct CommitCommand {
/// Commit type /// Commit type
#[arg(short, long)] #[arg(long)]
commit_type: Option<String>, commit_type: Option<String>,
/// Commit scope /// Commit scope
@@ -38,7 +40,7 @@ pub struct CommitCommand {
date: bool, date: bool,
/// Manual input (skip AI generation) /// Manual input (skip AI generation)
#[arg(short, long)] #[arg(long)]
manual: bool, manual: bool,
/// Sign the commit /// Sign the commit
@@ -72,23 +74,37 @@ pub struct CommitCommand {
/// Skip interactive prompts /// Skip interactive prompts
#[arg(short = 'y', long)] #[arg(short = 'y', long)]
yes: bool, yes: bool,
/// Push after committing
#[arg(long)]
push: bool,
/// Remote to push to
#[arg(long, default_value = "origin")]
remote: String,
} }
impl CommitCommand { impl CommitCommand {
pub async fn execute(&self) -> Result<()> { pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
// Find git repository // Find git repository
let repo = find_repo(std::env::current_dir()?.as_path())?; let repo = find_repo(std::env::current_dir()?.as_path())?;
// Load configuration
let manager = if let Some(ref path) = config_path {
ConfigManager::with_path(path)?
} else {
ConfigManager::new()?
};
let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
// Check for changes // Check for changes
let status = repo.status_summary()?; let status = repo.status_summary()?;
if status.clean && !self.amend { if status.clean && !self.amend {
bail!("No changes to commit. Working tree is clean."); bail!("{}", messages.no_changes());
} }
// Load configuration
let manager = ConfigManager::new()?;
let config = manager.config();
// Determine commit format // Determine commit format
let format = if self.conventional { let format = if self.conventional {
CommitFormat::Conventional CommitFormat::Conventional
@@ -98,10 +114,23 @@ impl CommitCommand {
config.commit.format config.commit.format
}; };
// 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());
repo.stage_all()?;
println!("{}", messages.staged_all().green());
// Re-check status after staging to ensure changes are detected
let new_status = repo.status_summary()?;
if new_status.staged == 0 {
bail!("Failed to stage changes. Please try running 'git add -A' manually.");
}
}
// Stage all if requested // Stage all if requested
if self.all { if self.all {
repo.stage_all()?; repo.stage_all()?;
println!("{}", "✓ Staged all changes".green()); println!("{}", messages.staged_all().green());
} }
// Generate or build commit message // Generate or build commit message
@@ -111,12 +140,12 @@ impl CommitCommand {
} else if self.manual || self.message.is_some() { } else if self.manual || self.message.is_some() {
// Manual commit // Manual commit
self.create_manual_commit(format)? self.create_manual_commit(format)?
} else if config.commit.auto_generate && !self.yes { } else if config.commit.auto_generate {
// AI-generated commit // AI-generated commit
self.generate_commit(&repo, format).await? self.generate_commit(&repo, format, &messages).await?
} else { } else {
// Interactive commit creation // Interactive commit creation
self.create_interactive_commit(format).await? self.create_interactive_commit(format, &messages).await?
}; };
// Validate message // Validate message
@@ -132,32 +161,32 @@ impl CommitCommand {
// Show commit preview // Show commit preview
if !self.yes { if !self.yes {
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
println!("{}", "Commit preview:".bold()); println!("{}", messages.commit_preview().bold());
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
println!("{}", commit_message); println!("{}", commit_message);
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt("Do you want to proceed with this commit?") .with_prompt(messages.proceed_commit())
.default(true) .default(true)
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", "Commit cancelled.".yellow()); println!("{}", messages.commit_cancelled().yellow());
return Ok(()); return Ok(());
} }
} }
let result = if self.amend { let result = if self.amend {
if self.dry_run { if self.dry_run {
println!("\n{}", "Dry run - commit not amended.".yellow()); println!("\n{} {}", messages.dry_run(), "- commit not amended.".yellow());
return Ok(()); return Ok(());
} }
self.amend_commit(&repo, &commit_message)?; self.amend_commit(&repo, &commit_message)?;
None None
} else { } else {
if self.dry_run { if self.dry_run {
println!("\n{}", "Dry run - commit not created.".yellow()); println!("\n{} {}", messages.dry_run(), "- commit not created.".yellow());
return Ok(()); return Ok(());
} }
CommitBuilder::new() CommitBuilder::new()
@@ -167,9 +196,27 @@ impl CommitCommand {
}; };
if let Some(commit_oid) = result { if let Some(commit_oid) = result {
println!("{} {}", "✓ Created commit".green().bold(), commit_oid.to_string()[..8].to_string().cyan()); println!("{} {}", messages.commit_created().green().bold(), commit_oid.to_string()[..8].to_string().cyan());
} else { } else {
println!("{} {}", "✓ Amended commit".green().bold(), "successfully"); println!("{} {}", messages.commit_amended().green().bold(), "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 should_push {
println!("\n{}", messages.pushing_commit(&self.remote));
repo.push(&self.remote, "HEAD")?;
println!("{}", messages.pushed_commit(&self.remote));
}
} }
Ok(()) Ok(())
@@ -181,12 +228,22 @@ impl CommitCommand {
} }
fn create_manual_commit(&self, format: CommitFormat) -> Result<String> { fn create_manual_commit(&self, format: CommitFormat) -> Result<String> {
let commit_type = self.commit_type.clone()
.ok_or_else(|| anyhow::anyhow!("Commit type required for manual commit. Use -t <type>"))?;
let description = self.message.clone() let description = self.message.clone()
.ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?; .ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?;
// Try to extract commit type from message if not provided
let commit_type = if let Some(ref ct) = self.commit_type {
ct.clone()
} else {
// Parse from conventional commit format: "type: description"
description
.split(':')
.next()
.unwrap_or("feat")
.trim()
.to_string()
};
let builder = CommitBuilder::new() let builder = CommitBuilder::new()
.commit_type(commit_type) .commit_type(commit_type)
.description(description) .description(description)
@@ -198,7 +255,7 @@ impl CommitCommand {
builder.build_message() builder.build_message()
} }
async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat) -> Result<String> { async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat, messages: &Messages) -> Result<String> {
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
@@ -206,53 +263,58 @@ impl CommitCommand {
let generator = ContentGenerator::new(&config.llm).await let generator = ContentGenerator::new(&config.llm).await
.context("Failed to initialize LLM. Use --manual for manual commit.")?; .context("Failed to initialize LLM. Use --manual for manual commit.")?;
println!("{} AI is analyzing your changes...", "🤖".to_string()); println!("{}", messages.ai_analyzing());
let language_str = &config.language.output_language;
let language = Language::from_str(language_str).unwrap_or(Language::English);
let generated = if self.yes { let generated = if self.yes {
generator.generate_commit_from_repo(repo, format).await? // Non-interactive mode: generate directly
generator.generate_commit_from_repo(repo, format, language).await?
} else { } else {
generator.generate_commit_interactive(repo, format).await? // Interactive mode: allow user to review and regenerate
generator.generate_commit_interactive(repo, format, language).await?
}; };
Ok(generated.to_conventional()) Ok(generated.to_conventional())
} }
async fn create_interactive_commit(&self, format: CommitFormat) -> Result<String> { async fn create_interactive_commit(&self, format: CommitFormat, messages: &Messages) -> Result<String> {
let types = get_commit_types(format == CommitFormat::Commitlint); let types = get_commit_types(format == CommitFormat::Commitlint);
// Select type // Select type
let type_idx = Select::new() let type_idx = Select::new()
.with_prompt("Select commit type") .with_prompt(messages.select_commit_type())
.items(types) .items(types)
.interact()?; .interact()?;
let commit_type = types[type_idx].to_string(); let commit_type = types[type_idx].to_string();
// Enter scope (optional) // Enter scope (optional)
let scope: String = Input::new() let scope: String = Input::new()
.with_prompt("Scope (optional, press Enter to skip)") .with_prompt(messages.scope_optional())
.allow_empty(true) .allow_empty(true)
.interact_text()?; .interact_text()?;
let scope = if scope.is_empty() { None } else { Some(scope) }; let scope = if scope.is_empty() { None } else { Some(scope) };
// Enter description // Enter description
let description: String = Input::new() let description: String = Input::new()
.with_prompt("Description") .with_prompt(messages.description())
.interact_text()?; .interact_text()?;
// Breaking change // Breaking change
let breaking = Confirm::new() let breaking = Confirm::new()
.with_prompt("Is this a breaking change?") .with_prompt(messages.breaking_change())
.default(false) .default(false)
.interact()?; .interact()?;
// Add body // Add body
let add_body = Confirm::new() let add_body = Confirm::new()
.with_prompt("Add body to commit?") .with_prompt(messages.add_body())
.default(false) .default(false)
.interact()?; .interact()?;
let body = if add_body { let body = if add_body {
let body_text = crate::utils::editor::edit_content("Enter commit body...")?; let body_text = crate::utils::editor::edit_content(messages.enter_commit_body())?;
if body_text.trim().is_empty() { if body_text.trim().is_empty() {
None None
} else { } else {
@@ -293,8 +355,23 @@ impl CommitCommand {
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); 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(()) Ok(())

View File

@@ -2,8 +2,9 @@ use anyhow::{bail, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::manager::ConfigManager; use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat; use crate::config::CommitFormat;
/// Mask API key with asterisks for security /// Mask API key with asterisks for security
@@ -146,7 +147,25 @@ enum ConfigSubcommand {
/// Path /// Path
path: String, path: String,
}, },
/// Set output language
SetLanguage {
/// Language code (en, zh, ja, ko, es, fr, de)
language: Option<String>,
},
/// Set whether to keep commit types in English
SetKeepTypesEnglish {
/// Keep types in English (true/false)
keep: bool,
},
/// Set whether to keep changelog types in English
SetKeepChangelogTypesEnglish {
/// Keep types in English (true/false)
keep: bool,
},
/// Reset configuration to defaults /// Reset configuration to defaults
Reset { Reset {
/// Skip confirmation /// Skip confirmation
@@ -173,40 +192,60 @@ enum ConfigSubcommand {
/// Test LLM connection /// Test LLM connection
TestLlm, TestLlm,
/// Show config file path
Path,
} }
impl ConfigCommand { impl ConfigCommand {
pub async fn execute(&self) -> Result<()> { pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command { match &self.command {
Some(ConfigSubcommand::Show) => self.show_config().await, Some(ConfigSubcommand::Show) => self.show_config(&config_path).await,
Some(ConfigSubcommand::List) => self.list_config().await, Some(ConfigSubcommand::List) => self.list_config(&config_path).await,
Some(ConfigSubcommand::Edit) => self.edit_config().await, Some(ConfigSubcommand::Edit) => self.edit_config(&config_path).await,
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value).await, Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value, &config_path).await,
Some(ConfigSubcommand::Get { key }) => self.get_value(key).await, Some(ConfigSubcommand::Get { key }) => self.get_value(key, &config_path).await,
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref()).await, Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref(), &config_path).await,
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key).await, Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key, &config_path).await,
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key).await, Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key, &config_path).await,
Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key).await, Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key, &config_path).await,
Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key).await, Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key, &config_path).await,
Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key).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()).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()).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()).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()).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).await, Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format, &config_path).await,
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await, Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix, &config_path).await,
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await, Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path, &config_path).await,
Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await, Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref(), &config_path).await,
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await, Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep, &config_path).await,
Some(ConfigSubcommand::Import { file }) => self.import_config(file).await, Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await,
Some(ConfigSubcommand::ListModels) => self.list_models().await, Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await,
Some(ConfigSubcommand::TestLlm) => self.test_llm().await, Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref(), &config_path).await,
None => self.show_config().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<()> { fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
let manager = ConfigManager::new()?; 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(); let config = manager.config();
println!("{}", "\nQuiCommit Configuration".bold()); println!("{}", "\nQuiCommit Configuration".bold());
@@ -268,6 +307,12 @@ impl ConfigCommand {
println!(" GPG sign: {}", if config.tag.gpg_sign { "yes".green() } else { "no".red() }); println!(" GPG sign: {}", if config.tag.gpg_sign { "yes".green() } else { "no".red() });
println!(" Include changelog: {}", if config.tag.include_changelog { "yes".green() } else { "no".red() }); println!(" Include changelog: {}", if config.tag.include_changelog { "yes".green() } else { "no".red() });
println!("\n{}", "Language Configuration:".bold());
let language = manager.get_language().unwrap_or(Language::English);
println!(" Output language: {}", language.display_name().cyan());
println!(" Keep commit types in English: {}", if manager.keep_types_english() { "yes".green() } else { "no".red() });
println!(" Keep changelog types in English: {}", if manager.keep_changelog_types_english() { "yes".green() } else { "no".red() });
println!("\n{}", "Changelog Configuration:".bold()); println!("\n{}", "Changelog Configuration:".bold());
println!(" Path: {}", config.changelog.path); println!(" Path: {}", config.changelog.path);
println!(" Auto-generate: {}", if config.changelog.auto_generate { "yes".green() } else { "no".red() }); println!(" Auto-generate: {}", if config.changelog.auto_generate { "yes".green() } else { "no".red() });
@@ -279,8 +324,8 @@ impl ConfigCommand {
} }
/// List all configuration information with masked API keys /// List all configuration information with masked API keys
async fn list_config(&self) -> Result<()> { async fn list_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let config = manager.config(); let config = manager.config();
println!("{}", "\nQuiCommit Configuration".bold()); println!("{}", "\nQuiCommit Configuration".bold());
@@ -377,15 +422,15 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn edit_config(&self) -> Result<()> { async fn edit_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
crate::utils::editor::edit_file(manager.path())?; crate::utils::editor::edit_file(manager.path())?;
println!("{} Configuration updated", "".green()); println!("{} Configuration updated", "".green());
Ok(()) Ok(())
} }
async fn set_value(&self, key: &str, value: &str) -> Result<()> { async fn set_value(&self, key: &str, value: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
match key { match key {
"llm.provider" => manager.set_llm_provider(value.to_string()), "llm.provider" => manager.set_llm_provider(value.to_string()),
@@ -423,8 +468,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn get_value(&self, key: &str) -> Result<()> { async fn get_value(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let config = manager.config(); let config = manager.config();
let value = match key { let value = match key {
@@ -442,8 +487,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_llm(&self, provider: Option<&str>) -> Result<()> { async fn set_llm(&self, provider: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let provider = if let Some(p) = provider { let provider = if let Some(p) = provider {
p.to_string() p.to_string()
@@ -575,48 +620,48 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_openai_key(&self, key: &str) -> Result<()> { async fn set_openai_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_openai_api_key(key.to_string()); manager.set_openai_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} OpenAI API key set", "".green()); println!("{} OpenAI API key set", "".green());
Ok(()) Ok(())
} }
async fn set_anthropic_key(&self, key: &str) -> Result<()> { async fn set_anthropic_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_anthropic_api_key(key.to_string()); manager.set_anthropic_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} Anthropic API key set", "".green()); println!("{} Anthropic API key set", "".green());
Ok(()) Ok(())
} }
async fn set_kimi_key(&self, key: &str) -> Result<()> { async fn set_kimi_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_kimi_api_key(key.to_string()); manager.set_kimi_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} Kimi API key set", "".green()); println!("{} Kimi API key set", "".green());
Ok(()) Ok(())
} }
async fn set_deepseek_key(&self, key: &str) -> Result<()> { async fn set_deepseek_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_deepseek_api_key(key.to_string()); manager.set_deepseek_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} DeepSeek API key set", "".green()); println!("{} DeepSeek API key set", "".green());
Ok(()) Ok(())
} }
async fn set_openrouter_key(&self, key: &str) -> Result<()> { async fn set_openrouter_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_openrouter_api_key(key.to_string()); manager.set_openrouter_api_key(key.to_string());
manager.save()?; manager.save()?;
println!("{} OpenRouter API key set", "".green()); println!("{} OpenRouter API key set", "".green());
Ok(()) Ok(())
} }
async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url { if let Some(url) = base_url {
manager.set_kimi_base_url(url.to_string()); manager.set_kimi_base_url(url.to_string());
@@ -630,8 +675,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url { if let Some(url) = base_url {
manager.set_deepseek_base_url(url.to_string()); manager.set_deepseek_base_url(url.to_string());
@@ -645,8 +690,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> { async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if let Some(url) = base_url { if let Some(url) = base_url {
manager.set_openrouter_base_url(url.to_string()); manager.set_openrouter_base_url(url.to_string());
@@ -660,8 +705,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>) -> Result<()> { async fn set_ollama(&self, url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
if let Some(u) = url { if let Some(u) = url {
manager.config_mut().llm.ollama.url = u.to_string(); manager.config_mut().llm.ollama.url = u.to_string();
@@ -675,8 +720,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_commit_format(&self, format: &str) -> Result<()> { async fn set_commit_format(&self, format: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
let format = match format { let format = match format {
"conventional" => CommitFormat::Conventional, "conventional" => CommitFormat::Conventional,
@@ -690,23 +735,71 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_version_prefix(&self, prefix: &str) -> Result<()> { async fn set_version_prefix(&self, prefix: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_version_prefix(prefix.to_string()); manager.set_version_prefix(prefix.to_string());
manager.save()?; manager.save()?;
println!("{} Set version prefix to '{}'", "".green(), prefix); println!("{} Set version prefix to '{}'", "".green(), prefix);
Ok(()) Ok(())
} }
async fn set_changelog_path(&self, path: &str) -> Result<()> { async fn set_changelog_path(&self, path: &str, config_path: &Option<PathBuf>) -> Result<()> {
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.set_changelog_path(path.to_string()); manager.set_changelog_path(path.to_string());
manager.save()?; manager.save()?;
println!("{} Set changelog path to {}", "".green(), path); println!("{} Set changelog path to {}", "".green(), path);
Ok(()) Ok(())
} }
async fn reset(&self, force: bool) -> Result<()> { 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()
} else {
let languages = vec![
Language::English,
Language::Chinese,
Language::Japanese,
Language::Korean,
Language::Spanish,
Language::French,
Language::German,
];
let language_names: Vec<String> = languages.iter().map(|l| l.display_name().to_string()).collect();
let idx = Select::new()
.with_prompt("Select language")
.items(&language_names)
.default(0)
.interact()?;
languages[idx].to_code().to_string()
};
manager.set_output_language(language_code.clone());
manager.save()?;
println!("{} Set output language to {}", "".green(), language_code.cyan());
Ok(())
}
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" };
println!("{} Keep commit types in English: {}", "".green(), status);
Ok(())
}
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" };
println!("{} Keep changelog types in English: {}", "".green(), status);
Ok(())
}
async fn reset(&self, force: bool, config_path: &Option<PathBuf>) -> Result<()> {
if !force { if !force {
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt("Are you sure you want to reset all configuration?") .with_prompt("Are you sure you want to reset all configuration?")
@@ -719,7 +812,7 @@ impl ConfigCommand {
} }
} }
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.reset(); manager.reset();
manager.save()?; manager.save()?;
@@ -727,8 +820,8 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn export_config(&self, output: Option<&str>) -> Result<()> { async fn export_config(&self, output: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let toml = manager.export()?; let toml = manager.export()?;
if let Some(path) = output { if let Some(path) = output {
@@ -741,10 +834,10 @@ impl ConfigCommand {
Ok(()) 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 toml = std::fs::read_to_string(file)?;
let mut manager = ConfigManager::new()?; let mut manager = self.get_manager(config_path)?;
manager.import(&toml)?; manager.import(&toml)?;
manager.save()?; manager.save()?;
@@ -752,17 +845,17 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn list_models(&self) -> Result<()> { async fn list_models(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let config = manager.config(); let config = manager.config();
match config.llm.provider.as_str() { match config.llm.provider.as_str() {
"ollama" => { "ollama" => {
let client = crate::llm::OllamaClient::new( let client = crate::llm::OllamaClient::new(
&config.llm.ollama.url, &config.llm.ollama.url,
&config.llm.ollama.model, &config.llm.ollama.model,
); );
println!("Fetching available models from Ollama..."); println!("Fetching available models from Ollama...");
match client.list_models().await { match client.list_models().await {
Ok(models) => { Ok(models) => {
@@ -784,7 +877,7 @@ impl ConfigCommand {
key, key,
&config.llm.openai.model, &config.llm.openai.model,
)?; )?;
println!("Fetching available models from OpenAI..."); println!("Fetching available models from OpenAI...");
match client.list_models().await { match client.list_models().await {
Ok(models) => { Ok(models) => {
@@ -802,16 +895,115 @@ impl ConfigCommand {
bail!("OpenAI API key not configured"); 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 => { provider => {
println!("Listing models not supported for provider: {}", provider); println!("Listing models not supported for provider: {}", provider);
} }
} }
Ok(()) Ok(())
} }
async fn test_llm(&self) -> Result<()> { async fn test_llm(&self, config_path: &Option<PathBuf>) -> Result<()> {
let manager = ConfigManager::new()?; let manager = self.get_manager(config_path)?;
let config = manager.config(); let config = manager.config();
println!("Testing LLM connection ({})...", config.llm.provider.cyan()); println!("Testing LLM connection ({})...", config.llm.provider.cyan());
@@ -830,7 +1022,7 @@ impl ConfigCommand {
fn main() { fn main() {
+ println!("Hello, World!"); + println!("Hello, World!");
}"#; }"#;
match client.generate_commit_message(sample_diff, crate::config::CommitFormat::Conventional).await { match client.generate_commit_message(sample_diff, crate::config::CommitFormat::Conventional, crate::config::Language::English).await {
Ok(response) => { Ok(response) => {
println!("{} Generation test passed", "".green()); println!("{} Generation test passed", "".green());
println!("Response: {}", response.description.dimmed()); println!("Response: {}", response.description.dimmed());

View File

@@ -2,10 +2,12 @@ use anyhow::Result;
use clap::Parser; use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{GitProfile}; use crate::config::{GitProfile, Language};
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
use crate::config::profile::{GpgConfig, SshConfig}; use crate::config::profile::{GpgConfig, SshConfig};
use crate::i18n::Messages;
use crate::utils::validators::validate_email; use crate::utils::validators::validate_email;
/// Initialize quicommit configuration /// Initialize quicommit configuration
@@ -21,10 +23,13 @@ pub struct InitCommand {
} }
impl InitCommand { impl InitCommand {
pub async fn execute(&self) -> Result<()> { pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
println!("{}", "🚀 Initializing QuiCommit...".bold().cyan()); 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 // Check if config already exists
if config_path.exists() && !self.reset { if config_path.exists() && !self.reset {
@@ -38,28 +43,36 @@ impl InitCommand {
println!("{}", "Initialization cancelled.".yellow()); println!("{}", "Initialization cancelled.".yellow());
return Ok(()); return Ok(());
} }
} else {
println!("{}", "Configuration already exists. Use --reset to overwrite.".yellow());
return Ok(());
} }
} }
let mut manager = if self.reset { // Create parent directory if needed
ConfigManager::new()? if let Some(parent) = config_path.parent() {
} else { std::fs::create_dir_all(parent)
ConfigManager::new().or_else(|_| Ok::<_, anyhow::Error>(ConfigManager::default()))? .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 { if self.yes {
// Quick setup with defaults
self.quick_setup(&mut manager).await?; self.quick_setup(&mut manager).await?;
} else { } else {
// Interactive setup
self.interactive_setup(&mut manager).await?; self.interactive_setup(&mut manager).await?;
} }
manager.save()?; manager.save()?;
println!("{}", "✅ QuiCommit initialized successfully!".bold().green()); // Get configured language for final messages
println!("\nConfig file: {}", config_path.display()); let language = manager.get_language().unwrap_or(Language::English);
println!("\nNext steps:"); let messages = Messages::new(language);
println!("{}", messages.init_success().bold().green());
println!("\n{}: {}", messages.config_file(), config_path.display());
println!("\n{}:", messages.next_steps());
println!(" 1. Create a profile: {}", "quicommit profile add".cyan()); println!(" 1. Create a profile: {}", "quicommit profile add".cyan());
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan()); println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
println!(" 3. Start committing: {}", "quicommit commit".cyan()); println!(" 3. Start committing: {}", "quicommit commit".cyan());
@@ -90,11 +103,35 @@ impl InitCommand {
} }
async fn interactive_setup(&self, manager: &mut ConfigManager) -> Result<()> { async fn interactive_setup(&self, manager: &mut ConfigManager) -> Result<()> {
println!("\n{}", "Let's set up your first profile:".bold()); let messages = Messages::new(Language::English);
println!("\n{}", messages.setup_profile().bold());
// Language selection
println!("\n{}", messages.select_output_language().bold());
let languages = vec![
Language::English,
Language::Chinese,
Language::Japanese,
Language::Korean,
Language::Spanish,
Language::French,
Language::German,
];
let language_names: Vec<String> = languages.iter().map(|l| l.display_name().to_string()).collect();
let language_idx = Select::new()
.items(&language_names)
.default(0)
.interact()?;
let selected_language = languages[language_idx];
manager.set_output_language(selected_language.to_code().to_string());
// Update messages to selected language
let messages = Messages::new(selected_language);
// Profile name // Profile name
let profile_name: String = Input::new() let profile_name: String = Input::new()
.with_prompt("Profile name") .with_prompt(messages.profile_name())
.default("personal".to_string()) .default("personal".to_string())
.interact_text()?; .interact_text()?;
@@ -110,12 +147,12 @@ impl InitCommand {
.unwrap_or_default(); .unwrap_or_default();
let user_name: String = Input::new() let user_name: String = Input::new()
.with_prompt("Git user name") .with_prompt(messages.git_user_name())
.default(default_name) .default(default_name)
.interact_text()?; .interact_text()?;
let user_email: String = Input::new() let user_email: String = Input::new()
.with_prompt("Git user email") .with_prompt(messages.git_user_email())
.default(default_email) .default(default_email)
.validate_with(|input: &String| { .validate_with(|input: &String| {
validate_email(input).map_err(|e| e.to_string()) validate_email(input).map_err(|e| e.to_string())
@@ -123,18 +160,18 @@ impl InitCommand {
.interact_text()?; .interact_text()?;
let description: String = Input::new() let description: String = Input::new()
.with_prompt("Profile description (optional)") .with_prompt(messages.profile_description())
.allow_empty(true) .allow_empty(true)
.interact_text()?; .interact_text()?;
let is_work = Confirm::new() let is_work = Confirm::new()
.with_prompt("Is this a work profile?") .with_prompt(messages.is_work_profile())
.default(false) .default(false)
.interact()?; .interact()?;
let organization = if is_work { let organization = if is_work {
Some(Input::new() Some(Input::new()
.with_prompt("Organization/Company name") .with_prompt(messages.organization_name())
.interact_text()?) .interact_text()?)
} else { } else {
None None
@@ -142,24 +179,24 @@ impl InitCommand {
// SSH configuration // SSH configuration
let setup_ssh = Confirm::new() let setup_ssh = Confirm::new()
.with_prompt("Configure SSH key?") .with_prompt(messages.configure_ssh())
.default(false) .default(false)
.interact()?; .interact()?;
let ssh_config = if setup_ssh { let ssh_config = if setup_ssh {
Some(self.setup_ssh_interactive().await?) Some(self.setup_ssh_interactive(&messages).await?)
} else { } else {
None None
}; };
// GPG configuration // GPG configuration
let setup_gpg = Confirm::new() let setup_gpg = Confirm::new()
.with_prompt("Configure GPG signing?") .with_prompt(messages.configure_gpg())
.default(false) .default(false)
.interact()?; .interact()?;
let gpg_config = if setup_gpg { let gpg_config = if setup_gpg {
Some(self.setup_gpg_interactive().await?) Some(self.setup_gpg_interactive(&messages).await?)
} else { } else {
None None
}; };
@@ -184,7 +221,7 @@ impl InitCommand {
manager.set_default_profile(Some(profile_name))?; manager.set_default_profile(Some(profile_name))?;
// LLM provider selection // LLM provider selection
println!("\n{}", "Select your preferred LLM provider:".bold()); println!("\n{}", messages.select_llm_provider().bold());
let providers = vec![ let providers = vec![
"Ollama (local)", "Ollama (local)",
"OpenAI", "OpenAI",
@@ -213,27 +250,27 @@ impl InitCommand {
// Configure API key if needed // Configure API key if needed
if provider == "openai" { if provider == "openai" {
let api_key: String = Input::new() let api_key: String = Input::new()
.with_prompt("OpenAI API key") .with_prompt(messages.openai_api_key())
.interact_text()?; .interact_text()?;
manager.set_openai_api_key(api_key); manager.set_openai_api_key(api_key);
} else if provider == "anthropic" { } else if provider == "anthropic" {
let api_key: String = Input::new() let api_key: String = Input::new()
.with_prompt("Anthropic API key") .with_prompt(messages.anthropic_api_key())
.interact_text()?; .interact_text()?;
manager.set_anthropic_api_key(api_key); manager.set_anthropic_api_key(api_key);
} else if provider == "kimi" { } else if provider == "kimi" {
let api_key: String = Input::new() let api_key: String = Input::new()
.with_prompt("Kimi API key") .with_prompt(messages.kimi_api_key())
.interact_text()?; .interact_text()?;
manager.set_kimi_api_key(api_key); manager.set_kimi_api_key(api_key);
} else if provider == "deepseek" { } else if provider == "deepseek" {
let api_key: String = Input::new() let api_key: String = Input::new()
.with_prompt("DeepSeek API key") .with_prompt(messages.deepseek_api_key())
.interact_text()?; .interact_text()?;
manager.set_deepseek_api_key(api_key); manager.set_deepseek_api_key(api_key);
} else if provider == "openrouter" { } else if provider == "openrouter" {
let api_key: String = Input::new() let api_key: String = Input::new()
.with_prompt("OpenRouter API key") .with_prompt(messages.openrouter_api_key())
.interact_text()?; .interact_text()?;
manager.set_openrouter_api_key(api_key); manager.set_openrouter_api_key(api_key);
} }
@@ -241,7 +278,7 @@ impl InitCommand {
Ok(()) Ok(())
} }
async fn setup_ssh_interactive(&self) -> Result<SshConfig> { async fn setup_ssh_interactive(&self, messages: &Messages) -> Result<SshConfig> {
use std::path::PathBuf; use std::path::PathBuf;
let ssh_dir = dirs::home_dir() let ssh_dir = dirs::home_dir()
@@ -249,17 +286,17 @@ impl InitCommand {
.unwrap_or_else(|| PathBuf::from("~/.ssh")); .unwrap_or_else(|| PathBuf::from("~/.ssh"));
let key_path: String = Input::new() 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()) .default(ssh_dir.join("id_rsa").display().to_string())
.interact_text()?; .interact_text()?;
let has_passphrase = Confirm::new() let has_passphrase = Confirm::new()
.with_prompt("Does this key have a passphrase?") .with_prompt(messages.has_passphrase())
.default(false) .default(false)
.interact()?; .interact()?;
let passphrase = if has_passphrase { let passphrase = if has_passphrase {
Some(crate::utils::password_input("SSH key passphrase")?) Some(crate::utils::password_input(messages.ssh_key_passphrase())?)
} else { } else {
None None
}; };
@@ -274,13 +311,13 @@ impl InitCommand {
}) })
} }
async fn setup_gpg_interactive(&self) -> Result<GpgConfig> { async fn setup_gpg_interactive(&self, messages: &Messages) -> Result<GpgConfig> {
let key_id: String = Input::new() let key_id: String = Input::new()
.with_prompt("GPG key ID") .with_prompt(messages.gpg_key_id())
.interact_text()?; .interact_text()?;
let use_agent = Confirm::new() let use_agent = Confirm::new()
.with_prompt("Use GPG agent?") .with_prompt(messages.use_gpg_agent())
.default(true) .default(true)
.interact()?; .interact()?;

View File

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

View File

@@ -3,13 +3,15 @@ use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use semver::Version; use semver::Version;
use std::path::PathBuf;
use crate::config::manager::ConfigManager; use crate::config::{Language, manager::ConfigManager};
use crate::git::{find_repo, GitRepo}; use crate::git::{find_repo, GitRepo};
use crate::generator::ContentGenerator; use crate::generator::ContentGenerator;
use crate::git::tag::{ use crate::git::tag::{
bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump, bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump,
}; };
use crate::i18n::Messages;
/// Generate and create Git tags /// Generate and create Git tags
#[derive(Parser)] #[derive(Parser)]
@@ -60,10 +62,16 @@ pub struct TagCommand {
} }
impl 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 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 config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
// Determine tag name // Determine tag name
let tag_name = if let Some(name) = &self.name { let tag_name = if let Some(name) = &self.name {
@@ -80,7 +88,7 @@ impl TagCommand {
format!("{}{}", prefix, new_version) format!("{}{}", prefix, new_version)
} else { } else {
// Interactive mode // Interactive mode
self.select_version_interactive(&repo, &config.tag.version_prefix).await? self.select_version_interactive(&repo, &config.tag.version_prefix, &messages).await?
}; };
// Validate tag name (if it looks like a version) // Validate tag name (if it looks like a version)
@@ -96,7 +104,7 @@ impl TagCommand {
.interact()?; .interact()?;
if !proceed { if !proceed {
bail!("Tag creation cancelled"); bail!("{}", messages.tag_cancelled());
} }
} }
} }
@@ -108,16 +116,16 @@ impl TagCommand {
} else if let Some(msg) = &self.message { } else if let Some(msg) = &self.message {
Some(msg.clone()) Some(msg.clone())
} else if self.generate || (config.tag.auto_generate && !self.yes) { } else if self.generate || (config.tag.auto_generate && !self.yes) {
Some(self.generate_tag_message(&repo, &tag_name).await?) Some(self.generate_tag_message(&repo, &tag_name, &messages).await?)
} else if !self.yes { } else if !self.yes {
Some(self.input_message_interactive(&tag_name)?) Some(self.input_message_interactive(&tag_name, &messages)?)
} else { } else {
Some(format!("Release {}", tag_name)) Some(format!("Release {}", tag_name))
}; };
// Show preview // Show preview
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
println!("{}", "Tag preview:".bold()); println!("{}", messages.tag_preview().bold());
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
println!("Name: {}", tag_name.cyan()); println!("Name: {}", tag_name.cyan());
if let Some(ref msg) = message { if let Some(ref msg) = message {
@@ -129,18 +137,18 @@ impl TagCommand {
if !self.yes { if !self.yes {
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt("Create this tag?") .with_prompt(messages.create_tag())
.default(true) .default(true)
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", "Tag creation cancelled.".yellow()); println!("{}", messages.tag_cancelled().yellow());
return Ok(()); return Ok(());
} }
} }
if self.dry_run { if self.dry_run {
println!("\n{}", "Dry run - tag not created.".yellow()); println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow());
return Ok(()); return Ok(());
} }
@@ -153,41 +161,52 @@ impl TagCommand {
builder.execute(&repo)?; builder.execute(&repo)?;
println!("{} Created tag {}", "".green(), tag_name.cyan()); println!("{} {}", messages.tag_created().green(), tag_name.cyan());
// Push if requested // Push if requested or ask user
if self.push { if self.push {
println!("{} Pushing tag to {}...", "".blue(), &self.remote); println!("{}", messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{} Pushed tag to {}", "".green(), &self.remote); println!("{}", messages.pushed_tag(&self.remote));
} else if !self.yes && !self.dry_run {
let should_push = Confirm::new()
.with_prompt(messages.push_after_tag())
.default(false)
.interact()?;
if should_push {
println!("{}", messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{}", messages.pushed_tag(&self.remote));
}
} }
Ok(()) Ok(())
} }
async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str) -> Result<String> { async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str, messages: &Messages) -> Result<String> {
loop { loop {
let latest = get_latest_version(repo, prefix)?; let latest = get_latest_version(repo, prefix)?;
println!("\n{}", "Version selection:".bold()); println!("\n{}", messages.version_selection().bold());
if let Some(ref version) = latest { if let Some(ref version) = latest {
println!("Latest version: {}{}", prefix, version); println!("{} {}{}", messages.latest_version(), prefix, version);
} else { } else {
println!("No existing version tags found"); println!("{}", messages.no_existing_version_tags());
} }
let options = vec![ let options = vec![
"Auto-detect bump from commits", messages.auto_detect_bump(),
"Bump major version", messages.bump_major_version(),
"Bump minor version", messages.bump_minor_version(),
"Bump patch version", messages.bump_patch_version(),
"Enter custom version", messages.enter_custom_version(),
"Enter custom tag name", messages.enter_custom_tag_name(),
]; ];
let selection = Select::new() let selection = Select::new()
.with_prompt("Select option") .with_prompt(messages.select_option())
.items(&options) .items(&options)
.default(0) .default(0)
.interact()?; .interact()?;
@@ -201,10 +220,10 @@ impl TagCommand {
.map(|v| bump_version(v, bump, None)) .map(|v| bump_version(v, bump, None))
.unwrap_or_else(|| Version::new(0, 1, 0)); .unwrap_or_else(|| Version::new(0, 1, 0));
println!("Suggested bump: {:?}{}{}", bump, prefix, version); println!("{} {:?}{}{}", messages.suggested_bump(), bump, prefix, version);
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt("Use this version?") .with_prompt(messages.use_this_version())
.default(true) .default(true)
.interact()?; .interact()?;
@@ -233,14 +252,14 @@ impl TagCommand {
} }
4 => { 4 => {
let input: String = Input::new() let input: String = Input::new()
.with_prompt("Enter version (e.g., 1.2.3)") .with_prompt(messages.enter_version())
.interact_text()?; .interact_text()?;
let version = Version::parse(&input)?; let version = Version::parse(&input)?;
return Ok(format!("{}{}", prefix, version)); return Ok(format!("{}{}", prefix, version));
} }
5 => { 5 => {
let input: String = Input::new() let input: String = Input::new()
.with_prompt("Enter tag name") .with_prompt(messages.enter_tag_name())
.interact_text()?; .interact_text()?;
return Ok(input); return Ok(input);
} }
@@ -249,9 +268,10 @@ impl TagCommand {
} }
} }
async fn generate_tag_message(&self, repo: &GitRepo, version: &str) -> Result<String> { async fn generate_tag_message(&self, repo: &GitRepo, version: &str, messages: &Messages) -> Result<String> {
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
// Get commits since last tag // Get commits since last tag
let tags = repo.get_tags()?; let tags = repo.get_tags()?;
@@ -265,17 +285,17 @@ impl TagCommand {
return Ok(format!("Release {}", version)); return Ok(format!("Release {}", version));
} }
println!("{} AI is generating tag message from {} commits...", "🤖", commits.len()); println!("{}", messages.ai_generating_tag(commits.len()));
let generator = ContentGenerator::new(&config.llm).await?; let generator = ContentGenerator::new(&config.llm).await?;
generator.generate_tag_message(version, &commits).await generator.generate_tag_message(version, &commits, language).await
} }
fn input_message_interactive(&self, version: &str) -> Result<String> { fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
let default_msg = format!("Release {}", version); let default_msg = format!("Release {}", version);
let use_editor = Confirm::new() let use_editor = Confirm::new()
.with_prompt("Open editor for tag message?") .with_prompt(messages.open_editor())
.default(false) .default(false)
.interact()?; .interact()?;
@@ -283,7 +303,7 @@ impl TagCommand {
crate::utils::editor::edit_content(&default_msg) crate::utils::editor::edit_content(&default_msg)
} else { } else {
Ok(Input::new() Ok(Input::new()
.with_prompt("Tag message") .with_prompt(messages.tag_message())
.default(default_msg) .default(default_msg)
.interact_text()?) .interact_text()?)
} }

View File

@@ -1,4 +1,4 @@
use super::{AppConfig, GitProfile, TokenConfig, TokenType}; use super::{AppConfig, GitProfile, TokenConfig};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -19,7 +19,11 @@ impl ConfigManager {
/// Create config manager with specific path /// Create config manager with specific path
pub fn with_path(path: &Path) -> Result<Self> { 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 { Ok(Self {
config, config,
config_path: path.to_path_buf(), 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 /// Get configuration reference
pub fn config(&self) -> &AppConfig { pub fn config(&self) -> &AppConfig {
&self.config &self.config
@@ -391,6 +404,46 @@ impl ConfigManager {
self.modified = true; self.modified = true;
} }
// Language configuration
/// Get output language
pub fn output_language(&self) -> &str {
&self.config.language.output_language
}
/// Set output language
pub fn set_output_language(&mut self, language: String) {
self.config.language.output_language = language;
self.modified = true;
}
/// Get language enum from config
pub fn get_language(&self) -> Option<super::Language> {
super::Language::from_str(&self.config.language.output_language)
}
/// Check if commit types should be kept in English
pub fn keep_types_english(&self) -> bool {
self.config.language.keep_types_english
}
/// Set keep types English flag
pub fn set_keep_types_english(&mut self, keep: bool) {
self.config.language.keep_types_english = keep;
self.modified = true;
}
/// Check if changelog types should be kept in English
pub fn keep_changelog_types_english(&self) -> bool {
self.config.language.keep_changelog_types_english
}
/// Set keep changelog types English flag
pub fn set_keep_changelog_types_english(&mut self, keep: bool) {
self.config.language.keep_changelog_types_english = keep;
self.modified = true;
}
/// Export configuration to TOML string /// Export configuration to TOML string
pub fn export(&self) -> Result<String> { pub fn export(&self) -> Result<String> {
toml::to_string_pretty(&self.config) toml::to_string_pretty(&self.config)

View File

@@ -53,6 +53,10 @@ pub struct AppConfig {
/// Theme settings /// Theme settings
#[serde(default)] #[serde(default)]
pub theme: ThemeConfig, pub theme: ThemeConfig,
/// Language settings
#[serde(default)]
pub language: LanguageConfig,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -68,6 +72,7 @@ impl Default for AppConfig {
repo_profiles: HashMap::new(), repo_profiles: HashMap::new(),
encrypt_sensitive: true, encrypt_sensitive: true,
theme: ThemeConfig::default(), theme: ThemeConfig::default(),
language: LanguageConfig::default(),
} }
} }
} }
@@ -497,6 +502,83 @@ impl Default for ThemeConfig {
} }
} }
/// Language configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageConfig {
/// Output language for messages (en, zh, etc.)
#[serde(default = "default_output_language")]
pub output_language: String,
/// Keep commit types in English
#[serde(default = "default_true")]
pub keep_types_english: bool,
/// Keep changelog types in English
#[serde(default = "default_true")]
pub keep_changelog_types_english: bool,
}
impl Default for LanguageConfig {
fn default() -> Self {
Self {
output_language: default_output_language(),
keep_types_english: true,
keep_changelog_types_english: true,
}
}
}
/// Supported languages
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
English,
Chinese,
Japanese,
Korean,
Spanish,
French,
German,
}
impl Language {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"en" | "english" => Some(Language::English),
"zh" | "chinese" | "zh-cn" | "zh-tw" => Some(Language::Chinese),
"ja" | "japanese" => Some(Language::Japanese),
"ko" | "korean" => Some(Language::Korean),
"es" | "spanish" => Some(Language::Spanish),
"fr" | "french" => Some(Language::French),
"de" | "german" => Some(Language::German),
_ => None,
}
}
pub fn to_code(&self) -> &str {
match self {
Language::English => "en",
Language::Chinese => "zh",
Language::Japanese => "ja",
Language::Korean => "ko",
Language::Spanish => "es",
Language::French => "fr",
Language::German => "de",
}
}
pub fn display_name(&self) -> &str {
match self {
Language::English => "English",
Language::Chinese => "中文",
Language::Japanese => "日本語",
Language::Korean => "한국어",
Language::Spanish => "Español",
Language::French => "Français",
Language::German => "Deutsch",
}
}
}
// Default value functions // Default value functions
fn default_version() -> String { fn default_version() -> String {
"1".to_string() "1".to_string()
@@ -594,6 +676,10 @@ fn default_date_format() -> String {
"%Y-%m-%d".to_string() "%Y-%m-%d".to_string()
} }
fn default_output_language() -> String {
"en".to_string()
}
impl AppConfig { impl AppConfig {
/// Load configuration from file /// Load configuration from file
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {

View File

@@ -177,8 +177,17 @@ impl GitProfile {
if let Some(ref ssh) = self.ssh { if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path { if let Some(ref key_path) = ssh.private_key_path {
config.set_str("core.sshCommand", let path_str = key_path.display().to_string();
&format!("ssh -i {}", key_path.display()))?; #[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() { if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", 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(()) Ok(())
@@ -336,7 +369,15 @@ impl SshConfig {
if let Some(ref cmd) = self.ssh_command { if let Some(ref cmd) = self.ssh_command {
Some(cmd.clone()) Some(cmd.clone())
} else if let Some(ref key_path) = self.private_key_path { } 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 { } else {
None None
} }
@@ -496,7 +537,11 @@ pub struct ConfigDifference {
} }
fn default_gpg_program() -> String { 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 { fn default_true() -> bool {

View File

@@ -1,8 +1,7 @@
use crate::config::{CommitFormat, LlmConfig}; use crate::config::{CommitFormat, LlmConfig, Language};
use crate::git::{CommitInfo, GitRepo}; use crate::git::{CommitInfo, GitRepo};
use crate::llm::{GeneratedCommit, LlmClient}; use crate::llm::{GeneratedCommit, LlmClient};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::Utc;
/// Content generator using LLM /// Content generator using LLM
pub struct ContentGenerator { pub struct ContentGenerator {
@@ -27,6 +26,7 @@ impl ContentGenerator {
&self, &self,
diff: &str, diff: &str,
format: CommitFormat, format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
// Truncate diff if too long // Truncate diff if too long
let max_diff_len = 4000; let max_diff_len = 4000;
@@ -36,7 +36,7 @@ impl ContentGenerator {
diff.to_string() diff.to_string()
}; };
self.llm_client.generate_commit_message(&truncated_diff, format).await self.llm_client.generate_commit_message(&truncated_diff, format, language).await
} }
/// Generate commit message from repository changes /// Generate commit message from repository changes
@@ -44,6 +44,7 @@ impl ContentGenerator {
&self, &self,
repo: &GitRepo, repo: &GitRepo,
format: CommitFormat, format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
let diff = repo.get_staged_diff() let diff = repo.get_staged_diff()
.context("Failed to get staged diff")?; .context("Failed to get staged diff")?;
@@ -52,7 +53,7 @@ impl ContentGenerator {
anyhow::bail!("No staged changes to generate commit from"); anyhow::bail!("No staged changes to generate commit from");
} }
self.generate_commit_message(&diff, format).await self.generate_commit_message(&diff, format, language).await
} }
/// Generate tag message /// Generate tag message
@@ -60,13 +61,14 @@ impl ContentGenerator {
&self, &self,
version: &str, version: &str,
commits: &[CommitInfo], commits: &[CommitInfo],
language: Language,
) -> Result<String> { ) -> Result<String> {
let commit_messages: Vec<String> = commits let commit_messages: Vec<String> = commits
.iter() .iter()
.map(|c| c.subject().to_string()) .map(|c| c.subject().to_string())
.collect(); .collect();
self.llm_client.generate_tag_message(version, &commit_messages).await self.llm_client.generate_tag_message(version, &commit_messages, language).await
} }
/// Generate changelog entry /// Generate changelog entry
@@ -74,6 +76,7 @@ impl ContentGenerator {
&self, &self,
version: &str, version: &str,
commits: &[CommitInfo], commits: &[CommitInfo],
language: Language,
) -> Result<String> { ) -> Result<String> {
let typed_commits: Vec<(String, String)> = commits let typed_commits: Vec<(String, String)> = commits
.iter() .iter()
@@ -83,7 +86,7 @@ impl ContentGenerator {
}) })
.collect(); .collect();
self.llm_client.generate_changelog_entry(version, &typed_commits).await self.llm_client.generate_changelog_entry(version, &typed_commits, language).await
} }
/// Generate changelog from repository /// Generate changelog from repository
@@ -92,6 +95,7 @@ impl ContentGenerator {
repo: &GitRepo, repo: &GitRepo,
version: &str, version: &str,
from_tag: Option<&str>, from_tag: Option<&str>,
language: Language,
) -> Result<String> { ) -> Result<String> {
let commits = if let Some(tag) = from_tag { let commits = if let Some(tag) = from_tag {
repo.get_commits_between(tag, "HEAD")? repo.get_commits_between(tag, "HEAD")?
@@ -99,7 +103,7 @@ impl ContentGenerator {
repo.get_commits(50)? repo.get_commits(50)?
}; };
self.generate_changelog_entry(version, &commits).await self.generate_changelog_entry(version, &commits, language).await
} }
/// Interactive commit generation with user feedback /// Interactive commit generation with user feedback
@@ -107,9 +111,9 @@ impl ContentGenerator {
&self, &self,
repo: &GitRepo, repo: &GitRepo,
format: CommitFormat, format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
use dialoguer::{Confirm, Select}; use dialoguer::Select;
use console::Term;
let diff = repo.get_staged_diff()?; let diff = repo.get_staged_diff()?;
@@ -126,7 +130,7 @@ impl ContentGenerator {
// Generate initial commit // Generate initial commit
println!("\nGenerating commit message..."); println!("\nGenerating commit message...");
let mut generated = self.generate_commit_message(&diff, format).await?; let mut generated = self.generate_commit_message(&diff, format, language).await?;
loop { loop {
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
@@ -139,7 +143,6 @@ impl ContentGenerator {
"✓ Accept and commit", "✓ Accept and commit",
"🔄 Regenerate", "🔄 Regenerate",
"✏️ Edit", "✏️ Edit",
"📋 Copy to clipboard",
"❌ Cancel", "❌ Cancel",
]; ];
@@ -153,30 +156,19 @@ impl ContentGenerator {
0 => return Ok(generated), 0 => return Ok(generated),
1 => { 1 => {
println!("Regenerating..."); println!("Regenerating...");
generated = self.generate_commit_message(&diff, format).await?; generated = self.generate_commit_message(&diff, format, language).await?;
} }
2 => { 2 => {
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?; let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
generated = self.parse_edited_commit(&edited, format)?; generated = self.parse_edited_commit(&edited, format)?;
} }
3 => { 3 => anyhow::bail!("Cancelled by user"),
#[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"),
_ => {} _ => {}
} }
} }
} }
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); let parsed = crate::git::commit::parse_commit_message(edited);
Ok(GeneratedCommit { Ok(GeneratedCommit {

View File

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

View File

@@ -1,5 +1,5 @@
use super::GitRepo; use super::GitRepo;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Result};
use chrono::Local; use chrono::Local;
/// Commit builder for creating commits /// Commit builder for creating commits
@@ -47,6 +47,12 @@ impl CommitBuilder {
self self
} }
/// Set scope (optional)
pub fn scope_opt(mut self, scope: Option<String>) -> Self {
self.scope = scope;
self
}
/// Set description /// Set description
pub fn description(mut self, description: impl Into<String>) -> Self { pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into()); self.description = Some(description.into());
@@ -59,6 +65,12 @@ impl CommitBuilder {
self self
} }
/// Set body (optional)
pub fn body_opt(mut self, body: Option<String>) -> Self {
self.body = body;
self
}
/// Set footer /// Set footer
pub fn footer(mut self, footer: impl Into<String>) -> Self { pub fn footer(mut self, footer: impl Into<String>) -> Self {
self.footer = Some(footer.into()); self.footer = Some(footer.into());
@@ -174,8 +186,23 @@ impl CommitBuilder {
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); 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(()) Ok(())

View File

@@ -1,17 +1,173 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType}; use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf, Component};
use std::collections::HashMap; use std::collections::HashMap;
use tempfile;
pub mod changelog; pub mod changelog;
pub mod commit; pub mod commit;
pub mod tag; pub mod tag;
pub use changelog::ChangelogGenerator; #[cfg(target_os = "windows")]
pub use commit::CommitBuilder; use std::os::windows::ffi::OsStringExt;
pub use tag::TagBuilder;
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 { pub struct GitRepo {
repo: Repository, repo: Repository,
path: PathBuf, path: PathBuf,
@@ -19,28 +175,45 @@ pub struct GitRepo {
} }
impl GitRepo { impl GitRepo {
/// Open a git repository
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> { pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref(); let path = path.as_ref();
let absolute_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let absolute_path = get_absolute_path(path)?;
let repo = Repository::discover(&absolute_path) let resolved_path = resolve_path_without_canonicalize(&absolute_path);
.or_else(|_| Repository::open(&absolute_path))
.with_context(|| { let repo = try_open_repo_with_git2(&resolved_path)
format!( .or_else(|git2_err| {
"Failed to open git repository at '{:?}'. Please ensure:\n\ try_open_repo_with_git_cli(&resolved_path)
1. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\ .map_err(|cli_err| {
2. The path is correct and contains a valid '.git' folder.", let diagnosis = diagnose_repo_issue(&resolved_path);
absolute_path, anyhow::anyhow!(
absolute_path.display() "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(); let config = repo.config().ok();
Ok(Self { Ok(Self {
repo, repo,
path: absolute_path, path: normalize_path_for_git2(&repo_path),
config, config,
}) })
} }
@@ -157,28 +330,19 @@ impl GitRepo {
/// Get staged diff /// Get staged diff
pub fn get_staged_diff(&self) -> Result<String> { pub fn get_staged_diff(&self) -> Result<String> {
let head = self.repo.head().ok(); // Use git CLI to get staged diff for better compatibility
let head_tree = head.as_ref() let output = std::process::Command::new("git")
.and_then(|h| h.peel_to_tree().ok()); .args(&["diff", "--cached"])
.current_dir(&self.path)
.output()
.with_context(|| "Failed to get staged diff with git command")?;
let mut index = self.repo.index()?; if !output.status.success() {
let index_tree = index.write_tree()?; let stderr = String::from_utf8_lossy(&output.stderr);
let index_tree = self.repo.find_tree(index_tree)?; bail!("Failed to get staged diff: {}", stderr);
}
let diff = if let Some(head) = head_tree {
self.repo.diff_tree_to_index(Some(&head), Some(&index), None)?
} else {
self.repo.diff_tree_to_index(None, Some(&index), None)?
};
let mut diff_text = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
if let Ok(content) = std::str::from_utf8(line.content()) {
diff_text.push_str(content);
}
true
})?;
let diff_text = String::from_utf8_lossy(&output.stdout).to_string();
Ok(diff_text) Ok(diff_text)
} }
@@ -265,28 +429,21 @@ impl GitRepo {
/// Stage all changes including subdirectories /// Stage all changes including subdirectories
pub fn stage_all(&self) -> Result<()> { pub fn stage_all(&self) -> Result<()> {
let mut index = self.repo.index()?; // Use git command for reliable staging (handles all edge cases)
let output = std::process::Command::new("git")
.args(&["add", "-A"])
.current_dir(&self.path)
.output()
.with_context(|| "Failed to stage changes with git command")?;
fn add_directory_recursive(index: &mut git2::Index, base_dir: &Path, current_dir: &Path) -> Result<()> { if !output.status.success() {
for entry in std::fs::read_dir(current_dir) let stderr = String::from_utf8_lossy(&output.stderr);
.with_context(|| format!("Failed to read directory: {:?}", current_dir))? bail!("Failed to stage changes: {}", stderr);
{
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Ok(rel_path) = path.strip_prefix(base_dir) {
let _ = index.add_path(rel_path);
}
} else if path.is_dir() {
add_directory_recursive(index, base_dir, &path)?;
}
}
Ok(())
} }
add_directory_recursive(&mut index, &self.path, &self.path)?; // Force refresh the git2 index to pick up changes from git CLI
index.write()?; let _ = self.repo.index()?.write();
Ok(()) Ok(())
} }
@@ -357,19 +514,29 @@ impl GitRepo {
let temp_file = tempfile::NamedTempFile::new()?; let temp_file = tempfile::NamedTempFile::new()?;
std::fs::write(temp_file.path(), message)?; 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") let output = std::process::Command::new("git")
.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()]) .args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
.current_dir(&self.path) .current_dir(&self.path)
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); 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()?; let head = self.repo.head()?;
@@ -572,33 +739,50 @@ impl GitRepo {
/// Get repository status summary /// Get repository status summary
pub fn status_summary(&self) -> Result<StatusSummary> { pub fn status_summary(&self) -> Result<StatusSummary> {
let statuses = self.repo.statuses(Some(StatusOptions::new().include_untracked(true)))?; // Use git CLI for more reliable status detection
let output = std::process::Command::new("git")
.args(&["status", "--porcelain"])
.current_dir(&self.path)
.output()
.with_context(|| "Failed to get status with git command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to get status: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut staged = 0; let mut staged = 0;
let mut unstaged = 0; let mut unstaged = 0;
let mut untracked = 0; let mut untracked = 0;
let mut conflicted = 0; let mut conflicted = 0;
for entry in statuses.iter() { for line in stdout.lines() {
let status = entry.status(); if line.len() >= 2 {
let index_status = line.chars().next().unwrap();
let worktree_status = line.chars().nth(1).unwrap();
if status.is_index_new() || status.is_index_modified() || // Staged changes (first column not space)
status.is_index_deleted() || status.is_index_renamed() || if index_status != ' ' && index_status != '?' {
status.is_index_typechange() { staged += 1;
staged += 1; }
}
if status.is_wt_modified() || status.is_wt_deleted() || // Unstaged changes (second column not space)
status.is_wt_renamed() || status.is_wt_typechange() { if worktree_status != ' ' && worktree_status != '?' {
unstaged += 1; unstaged += 1;
} }
if status.is_wt_new() { // Untracked files (both columns are ?)
untracked += 1; if index_status == '?' && worktree_status == '?' {
} untracked += 1;
}
if status.is_conflicted() { // Conflicted files (both columns are U or DD, AA, etc.)
conflicted += 1; if (index_status == 'U' || worktree_status == 'U') ||
(index_status == 'A' && worktree_status == 'A') ||
(index_status == 'D' && worktree_status == 'D') {
conflicted += 1;
}
} }
} }
@@ -684,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> { pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
let start_path = start_path.as_ref(); 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); 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() { 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) { if let Ok(repo) = GitRepo::open(parent) {
return Ok(repo); return Ok(repo);
} }
current = parent; 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 /// Check if path is inside a git repository

View File

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

1151
src/i18n/messages.rs Normal file

File diff suppressed because it is too large Load Diff

5
src/i18n/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod messages;
pub mod translator;
pub use messages::Messages;
pub use translator::translate_changelog_category;

233
src/i18n/translator.rs Normal file
View File

@@ -0,0 +1,233 @@
use crate::config::Language;
pub struct Translator {
language: Language,
keep_types_english: bool,
keep_changelog_types_english: bool,
}
impl Translator {
pub fn new(language: Language, keep_types_english: bool, keep_changelog_types_english: bool) -> Self {
Self {
language,
keep_types_english,
keep_changelog_types_english,
}
}
pub fn translate_commit_type(&self, commit_type: &str) -> String {
if self.keep_types_english {
return commit_type.to_string();
}
match self.language {
Language::English => commit_type.to_string(),
Language::Chinese => self.translate_commit_type_zh(commit_type),
Language::Japanese => self.translate_commit_type_ja(commit_type),
Language::Korean => self.translate_commit_type_ko(commit_type),
Language::Spanish => self.translate_commit_type_es(commit_type),
Language::French => self.translate_commit_type_fr(commit_type),
Language::German => self.translate_commit_type_de(commit_type),
}
}
pub fn translate_changelog_category(&self, category: &str) -> String {
if self.keep_changelog_types_english {
return category.to_string();
}
match self.language {
Language::English => category.to_string(),
Language::Chinese => self.translate_changelog_category_zh(category),
Language::Japanese => self.translate_changelog_category_ja(category),
Language::Korean => self.translate_changelog_category_ko(category),
Language::Spanish => self.translate_changelog_category_es(category),
Language::French => self.translate_changelog_category_fr(category),
Language::German => self.translate_changelog_category_de(category),
}
}
fn translate_commit_type_zh(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "新功能".to_string(),
"fix" => "修复".to_string(),
"docs" => "文档".to_string(),
"style" => "样式".to_string(),
"refactor" => "重构".to_string(),
"perf" => "性能".to_string(),
"test" => "测试".to_string(),
"build" => "构建".to_string(),
"ci" => "CI".to_string(),
"chore" => "杂项".to_string(),
"revert" => "回滚".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_ja(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "機能".to_string(),
"fix" => "修正".to_string(),
"docs" => "ドキュメント".to_string(),
"style" => "スタイル".to_string(),
"refactor" => "リファクタリング".to_string(),
"perf" => "パフォーマンス".to_string(),
"test" => "テスト".to_string(),
"build" => "ビルド".to_string(),
"ci" => "CI".to_string(),
"chore" => "雑務".to_string(),
"revert" => "取り消し".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_ko(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "기능".to_string(),
"fix" => "버그 수정".to_string(),
"docs" => "문서".to_string(),
"style" => "스타일".to_string(),
"refactor" => "리팩토링".to_string(),
"perf" => "성능".to_string(),
"test" => "테스트".to_string(),
"build" => "빌드".to_string(),
"ci" => "CI".to_string(),
"chore" => "기타".to_string(),
"revert" => "되돌리기".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_es(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "nueva función".to_string(),
"fix" => "corrección".to_string(),
"docs" => "documentación".to_string(),
"style" => "estilo".to_string(),
"refactor" => "refactorización".to_string(),
"perf" => "rendimiento".to_string(),
"test" => "pruebas".to_string(),
"build" => "construcción".to_string(),
"ci" => "CI".to_string(),
"chore" => "tareas".to_string(),
"revert" => "revertir".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_fr(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "nouvelle fonctionnalité".to_string(),
"fix" => "correction".to_string(),
"docs" => "documentation".to_string(),
"style" => "style".to_string(),
"refactor" => "refactorisation".to_string(),
"perf" => "performance".to_string(),
"test" => "tests".to_string(),
"build" => "construction".to_string(),
"ci" => "CI".to_string(),
"chore" => "tâches".to_string(),
"revert" => "rétablir".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_de(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "Neue Funktion".to_string(),
"fix" => "Korrektur".to_string(),
"docs" => "Dokumentation".to_string(),
"style" => "Stil".to_string(),
"refactor" => "Refactoring".to_string(),
"perf" => "Leistung".to_string(),
"test" => "Tests".to_string(),
"build" => "Build".to_string(),
"ci" => "CI".to_string(),
"chore" => "Wartung".to_string(),
"revert" => "Zurücksetzen".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_changelog_category_zh(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "新增".to_string(),
"changed" => "更改".to_string(),
"deprecated" => "弃用".to_string(),
"removed" => "移除".to_string(),
"fixed" => "修复".to_string(),
"security" => "安全".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_ja(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "追加".to_string(),
"changed" => "変更".to_string(),
"deprecated" => "非推奨".to_string(),
"removed" => "削除".to_string(),
"fixed" => "修正".to_string(),
"security" => "セキュリティ".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_ko(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "추가됨".to_string(),
"changed" => "변경됨".to_string(),
"deprecated" => "사용 중단".to_string(),
"removed" => "제거됨".to_string(),
"fixed" => "수정됨".to_string(),
"security" => "보안".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_es(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "Agregado".to_string(),
"changed" => "Cambiado".to_string(),
"deprecated" => "Obsoleto".to_string(),
"removed" => "Eliminado".to_string(),
"fixed" => "Corregido".to_string(),
"security" => "Seguridad".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_fr(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "Ajouté".to_string(),
"changed" => "Modifié".to_string(),
"deprecated" => "Obsolète".to_string(),
"removed" => "Supprimé".to_string(),
"fixed" => "Corrigé".to_string(),
"security" => "Sécurité".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_de(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "Hinzugefügt".to_string(),
"changed" => "Geändert".to_string(),
"deprecated" => "Veraltet".to_string(),
"removed" => "Entfernt".to_string(),
"fixed" => "Behoben".to_string(),
"security" => "Sicherheit".to_string(),
_ => category.to_string(),
}
}
}
pub fn translate_commit_type(commit_type: &str, language: Language, keep_english: bool) -> String {
let translator = Translator::new(language, keep_english, true);
translator.translate_commit_type(commit_type)
}
pub fn translate_changelog_category(category: &str, language: Language, keep_english: bool) -> String {
let translator = Translator::new(language, true, keep_english);
translator.translate_changelog_category(category)
}

View File

@@ -70,10 +70,16 @@ impl AnthropicClient {
Ok(self) 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 /// Validate API key
pub async fn validate_key(&self) -> Result<bool> { pub async fn validate_key(&self) -> Result<bool> {
let url = "https://api.anthropic.com/v1/messages"; let url = "https://api.anthropic.com/v1/messages";
let request = MessagesRequest { let request = MessagesRequest {
model: self.model.clone(), model: self.model.clone(),
max_tokens: 5, max_tokens: 5,
@@ -84,7 +90,7 @@ impl AnthropicClient {
}], }],
system: None, system: None,
}; };
let response = self.client let response = self.client
.post(url) .post(url)
.header("x-api-key", &self.api_key) .header("x-api-key", &self.api_key)
@@ -93,7 +99,7 @@ impl AnthropicClient {
.json(&request) .json(&request)
.send() .send()
.await; .await;
match response { match response {
Ok(resp) => { Ok(resp) => {
if resp.status().is_success() { if resp.status().is_success() {

View File

@@ -82,25 +82,53 @@ impl DeepSeekClient {
Ok(self) Ok(self)
} }
/// Validate API key /// List available models
pub async fn validate_key(&self) -> Result<bool> { pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url); let url = format!("{}/models", self.base_url);
let response = self.client let response = self.client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
.send() .send()
.await .await
.context("Failed to validate DeepSeek API key")?; .context("Failed to list DeepSeek models")?;
if response.status().is_success() { if !response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); 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) Ok(self)
} }
/// Validate API key /// List available models
pub async fn validate_key(&self) -> Result<bool> { pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url); let url = format!("{}/models", self.base_url);
let response = self.client let response = self.client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
.send() .send()
.await .await
.context("Failed to validate Kimi API key")?; .context("Failed to list Kimi models")?;
if response.status().is_success() { if !response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); 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,7 +1,7 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
use crate::config::Language;
pub mod ollama; pub mod ollama;
pub mod openai; pub mod openai;
@@ -120,17 +120,22 @@ impl LlmClient {
&self, &self,
diff: &str, diff: &str,
format: crate::config::CommitFormat, format: crate::config::CommitFormat,
language: Language,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
let system_prompt = match format { let system_prompt = get_commit_system_prompt(format, language);
crate::config::CommitFormat::Conventional => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT // Add language instruction to the prompt
} let language_instruction = match language {
crate::config::CommitFormat::Commitlint => { Language::Chinese => "\n\n请用中文生成提交消息。",
COMMITLINT_SYSTEM_PROMPT Language::Japanese => "\n\n日本語でコミットメッセージを生成してください。",
} Language::Korean => "\n\n한국어로 커밋 메시지를 생성하세요.",
Language::Spanish => "\n\nPor favor, genera el mensaje de commit en español.",
Language::French => "\n\nVeuillez générer le message de commit en français.",
Language::German => "\n\nBitte generieren Sie die Commit-Nachricht auf Deutsch.",
Language::English => "",
}; };
let prompt = format!("{}", diff); let prompt = format!("{}{}", diff, language_instruction);
let response = self.provider.generate_with_system(system_prompt, &prompt).await?; let response = self.provider.generate_with_system(system_prompt, &prompt).await?;
self.parse_commit_response(&response, format) self.parse_commit_response(&response, format)
@@ -141,10 +146,23 @@ impl LlmClient {
&self, &self,
version: &str, version: &str,
commits: &[String], commits: &[String],
language: Language,
) -> Result<String> { ) -> Result<String> {
let system_prompt = TAG_MESSAGE_SYSTEM_PROMPT; let system_prompt = get_tag_system_prompt(language);
let commits_text = commits.join("\n"); let commits_text = commits.join("\n");
let prompt = format!("Version: {}\n\nCommits:\n{}", version, commits_text);
// Add language instruction to the prompt
let language_instruction = match language {
Language::Chinese => "\n\n请用中文生成标签消息。",
Language::Japanese => "\n\n日本語でタグメッセージを生成してください。",
Language::Korean => "\n\n한국어로 태그 메시지를 생성하세요.",
Language::Spanish => "\n\nPor favor, genera el mensaje de etiqueta en español.",
Language::French => "\n\nVeuillez générer le message de balise en français.",
Language::German => "\n\nBitte generieren Sie die Tag-Nachricht auf Deutsch.",
Language::English => "",
};
let prompt = format!("Version: {}\n\nCommits:\n{}{}", version, commits_text, language_instruction);
self.provider.generate_with_system(system_prompt, &prompt).await self.provider.generate_with_system(system_prompt, &prompt).await
} }
@@ -154,8 +172,9 @@ impl LlmClient {
&self, &self,
version: &str, version: &str,
commits: &[(String, String)], // (type, message) commits: &[(String, String)], // (type, message)
language: Language,
) -> Result<String> { ) -> Result<String> {
let system_prompt = CHANGELOG_SYSTEM_PROMPT; let system_prompt = get_changelog_system_prompt(language);
let commits_text = commits let commits_text = commits
.iter() .iter()
@@ -163,7 +182,18 @@ impl LlmClient {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let prompt = format!("Version: {}\n\nCommits:\n{}", version, commits_text); // Add language instruction to the prompt
let language_instruction = match language {
Language::Chinese => "\n\n请用中文生成变更日志。",
Language::Japanese => "\n\n日本語で変更ログを生成してください。",
Language::Korean => "\n\n한국어로 변경 로그를 생성하세요.",
Language::Spanish => "\n\nPor favor, genera el registro de cambios en español.",
Language::French => "\n\nVeuillez générer le journal des modifications en français.",
Language::German => "\n\nBitte generieren Sie das Changelog auf Deutsch.",
Language::English => "",
};
let prompt = format!("Version: {}\n\nCommits:\n{}{}", version, commits_text, language_instruction);
self.provider.generate_with_system(system_prompt, &prompt).await self.provider.generate_with_system(system_prompt, &prompt).await
} }
@@ -362,6 +392,58 @@ impl GeneratedCommit {
} }
} }
/// HTTP client helper
pub(crate) fn create_http_client(timeout: Duration) -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(timeout)
.build()
.context("Failed to create HTTP client")
}
/// Get commit system prompt based on format and language
fn get_commit_system_prompt(format: crate::config::CommitFormat, language: Language) -> &'static str {
match (format, language) {
(crate::config::CommitFormat::Conventional, Language::Chinese) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ZH,
(crate::config::CommitFormat::Conventional, Language::Japanese) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_JA,
(crate::config::CommitFormat::Conventional, Language::Korean) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_KO,
(crate::config::CommitFormat::Conventional, Language::Spanish) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ES,
(crate::config::CommitFormat::Conventional, Language::French) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_FR,
(crate::config::CommitFormat::Conventional, Language::German) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_DE,
(crate::config::CommitFormat::Conventional, _) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT,
(crate::config::CommitFormat::Commitlint, Language::Chinese) => COMMITLINT_SYSTEM_PROMPT_ZH,
(crate::config::CommitFormat::Commitlint, Language::Japanese) => COMMITLINT_SYSTEM_PROMPT_JA,
(crate::config::CommitFormat::Commitlint, Language::Korean) => COMMITLINT_SYSTEM_PROMPT_KO,
(crate::config::CommitFormat::Commitlint, Language::Spanish) => COMMITLINT_SYSTEM_PROMPT_ES,
(crate::config::CommitFormat::Commitlint, Language::French) => COMMITLINT_SYSTEM_PROMPT_FR,
(crate::config::CommitFormat::Commitlint, Language::German) => COMMITLINT_SYSTEM_PROMPT_DE,
(crate::config::CommitFormat::Commitlint, _) => COMMITLINT_SYSTEM_PROMPT,
}
}
fn get_tag_system_prompt(language: Language) -> &'static str {
match language {
Language::Chinese => TAG_MESSAGE_SYSTEM_PROMPT_ZH,
Language::Japanese => TAG_MESSAGE_SYSTEM_PROMPT_JA,
Language::Korean => TAG_MESSAGE_SYSTEM_PROMPT_KO,
Language::Spanish => TAG_MESSAGE_SYSTEM_PROMPT_ES,
Language::French => TAG_MESSAGE_SYSTEM_PROMPT_FR,
Language::German => TAG_MESSAGE_SYSTEM_PROMPT_DE,
_ => TAG_MESSAGE_SYSTEM_PROMPT,
}
}
fn get_changelog_system_prompt(language: Language) -> &'static str {
match language {
Language::Chinese => CHANGELOG_SYSTEM_PROMPT_ZH,
Language::Japanese => CHANGELOG_SYSTEM_PROMPT_JA,
Language::Korean => CHANGELOG_SYSTEM_PROMPT_KO,
Language::Spanish => CHANGELOG_SYSTEM_PROMPT_ES,
Language::French => CHANGELOG_SYSTEM_PROMPT_FR,
Language::German => CHANGELOG_SYSTEM_PROMPT_DE,
_ => CHANGELOG_SYSTEM_PROMPT,
}
}
// System prompts for LLM // System prompts for LLM
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates conventional commit messages. const CONVENTIONAL_COMMIT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates conventional commit messages.
@@ -394,6 +476,186 @@ Rules:
Output ONLY the commit message, nothing else. Output ONLY the commit message, nothing else.
"#; "#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成符合 Conventional Commits 规范的提交消息的助手。
分析提供的 git diff并生成符合 Conventional Commits 规范的提交消息。
格式: <type>[可选作用域]: <描述>
类型:
- feat: 新功能
- fix: 修复错误
- docs: 仅文档更改
- style: 不影响代码含义的更改(格式化、分号等)
- refactor: 既不修复错误也不添加功能的代码更改
- perf: 提高性能的代码更改
- test: 添加或更正测试
- build: 更改构建系统或依赖项
- ci: 更改 CI 配置
- chore: 其他不修改 src 或测试文件的更改
- revert: 撤销之前的提交
规则:
1. 类型和小写使用小写
2. 描述保持在 100 个字符以内
3. 使用祈使语气("添加"而不是"已添加"
4. 不要大写首字母
5. 结尾不要句号
6. 如果更改特定于模块/组件,请包含作用域
仅输出提交消息,不要输出其他内容。
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_JA: &str = r#"あなたはConventional Commits仕様に従ったコミットメッセージを生成するアシスタントです。
提供されたgit diffを分析し、Conventional Commits仕様に従ったコミットメッセージを生成してください。
形式: <type>[オプションのスコープ]: <説明>
タイプ:
- feat: 新機能
- fix: バグ修正
- docs: ドキュメントのみの変更
- style: コードの意味に影響しない変更(フォーマット、セミコロンなど)
- refactor: バグ修正や機能追加を伴わないコード変更
- perf: パフォーマンスを向上させるコード変更
- test: テストの追加または修正
- build: ビルドシステムまたは依存関係の変更
- ci: CI設定の変更
- chore: srcやテストファイルを変更しないその他の変更
- revert: 以前のコミットを取り消す
ルール:
1. タイプとスコープは小文字を使用
2. 説明は100文字以内にする
3. 命令形を使用する("追加"ではなく"追加する"
4. 先頭を大文字にしない
5. 最後にピリオドを付けない
6. 変更がモジュール/コンポーネントに固有の場合はスコープを含める
コミットメッセージのみを出力し、それ以外は出力しないでください。
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_KO: &str = r#"당신은 Conventional Commits 사양에 따른 커밋 메시지를 생성하는 도우미입니다.
제공된 git diff를 분석하고 Conventional Commits 사양에 따른 커밋 메시지를 생성하세요.
형식: <type>[선택적 범위]: <설명>
유형:
- feat: 새 기능
- fix: 버그 수정
- docs: 문서 변경만
- style: 코드 의미에 영향을 주지 않는 변경(서식, 세미콜론 등)
- refactor: 버그를 수정하거나 기능을 추가하지 않는 코드 변경
- perf: 성능을 향상시키는 코드 변경
- test: 테스트 추가 또는 수정
- build: 빌드 시스템 또는 종속성 변경
- ci: CI 구성 변경
- chore: src 또는 테스트 파일을 수정하지 않는 기타 변경
- revert: 이전 커밋 되돌리기
규칙:
1. 유형과 범위는 소문자 사용
2. 설명은 100자 이내로 유지
3. 명령형 사용("추가"가 아닌 "추가하다")
4. 첫 글자 대문자화하지 않음
5. 끝에 마침표 사용하지 않음
6. 변경 사항이 모듈/구성 요소에 특정한 경우 범위 포함
커밋 메시지만 출력하고 다른 내용은 출력하지 마세요.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de commit siguiendo la especificación Conventional Commits.
Analiza el diff de git proporcionado y genera un mensaje de commit siguiendo la especificación Conventional Commits.
Formato: <tipo>[alcance opcional]: <descripción>
Tipos:
- feat: Una nueva característica
- fix: Una corrección de error
- docs: Solo cambios en documentación
- style: Cambios que no afectan el significado del código (formato, punto y coma, etc.)
- refactor: Cambio de código que no corrige un error ni agrega una característica
- perf: Cambio de código que mejora el rendimiento
- test: Agregar o corregir pruebas
- build: Cambios en el sistema de construcción o dependencias
- ci: Cambios en la configuración de CI
- chore: Otros cambios que no modifican archivos src o de prueba
- revert: Revierte un commit anterior
Reglas:
1. Usa minúsculas para tipo y alcance
2. Mantén la descripción bajo 100 caracteres
3. Usa modo imperativo ("agregar" no "agregado")
4. No capitalices la primera letra
5. Sin punto al final
6. Incluye alcance si el cambio es específico de un módulo/componente
Genera SOLO el mensaje de commit, nada más.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages de commit suivant la spécification Conventional Commits.
Analysez le diff git fourni et générez un message de commit suivant la spécification Conventional Commits.
Format: <type>[portée optionnelle]: <description>
Types:
- feat: Une nouvelle fonctionnalité
- fix: Une correction de bug
- docs: Changements de documentation uniquement
- style: Changements qui n'affectent pas la signification du code (formatage, points-virgules, etc.)
- refactor: Changement de code qui ne corrige pas un bug ni n'ajoute une fonctionnalité
- perf: Changement de code qui améliore les performances
- test: Ajout ou correction de tests
- build: Changements du système de build ou des dépendances
- ci: Changements de la configuration CI
- chore: Autres changements qui ne modifient pas les fichiers src ou de test
- revert: Révertit un commit précédent
Règles:
1. Utilisez des minuscules pour le type et la portée
2. Gardez la description sous 100 caractères
3. Utilisez le mode impératif ("ajouter" non "ajouté")
4. Ne capitalisez pas la première lettre
5. Pas de point à la fin
6. Incluez la portée si le changement est spécifique à un module/composant
Générez SEULEMENT le message de commit, rien d'autre.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Commit-Nachrichten gemäß der Conventional Commits-Spezifikation generiert.
Analysieren Sie den bereitgestellten git diff und generieren Sie eine Commit-Nachricht gemäß der Conventional Commits-Spezifikation.
Format: <typ>[optionaler Bereich]: <beschreibung>
Typen:
- feat: Eine neue Funktion
- fix: Ein Bugfix
- docs: Nur Dokumentationsänderungen
- style: Änderungen, die die Code-Bedeutung nicht beeinflussen (Formatierung, Semikolons usw.)
- refactor: Code-Änderung, die weder einen Bug behebt noch eine Funktion hinzufügt
- perf: Code-Änderung, die die Leistung verbessert
- test: Hinzufügen oder Korrigieren von Tests
- build: Änderungen am Build-System oder Abhängigkeiten
- ci: Änderungen an der CI-Konfiguration
- chore: Andere Änderungen, die src- oder Testdateien nicht ändern
- revert: Setzt einen vorherigen Commit zurück
Regeln:
1. Verwenden Sie Kleinbuchstaben für Typ und Bereich
2. Halten Sie die Beschreibung unter 100 Zeichen
3. Verwenden Sie den Imperativ ("hinzufügen" nicht "hinzugefügt")
4. Großschreiben Sie den ersten Buchstaben nicht
5. Kein Punkt am Ende
6. Fügen Sie einen Bereich ein, wenn die Änderung spezifisch für ein Modul/Komponente ist
Geben Sie NUR die Commit-Nachricht aus, nichts anderes.
"#;
const COMMITLINT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates commit messages following @commitlint/config-conventional. const COMMITLINT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates commit messages following @commitlint/config-conventional.
Analyze the git diff and generate a commit message. Analyze the git diff and generate a commit message.
@@ -412,6 +674,114 @@ Rules:
Output ONLY the commit message, nothing else. Output ONLY the commit message, nothing else.
"#; "#;
const COMMITLINT_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成符合 @commitlint/config-conventional 规范的提交消息的助手。
分析 git diff 并生成提交消息。
格式: <type>[可选作用域]: <主题>
类型: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
规则:
1. 主题不应以大写字母开头
2. 主题不应以句号结尾
3. 主题应为 4-100 个字符
4. 使用祈使语气
5. 简洁但描述性强
仅输出提交消息,不要输出其他内容。
"#;
const COMMITLINT_SYSTEM_PROMPT_JA: &str = r#"あなたは@commitlint/config-conventionalに従ったコミットメッセージを生成するアシスタントです。
git diffを分析し、コミットメッセージを生成してください。
形式: <type>[オプションのスコープ]: <件名>
タイプ: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
ルール:
1. 件名は大文字で始めないでください
2. 件名はピリオドで終わらないでください
3. 件名は4-100文字である必要があります
4. 命令形を使用してください
5. 簡潔ですが説明的であること
コミットメッセージのみを出力し、それ以外は出力しないでください。
"#;
const COMMITLINT_SYSTEM_PROMPT_KO: &str = r#"당신은 @commitlint/config-conventional에 따른 커밋 메시지를 생성하는 도우미입니다.
git diff를 분석하고 커밋 메시지를 생성하세요.
형식: <type>[선택적 범위]: <제목>
유형: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
규칙:
1. 제목은 대문자로 시작하지 않아야 합니다
2. 제목은 마침표로 끝나지 않아야 합니다
3. 제목은 4-100자여야 합니다
4. 명령형을 사용하세요
5. 간결하지만 설명적이어야 합니다
커밋 메시지만 출력하고 다른 내용은 출력하지 마세요.
"#;
const COMMITLINT_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de commit siguiendo @commitlint/config-conventional.
Analiza el diff de git y genera un mensaje de commit.
Formato: <tipo>[alcance opcional]: <asunto>
Tipos: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Reglas:
1. El asunto no debe comenzar con mayúscula
2. El asunto no debe terminar con punto
3. El asunto debe tener 4-100 caracteres
4. Usa modo imperativo
5. Sé conciso pero descriptivo
Genera SOLO el mensaje de commit, nada más.
"#;
const COMMITLINT_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages de commit suivant @commitlint/config-conventional.
Analysez le diff git et générez un message de commit.
Format: <type>[portée optionnelle]: <sujet>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Règles:
1. Le sujet ne doit pas commencer par une majuscule
2. Le sujet ne doit pas se terminer par un point
3. Le sujet doit avoir 4-100 caractères
4. Utilisez le mode impératif
5. Soyez concis mais descriptif
Générez SEULEMENT le message de commit, rien d'autre.
"#;
const COMMITLINT_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Commit-Nachrichten gemäß @commitlint/config-conventional generiert.
Analysieren Sie den git diff und generieren Sie eine Commit-Nachricht.
Format: <typ>[optionaler Bereich]: <betreff>
Typen: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Regeln:
1. Der Betreff sollte nicht mit einem Großbuchstaben beginnen
2. Der Betreff sollte nicht mit einem Punkt enden
3. Der Betreff sollte 4-100 Zeichen haben
4. Verwenden Sie den Imperativ
5. Seien Sie prägnant aber beschreibend
Geben Sie NUR die Commit-Nachricht aus, nichts anderes.
"#;
const TAG_MESSAGE_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates git tag annotation messages. const TAG_MESSAGE_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates git tag annotation messages.
Given a version number and a list of commits, generate a concise but informative tag message. Given a version number and a list of commits, generate a concise but informative tag message.
@@ -432,6 +802,126 @@ Changes:
... ...
"#; "#;
const TAG_MESSAGE_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成 git 标签注释消息的助手。
给定版本号和提交列表,生成简洁但信息丰富的标签消息。
消息应该:
1. 以发布的简要摘要开始
2. 按类型分组更改(功能、修复等)
3. 适合 git 标注标签
格式:
<version> 发布
更改摘要...
更改:
- 功能:描述
- 修复:描述
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_JA: &str = r#"あなたはgitタグ注釈メッセージを生成するアシスタントです。
バージョン番号とコミットのリストを考慮して、簡潔ですが情報豊富なタグメッセージを生成してください。
メッセージは以下のようであるべきです:
1. リリースの簡単な要約から始める
2. タイプ別に変更をグループ化する(機能、修正など)
3. git注釈タグに適している
形式:
<version> リリース
変更の概要...
変更:
- 機能:説明
- 修正:説明
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_KO: &str = r#"당신은 git 태그 주석 메시지를 생성하는 도우미입니다.
버전 번호와 커밋 목록을 고려하여 간결하지만 정보가 풍부한 태그 메시지를 생성하세요.
메시지는 다음과 같아야 합니다:
1. 릴리스의 간단한 요약으로 시작
2. 유형별로 변경 사항 그룹화(기능, 수정 등)
3. git 주석 태그에 적합
형식:
<version> 릴리스
변경 사항 요약...
변경 사항:
- 기능: 설명
- 수정: 설명
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de anotación de etiquetas git.
Dado un número de versión y una lista de commits, genera un mensaje de etiqueta conciso pero informativo.
El mensaje debe:
1. Comenzar con un resumen breve de la versión
2. Agrupar cambios por tipo (características, correcciones, etc.)
3. Ser adecuado para una etiqueta git anotada
Formato:
<version> Versión
Resumen de cambios...
Cambios:
- Característica: descripción
- Corrección: descripción
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages d'annotation de balises git.
Étant donné un numéro de version et une liste de commits, générez un message de balise concis mais informatif.
Le message doit :
1. Commencer par un bref résumé de la version
2. Grouper les changements par type (fonctionnalités, corrections, etc.)
3. Être adapté à une balise git annotée
Format :
<version> Version
Résumé des changements...
Changements :
- Fonctionnalité : description
- Correction : description
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der git-Tag-Anmerkungsnachrichten generiert.
Gegeben eine Versionsnummer und eine Liste von Commits, generieren Sie eine prägnante aber informative Tag-Nachricht.
Die Nachricht sollte:
1. Mit einer kurzen Zusammenfassung der Version beginnen
2. Änderungen nach Typ gruppieren (Funktionen, Fixes, etc.)
3. Für ein git-annotiertes Tag geeignet sein
Format:
<version> Version
Zusammenfassung der Änderungen...
Änderungen:
- Funktion: Beschreibung
- Fix: Beschreibung
...
"#;
const CHANGELOG_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates changelog entries. const CHANGELOG_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates changelog entries.
Given a version and a list of commits, generate a well-formatted changelog section. Given a version and a list of commits, generate a well-formatted changelog section.
@@ -445,10 +935,80 @@ Group commits by:
Format in markdown with proper headings and bullet points. Format in markdown with proper headings and bullet points.
"#; "#;
/// HTTP client helper const CHANGELOG_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成变更日志条目的助手。
pub(crate) fn create_http_client(timeout: Duration) -> Result<reqwest::Client> {
reqwest::Client::builder() 给定版本和提交列表,生成格式良好的变更日志部分。
.timeout(timeout)
.build() 按以下方式分组提交:
.context("Failed to create HTTP client") - 功能 (feat)
} - 错误修复 (fix)
- 文档 (docs)
- 其他更改
使用适当的标题和项目符号以 markdown 格式输出。
"#;
const CHANGELOG_SYSTEM_PROMPT_JA: &str = r#"あなたは変更ログエントリを生成するアシスタントです。
バージョンとコミットのリストを考慮して、適切にフォーマットされた変更ログセクションを生成してください。
コミットを以下でグループ化してください:
- 機能 (feat)
- バグ修正 (fix)
- ドキュメント (docs)
- その他の変更
適切な見出しと箇条書きを使用してmarkdown形式でフォーマットしてください。
"#;
const CHANGELOG_SYSTEM_PROMPT_KO: &str = r#"당신은 변경 로그 항목을 생성하는 도우미입니다.
버전과 커밋 목록을 고려하여 잘 포맷된 변경 로그 섹션을 생성하세요.
다음으로 커밋을 그룹화하세요:
- 기능 (feat)
- 버그 수정 (fix)
- 문서 (docs)
- 기타 변경 사항
적절한 제목과 글머리 기호를 사용하여 markdown 형식으로 포맷하세요.
"#;
const CHANGELOG_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera entradas de registro de cambios.
Dada una versión y una lista de commits, genera una sección de registro de cambios bien formateada.
Agrupa los commits por:
- Características (feat)
- Correcciones de errores (fix)
- Documentación (docs)
- Otros cambios
Formatea en markdown con encabezados y viñetas apropiados.
"#;
const CHANGELOG_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des entrées de journal des modifications.
Étant donné une version et une liste de commits, générez une section de journal des modifications bien formatée.
Groupez les commits par :
- Fonctionnalités (feat)
- Corrections de bugs (fix)
- Documentation (docs)
- Autres modifications
Formatez en markdown avec des en-têtes et des puces appropriés.
"#;
const CHANGELOG_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Changelog-Einträge generiert.
Gegeben eine Version und eine Liste von Commits, generieren Sie einen gut formatierten Changelog-Abschnitt.
Gruppieren Sie Commits nach:
- Funktionen (feat)
- Bugfixes (fix)
- Dokumentation (docs)
- Andere Änderungen
Formatieren Sie in Markdown mit geeigneten Überschriften und Aufzählungspunkten.
"#;

View File

@@ -82,10 +82,10 @@ impl OpenRouterClient {
Ok(self) Ok(self)
} }
/// Validate API key /// List available models
pub async fn validate_key(&self) -> Result<bool> { pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url); let url = format!("{}/models", self.base_url);
let response = self.client let response = self.client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
@@ -93,16 +93,44 @@ impl OpenRouterClient {
.header("X-Title", "QuiCommit") .header("X-Title", "QuiCommit")
.send() .send()
.await .await
.context("Failed to validate OpenRouter API key")?; .context("Failed to list OpenRouter models")?;
if response.status().is_success() { if !response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); 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 /// 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 // Since OpenRouter supports many models, we'll allow any model name
// but provide some popular ones as suggestions // but provide some popular ones as suggestions
true true

View File

@@ -1,11 +1,13 @@
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing::debug; use tracing::debug;
mod commands; mod commands;
mod config; mod config;
mod generator; mod generator;
mod git; mod git;
mod i18n;
mod llm; mod llm;
mod utils; mod utils;
@@ -73,7 +75,6 @@ enum Commands {
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
// Initialize logging
let log_level = match cli.verbose { let log_level = match cli.verbose {
0 => "warn", 0 => "warn",
1 => "info", 1 => "info",
@@ -88,13 +89,14 @@ async fn main() -> Result<()> {
debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION")); debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION"));
// Execute command let config_path: Option<PathBuf> = cli.config.map(PathBuf::from);
match cli.command { match cli.command {
Commands::Init(cmd) => cmd.execute().await, Commands::Init(cmd) => cmd.execute(config_path).await,
Commands::Commit(cmd) => cmd.execute().await, Commands::Commit(cmd) => cmd.execute(config_path).await,
Commands::Tag(cmd) => cmd.execute().await, Commands::Tag(cmd) => cmd.execute(config_path).await,
Commands::Changelog(cmd) => cmd.execute().await, Commands::Changelog(cmd) => cmd.execute(config_path).await,
Commands::Profile(cmd) => cmd.execute().await, Commands::Profile(cmd) => cmd.execute(config_path).await,
Commands::Config(cmd) => cmd.execute().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")) .or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
if cfg!(target_os = "windows") { 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() "notepad".to_string()
} else if cfg!(target_os = "macos") {
if which::which("code").is_ok() {
return "code --wait".to_string();
}
"vi".to_string()
} else { } else {
if which::which("nano").is_ok() {
return "nano".to_string();
}
"vi".to_string() "vi".to_string()
} }
}) })

View File

@@ -1,4 +1,3 @@
use chrono::{DateTime, Local, Utc};
use regex::Regex; use regex::Regex;
/// Format commit message with conventional commit format /// Format commit message with conventional commit format
@@ -12,7 +11,6 @@ pub fn format_conventional_commit(
) -> String { ) -> String {
let mut message = String::new(); let mut message = String::new();
// Type and scope
message.push_str(commit_type); message.push_str(commit_type);
if let Some(s) = scope { if let Some(s) = scope {
message.push_str(&format!("({})", s)); message.push_str(&format!("({})", s));
@@ -22,12 +20,10 @@ pub fn format_conventional_commit(
} }
message.push_str(&format!(": {}", description)); message.push_str(&format!(": {}", description));
// Body
if let Some(b) = body { if let Some(b) = body {
message.push_str(&format!("\n\n{}", b)); message.push_str(&format!("\n\n{}", b));
} }
// Footer
if let Some(f) = footer { if let Some(f) = footer {
message.push_str(&format!("\n\n{}", f)); message.push_str(&format!("\n\n{}", f));
} }
@@ -46,26 +42,22 @@ pub fn format_commitlint_commit(
) -> String { ) -> String {
let mut message = String::new(); let mut message = String::new();
// Header
message.push_str(commit_type); message.push_str(commit_type);
if let Some(s) = scope { if let Some(s) = scope {
message.push_str(&format!("({})", s)); message.push_str(&format!("({})", s));
} }
message.push_str(&format!(": {}", subject)); message.push_str(&format!(": {}", subject));
// References
if let Some(refs) = references { if let Some(refs) = references {
for reference in refs { for reference in refs {
message.push_str(&format!(" #{}", reference)); message.push_str(&format!(" #{}", reference));
} }
} }
// Body
if let Some(b) = body { if let Some(b) = body {
message.push_str(&format!("\n\n{}", b)); message.push_str(&format!("\n\n{}", b));
} }
// Footer
if let Some(f) = footer { if let Some(f) = footer {
message.push_str(&format!("\n\n{}", f)); message.push_str(&format!("\n\n{}", f));
} }
@@ -73,38 +65,11 @@ pub fn format_commitlint_commit(
message 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 /// Wrap text at specified width
pub fn wrap_text(text: &str, width: usize) -> String { pub fn wrap_text(text: &str, width: usize) -> String {
textwrap::fill(text, width) 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) /// Clean commit message (remove comments, extra whitespace)
pub fn clean_message(message: &str) -> String { pub fn clean_message(message: &str) -> String {
let comment_regex = Regex::new(r"^#.*$").unwrap(); let comment_regex = Regex::new(r"^#.*$").unwrap();
@@ -118,44 +83,6 @@ pub fn clean_message(message: &str) -> String {
.to_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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -189,10 +116,4 @@ mod tests {
assert!(msg.starts_with("feat!: change API response format")); 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,}$" r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
).unwrap(); ).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 /// Regex for GPG key ID validation
static ref GPG_KEY_ID_REGEX: Regex = Regex::new( static ref GPG_KEY_ID_REGEX: Regex = Regex::new(
r"^[A-F0-9]{16,40}$" 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 { if first_line.len() > 100 {
bail!("Commit subject too long (max 100 characters)"); 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<()> { pub fn validate_commitlint_commit(message: &str) -> Result<()> {
let first_line = message.lines().next().unwrap_or(""); 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(); let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 { if parts.len() != 2 {
bail!("Invalid commit format. Expected: <type>[optional scope]: <subject>"); 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 type_part = parts[0];
let subject = parts[1].trim(); let subject = parts[1].trim();
// Extract type (handle scope and breaking indicator)
let commit_type = type_part let commit_type = type_part
.split('(') .split('(')
.next() .next()
@@ -117,7 +109,6 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
); );
} }
// Validate subject
if subject.is_empty() { if subject.is_empty() {
bail!("Commit subject cannot be 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)"); 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) { if subject.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
bail!("Commit subject should not start with uppercase letter"); bail!("Commit subject should not start with uppercase letter");
} }
// Subject should not end with period
if subject.ends_with('.') { if subject.ends_with('.') {
bail!("Commit subject should not end with a period"); bail!("Commit subject should not end with a period");
} }
@@ -179,15 +168,6 @@ pub fn validate_email(email: &str) -> Result<()> {
Ok(()) 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 /// Validate GPG key ID
pub fn validate_gpg_key_id(key_id: &str) -> Result<()> { pub fn validate_gpg_key_id(key_id: &str) -> Result<()> {
if !GPG_KEY_ID_REGEX.is_match(key_id) { if !GPG_KEY_ID_REGEX.is_match(key_id) {

View File

@@ -1,64 +1,642 @@
use assert_cmd::Command; use assert_cmd::Command;
use predicates::prelude::*; use predicates::prelude::*;
use std::fs; use std::fs;
use std::path::PathBuf;
use tempfile::TempDir; use tempfile::TempDir;
#[test] fn create_git_repo(dir: &PathBuf) -> std::process::Output {
fn test_cli_help() { std::process::Command::new("git")
let mut cmd = Command::cargo_bin("quicommit").unwrap(); .args(&["init"])
cmd.arg("--help"); .current_dir(dir)
cmd.assert() .output()
.success() .expect("Failed to init git repo")
.stdout(predicate::str::contains("QuiCommit"));
} }
#[test] fn configure_git_user(dir: &PathBuf) {
fn test_version() { std::process::Command::new("git")
let mut cmd = Command::cargo_bin("quicommit").unwrap(); .args(&["config", "user.name", "Test User"])
cmd.arg("--version"); .current_dir(dir)
cmd.assert() .output()
.success() .expect("Failed to configure git user name");
.stdout(predicate::str::contains("0.1.0"));
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 create_test_file(dir: &PathBuf, name: &str, content: &str) {
fn test_config_show() { let file_path = dir.join(name);
let temp_dir = TempDir::new().unwrap(); fs::write(&file_path, content).expect("Failed to create test file");
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();
} }
#[test] fn stage_file(dir: &PathBuf, name: &str) {
fn test_profile_list_empty() { std::process::Command::new("git")
let temp_dir = TempDir::new().unwrap(); .args(&["add", name])
.current_dir(dir)
let mut cmd = Command::cargo_bin("quicommit").unwrap(); .output()
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml")) .expect("Failed to stage file");
.arg("profile")
.arg("list");
cmd.assert()
.success()
.stdout(predicate::str::contains("No profiles configured"));
} }
#[test] fn create_commit(dir: &PathBuf, message: &str) {
fn test_init_quick() { std::process::Command::new("git")
let temp_dir = TempDir::new().unwrap(); .args(&["commit", "-m", message])
.current_dir(dir)
let mut cmd = Command::cargo_bin("quicommit").unwrap(); .output()
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml")) .expect("Failed to create commit");
.arg("init") }
.arg("--yes");
mod cli_basic {
cmd.assert() use super::*;
.success()
.stdout(predicate::str::contains("initialized successfully")); #[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"));
}
} }