11 Commits

Author SHA1 Message Date
d6958dc41e chore: 升级版本至 0.7.0 并启用 regex unicode-perl 特性 2026-08-21 14:07:02 +08:00
a40c88c768 feat(changelog): 添加 --no-generate 标志以支持纯模板生成
- 新增 `--no-generate` 参数,强制使用模板生成而非 AI
- 调整 `--yes` 参数仅跳过交互提示,不改变生成行为
- 统一使用工具函数替代 println 输出
2026-08-21 13:57:25 +08:00
e6b8b344aa refactor: 将 lazy_static 和 atty 替换为标准库实现以减少依赖 2026-08-18 13:45:13 +08:00
a0ea03fd90 chore(release): bump version to 0.6.0 and update changelog
- CHANGELOG 记录 LLM 层迁移至 rig-core 0.41 及四项行为变化
- README(中/英)项目结构树更新为新 llm 模块布局
- 仓库内 AI 服务集成文档同步为新实现(.qoder,不入库)
2026-08-17 16:08:46 +08:00
3c2b96a4d1 refactor(llm): 删除手写 provider 实现与冗余依赖
- 删除 6 个手写 provider 文件、LlmProvider trait、动态分发、HTTP 客户端工厂与门面死代码
- llm 模块收敛为 rig 门面 + 提示词/解析/思考状态四个模块
- 移除 reqwest(0.12) 与 async-trait 直接依赖(futures-util 由流式消费保留)
- 依赖树确认无 rig-agent/fastembed/lancedb/milvus 等组件
2026-08-17 16:04:26 +08:00
16ffc94a06 feat(llm): 生成器与连接测试切换到新 rig 门面(全量上线)
- 新门面补齐 commit/tag/changelog 三个业务方法(提示词与解析复用迁移模块)
- ContentGenerator 改用 rig 门面(构造/可用性检查/三个生成方法)
- test_connection 走新门面公开接口,不再访问私有字段
- 新增离线业务方法测试与 6 家 provider 的 #[ignore] 实网冒烟用例
2026-08-17 15:56:56 +08:00
9f177f7a1f feat(llm): 新门面接入 OpenAI 与 OpenRouter
- OpenAI 使用 chat completions 协议面(completions_api),兼容第三方兼容网关
- o 系列模型非 thinking 传 reasoning_effort=none;thinking 走流式思考显示
- OpenRouter 携带 QuiCommit 应用标识头(X-OpenRouter-Title/HTTP-Referer)
- 新增 5 个测试:请求形状、o 系列规则、标识头、流式事件
2026-08-17 15:52:41 +08:00
18728a4a2e feat(llm): 新门面接入 Anthropic(thinking 预算与温度规则)
- provider 枚举增加 Anthropic 变体;base_url 由 rig 规范化(修复旧实现忽略配置)
- thinking 透传 thinking 块与预算 token(缺省 1024);thinking 时省略温度
- max_tokens 下限规则:不低于预算 + 100(沿用旧实现)
- 流式思考事件驱动显示与正文聚合与 DeepSeek/Kimi 机制一致
- 新增 5 个测试:请求形状、request_options 规则、SSE 事件序列
2026-08-17 15:47:31 +08:00
f534ccc698 feat(llm): 新门面接入 DeepSeek 与 Kimi,实现 thinking 流式机制
- provider 枚举增加 DeepSeek/Kimi 变体(base_url/API key 走配置)
- thinking 参数经 additional_params 透传(enabled/disabled 显式发送)
- Kimi 温度特例:thinking 恒 1.0、普通恒 0.6;DeepSeek thinking 省略温度
- 流式路径:Reasoning/ReasoningDelta 事件驱动思考状态显示,正文聚合,
  仅思考无正文时给出与旧实现一致的错误提示
- 新增 7 个测试:请求形状捕获 + SSE mock 事件序列验证
2026-08-17 15:42:12 +08:00
29c6ff3935 feat(llm): 引入 rig-core 并建立新 LLM 门面(Ollama 首个打通)
- 依赖改用 rig-core 0.41(仅 reqwest+rustls,不引入 agent/derive)
- 新门面:provider 枚举 + 统一配置 + 单一生成入口 + 凭据校验 + 错误映射
- Ollama 走 rig /api/chat,参数映射与错误文案与旧实现一致
- 用 rig 请求录制 mock 断言请求形状;8 个新单元测试
2026-08-17 15:33:00 +08:00
bba15501c6 refactor(llm): 拆分提示词与提交解析逻辑到独立模块
将 21 个多语言提示词及选择函数迁入 prompts 模块,将 commit 响应
解析与 GeneratedCommit 迁入 parsing 模块,为 rig 迁移期间新旧
实现共享同一份提示词与解析逻辑做准备。无行为变化。
2026-08-17 15:21:46 +08:00
34 changed files with 8107 additions and 5741 deletions

View File

@@ -9,6 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
暂无。
## [0.6.1] - 2026-08-18
### 🔧 其他变更
- 依赖瘦身:移除未使用的直接依赖 `config``handlebars``shell-words``walkdir``console``lazy_static``atty`crate 总数由 341 降至 296release 二进制体积由 7.38 MB 降至 7.00 MB-5.2%
- `lazy_static!` 宏替换为标准库 `LazyLock``atty` 替换为标准库 `std::io::IsTerminal`
- `tokio` 特性由 `full` 精简为 `macros` + `rt-multi-thread`
- `regex` 关闭默认 unicode 特性(保留 `std` + `perf`),行为变化:`\s`/`\d`/`\w` 字符类仅匹配 ASCII对提交信息、版本号、邮箱、GPG Key ID 校验无实质影响
## [0.6.0] - 2026-08-17
### ✨ 新功能
- LLM 层整体迁移至 rig-core 0.41:六家 providerOllama / OpenAI / Anthropic / Kimi / DeepSeek / OpenRouter统一由 rig 原生客户端驱动,删除约 2100 行手写 HTTP/SSE/重试代码
- 新 LLM 门面(`src/llm/rig/`provider 枚举 + 单一生成入口 + 凭据校验rig VerifyClient+ 错误映射层;提示词与提交解析拆分至独立模块
- Anthropic 支持自定义 `base_url`(此前被静默忽略,本次修复)
- 新增基于 rig mock 后端的 LLM 层离线测试(请求形状、流式事件、错误映射),并附 6 家 provider 的 `#[ignore]` 实网冒烟用例
### 🐞 错误修复
- 各 provider 超时统一由 `llm.timeout` 配置控制(此前默认超时 60/120/300 秒不一致)
- 消除 Ollama 客户端构造时的 panic`expect` 式构造)
### 🔧 其他变更(行为变化)
- Ollama 请求端点由 `/api/generate` 改为 `/api/chat`(功能等价;自定义 Ollama 代理需兼容 chat 端点)
- 非流式请求不再做应用层重试(此前按错误文案字符串匹配重试 3 次);流式请求由 rig 的 SSE 机制自动重连
- 可用性校验端点变化DeepSeek 改为 `/user/balance`、OpenRouter 改为 `/key`、Anthropic 改为 `/v1/models`(对外表现为 `LLM provider 'xxx' is not available` 语义不变)
- 依赖瘦身:移除 `reqwest`(0.12) 与 `async-trait` 直接依赖,不引入 rig agent/fastembed/lancedb 等组件
## [0.5.0] - 2026-07-24
### ✨ 新功能

View File

@@ -1,6 +1,6 @@
[package]
name = "quicommit"
version = "0.5.5"
version = "0.7.0"
edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
@@ -19,11 +19,9 @@ path = "src/main.rs"
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
clap_complete = "4.5"
dialoguer = "0.11"
console = "0.15"
indicatif = "0.17"
# Configuration management
config = "0.14"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
dirs = "5.0"
@@ -32,9 +30,7 @@ dirs = "5.0"
git2 = "0.20.3"
which = "6.0"
# HTTP client for LLM APIs
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
tokio = { version = "1.35", features = ["full"] }
tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] }
# Error handling
thiserror = "1.0"
@@ -46,21 +42,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# Utilities
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
regex = { version = "1.10", default-features = false, features = ["std", "perf", "unicode-perl"] }
roxmltree = "0.20"
lazy_static = "1.4"
colored = "2.1"
handlebars = "5.1"
semver = "1.0"
walkdir = "2.4"
tempfile = "3.9"
sha2 = "0.10"
hex = "0.4"
textwrap = "0.16"
async-trait = "0.1"
futures-util = "0.3"
serde_json = "1.0"
atty = "0.2"
# Encryption for sensitive data (SSH keys, GPG, etc.)
aes-gcm = "0.10"
@@ -74,8 +65,8 @@ keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "sy
# Interactive editor
edit = "0.1"
# Shell completion generation
shell-words = "1.1"
# LLM integration (rig-core only: HTTP backend + rustls; no agent/derive)
rig-core = { version = "0.41", default-features = false, features = ["reqwest", "rustls"] }
[dev-dependencies]
assert_cmd = "2.0"
@@ -83,6 +74,9 @@ predicates = "3.1"
tempfile = "3.9"
mockall = "0.12"
wiremock = "0.6"
# rig mock HTTP backends for LLM layer tests
rig-core = { version = "0.41", default-features = false, features = ["test-utils"] }
http = "1"
[profile.release]
opt-level = "s"

View File

@@ -236,6 +236,10 @@ quicommit profile token
quicommit config set-llm ollama
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
> ⚠️ **Security note**: passing your API key as a CLI argument puts it in your shell
> history and process list. Prefer running `quicommit config set-api-key` (or
> `config set-llm`) without the key to enter it via a hidden prompt instead.
# Configure OpenAI
quicommit config set-llm openai
quicommit config set-api-key YOUR_API_KEY
@@ -329,7 +333,7 @@ quicommit config reset --force
| `--commitlint` | Use commitlint format |
| `--no-verify` | Skip commit message verification |
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation |
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
| `--push` | Push after committing |
| `--remote` | Specify remote repository (default: origin) |
@@ -349,7 +353,7 @@ quicommit config reset --force
| `-r, --remote` | Specify remote repository (default: origin) |
| `--dry-run` | Dry run |
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation |
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
### Changelog Options
@@ -366,7 +370,8 @@ quicommit config reset --force
| `--format` | Format (keep-a-changelog, github-releases) |
| `--dry-run` | Dry run (output to stdout) |
| `--think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation |
| `--no-generate` | Generate deterministically from template (no AI call) |
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
## Configuration File
@@ -425,6 +430,9 @@ auto_generate = true
path = "CHANGELOG.md"
auto_generate = true
[output]
emoji = true
[repo_profiles]
"/path/to/work/project" = "work"
"/path/to/personal/project" = "personal"
@@ -436,7 +444,15 @@ auto_generate = true
|----------|-------------|
| `QUICOMMIT_CONFIG` | Configuration file path |
| `EDITOR` | Default editor |
| `NO_COLOR` | Disable colored output |
| `NO_COLOR` | Disable colored output and emoji decorations (any value) |
| `RUST_LOG` | Overrides `-v` log filtering (e.g. `debug`) |
## Global Options
- `-v, --verbose` (repeatable): `-v` shows info logs, `-vv` debug, `-vvv` trace.
- `--no-color`: disable colors **and** emoji decorations (highest priority).
- `--emoji` / `--no-emoji`: force emoji decorations on/off, overriding the `output.emoji` config value.
- Emoji decorations can also be configured via `config set output.emoji true|false`.
## Troubleshooting
@@ -453,7 +469,7 @@ quicommit config set llm.provider ollama
# Get configuration value
quicommit config get llm.provider
# Set API key (stored in system keyring)
# Set API key (stored in system keyring; omit the value for hidden prompt input)
quicommit config set-api-key YOUR_API_KEY
# Delete API key from keyring
@@ -542,13 +558,12 @@ src/
│ ├── commit.rs
│ ├── tag.rs
│ └── changelog.rs
├── llm/ # LLM provider implementations
│ ├── ollama.rs
│ ├── openai.rs
│ ├── anthropic.rs
│ ├── kimi.rs
── deepseek.rs
│ └── openrouter.rs
├── llm/ # LLM integration (rig-core based)
│ ├── mod.rs
│ ├── prompts.rs
│ ├── parsing.rs
│ ├── thinking.rs
── rig/ # rig provider facade
├── i18n/ # Internationalization support
│ ├── messages.rs
│ └── translator.rs

View File

@@ -230,6 +230,10 @@ quicommit profile token
quicommit config set-llm ollama
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
> ⚠️ **安全提示**:把 API 密钥作为命令行参数传入会留在 shell history 与进程列表中。
> 建议直接运行 `quicommit config set-api-key`(或 `config set-llm`)不携带密钥,
> 以隐藏方式输入。
# 配置OpenAI
quicommit config set-llm openai
quicommit config set-api-key YOUR_API_KEY
@@ -323,7 +327,7 @@ quicommit config reset --force
| `--commitlint` | 使用commitlint格式 |
| `--no-verify` | 不验证提交信息 |
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 |
| `-y, --yes` | 跳过确认提示(生成行为不变) |
| `--push` | 提交后推送到远程 |
| `--remote` | 指定远程仓库默认origin |
@@ -343,7 +347,7 @@ quicommit config reset --force
| `-r, --remote` | 指定远程仓库默认origin |
| `--dry-run` | 试运行 |
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 |
| `-y, --yes` | 跳过确认提示(生成行为不变) |
### changelog命令选项
@@ -360,7 +364,8 @@ quicommit config reset --force
| `--format` | 格式keep-a-changelog、github-releases |
| `--dry-run` | 试运行输出到stdout |
| `--think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 |
| `--no-generate` | 使用模板确定性生成(不调用 AI |
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
## 配置文件
@@ -419,6 +424,9 @@ auto_generate = true
path = "CHANGELOG.md"
auto_generate = true
[output]
emoji = true
[repo_profiles]
"/path/to/work/project" = "work"
"/path/to/personal/project" = "personal"
@@ -430,7 +438,15 @@ auto_generate = true
|--------|------|
| `QUICOMMIT_CONFIG` | 配置文件路径 |
| `EDITOR` | 默认编辑器 |
| `NO_COLOR` | 禁用彩色输出 |
| `NO_COLOR` | 禁用彩色与 Emoji 装饰输出(任意值即生效) |
| `RUST_LOG` | 覆盖 `-v` 的日志过滤(例如 `debug` |
## 全局选项
- `-v, --verbose`(可叠加):`-v` 显示 info 日志、`-vv` debug、`-vvv` trace。
- `--no-color`:禁用彩色**与** Emoji 装饰(最高优先级)。
- `--emoji` / `--no-emoji`:强制开启/关闭 Emoji 装饰,覆盖 `output.emoji` 配置。
- Emoji 装饰也可通过 `quicommit config set output.emoji true|false` 配置。
## 故障排除
@@ -447,7 +463,7 @@ quicommit config set llm.provider ollama
# 获取配置值
quicommit config get llm.provider
# 设置API密钥存储在系统密钥环中
# 设置API密钥存储在系统密钥环中;省略密钥值可通过隐藏提示输入
quicommit config set-api-key YOUR_API_KEY
# 从密钥环删除API密钥
@@ -536,13 +552,12 @@ src/
│ ├── commit.rs
│ ├── tag.rs
│ └── changelog.rs
├── llm/ # LLM提供商实现
│ ├── ollama.rs
│ ├── openai.rs
│ ├── anthropic.rs
│ ├── kimi.rs
── deepseek.rs
│ └── openrouter.rs
├── llm/ # LLM 集成(基于 rig-core
│ ├── mod.rs
│ ├── prompts.rs
│ ├── parsing.rs
│ ├── thinking.rs
── rig/ # rig provider 门面
├── i18n/ # 国际化支持
│ ├── messages.rs
│ └── translator.rs

View File

@@ -11,6 +11,7 @@ use crate::git::GitRepo;
use crate::git::find_repo;
use crate::git::{CommitInfo, changelog::*};
use crate::i18n::{Messages, translate_changelog_category};
use crate::utils::{print_progress, print_success, print_warning};
/// Generate changelog
#[derive(Parser)]
@@ -60,7 +61,11 @@ pub struct ChangelogCommand {
#[arg(long)]
think: bool,
/// Skip interactive prompts
/// Generate deterministically from template (no AI call)
#[arg(long, conflicts_with = "generate")]
no_generate: bool,
/// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)]
yes: bool,
}
@@ -85,7 +90,7 @@ impl ChangelogCommand {
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
init_changelog(&path)?;
println!("{}", messages.initialized_changelog(&format!("{:?}", path)));
print_success(&messages.initialized_changelog(&path.display().to_string()));
return Ok(());
}
@@ -101,10 +106,7 @@ impl ChangelogCommand {
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
Some("custom") => ChangelogFormat::Custom,
None => ChangelogFormat::KeepAChangelog,
Some(f) => bail!(
"Unknown format: {}. Use: keep-a-changelog, github-releases",
f
),
Some(f) => bail!("{}", messages.unknown_changelog_format(f)),
};
// Get version
@@ -120,7 +122,7 @@ impl ChangelogCommand {
};
// Get commits
println!("{}", messages.fetching_commits());
print_progress(messages.fetching_commits());
// Determine from_tag: use explicit --from, or auto-detect from changelog
let from_tag = self.resolve_from_tag(&repo, &output_path, &messages);
@@ -130,10 +132,10 @@ impl ChangelogCommand {
bail!("{}", messages.no_commits_found());
}
println!("{}", messages.found_commits(commits.len()));
print_success(&messages.found_commits(commits.len()));
// Generate changelog
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) {
let changelog = if self.generate || (config.changelog.auto_generate && !self.no_generate) {
self.generate_with_ai(&version, &commits, &messages).await?
} else {
self.generate_with_template(format, &version, &commits, language)?
@@ -161,12 +163,12 @@ impl ChangelogCommand {
println!("{}", "".repeat(60));
let confirm = Confirm::new()
.with_prompt(messages.write_to_file(&format!("{:?}", output_path)))
.with_prompt(messages.write_to_file(&output_path.display().to_string()))
.default(true)
.interact()?;
if !confirm {
println!("{}", messages.cancelled().yellow());
print_warning(messages.cancelled());
return Ok(());
}
}
@@ -185,7 +187,11 @@ impl ChangelogCommand {
std::fs::write(&output_path, content)?;
}
println!("{} {:?}", messages.changelog_written(), output_path);
print_success(&format!(
"{} {}",
messages.changelog_written(),
output_path.display()
));
Ok(())
}
@@ -221,12 +227,13 @@ impl ChangelogCommand {
let manager = ConfigManager::new()?;
let language = manager.get_language().unwrap_or(Language::English);
println!("{}", messages.ai_generating_changelog());
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
generator
let spinner = crate::utils::Spinner::start(messages.ai_generating_changelog());
let result = generator
.generate_changelog_entry(version, commits, language)
.await
.await;
spinner.finish_clear();
result
}
fn generate_with_template(

View File

@@ -11,6 +11,7 @@ use crate::git::commit::{CommitBuilder, create_date_commit_message};
use crate::git::{GitRepo, find_repo};
use crate::i18n::Messages;
use crate::utils::validators::get_commit_types;
use crate::utils::{print_progress, print_success, print_warning};
/// Generate and execute conventional commits
#[derive(Parser)]
@@ -75,7 +76,7 @@ pub struct CommitCommand {
#[arg(short = 't', long)]
think: bool,
/// Skip interactive prompts
/// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)]
yes: bool,
@@ -121,13 +122,13 @@ impl CommitCommand {
// Auto-add if no files are staged and there are unstaged/untracked changes
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
println!("{}", messages.auto_stage_changes().yellow());
let removed = repo.stage_all()?;
let removed = repo.stage_all(&messages)?;
println!("{}", messages.staged_all().green());
if !removed.is_empty() {
println!(
"{}",
format!("Removed {} ignored files from staging:", removed.len()).yellow()
);
print_warning(&format!(
"Removed {} ignored files from staging:",
removed.len()
));
for file in &removed {
println!("{}", file);
}
@@ -136,19 +137,19 @@ impl CommitCommand {
// Re-check status after staging to ensure changes are detected
let new_status = repo.status_summary()?;
if new_status.staged == 0 {
bail!("Failed to stage changes. Please try running 'git add -A' manually.");
bail!("{}", messages.failed_to_stage());
}
}
// Stage all if requested
if self.all {
let removed = repo.stage_all()?;
let removed = repo.stage_all(&messages)?;
println!("{}", messages.staged_all().green());
if !removed.is_empty() {
println!(
"{}",
format!("Removed {} ignored files from staging:", removed.len()).yellow()
);
print_warning(&format!(
"Removed {} ignored files from staging:",
removed.len()
));
for file in &removed {
println!("{}", file);
}
@@ -194,29 +195,21 @@ impl CommitCommand {
.interact()?;
if !confirm {
println!("{}", messages.commit_cancelled().yellow());
print_warning(messages.commit_cancelled());
return Ok(());
}
}
let result = if self.amend {
if self.dry_run {
println!(
"\n{} {}",
messages.dry_run(),
"- commit not amended.".yellow()
);
println!("\n{}", messages.dry_run_commit_not_amended().yellow());
return Ok(());
}
self.amend_commit(&repo, &commit_message)?;
None
} else {
if self.dry_run {
println!(
"\n{} {}",
messages.dry_run(),
"- commit not created.".yellow()
);
println!("\n{}", messages.dry_run_commit_not_created().yellow());
return Ok(());
}
CommitBuilder::new()
@@ -226,30 +219,34 @@ impl CommitCommand {
};
if let Some(commit_oid) = result {
println!(
print_success(&format!(
"{} {}",
messages.commit_created().green().bold(),
commit_oid.to_string()[..8].to_string().cyan()
);
));
} else {
println!("{} successfully", messages.commit_amended().green().bold());
print_success(messages.commit_amended_successfully());
}
// Push after commit if requested or ask user
if self.push {
println!("\n{}", messages.pushing_commit(&self.remote));
repo.push(&self.remote, "HEAD")?;
println!("{}", messages.pushed_commit(&self.remote));
} else if !self.yes && !self.dry_run {
let should_push = Confirm::new()
.with_prompt(messages.push_after_commit())
.default(false)
.interact()?;
if self.push || (!self.yes && !self.dry_run) {
let branch = repo
.current_branch()
.unwrap_or_else(|_| "HEAD (detached)".to_string());
let should_push = if self.push {
true
} else {
Confirm::new()
.with_prompt(messages.push_after_commit(&branch))
.default(false)
.interact()?
};
if should_push {
println!("\n{}", messages.pushing_commit(&self.remote));
print_progress(&messages.pushing_commit(&self.remote, &branch));
repo.push(&self.remote, "HEAD")?;
println!("{}", messages.pushed_commit(&self.remote));
print_success(&messages.pushed_commit(&self.remote, &branch));
}
}
@@ -306,21 +303,22 @@ impl CommitCommand {
.await
.context("Failed to initialize LLM. Use --manual for manual commit.")?;
println!("{}", messages.ai_analyzing());
let spinner = crate::utils::Spinner::start(messages.ai_analyzing());
let language = manager.get_language().unwrap_or(Language::English);
let generated = if self.yes {
generator
.generate_commit_from_repo(repo, format, language)
.await?
.await
} else {
generator
.generate_commit_interactive(repo, format, language)
.await?
.generate_commit_interactive(repo, format, language, messages)
.await
};
spinner.finish_clear();
Ok(generated.to_conventional())
Ok(generated?.to_conventional())
}
async fn create_interactive_commit(

File diff suppressed because it is too large Load Diff

View File

@@ -1,458 +1,457 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::config::{TokenConfig, TokenType};
/// Git credential helper command.
///
/// Implements the git credential helper protocol
/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git
/// via `credential.helper` configuration, not by end users. Hidden from the
/// main help output.
#[derive(Parser)]
#[command(hide = true)]
pub struct CredentialCommand {
#[command(subcommand)]
command: CredentialSubcommand,
}
#[derive(Subcommand)]
enum CredentialSubcommand {
/// Read attributes on stdin and output credentials on stdout.
#[command(hide = true)]
Get,
/// Read attributes (including password) on stdin and store them.
#[command(hide = true)]
Store,
/// Read attributes on stdin and erase any matching stored credentials.
#[command(hide = true)]
Erase,
}
impl CredentialCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
CredentialSubcommand::Get => Self::get(config_path),
CredentialSubcommand::Store => Self::store(config_path),
CredentialSubcommand::Erase => Self::erase(config_path),
}
}
fn get_manager(config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
/// `git credential get`: look up a PAT for the requested host and emit it
/// on stdout following the git credential helper protocol.
fn get(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let manager = match Self::get_manager(&config_path) {
Ok(m) => m,
Err(_) => return Ok(()),
};
let (profile_name, pat) = match find_pat_for_service(&manager, &service) {
Some(tuple) => tuple,
None => return Ok(()),
};
// Prefer a git-supplied username; fall back to the profile's user_name.
let username = attrs.username.clone().or_else(|| {
manager
.get_profile(&profile_name)
.map(|p| p.user_name.clone())
});
let output = CredentialAttributes {
protocol: attrs.protocol.clone(),
host: attrs.host.clone(),
path: attrs.path.clone(),
username,
password: Some(pat),
};
output.to_stdout()?;
Ok(())
}
/// `git credential store`: persist the PAT provided by git using the
/// existing keyring-backed storage, associated with a matching profile.
fn store(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let password = match attrs.password.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
let mut manager = Self::get_manager(&config_path)?;
let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) {
Some(name) => name,
None => return Ok(()),
};
// Store PAT in keyring using the existing profile-bound logic.
if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) {
eprintln!("[quicommit credential] failed to store PAT: {}", e);
return Ok(());
}
// Register the token in the profile config if not already present.
let already_has = manager
.get_profile(&profile_name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if !already_has {
let _ = manager.add_token_to_profile(
&profile_name,
service.clone(),
TokenConfig::new(TokenType::Personal),
);
}
manager.save()?;
Ok(())
}
/// `git credential erase`: remove any PAT stored for the requested host
/// from the keyring and the associated profile config entries.
fn erase(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let mut manager = Self::get_manager(&config_path)?;
let profile_names: Vec<String> = manager
.list_profiles()
.into_iter()
.filter(|name| {
manager
.get_profile(name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false)
})
.cloned()
.collect();
for name in &profile_names {
if let Err(e) = manager.remove_token_from_profile(name, &service) {
eprintln!(
"[quicommit credential] failed to erase PAT for '{}': {}",
name, e
);
}
}
manager.save()?;
Ok(())
}
}
/// Credential attributes exchanged with git via stdin/stdout following the
/// git credential helper protocol (`key=value`, one per line, blank line ends).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialAttributes {
pub protocol: Option<String>,
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
impl CredentialAttributes {
/// Read attributes from stdin following the git credential helper protocol.
pub fn from_stdin() -> Result<Self> {
let stdin = io::stdin();
let mut attrs = Self::default();
for line in stdin.lock().lines() {
let line =
line.context("Failed to read credential attributes from stdin")?;
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {} // ignore unknown keys
}
}
}
Ok(attrs)
}
/// Write attributes to stdout following the git credential helper protocol.
pub fn to_stdout(&self) -> Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
if let Some(ref v) = self.protocol {
writeln!(handle, "protocol={}", v)?;
}
if let Some(ref v) = self.host {
writeln!(handle, "host={}", v)?;
}
if let Some(ref v) = self.path {
writeln!(handle, "path={}", v)?;
}
if let Some(ref v) = self.username {
writeln!(handle, "username={}", v)?;
}
if let Some(ref v) = self.password {
writeln!(handle, "password={}", v)?;
}
writeln!(handle)?; // blank line terminates the attribute list
handle.flush()?;
Ok(())
}
/// Parse attributes from a text block following the git credential helper
/// protocol. Intended for testing and non-stdin input handling.
pub fn parse_str(input: &str) -> Self {
let mut attrs = Self::default();
for line in input.lines() {
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {}
}
}
}
attrs
}
/// Serialize attributes to a string following the git credential helper
/// protocol. Intended for testing and non-stdout output handling.
pub fn serialize(&self) -> String {
let mut out = String::new();
if let Some(ref v) = self.protocol {
out.push_str(&format!("protocol={}\n", v));
}
if let Some(ref v) = self.host {
out.push_str(&format!("host={}\n", v));
}
if let Some(ref v) = self.path {
out.push_str(&format!("path={}\n", v));
}
if let Some(ref v) = self.username {
out.push_str(&format!("username={}\n", v));
}
if let Some(ref v) = self.password {
out.push_str(&format!("password={}\n", v));
}
out.push('\n');
out
}
}
/// Map a git host to a service name used by the keyring-backed PAT storage.
///
/// Common git hosting services are mapped to short canonical names. Unknown
/// hosts are used as-is (lowercased, trailing slash trimmed).
pub fn host_to_service(host: &str) -> String {
let host = host.to_lowercase();
let host = host.trim_end_matches('/');
match host {
"github.com" | "www.github.com" => "github".to_string(),
"gitlab.com" | "www.gitlab.com" => "gitlab".to_string(),
"bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(),
"codeberg.org" | "www.codeberg.org" => "codeberg".to_string(),
"gitea.com" | "www.gitea.com" => "gitea".to_string(),
"gitee.com" | "www.gitee.com" => "gitee".to_string(),
other => other.to_string(),
}
}
/// Search all profiles for one that has a PAT stored for the given service.
/// Returns the profile name and the PAT value.
fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> {
for profile_name in manager.list_profiles() {
if manager.has_pat_for_profile(profile_name, service) {
if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) {
return Some((profile_name.clone(), pat));
}
}
}
None
}
/// Determine which profile to use when storing a credential.
///
/// Prefers a profile whose `user_name` or `user_email` matches the
/// git-supplied username; otherwise falls back to the default profile.
fn find_profile_for_store(
manager: &ConfigManager,
username: Option<&str>,
) -> Option<String> {
if let Some(username) = username {
for name in manager.list_profiles() {
if let Some(profile) = manager.get_profile(name) {
if profile.user_name == username || profile.user_email == username {
return Some(name.clone());
}
}
}
}
manager.default_profile_name().cloned()
}
/// Extract a PAT for the given host from saved credentials across all profiles.
///
/// This is intended for use by other parts of the application (e.g. when
/// verifying access to a git hosting service) and searches every configured
/// profile for a stored PAT matching the host.
pub fn get_pat_for_host(host: &str) -> Result<Option<String>> {
let manager = ConfigManager::new()?;
let service = host_to_service(host);
Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_to_service_known_hosts() {
assert_eq!(host_to_service("github.com"), "github");
assert_eq!(host_to_service("www.github.com"), "github");
assert_eq!(host_to_service("gitlab.com"), "gitlab");
assert_eq!(host_to_service("bitbucket.org"), "bitbucket");
assert_eq!(host_to_service("codeberg.org"), "codeberg");
assert_eq!(host_to_service("gitea.com"), "gitea");
assert_eq!(host_to_service("gitee.com"), "gitee");
}
#[test]
fn test_host_to_service_case_insensitive() {
assert_eq!(host_to_service("GitHub.Com"), "github");
assert_eq!(host_to_service("GITLAB.COM"), "gitlab");
}
#[test]
fn test_host_to_service_trailing_slash() {
assert_eq!(host_to_service("github.com/"), "github");
assert_eq!(host_to_service("gitlab.com//"), "gitlab");
}
#[test]
fn test_host_to_service_unknown_host() {
assert_eq!(host_to_service("example.com"), "example.com");
assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp");
}
#[test]
fn test_credential_attributes_parse_basic() {
let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert_eq!(attrs.username.as_deref(), Some("octocat"));
assert_eq!(attrs.password.as_deref(), Some("ghp_token123"));
assert!(attrs.path.is_none());
}
#[test]
fn test_credential_attributes_parse_with_path() {
let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.path.as_deref(), Some("owner/repo.git"));
}
#[test]
fn test_credential_attributes_parse_empty() {
let attrs = CredentialAttributes::parse_str("");
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_parse_ignores_unknown_keys() {
let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
}
#[test]
fn test_credential_attributes_parse_stops_at_blank_line() {
let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_serialize_roundtrip() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: Some("octocat".to_string()),
password: Some("ghp_token".to_string()),
};
let serialized = attrs.serialize();
let reparsed = CredentialAttributes::parse_str(&serialized);
assert_eq!(attrs, reparsed);
}
#[test]
fn test_credential_attributes_serialize_includes_blank_line() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: None,
password: None,
};
let serialized = attrs.serialize();
assert!(serialized.ends_with("\n\n"));
assert!(serialized.contains("protocol=https\n"));
assert!(serialized.contains("host=github.com\n"));
}
#[test]
fn test_credential_attributes_default() {
let attrs = CredentialAttributes::default();
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.path.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
}
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::config::{TokenConfig, TokenType};
/// Git credential helper command.
///
/// Implements the git credential helper protocol
/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git
/// via `credential.helper` configuration, not by end users. Hidden from the
/// main help output.
#[derive(Parser)]
#[command(hide = true)]
pub struct CredentialCommand {
#[command(subcommand)]
command: CredentialSubcommand,
}
#[derive(Subcommand)]
enum CredentialSubcommand {
/// Read attributes on stdin and output credentials on stdout.
#[command(hide = true)]
Get,
/// Read attributes (including password) on stdin and store them.
#[command(hide = true)]
Store,
/// Read attributes on stdin and erase any matching stored credentials.
#[command(hide = true)]
Erase,
}
impl CredentialCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
CredentialSubcommand::Get => Self::get(config_path),
CredentialSubcommand::Store => Self::store(config_path),
CredentialSubcommand::Erase => Self::erase(config_path),
}
}
fn get_manager(config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
/// `git credential get`: look up a PAT for the requested host and emit it
/// on stdout following the git credential helper protocol.
fn get(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let manager = match Self::get_manager(&config_path) {
Ok(m) => m,
Err(_) => return Ok(()),
};
let (profile_name, pat) = match find_pat_for_service(&manager, &service) {
Some(tuple) => tuple,
None => return Ok(()),
};
// Prefer a git-supplied username; fall back to the profile's user_name.
let username = attrs.username.clone().or_else(|| {
manager
.get_profile(&profile_name)
.map(|p| p.user_name.clone())
});
let output = CredentialAttributes {
protocol: attrs.protocol.clone(),
host: attrs.host.clone(),
path: attrs.path.clone(),
username,
password: Some(pat),
};
output.to_stdout()?;
Ok(())
}
/// `git credential store`: persist the PAT provided by git using the
/// existing keyring-backed storage, associated with a matching profile.
fn store(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let password = match attrs.password.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
let mut manager = Self::get_manager(&config_path)?;
let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) {
Some(name) => name,
None => return Ok(()),
};
// Store PAT in keyring using the existing profile-bound logic.
if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) {
eprintln!("[quicommit credential] failed to store PAT: {}", e);
return Ok(());
}
// Register the token in the profile config if not already present.
let already_has = manager
.get_profile(&profile_name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if !already_has {
let _ = manager.add_token_to_profile(
&profile_name,
service.clone(),
TokenConfig::new(TokenType::Personal),
);
}
manager.save()?;
Ok(())
}
/// `git credential erase`: remove any PAT stored for the requested host
/// from the keyring and the associated profile config entries.
fn erase(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let mut manager = Self::get_manager(&config_path)?;
let profile_names: Vec<String> = manager.list_profiles().into_iter().cloned().collect();
for name in &profile_names {
let registered = manager
.get_profile(name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if registered {
if let Err(e) = manager.remove_token_from_profile(name, &service) {
eprintln!(
"[quicommit credential] failed to erase PAT for '{}': {}",
name, e
);
}
} else if let Ok(true) = manager.delete_orphan_pat(name, &service) {
// Keyring PAT without a config token entry (issue 24).
eprintln!(
"[quicommit credential] erased unregistered PAT for '{}' (service {}) from keyring",
name, service
);
}
}
manager.save()?;
Ok(())
}
}
/// Credential attributes exchanged with git via stdin/stdout following the
/// git credential helper protocol (`key=value`, one per line, blank line ends).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialAttributes {
pub protocol: Option<String>,
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
impl CredentialAttributes {
/// Read attributes from stdin following the git credential helper protocol.
pub fn from_stdin() -> Result<Self> {
let stdin = io::stdin();
let mut attrs = Self::default();
for line in stdin.lock().lines() {
let line = line.context("Failed to read credential attributes from stdin")?;
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {} // ignore unknown keys
}
}
}
Ok(attrs)
}
/// Write attributes to stdout following the git credential helper protocol.
pub fn to_stdout(&self) -> Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
if let Some(ref v) = self.protocol {
writeln!(handle, "protocol={}", v)?;
}
if let Some(ref v) = self.host {
writeln!(handle, "host={}", v)?;
}
if let Some(ref v) = self.path {
writeln!(handle, "path={}", v)?;
}
if let Some(ref v) = self.username {
writeln!(handle, "username={}", v)?;
}
if let Some(ref v) = self.password {
writeln!(handle, "password={}", v)?;
}
writeln!(handle)?; // blank line terminates the attribute list
handle.flush()?;
Ok(())
}
/// Parse attributes from a text block following the git credential helper
/// protocol. Intended for testing and non-stdin input handling.
pub fn parse_str(input: &str) -> Self {
let mut attrs = Self::default();
for line in input.lines() {
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {}
}
}
}
attrs
}
/// Serialize attributes to a string following the git credential helper
/// protocol. Intended for testing and non-stdout output handling.
pub fn serialize(&self) -> String {
let mut out = String::new();
if let Some(ref v) = self.protocol {
out.push_str(&format!("protocol={}\n", v));
}
if let Some(ref v) = self.host {
out.push_str(&format!("host={}\n", v));
}
if let Some(ref v) = self.path {
out.push_str(&format!("path={}\n", v));
}
if let Some(ref v) = self.username {
out.push_str(&format!("username={}\n", v));
}
if let Some(ref v) = self.password {
out.push_str(&format!("password={}\n", v));
}
out.push('\n');
out
}
}
/// Map a git host to a service name used by the keyring-backed PAT storage.
///
/// Common git hosting services are mapped to short canonical names. Unknown
/// hosts are used as-is (lowercased, trailing slash trimmed).
pub fn host_to_service(host: &str) -> String {
let host = host.to_lowercase();
let host = host.trim_end_matches('/');
match host {
"github.com" | "www.github.com" => "github".to_string(),
"gitlab.com" | "www.gitlab.com" => "gitlab".to_string(),
"bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(),
"codeberg.org" | "www.codeberg.org" => "codeberg".to_string(),
"gitea.com" | "www.gitea.com" => "gitea".to_string(),
"gitee.com" | "www.gitee.com" => "gitee".to_string(),
other => other.to_string(),
}
}
/// Search all profiles for one that has a PAT stored for the given service.
/// Returns the profile name and the PAT value.
fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> {
for profile_name in manager.list_profiles() {
if manager.has_pat_for_profile(profile_name, service) {
if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) {
return Some((profile_name.clone(), pat));
}
}
}
None
}
/// Determine which profile to use when storing a credential.
///
/// Prefers a profile whose `user_name` or `user_email` matches the
/// git-supplied username; otherwise falls back to the default profile.
fn find_profile_for_store(manager: &ConfigManager, username: Option<&str>) -> Option<String> {
if let Some(username) = username {
for name in manager.list_profiles() {
if let Some(profile) = manager.get_profile(name) {
if profile.user_name == username || profile.user_email == username {
return Some(name.clone());
}
}
}
}
manager.default_profile_name().cloned()
}
/// Extract a PAT for the given host from saved credentials across all profiles.
///
/// This is intended for use by other parts of the application (e.g. when
/// verifying access to a git hosting service) and searches every configured
/// profile for a stored PAT matching the host.
pub fn get_pat_for_host(host: &str) -> Result<Option<String>> {
let manager = ConfigManager::new()?;
let service = host_to_service(host);
Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_to_service_known_hosts() {
assert_eq!(host_to_service("github.com"), "github");
assert_eq!(host_to_service("www.github.com"), "github");
assert_eq!(host_to_service("gitlab.com"), "gitlab");
assert_eq!(host_to_service("bitbucket.org"), "bitbucket");
assert_eq!(host_to_service("codeberg.org"), "codeberg");
assert_eq!(host_to_service("gitea.com"), "gitea");
assert_eq!(host_to_service("gitee.com"), "gitee");
}
#[test]
fn test_host_to_service_case_insensitive() {
assert_eq!(host_to_service("GitHub.Com"), "github");
assert_eq!(host_to_service("GITLAB.COM"), "gitlab");
}
#[test]
fn test_host_to_service_trailing_slash() {
assert_eq!(host_to_service("github.com/"), "github");
assert_eq!(host_to_service("gitlab.com//"), "gitlab");
}
#[test]
fn test_host_to_service_unknown_host() {
assert_eq!(host_to_service("example.com"), "example.com");
assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp");
}
#[test]
fn test_credential_attributes_parse_basic() {
let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert_eq!(attrs.username.as_deref(), Some("octocat"));
assert_eq!(attrs.password.as_deref(), Some("ghp_token123"));
assert!(attrs.path.is_none());
}
#[test]
fn test_credential_attributes_parse_with_path() {
let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.path.as_deref(), Some("owner/repo.git"));
}
#[test]
fn test_credential_attributes_parse_empty() {
let attrs = CredentialAttributes::parse_str("");
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_parse_ignores_unknown_keys() {
let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
}
#[test]
fn test_credential_attributes_parse_stops_at_blank_line() {
let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_serialize_roundtrip() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: Some("octocat".to_string()),
password: Some("ghp_token".to_string()),
};
let serialized = attrs.serialize();
let reparsed = CredentialAttributes::parse_str(&serialized);
assert_eq!(attrs, reparsed);
}
#[test]
fn test_credential_attributes_serialize_includes_blank_line() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: None,
password: None,
};
let serialized = attrs.serialize();
assert!(serialized.ends_with("\n\n"));
assert!(serialized.contains("protocol=https\n"));
assert!(serialized.contains("host=github.com\n"));
}
#[test]
fn test_credential_attributes_default() {
let attrs = CredentialAttributes::default();
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.path.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
}

View File

@@ -10,6 +10,7 @@ use crate::config::{GitProfile, Language};
use crate::i18n::Messages;
use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key};
use crate::utils::validators::validate_email;
use crate::utils::{print_success, print_warning};
/// Initialize quicommit configuration
#[derive(Parser)]
@@ -34,19 +35,16 @@ impl InitCommand {
if config_path.exists() && !self.reset {
if !self.yes {
let overwrite = Confirm::new()
.with_prompt("Configuration already exists. Overwrite?")
.with_prompt(messages.config_exists_overwrite())
.default(false)
.interact()?;
if !overwrite {
println!("{}", "Initialization cancelled.".yellow());
print_warning(messages.init_cancelled());
return Ok(());
}
} else {
println!(
"{}",
"Configuration already exists. Use --reset to overwrite.".yellow()
);
print_warning(messages.config_exists_use_reset());
return Ok(());
}
}
@@ -59,7 +57,7 @@ impl InitCommand {
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
if self.yes {
self.quick_setup(&mut manager).await?;
self.quick_setup(&mut manager, &messages).await?;
} else {
self.interactive_setup(&mut manager).await?;
}
@@ -69,27 +67,52 @@ impl InitCommand {
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
println!("{}", messages.init_success().bold().green());
print_success(messages.init_success());
println!("\n{}: {}", messages.config_file(), config_path.display());
println!("\n{}:", messages.next_steps());
println!(" 1. Create a profile: {}", "quicommit profile add".cyan());
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
println!(" 3. Start committing: {}", "quicommit commit".cyan());
println!(
" 1. {}: {}",
messages.next_steps_create_profile(),
"quicommit profile add".cyan()
);
println!(
" 2. {}: {}",
messages.next_steps_configure_llm(),
"quicommit config set-llm".cyan()
);
println!(
" 3. {}: {}",
messages.next_steps_start_committing(),
"quicommit commit".cyan()
);
Ok(())
}
async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> {
async fn quick_setup(&self, manager: &mut ConfigManager, messages: &Messages) -> Result<()> {
let git_config = git2::Config::open_default()?;
let user_name = git_config
.get_string("user.name")
.unwrap_or_else(|_| "User".to_string());
let user_email = git_config
.get_string("user.email")
.unwrap_or_else(|_| "user@example.com".to_string());
let name_result = git_config.get_string("user.name");
let email_result = git_config.get_string("user.email");
let name_missing = name_result.is_err();
let email_missing = email_result.is_err();
let user_name = name_result.unwrap_or_else(|_| "User".to_string());
let user_email = email_result.unwrap_or_else(|_| "user@example.com".to_string());
let profile = GitProfile::new("default".to_string(), user_name, user_email);
let profile = GitProfile::new("default".to_string(), user_name.clone(), user_email.clone());
println!("\n{}", messages.quick_setup_summary().bold());
println!(" {}: {}", messages.profile_label(), "default".cyan());
println!(
" {}: {} <{}>",
messages.identity_label(),
user_name,
user_email
);
if name_missing || email_missing {
print_warning(messages.identity_placeholder_warning());
}
println!(" {}: {}", messages.llm_provider_label(), "ollama".cyan());
manager.add_profile("default".to_string(), profile)?;
manager.set_default_profile(Some("default".to_string()))?;
@@ -231,11 +254,8 @@ impl InitCommand {
let keyring_available = keyring.is_available();
if !keyring_available {
println!(
"\n{}",
"⚠ Keyring is not available on this system.".yellow()
);
println!("{}", keyring.get_status_message().yellow());
print_warning(messages.keyring_unavailable());
print_warning(&keyring.get_status_message());
}
let api_key = if provider_needs_api_key(&provider) {
@@ -246,11 +266,7 @@ impl InitCommand {
.ok();
if let Some(_key) = env_key {
println!(
"\n{} {}",
"".green(),
"Found API key in environment variable.".green()
);
print_success(messages.api_key_found_env());
None
} else if keyring_available {
let prompt = match provider.as_str() {
@@ -259,16 +275,13 @@ impl InitCommand {
"kimi" => messages.kimi_api_key(),
"deepseek" => messages.deepseek_api_key(),
"openrouter" => messages.openrouter_api_key(),
_ => "API Key",
_ => messages.api_key_prompt_other(),
};
let key: String = Input::new().with_prompt(prompt).interact_text()?;
let key: String = crate::utils::password_input(prompt)?;
Some(key)
} else {
println!(
"\n{}",
"Please set the QUICOMMIT_API_KEY environment variable.".yellow()
);
print_warning(messages.please_set_api_key_env());
None
}
} else {
@@ -277,24 +290,26 @@ impl InitCommand {
let default_model = get_default_model(&provider);
let model: String = Input::new()
.with_prompt("Model name")
.with_prompt(messages.model_name())
.default(default_model.to_string())
.interact_text()?;
let base_url: Option<String> = if provider == "ollama" {
let url: String = Input::new()
.with_prompt("Ollama server URL")
.with_prompt(messages.ollama_server_url())
.default("http://localhost:11434".to_string())
.interact_text()?;
Some(url)
} else {
let use_custom_url = Confirm::new()
.with_prompt("Use custom API base URL?")
.with_prompt(messages.use_custom_base_url())
.default(false)
.interact()?;
if use_custom_url {
let url: String = Input::new().with_prompt("Base URL").interact_text()?;
let url: String = Input::new()
.with_prompt(messages.base_url_plain())
.interact_text()?;
Some(url)
} else {
None
@@ -309,11 +324,7 @@ impl InitCommand {
&& provider_needs_api_key(&provider)
{
manager.set_api_key(&key)?;
println!(
"\n{} {}",
"".green(),
"API key stored securely in system keyring.".green()
);
print_success(messages.api_key_stored_keyring());
}
Ok(())
@@ -332,7 +343,7 @@ impl InitCommand {
.interact_text()?;
let pub_key_path: String = Input::new()
.with_prompt("SSH public key path (optional, leave empty to auto-detect)")
.with_prompt(messages.ssh_public_key_path())
.default(ssh_dir.join("id_rsa.pub").display().to_string())
.allow_empty(true)
.interact_text()?;
@@ -354,12 +365,12 @@ impl InitCommand {
};
let agent_forwarding = Confirm::new()
.with_prompt("Enable SSH agent forwarding (-A)?")
.with_prompt(messages.ssh_agent_forwarding())
.default(false)
.interact()?;
let known_hosts: String = Input::new()
.with_prompt("Custom known_hosts file path (optional)")
.with_prompt(messages.known_hosts_path())
.allow_empty(true)
.interact_text()?;
let known_hosts_file = if known_hosts.is_empty() {
@@ -369,7 +380,7 @@ impl InitCommand {
};
let custom_cmd: String = Input::new()
.with_prompt("Custom SSH command (optional, overrides all other SSH settings)")
.with_prompt(messages.custom_ssh_command())
.allow_empty(true)
.interact_text()?;
let ssh_command = if custom_cmd.is_empty() {

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ use crate::git::tag::{
};
use crate::git::{GitRepo, find_repo};
use crate::i18n::Messages;
use crate::utils::{print_progress, print_success, print_warning};
/// Generate and create Git tags
#[derive(Parser)]
@@ -61,7 +62,7 @@ pub struct TagCommand {
#[arg(short = 't', long)]
think: bool,
/// Skip interactive prompts
/// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)]
yes: bool,
@@ -115,11 +116,11 @@ impl TagCommand {
{
let version_str = tag_name.trim_start_matches('v');
if let Err(e) = crate::utils::validators::validate_semver(version_str) {
println!("{}: {}", "Warning".yellow(), e);
print_warning(&format!("{}: {}", messages.warning(), e));
if !self.yes {
let proceed = Confirm::new()
.with_prompt("Proceed with this tag name anyway?")
.with_prompt(messages.proceed_invalid_tag_name())
.default(true)
.interact()?;
@@ -135,7 +136,7 @@ impl TagCommand {
None
} else if let Some(msg) = &self.message {
Some(msg.clone())
} else if self.generate || (config.tag.auto_generate && !self.yes) {
} else if self.generate || config.tag.auto_generate {
Some(
self.generate_tag_message(&repo, &tag_name, &messages)
.await?,
@@ -143,7 +144,7 @@ impl TagCommand {
} else if !self.yes {
Some(self.input_message_interactive(&tag_name, &messages)?)
} else {
Some(format!("Release {}", tag_name))
Some(messages.release_default(&tag_name))
};
// Show preview
@@ -165,13 +166,13 @@ impl TagCommand {
.interact()?;
if !confirm {
println!("{}", messages.tag_cancelled().yellow());
print_warning(messages.tag_cancelled());
return Ok(());
}
}
if self.dry_run {
println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow());
println!("\n{}", messages.dry_run_tag_not_created().yellow());
return Ok(());
}
@@ -184,13 +185,13 @@ impl TagCommand {
builder.execute(&repo)?;
println!("{} {}", messages.tag_created().green(), tag_name.cyan());
print_success(&format!("{} {}", messages.tag_created(), tag_name.cyan()));
// Push if requested or ask user
if self.push {
println!("{}", messages.pushing_tag(&self.remote));
print_progress(&messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{}", messages.pushed_tag(&self.remote));
print_success(&messages.pushed_tag(&self.remote));
} else if !self.yes && !self.dry_run {
let should_push = Confirm::new()
.with_prompt(messages.push_after_tag())
@@ -198,9 +199,9 @@ impl TagCommand {
.interact()?;
if should_push {
println!("{}", messages.pushing_tag(&self.remote));
print_progress(&messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{}", messages.pushed_tag(&self.remote));
print_success(&messages.pushed_tag(&self.remote));
}
}
@@ -323,15 +324,16 @@ impl TagCommand {
};
if commits.is_empty() {
return Ok(format!("Release {}", version));
return Ok(messages.release_default(version));
}
println!("{}", messages.ai_generating_tag(commits.len()));
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
generator
let spinner = crate::utils::Spinner::start(&messages.ai_generating_tag(commits.len()));
let result = generator
.generate_tag_message(version, &commits, language)
.await
.await;
spinner.finish_clear();
result
}
async fn auto_detect_version(
@@ -418,7 +420,7 @@ impl TagCommand {
}
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
let default_msg = format!("Release {}", version);
let default_msg = messages.release_default(version);
let use_editor = Confirm::new()
.with_prompt(messages.open_editor())

View File

@@ -278,6 +278,22 @@ impl ConfigManager {
Ok(())
}
/// Delete a PAT from the keyring even when no config token entry exists.
/// Returns true if a keyring entry was deleted (issue 24).
pub fn delete_orphan_pat(&self, profile_name: &str, service: &str) -> Result<bool> {
if let Some(profile) = self.get_profile(profile_name) {
let user_email = &profile.user_email;
if self.keyring.has_pat(profile_name, user_email, service) {
self.keyring.delete_pat(profile_name, user_email, service)?;
Ok(true)
} else {
Ok(false)
}
} else {
Ok(false)
}
}
/// Delete all PAT tokens for a profile (used when removing a profile)
pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> {
if let Some(profile) = self.get_profile(profile_name) {
@@ -658,6 +674,17 @@ impl ConfigManager {
self.modified = true;
}
/// Check if emoji decorations are enabled
pub fn emoji_enabled(&self) -> bool {
self.config.output.emoji
}
/// Set emoji decorations flag
pub fn set_emoji_enabled(&mut self, enabled: bool) {
self.config.output.emoji = enabled;
self.modified = true;
}
/// Export configuration to TOML string
pub fn export(&self) -> Result<String> {
toml::to_string_pretty(&self.config).context("Failed to serialize config")

View File

@@ -47,6 +47,10 @@ pub struct AppConfig {
/// Language settings
#[serde(default)]
pub language: LanguageConfig,
/// Output settings
#[serde(default)]
pub output: OutputConfig,
}
impl Default for AppConfig {
@@ -61,6 +65,7 @@ impl Default for AppConfig {
changelog: ChangelogConfig::default(),
repo_profiles: HashMap::new(),
language: LanguageConfig::default(),
output: OutputConfig::default(),
}
}
}
@@ -236,6 +241,20 @@ impl Default for LanguageConfig {
}
}
/// Output configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
/// Show emoji/symbol decorations (✓/✗/⚠/→)
#[serde(default = "default_true")]
pub emoji: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self { emoji: true }
}
}
/// Supported languages
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {

View File

@@ -1,7 +1,10 @@
use crate::config::manager::ConfigManager;
use crate::config::{CommitFormat, Language};
use crate::git::{CommitInfo, GitRepo};
use crate::llm::{GeneratedCommit, LlmClient};
use crate::i18n::Messages;
use crate::llm::parsing::GeneratedCommit;
use crate::llm::rig::LlmClient;
use crate::utils::{eprint_warning, success_prefix};
use anyhow::{Context, Result};
/// Content generator using LLM
@@ -32,11 +35,9 @@ impl ContentGenerator {
if thinking_enabled {
let provider = manager.llm_provider();
if !Self::supports_thinking(provider) {
eprintln!(
"Warning: Provider '{}' does not support thinking mode. \
Disabling thinking for this invocation.",
provider
);
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
eprint_warning(&messages.thinking_unsupported(provider));
thinking_enabled = false;
}
}
@@ -155,6 +156,7 @@ impl ContentGenerator {
repo: &GitRepo,
format: CommitFormat,
language: Language,
messages: &Messages,
) -> Result<GeneratedCommit> {
use dialoguer::Select;
@@ -166,33 +168,33 @@ impl ContentGenerator {
// Show diff summary
let files = repo.get_staged_files()?;
println!("\nStaged files ({}):", files.len());
println!("\n{}", messages.staged_files(files.len()));
for file in &files {
println!("{}", file);
}
// Generate initial commit
println!("\nGenerating commit message...");
println!("\n{}", messages.generating_commit_message());
let mut generated = self
.generate_commit_message(&diff, format, language)
.await?;
loop {
println!("\n{}", "".repeat(60));
println!("Generated commit message:");
println!("{}", messages.generated_commit_message());
println!("{}", "".repeat(60));
println!("{}", generated.to_conventional());
println!("{}", "".repeat(60));
let options = vec![
"✓ Accept and commit",
"🔄 Regenerate",
"✏️ Edit",
"❌ Cancel",
format!("{} {}", success_prefix(), messages.accept_and_commit()),
messages.regenerate().to_string(),
messages.edit().to_string(),
messages.cancel().to_string(),
];
let selection = Select::new()
.with_prompt("What would you like to do?")
.with_prompt(messages.what_would_you_like_to_do())
.items(&options)
.default(0)
.interact()?;
@@ -200,7 +202,7 @@ impl ContentGenerator {
match selection {
0 => return Ok(generated),
1 => {
println!("Regenerating...");
println!("{}", messages.regenerating());
generated = self
.generate_commit_message(&diff, format, language)
.await?;
@@ -209,7 +211,7 @@ impl ContentGenerator {
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
generated = self.parse_edited_commit(&edited, format)?;
}
3 => anyhow::bail!("Cancelled by user"),
3 => anyhow::bail!("{}", messages.cancelled_by_user()),
_ => {}
}
}

View File

@@ -76,7 +76,7 @@ fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
.or_else(|_| Repository::discover(&normalized))
.or_else(|_| Repository::open(&normalized));
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e))
repo.map_err(|e| anyhow::anyhow!("Failed to open repository: {}", e))
}
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
@@ -642,7 +642,7 @@ impl GitRepo {
}
/// Stage all changes including subdirectories, then remove ignored tracked files
pub fn stage_all(&self) -> Result<Vec<String>> {
pub fn stage_all(&self, messages: &crate::i18n::Messages) -> Result<Vec<String>> {
// Use git command for reliable staging (handles all edge cases)
let output = std::process::Command::new("git")
.args(["add", "-A"])
@@ -662,7 +662,11 @@ impl GitRepo {
match self.remove_ignored_from_index() {
Ok(removed) => Ok(removed),
Err(e) => {
eprintln!("Warning: failed to clean ignored files from index: {}", e);
crate::utils::eprint_warning(&format!(
"{}: {}",
messages.failed_clean_ignored(),
e
));
Ok(Vec::new())
}
}
@@ -937,16 +941,6 @@ impl GitRepo {
Ok(())
}
/// Create GPG signature for arbitrary content
fn create_gpg_signature_for_content(
&self,
_content: &str,
_gpg_program: &str,
_signing_key: &str,
) -> Result<String> {
Ok(String::new())
}
/// Delete a tag
pub fn delete_tag(&self, name: &str) -> Result<()> {
self.repo.tag_delete(name)?;
@@ -1515,7 +1509,14 @@ mod tests {
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
// Create a lightweight tag
repo.repo.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false)
repo.repo
.tag(
"v0.1.0",
head.as_object(),
&repo.repo.signature().unwrap(),
"",
false,
)
.unwrap();
let tags = repo.get_tags().unwrap();
@@ -1541,7 +1542,13 @@ mod tests {
// Create lightweight tag
repo.repo
.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false)
.tag(
"v0.1.0",
head.as_object(),
&repo.repo.signature().unwrap(),
"",
false,
)
.unwrap();
let tags = repo.get_tags().unwrap();

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
#![allow(dead_code)]
pub mod commands;
pub mod config;
pub mod generator;

View File

@@ -1,655 +0,0 @@
use super::thinking::ThinkingStateManager;
use super::{LlmProvider, create_http_client};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
/// Anthropic Claude API client
pub struct AnthropicClient {
api_key: String,
model: String,
client: reqwest::Client,
thinking_enabled: bool,
thinking_budget_tokens: u32,
max_tokens: u32,
temperature: f32,
top_p: Option<f32>,
thinking_state: Option<Arc<ThinkingStateManager>>,
}
#[derive(Debug, Serialize)]
struct MessagesRequest {
model: String,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f32>,
messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<Vec<SystemContent>>,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<ThinkingConfig>,
stream: bool,
}
#[derive(Debug, Serialize, Clone)]
struct SystemContent {
#[serde(rename = "type")]
content_type: String,
text: String,
}
#[derive(Debug, Serialize)]
struct ThinkingConfig {
#[serde(rename = "type")]
thinking_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
budget_tokens: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct AnthropicMessage {
role: String,
content: AnthropicContent,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
enum AnthropicContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct ContentBlock {
#[serde(rename = "type")]
content_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct MessagesResponse {
content: Vec<ResponseContentBlock>,
}
#[derive(Debug, Deserialize)]
struct ResponseContentBlock {
#[serde(rename = "type")]
content_type: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: AnthropicError,
}
#[derive(Debug, Deserialize)]
struct AnthropicError {
#[serde(rename = "type")]
error_type: String,
message: String,
}
// --- Streaming SSE event structures ---
#[derive(Debug, Deserialize)]
struct SseEvent {
#[serde(rename = "type")]
event_type: String,
#[serde(default)]
message: Option<SseMessage>,
#[serde(default)]
index: Option<u32>,
#[serde(default)]
content_block: Option<SseContentBlock>,
#[serde(default)]
delta: Option<SseDelta>,
#[serde(default)]
usage: Option<SseUsage>,
}
#[derive(Debug, Deserialize)]
struct SseMessage {
#[serde(default)]
content: Option<Vec<SseContentBlock>>,
}
#[derive(Debug, Deserialize)]
struct SseContentBlock {
#[serde(rename = "type")]
content_type: String,
#[serde(default)]
thinking: Option<String>,
#[serde(default)]
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SseDelta {
#[serde(rename = "type")]
delta_type: Option<String>,
#[serde(default)]
thinking: Option<String>,
#[serde(default)]
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SseUsage {
#[serde(default)]
output_tokens: Option<u32>,
}
impl AnthropicClient {
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
api_key: api_key.to_string(),
model: model.to_string(),
client,
thinking_enabled: false,
thinking_budget_tokens: 1024,
max_tokens: 500,
temperature: 0.7,
top_p: None,
thinking_state: None,
})
}
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
pub fn with_thinking(mut self, enabled: bool) -> Self {
self.thinking_enabled = enabled;
self
}
pub fn with_thinking_budget_tokens(mut self, budget_tokens: u32) -> Self {
self.thinking_budget_tokens = budget_tokens;
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = temperature;
self
}
pub fn with_top_p(mut self, top_p: f32) -> Self {
self.top_p = Some(top_p);
self
}
pub fn with_thinking_state(mut self, state: Arc<ThinkingStateManager>) -> Self {
self.thinking_state = Some(state);
self
}
pub async fn list_models(&self) -> Result<Vec<String>> {
Ok(ANTHROPIC_MODELS.iter().map(|&m| m.to_string()).collect())
}
pub async fn validate_key(&self) -> Result<bool> {
let url = "https://api.anthropic.com/v1/messages";
let request = MessagesRequest {
model: self.model.clone(),
max_tokens: 5,
temperature: Some(0.0),
top_p: None,
messages: vec![AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Text("Hi".to_string()),
}],
system: None,
thinking: None,
stream: false,
};
let response = self
.client
.post(url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&request)
.send()
.await;
match response {
Ok(resp) => {
if resp.status().is_success() {
Ok(true)
} else {
let status = resp.status();
if status.as_u16() == 401 {
Ok(false)
} else {
let text = resp.text().await.unwrap_or_default();
bail!("Anthropic API error: {} - {}", status, text)
}
}
}
Err(e) => Err(e.into()),
}
}
}
#[async_trait]
impl LlmProvider for AnthropicClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Text(prompt.to_string()),
}];
self.messages_request_with_retry(messages, None).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let messages = vec![AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Text(user.to_string()),
}];
let system = if system.is_empty() {
None
} else {
Some(vec![SystemContent {
content_type: "text".to_string(),
text: system.to_string(),
}])
};
self.messages_request_with_retry(messages, system).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"anthropic"
}
}
impl AnthropicClient {
async fn messages_request_with_retry(
&self,
messages: Vec<AnthropicMessage>,
system: Option<Vec<SystemContent>>,
) -> Result<String> {
let mut last_error = None;
for attempt in 1..=3 {
match self
.messages_request(messages.clone(), system.clone())
.await
{
Ok(result) => return Ok(result),
Err(e) => {
let err_msg = e.to_string();
let is_retryable = err_msg.contains("timeout")
|| err_msg.contains("connection")
|| err_msg.contains("temporary")
|| err_msg.contains("5")
&& (err_msg.contains("500")
|| err_msg.contains("502")
|| err_msg.contains("503")
|| err_msg.contains("504"));
if !is_retryable || attempt == 3 {
last_error = Some(e);
break;
}
tokio::time::sleep(Duration::from_millis(500 * 2u64.pow(attempt - 1))).await;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Request failed after retries")))
}
async fn messages_request(
&self,
messages: Vec<AnthropicMessage>,
system: Option<Vec<SystemContent>>,
) -> Result<String> {
if self.thinking_enabled {
self.streaming_messages_request(messages, system).await
} else {
self.non_streaming_messages_request(messages, system).await
}
}
async fn non_streaming_messages_request(
&self,
messages: Vec<AnthropicMessage>,
system: Option<Vec<SystemContent>>,
) -> Result<String> {
let url = "https://api.anthropic.com/v1/messages";
let temperature = if self.temperature == 0.0 {
None
} else {
Some(self.temperature)
};
let request = MessagesRequest {
model: self.model.clone(),
max_tokens: self.max_tokens,
temperature,
top_p: self.top_p,
messages,
system,
thinking: Some(ThinkingConfig {
thinking_type: "disabled".to_string(),
budget_tokens: None,
}),
stream: false,
};
let response = self
.client
.post(url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to Anthropic")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"Anthropic API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("Anthropic API error: {} - {}", status, text);
}
let result: MessagesResponse = response
.json()
.await
.context("Failed to parse Anthropic response")?;
result
.content
.into_iter()
.find(|c| c.content_type == "text")
.map(|c| c.text.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("No text response from Anthropic"))
}
/// Streaming request for thinking mode, filters thinking content blocks
async fn streaming_messages_request(
&self,
messages: Vec<AnthropicMessage>,
system: Option<Vec<SystemContent>>,
) -> Result<String> {
let url = "https://api.anthropic.com/v1/messages";
let thinking = ThinkingConfig {
thinking_type: "enabled".to_string(),
budget_tokens: Some(self.thinking_budget_tokens),
};
// max_tokens must exceed budget_tokens
let max_tokens = (self.max_tokens).max(self.thinking_budget_tokens + 100);
let request = MessagesRequest {
model: self.model.clone(),
max_tokens,
temperature: None, // must be omitted for thinking mode
top_p: None,
messages,
system,
thinking: Some(thinking),
stream: true,
};
let response = self
.client
.post(url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.json(&request)
.send()
.await
.context("Failed to send streaming request to Anthropic")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"Anthropic API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("Anthropic API error: {} - {}", status, text);
}
let mut content_buffer = String::new();
let mut in_thinking = false;
let mut has_reasoning = false;
let mut has_content = false;
let thinking_state = self.thinking_state.as_ref();
let mut byte_stream = response.bytes_stream();
let mut line_buffer = String::new();
use futures_util::StreamExt;
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk.context("Failed to read streaming response chunk")?;
let chunk_str =
String::from_utf8(chunk.to_vec()).context("Invalid UTF-8 in stream chunk")?;
line_buffer.push_str(&chunk_str);
while let Some(line_end) = line_buffer.find('\n') {
let line = line_buffer[..line_end].trim().to_string();
line_buffer = line_buffer[line_end + 1..].to_string();
if line.is_empty() {
continue;
}
// Parse SSE event line
if let Some(data) = line.strip_prefix("data: ") {
if let Ok(event) = serde_json::from_str::<SseEvent>(data) {
match event.event_type.as_str() {
"content_block_start" => {
if let Some(ref block) = event.content_block {
if block.content_type == "thinking" {
in_thinking = true;
if !has_reasoning {
has_reasoning = true;
if let Some(state) = thinking_state {
state.start_thinking();
}
}
}
}
}
"content_block_delta" => {
if let Some(ref delta) = event.delta {
// Thinking delta - ignore content but track state
if delta.thinking.is_some() {
continue;
}
// Text delta - collect
if in_thinking && delta.text.is_some() {
// Transition from thinking to text
if let Some(state) = thinking_state {
state.end_thinking();
}
in_thinking = false;
}
if let Some(ref text) = delta.text
&& !text.is_empty()
{
has_content = true;
content_buffer.push_str(text);
}
}
}
"content_block_stop" => {
if in_thinking {
if let Some(state) = thinking_state {
state.end_thinking();
}
in_thinking = false;
}
}
_ => {}
}
}
}
}
}
// Ensure thinking state is ended
if let Some(state) = thinking_state {
state.end_thinking();
}
let result = content_buffer.trim().to_string();
if result.is_empty() {
if has_reasoning && !has_content {
bail!(
"Anthropic returned thinking content but no final answer. \
The model may have entered an incomplete thinking state. \
Please try again or disable thinking mode."
);
}
bail!(
"No response from Anthropic. \
If thinking mode is enabled, try disabling it or ensure the model supports it."
);
}
Ok(result)
}
}
/// Available Anthropic models (Claude 4 series with extended thinking)
pub const ANTHROPIC_MODELS: &[&str] = &[
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5",
// Legacy models
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-2.1",
"claude-2.0",
"claude-instant-1.2",
];
pub fn is_valid_model(model: &str) -> bool {
ANTHROPIC_MODELS.contains(&model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation_claude4() {
assert!(is_valid_model("claude-opus-4-7"));
assert!(is_valid_model("claude-sonnet-4-6"));
assert!(is_valid_model("claude-haiku-4-5"));
assert!(is_valid_model("claude-3-sonnet-20240229"));
assert!(!is_valid_model("invalid-model"));
}
#[test]
fn test_thinking_config_serialization() {
let config = ThinkingConfig {
thinking_type: "enabled".to_string(),
budget_tokens: Some(2048),
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains(r#""type":"enabled""#));
assert!(json.contains(r#""budget_tokens":2048"#));
}
#[test]
fn test_thinking_config_disabled_serialization() {
let config = ThinkingConfig {
thinking_type: "disabled".to_string(),
budget_tokens: None,
};
let json = serde_json::to_string(&config).unwrap();
assert_eq!(json, r#"{"type":"disabled"}"#);
}
#[test]
fn test_system_content_serialization() {
let content = SystemContent {
content_type: "text".to_string(),
text: "You are helpful.".to_string(),
};
let json = serde_json::to_string(&content).unwrap();
assert!(json.contains(r#""type":"text""#));
}
#[test]
fn test_sse_event_parsing_content_block_start() {
let json = r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#;
let event: SseEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event_type, "content_block_start");
assert_eq!(event.content_block.unwrap().content_type, "thinking");
}
#[test]
fn test_sse_event_parsing_text_delta() {
let json = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
let event: SseEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event_type, "content_block_delta");
assert_eq!(event.delta.unwrap().text, Some("Hello".to_string()));
}
#[test]
fn test_anthropic_content_text() {
let msg = AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Text("Hello".to_string()),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""content":"Hello""#));
}
}

View File

@@ -1,622 +0,0 @@
use super::thinking::ThinkingStateManager;
use super::{LlmProvider, create_http_client};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
/// DeepSeek API client
pub struct DeepSeekClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
thinking_enabled: bool,
reasoning_effort: Option<String>,
max_tokens: u32,
temperature: f32,
thinking_state: Option<Arc<ThinkingStateManager>>,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
presence_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
frequency_penalty: Option<f32>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<ThinkingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
}
#[derive(Debug, Serialize)]
struct ThinkingConfig {
#[serde(rename = "type")]
thinking_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
#[serde(default)]
reasoning_content: Option<String>,
}
// --- Streaming response structures ---
#[derive(Debug, Deserialize)]
struct StreamChunk {
choices: Vec<StreamChoice>,
}
#[derive(Debug, Deserialize)]
struct StreamChoice {
delta: StreamDelta,
#[serde(default)]
finish_reason: Option<String>,
index: Option<u32>,
}
#[derive(Debug, Deserialize, Default)]
struct StreamDelta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl DeepSeekClient {
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(300))?;
Ok(Self {
base_url: "https://api.deepseek.com".to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
thinking_enabled: false,
reasoning_effort: None,
max_tokens: 500,
temperature: 0.7,
thinking_state: None,
})
}
pub fn with_base_url(api_key: &str, model: &str, base_url: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(300))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
thinking_enabled: false,
reasoning_effort: None,
max_tokens: 500,
temperature: 0.7,
thinking_state: None,
})
}
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
pub fn with_thinking(mut self, enabled: bool) -> Self {
self.thinking_enabled = enabled;
self
}
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = temperature;
self
}
pub fn with_thinking_state(mut self, state: Arc<ThinkingStateManager>) -> Self {
self.thinking_state = Some(state);
self
}
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to list DeepSeek models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("DeepSeek API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ModelId>,
}
#[derive(Deserialize)]
struct ModelId {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse DeepSeek response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
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)
}
}
}
}
}
#[async_trait]
impl LlmProvider for DeepSeekClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![Message {
role: "user".to_string(),
content: prompt.to_string(),
reasoning_content: None,
}];
self.chat_completion_with_retry(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
reasoning_content: None,
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
reasoning_content: None,
});
self.chat_completion_with_retry(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"deepseek"
}
}
impl DeepSeekClient {
async fn chat_completion_with_retry(&self, messages: Vec<Message>) -> Result<String> {
let mut last_error = None;
for attempt in 1..=3 {
match self.chat_completion(messages.clone()).await {
Ok(result) => return Ok(result),
Err(e) => {
let err_msg = e.to_string();
// 网络临时错误才重试
let is_retryable = err_msg.contains("timeout")
|| err_msg.contains("connection")
|| err_msg.contains("temporary")
|| err_msg.contains("5")
&& (err_msg.contains("500")
|| err_msg.contains("502")
|| err_msg.contains("503")
|| err_msg.contains("504"));
if !is_retryable || attempt == 3 {
last_error = Some(e);
break;
}
// 指数退避
tokio::time::sleep(Duration::from_millis(500 * 2u64.pow(attempt - 1))).await;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Request failed after retries")))
}
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let thinking = Some(ThinkingConfig {
thinking_type: if self.thinking_enabled {
"enabled".to_string()
} else {
"disabled".to_string()
},
});
// 思考模式下temperature/top_p 等参数不应传递
// 非思考模式下可以正常传递
let (temperature, max_tokens, top_p, presence_penalty, frequency_penalty) =
if self.thinking_enabled {
(None, Some(self.max_tokens), None, None, None)
} else {
(
Some(self.temperature),
Some(self.max_tokens),
None,
None,
None,
)
};
let reasoning_effort = if self.thinking_enabled {
self.reasoning_effort.clone()
} else {
None
};
let request = ChatCompletionRequest {
model: self.model.clone(),
messages: messages.clone(),
max_tokens,
temperature,
top_p,
presence_penalty,
frequency_penalty,
stream: self.thinking_enabled,
thinking,
reasoning_effort,
};
if self.thinking_enabled {
self.streaming_chat_completion(&url, &request).await
} else {
self.non_streaming_chat_completion(&url, &request).await
}
}
/// 非流式请求(非思考模式)
async fn non_streaming_chat_completion(
&self,
url: &str,
request: &ChatCompletionRequest,
) -> Result<String> {
let response = self
.client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(request)
.send()
.await
.context("Failed to send request to DeepSeek")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"DeepSeek API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("DeepSeek API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse DeepSeek response")?;
result
.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("No response from DeepSeek"))
}
/// 流式请求(思考模式),处理 reasoning_content 和 content
async fn streaming_chat_completion(
&self,
url: &str,
request: &ChatCompletionRequest,
) -> Result<String> {
let response = self
.client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.json(request)
.send()
.await
.context("Failed to send streaming request to DeepSeek")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"DeepSeek API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("DeepSeek API error: {} - {}", status, text);
}
let mut content_buffer = String::new();
let mut has_reasoning = false;
let mut has_content = false;
let mut stream_ended = false;
let thinking_state = self.thinking_state.as_ref();
let mut byte_stream = response.bytes_stream();
let mut line_buffer = String::new();
use futures_util::StreamExt;
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk.context("Failed to read streaming response chunk")?;
let chunk_str =
String::from_utf8(chunk.to_vec()).context("Invalid UTF-8 in stream chunk")?;
line_buffer.push_str(&chunk_str);
// 处理完整行
while let Some(line_end) = line_buffer.find('\n') {
let line = line_buffer[..line_end].trim().to_string();
line_buffer = line_buffer[line_end + 1..].to_string();
if line.is_empty() {
continue;
}
// SSE 格式data: {...} 或 data: [DONE]
if line == "data: [DONE]" {
stream_ended = true;
break;
}
if let Some(json_str) = line.strip_prefix("data: ") {
match serde_json::from_str::<StreamChunk>(json_str) {
Ok(chunk) => {
for choice in &chunk.choices {
// 处理 reasoning_content
if let Some(ref reasoning) = choice.delta.reasoning_content
&& !reasoning.is_empty()
{
if !has_reasoning {
has_reasoning = true;
if let Some(state) = thinking_state {
state.start_thinking();
}
}
// reasoning_content 不对外输出,仅用于内部状态判断
continue;
}
// 处理 content
if let Some(ref content) = choice.delta.content
&& !content.is_empty()
{
// reasoning 结束content 开始出现时移除 thinking 标识
if has_reasoning
&& !has_content
&& let Some(state) = thinking_state
{
state.end_thinking();
}
has_content = true;
content_buffer.push_str(content);
}
// 检查 finish_reason
if let Some(ref reason) = choice.finish_reason
&& reason == "stop"
{
stream_ended = true;
}
}
}
Err(_) => {
// 忽略无法解析的行(可能是心跳或注释)
}
}
}
}
if stream_ended {
break;
}
}
// 确保思考状态已结束
if let Some(state) = thinking_state {
state.end_thinking();
}
let result = content_buffer.trim().to_string();
if result.is_empty() {
if has_reasoning && !has_content {
bail!(
"DeepSeek returned reasoning content but no final answer. \
The model may have entered an incomplete thinking state. \
Please try again or disable thinking mode."
);
}
bail!(
"No response from DeepSeek. \
If thinking mode is enabled, try disabling it or ensure the model supports it."
);
}
Ok(result)
}
}
/// 可用 DeepSeek 模型列表
/// deepseek-chat / deepseek-reasoner 将于 2026-07-24 停用,推荐使用 V4 系列
pub const DEEPSEEK_MODELS: &[&str] = &[
"deepseek-v4-flash",
"deepseek-v4-pro",
// 兼容旧版模型 ID将于 2026-07-24 停用)
"deepseek-chat",
"deepseek-reasoner",
];
pub fn is_valid_model(model: &str) -> bool {
DEEPSEEK_MODELS.contains(&model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation_v4() {
assert!(is_valid_model("deepseek-v4-flash"));
assert!(is_valid_model("deepseek-v4-pro"));
assert!(is_valid_model("deepseek-chat"));
assert!(is_valid_model("deepseek-reasoner"));
assert!(!is_valid_model("invalid-model"));
assert!(!is_valid_model("deepseek-v3"));
}
#[test]
fn test_client_builder_defaults() {
let client = DeepSeekClient::new("test-key", "deepseek-v4-flash").unwrap();
assert!(!client.thinking_enabled);
assert_eq!(client.max_tokens, 500);
assert_eq!(client.temperature, 0.7);
assert!(client.reasoning_effort.is_none());
assert!(client.thinking_state.is_none());
}
#[test]
fn test_client_builder_with_thinking() {
let client = DeepSeekClient::new("test-key", "deepseek-v4-flash")
.unwrap()
.with_thinking(true)
.with_reasoning_effort(Some("high".to_string()))
.with_max_tokens(1000)
.with_temperature(0.5);
assert!(client.thinking_enabled);
assert_eq!(client.reasoning_effort, Some("high".to_string()));
assert_eq!(client.max_tokens, 1000);
assert_eq!(client.temperature, 0.5);
}
#[test]
fn test_thinking_config_serialization() {
let config = ThinkingConfig {
thinking_type: "enabled".to_string(),
};
let json = serde_json::to_string(&config).unwrap();
assert_eq!(json, r#"{"type":"enabled"}"#);
}
#[test]
fn test_message_serialization_without_reasoning() {
let msg = Message {
role: "user".to_string(),
content: "Hello".to_string(),
reasoning_content: None,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(!json.contains("reasoning_content"));
}
#[test]
fn test_stream_delta_parsing() {
let json = r#"{"content":"Hello","reasoning_content":null}"#;
let delta: StreamDelta = serde_json::from_str(json).unwrap();
assert_eq!(delta.content, Some("Hello".to_string()));
assert!(delta.reasoning_content.is_none());
}
#[test]
fn test_stream_delta_reasoning_only() {
let json = r#"{"content":null,"reasoning_content":"Let me think..."}"#;
let delta: StreamDelta = serde_json::from_str(json).unwrap();
assert!(delta.content.is_none());
assert_eq!(delta.reasoning_content, Some("Let me think...".to_string()));
}
}

View File

@@ -1,587 +0,0 @@
use super::thinking::ThinkingStateManager;
use super::{LlmProvider, create_http_client};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
/// Kimi API client (Moonshot AI)
pub struct KimiClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
thinking_enabled: bool,
max_tokens: u32,
temperature: f32,
thinking_state: Option<Arc<ThinkingStateManager>>,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<ThinkingConfig>,
}
#[derive(Debug, Serialize)]
struct ThinkingConfig {
#[serde(rename = "type")]
thinking_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
#[serde(default)]
reasoning_content: Option<String>,
}
// --- Streaming response structures ---
#[derive(Debug, Deserialize)]
struct StreamChunk {
choices: Vec<StreamChoice>,
}
#[derive(Debug, Deserialize)]
struct StreamChoice {
delta: StreamDelta,
#[serde(default)]
finish_reason: Option<String>,
index: Option<u32>,
}
#[derive(Debug, Deserialize, Default)]
struct StreamDelta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl KimiClient {
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(300))?;
Ok(Self {
base_url: "https://api.moonshot.cn/v1".to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
thinking_enabled: false,
max_tokens: 500,
temperature: 1.0,
thinking_state: None,
})
}
pub fn with_base_url(api_key: &str, model: &str, base_url: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(300))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
thinking_enabled: false,
max_tokens: 500,
temperature: 1.0,
thinking_state: None,
})
}
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
pub fn with_thinking(mut self, enabled: bool) -> Self {
self.thinking_enabled = enabled;
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = temperature;
self
}
pub fn with_thinking_state(mut self, state: Arc<ThinkingStateManager>) -> Self {
self.thinking_state = Some(state);
self
}
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to list Kimi models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("Kimi API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ModelId>,
}
#[derive(Deserialize)]
struct ModelId {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse Kimi response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
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)
}
}
}
}
}
#[async_trait]
impl LlmProvider for KimiClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![Message {
role: "user".to_string(),
content: prompt.to_string(),
reasoning_content: None,
}];
self.chat_completion_with_retry(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
reasoning_content: None,
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
reasoning_content: None,
});
self.chat_completion_with_retry(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"kimi"
}
}
impl KimiClient {
async fn chat_completion_with_retry(&self, messages: Vec<Message>) -> Result<String> {
let mut last_error = None;
for attempt in 1..=3 {
match self.chat_completion(messages.clone()).await {
Ok(result) => return Ok(result),
Err(e) => {
let err_msg = e.to_string();
let is_retryable = err_msg.contains("timeout")
|| err_msg.contains("connection")
|| err_msg.contains("temporary")
|| err_msg.contains("5")
&& (err_msg.contains("500")
|| err_msg.contains("502")
|| err_msg.contains("503")
|| err_msg.contains("504"));
if !is_retryable || attempt == 3 {
last_error = Some(e);
break;
}
tokio::time::sleep(Duration::from_millis(500 * 2u64.pow(attempt - 1))).await;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Request failed after retries")))
}
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let thinking = Some(ThinkingConfig {
thinking_type: if self.thinking_enabled {
"enabled".to_string()
} else {
"disabled".to_string()
},
});
// Kimi API temperature 要求:
// - 思考模式: temperature 必须为 1.0
// - 非思考模式: temperature 必须为 0.6
let temperature = if self.thinking_enabled {
Some(1.0)
} else {
Some(0.6)
};
let request = ChatCompletionRequest {
model: self.model.clone(),
messages: messages.clone(),
max_tokens: Some(self.max_tokens),
temperature,
stream: self.thinking_enabled,
thinking,
};
if self.thinking_enabled {
self.streaming_chat_completion(&url, &request).await
} else {
self.non_streaming_chat_completion(&url, &request).await
}
}
/// 非流式请求(非思考模式)
async fn non_streaming_chat_completion(
&self,
url: &str,
request: &ChatCompletionRequest,
) -> Result<String> {
let response = self
.client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(request)
.send()
.await
.context("Failed to send request to Kimi")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"Kimi API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("Kimi API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse Kimi response")?;
result
.choices
.into_iter()
.next()
.map(|c| {
let content = c.message.content.trim().to_string();
if content.is_empty() {
c.reasoning_content
.or(c.message.reasoning_content)
.map(|r| r.trim().to_string())
.unwrap_or_default()
} else {
content
}
})
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("No response from Kimi"))
}
/// 流式请求(思考模式),处理 reasoning_content 和 content
async fn streaming_chat_completion(
&self,
url: &str,
request: &ChatCompletionRequest,
) -> Result<String> {
let response = self
.client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.json(request)
.send()
.await
.context("Failed to send streaming request to Kimi")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"Kimi API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("Kimi API error: {} - {}", status, text);
}
let mut content_buffer = String::new();
let mut has_reasoning = false;
let mut has_content = false;
let mut stream_ended = false;
let thinking_state = self.thinking_state.as_ref();
let mut byte_stream = response.bytes_stream();
let mut line_buffer = String::new();
use futures_util::StreamExt;
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk.context("Failed to read streaming response chunk")?;
let chunk_str =
String::from_utf8(chunk.to_vec()).context("Invalid UTF-8 in stream chunk")?;
line_buffer.push_str(&chunk_str);
while let Some(line_end) = line_buffer.find('\n') {
let line = line_buffer[..line_end].trim().to_string();
line_buffer = line_buffer[line_end + 1..].to_string();
if line.is_empty() {
continue;
}
if line == "data: [DONE]" {
stream_ended = true;
break;
}
if let Some(json_str) = line.strip_prefix("data: ") {
match serde_json::from_str::<StreamChunk>(json_str) {
Ok(chunk) => {
for choice in &chunk.choices {
if let Some(ref reasoning) = choice.delta.reasoning_content
&& !reasoning.is_empty()
{
if !has_reasoning {
has_reasoning = true;
if let Some(state) = thinking_state {
state.start_thinking();
}
}
continue;
}
if let Some(ref content) = choice.delta.content
&& !content.is_empty()
{
if has_reasoning
&& !has_content
&& let Some(state) = thinking_state
{
state.end_thinking();
}
has_content = true;
content_buffer.push_str(content);
}
if let Some(ref reason) = choice.finish_reason
&& reason == "stop"
{
stream_ended = true;
}
}
}
Err(_) => {
// 忽略无法解析的行
}
}
}
}
if stream_ended {
break;
}
}
// 确保思考状态已结束
if let Some(state) = thinking_state {
state.end_thinking();
}
let result = content_buffer.trim().to_string();
if result.is_empty() {
if has_reasoning && !has_content {
bail!(
"Kimi returned reasoning content but no final answer. \
The model may have entered an incomplete thinking state. \
Please try again or disable thinking mode."
);
}
bail!(
"No response from Kimi. \
If thinking mode is enabled, try disabling it or ensure the model supports it."
);
}
Ok(result)
}
}
/// 可用 Kimi 模型列表
pub const KIMI_MODELS: &[&str] = &[
// K2 系列(推荐)
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
"kimi-k2-instruct",
"kimi-k2-instruct-0905",
// 兼容旧版模型 ID
"moonshot-v1-8k",
"moonshot-v1-32k",
"moonshot-v1-128k",
];
pub fn is_valid_model(model: &str) -> bool {
KIMI_MODELS.contains(&model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation_k2() {
assert!(is_valid_model("kimi-k2.6"));
assert!(is_valid_model("kimi-k2.5"));
assert!(is_valid_model("kimi-k2-thinking"));
assert!(is_valid_model("kimi-k2-thinking-turbo"));
assert!(is_valid_model("moonshot-v1-8k"));
assert!(is_valid_model("moonshot-v1-32k"));
assert!(is_valid_model("moonshot-v1-128k"));
assert!(!is_valid_model("invalid-model"));
assert!(!is_valid_model("kimi-k1.5"));
}
#[test]
fn test_client_builder_defaults() {
let client = KimiClient::new("test-key", "kimi-k2.6").unwrap();
assert!(!client.thinking_enabled);
assert_eq!(client.max_tokens, 500);
assert_eq!(client.temperature, 1.0);
assert!(client.thinking_state.is_none());
}
#[test]
fn test_client_builder_with_thinking() {
let client = KimiClient::new("test-key", "kimi-k2.6")
.unwrap()
.with_thinking(true)
.with_max_tokens(1000)
.with_temperature(0.5);
assert!(client.thinking_enabled);
assert_eq!(client.max_tokens, 1000);
assert_eq!(client.temperature, 0.5);
}
#[test]
fn test_thinking_config_serialization() {
let config = ThinkingConfig {
thinking_type: "enabled".to_string(),
};
let json = serde_json::to_string(&config).unwrap();
assert_eq!(json, r#"{"type":"enabled"}"#);
}
#[test]
fn test_client_new_defaults() {
let client = KimiClient::new("test-key", "kimi-k2.6").unwrap();
assert_eq!(client.name(), "kimi");
assert!(!client.thinking_enabled);
}
#[test]
fn test_message_serialization() {
let msg = Message {
role: "user".to_string(),
content: "Hello".to_string(),
reasoning_content: None,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(!json.contains("reasoning_content"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,229 +0,0 @@
use super::{LlmProvider, create_http_client};
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Ollama API client
pub struct OllamaClient {
base_url: String,
model: String,
client: reqwest::Client,
max_tokens: u32,
temperature: f32,
top_p: Option<f32>,
}
#[derive(Debug, Serialize)]
struct GenerateRequest {
model: String,
prompt: String,
system: Option<String>,
stream: bool,
options: GenerationOptions,
}
#[derive(Debug, Serialize, Default)]
struct GenerationOptions {
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
num_predict: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct GenerateResponse {
response: String,
done: bool,
}
#[derive(Debug, Deserialize)]
struct ListModelsResponse {
models: Vec<ModelInfo>,
}
#[derive(Debug, Deserialize)]
struct ModelInfo {
name: String,
}
impl OllamaClient {
/// Create new Ollama client
pub fn new(base_url: &str, model: &str) -> Self {
let client =
create_http_client(Duration::from_secs(120)).expect("Failed to create HTTP client");
Self {
base_url: base_url.trim_end_matches('/').to_string(),
model: model.to_string(),
client,
max_tokens: 500,
temperature: 0.7,
top_p: None,
}
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.client = create_http_client(timeout).expect("Failed to create HTTP client");
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = temperature;
self
}
pub fn with_top_p(mut self, top_p: f32) -> Self {
self.top_p = Some(top_p);
self
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/api/tags", self.base_url);
let response = self
.client
.get(&url)
.send()
.await
.context("Failed to list Ollama models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama API error: {} - {}", status, text);
}
let result: ListModelsResponse = response
.json()
.await
.context("Failed to parse Ollama response")?;
Ok(result.models.into_iter().map(|m| m.name).collect())
}
/// Pull a model
pub async fn pull_model(&self, model: &str) -> Result<()> {
let url = format!("{}/api/pull", self.base_url);
let request = serde_json::json!({
"name": model,
"stream": false,
});
let response = self
.client
.post(&url)
.json(&request)
.send()
.await
.context("Failed to pull Ollama model")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama pull error: {} - {}", status, text);
}
Ok(())
}
/// Check if model exists
pub async fn model_exists(&self, model: &str) -> bool {
match self.list_models().await {
Ok(models) => models.contains(&model.to_string()),
Err(_) => false,
}
}
}
#[async_trait]
impl LlmProvider for OllamaClient {
async fn generate(&self, prompt: &str) -> Result<String> {
self.generate_with_system("", prompt).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let url = format!("{}/api/generate", self.base_url);
let system = if system.is_empty() {
None
} else {
Some(system.to_string())
};
let request = GenerateRequest {
model: self.model.clone(),
prompt: user.to_string(),
system,
stream: false,
options: GenerationOptions {
temperature: Some(self.temperature),
num_predict: Some(self.max_tokens),
},
};
let response = self
.client
.post(&url)
.json(&request)
.send()
.await
.context("Failed to send request to Ollama")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama API error: {} - {}", status, text);
}
let result: GenerateResponse = response
.json()
.await
.context("Failed to parse Ollama response")?;
Ok(result.response.trim().to_string())
}
async fn is_available(&self) -> bool {
let url = format!("{}/api/tags", self.base_url);
match self.client.get(&url).send().await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
fn name(&self) -> &str {
"ollama"
}
}
#[cfg(test)]
mod tests {
use super::*;
// These tests require a running Ollama server
#[tokio::test]
#[ignore]
async fn test_ollama_connection() {
let client = OllamaClient::new("http://localhost:11434", "llama2");
assert!(client.is_available().await);
}
#[tokio::test]
#[ignore]
async fn test_ollama_generate() {
let client = OllamaClient::new("http://localhost:11434", "llama2");
let response = client.generate("Hello, how are you?").await;
assert!(response.is_ok());
println!("Response: {}", response.unwrap());
}
}

View File

@@ -1,659 +0,0 @@
use super::thinking::ThinkingStateManager;
use super::{LlmProvider, create_http_client};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
/// OpenAI API client with o-series reasoning support
pub struct OpenAiClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
thinking_enabled: bool,
reasoning_effort: Option<String>,
max_tokens: u32,
temperature: f32,
top_p: Option<f32>,
thinking_state: Option<Arc<ThinkingStateManager>>,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
}
// --- Streaming response structures ---
#[derive(Debug, Deserialize)]
struct StreamChunk {
choices: Vec<StreamChoice>,
}
#[derive(Debug, Deserialize)]
struct StreamChoice {
delta: StreamDelta,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct StreamDelta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl OpenAiClient {
/// Create new OpenAI client
pub fn new(base_url: &str, api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
thinking_enabled: false,
reasoning_effort: None,
max_tokens: 500,
temperature: 0.7,
top_p: None,
thinking_state: None,
})
}
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
pub fn with_thinking(mut self, enabled: bool) -> Self {
self.thinking_enabled = enabled;
self
}
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = temperature;
self
}
pub fn with_top_p(mut self, top_p: f32) -> Self {
self.top_p = Some(top_p);
self
}
pub fn with_thinking_state(mut self, state: Arc<ThinkingStateManager>) -> Self {
self.thinking_state = Some(state);
self
}
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to list OpenAI models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("OpenAI 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 OpenAI response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
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)
}
}
}
}
}
#[async_trait]
impl LlmProvider for OpenAiClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![Message {
role: "user".to_string(),
content: prompt.to_string(),
}];
self.chat_completion_with_retry(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion_with_retry(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"openai"
}
}
impl OpenAiClient {
async fn chat_completion_with_retry(&self, messages: Vec<Message>) -> Result<String> {
let mut last_error = None;
for attempt in 1..=3 {
match self.chat_completion(messages.clone()).await {
Ok(result) => return Ok(result),
Err(e) => {
let err_msg = e.to_string();
let is_retryable = err_msg.contains("timeout")
|| err_msg.contains("connection")
|| err_msg.contains("temporary")
|| err_msg.contains("5")
&& (err_msg.contains("500")
|| err_msg.contains("502")
|| err_msg.contains("503")
|| err_msg.contains("504"));
if !is_retryable || attempt == 3 {
last_error = Some(e);
break;
}
tokio::time::sleep(Duration::from_millis(500 * 2u64.pow(attempt - 1))).await;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Request failed after retries")))
}
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
if self.thinking_enabled {
self.streaming_chat_completion(messages).await
} else {
self.non_streaming_chat_completion(messages).await
}
}
async fn non_streaming_chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let request = ChatCompletionRequest {
model: self.model.clone(),
messages,
max_tokens: Some(self.max_tokens),
temperature: Some(self.temperature),
top_p: self.top_p,
reasoning_effort: if is_reasoning_model(&self.model) {
Some("none".to_string())
} else {
None
},
stream: false,
};
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to OpenAI")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"OpenAI API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("OpenAI API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse OpenAI response")?;
result
.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))
}
/// Streaming request for reasoning mode, filters reasoning_content from output
async fn streaming_chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
// For reasoning/thinking mode, omit temperature and top_p
let request = ChatCompletionRequest {
model: self.model.clone(),
messages,
max_tokens: Some(self.max_tokens),
temperature: None,
top_p: None,
reasoning_effort: self.reasoning_effort.clone(),
stream: true,
};
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.json(&request)
.send()
.await
.context("Failed to send streaming request to OpenAI")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"OpenAI API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("OpenAI API error: {} - {}", status, text);
}
let mut content_buffer = String::new();
let mut has_reasoning = false;
let mut has_content = false;
let thinking_state = self.thinking_state.as_ref();
let mut byte_stream = response.bytes_stream();
let mut line_buffer = String::new();
use futures_util::StreamExt;
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk.context("Failed to read streaming response chunk")?;
let chunk_str =
String::from_utf8(chunk.to_vec()).context("Invalid UTF-8 in stream chunk")?;
line_buffer.push_str(&chunk_str);
while let Some(line_end) = line_buffer.find('\n') {
let line = line_buffer[..line_end].trim().to_string();
line_buffer = line_buffer[line_end + 1..].to_string();
if line.is_empty() {
continue;
}
if line == "data: [DONE]" {
break;
}
if let Some(json_str) = line.strip_prefix("data: ") {
if let Ok(chunk) = serde_json::from_str::<StreamChunk>(json_str) {
for choice in &chunk.choices {
// Handle reasoning_content (o-series)
if let Some(ref reasoning) = choice.delta.reasoning_content
&& !reasoning.is_empty()
{
if !has_reasoning {
has_reasoning = true;
if let Some(state) = thinking_state {
state.start_thinking();
}
}
continue;
}
// Handle content
if let Some(ref content) = choice.delta.content
&& !content.is_empty()
{
if has_reasoning
&& !has_content
&& let Some(state) = thinking_state
{
state.end_thinking();
}
has_content = true;
content_buffer.push_str(content);
}
}
}
}
}
}
if let Some(state) = thinking_state {
state.end_thinking();
}
let result = content_buffer.trim().to_string();
if result.is_empty() {
if has_reasoning && !has_content {
bail!(
"OpenAI returned reasoning content but no final answer. \
The model may have entered an incomplete reasoning state. \
Please try again or disable thinking mode."
);
}
bail!(
"No response from OpenAI. \
If thinking mode is enabled, try disabling it or ensure the model supports reasoning."
);
}
Ok(result)
}
}
/// Azure OpenAI client (extends OpenAI with Azure-specific config)
pub struct AzureOpenAiClient {
endpoint: String,
api_key: String,
deployment: String,
api_version: String,
client: reqwest::Client,
thinking_enabled: bool,
reasoning_effort: Option<String>,
max_tokens: u32,
temperature: f32,
top_p: Option<f32>,
thinking_state: Option<Arc<ThinkingStateManager>>,
}
impl AzureOpenAiClient {
pub fn new(endpoint: &str, api_key: &str, deployment: &str, api_version: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
endpoint: endpoint.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
deployment: deployment.to_string(),
api_version: api_version.to_string(),
client,
thinking_enabled: false,
reasoning_effort: None,
max_tokens: 500,
temperature: 0.7,
top_p: None,
thinking_state: None,
})
}
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!(
"{}/openai/deployments/{}/chat/completions?api-version={}",
self.endpoint, self.deployment, self.api_version
);
let request = ChatCompletionRequest {
model: self.deployment.clone(),
messages,
max_tokens: Some(self.max_tokens),
temperature: Some(self.temperature),
top_p: self.top_p,
reasoning_effort: self.reasoning_effort.clone(),
stream: false,
};
let response = self
.client
.post(&url)
.header("api-key", &self.api_key)
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to Azure OpenAI")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("Azure OpenAI API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse Azure OpenAI response")?;
result
.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("No response from Azure OpenAI"))
}
}
#[async_trait]
impl LlmProvider for AzureOpenAiClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![Message {
role: "user".to_string(),
content: prompt.to_string(),
}];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
let url = format!(
"{}/openai/deployments/{}/chat/completions?api-version={}",
self.endpoint, self.deployment, self.api_version
);
let request = ChatCompletionRequest {
model: self.deployment.clone(),
messages: vec![Message {
role: "user".to_string(),
content: "Hi".to_string(),
}],
max_tokens: Some(5),
temperature: Some(0.0),
top_p: None,
reasoning_effort: None,
stream: false,
};
match self
.client
.post(&url)
.header("api-key", &self.api_key)
.json(&request)
.send()
.await
{
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
fn name(&self) -> &str {
"azure-openai"
}
}
/// Available OpenAI models (including o-series with reasoning)
pub const OPENAI_MODELS: &[&str] = &[
"o4-mini",
"o3",
"o3-mini",
"o1",
"o1-mini",
"o1-pro",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
];
pub fn is_valid_model(model: &str) -> bool {
OPENAI_MODELS.contains(&model)
}
fn is_reasoning_model(model: &str) -> bool {
model.starts_with("o")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation_o_series() {
assert!(is_valid_model("o4-mini"));
assert!(is_valid_model("o3"));
assert!(is_valid_model("o1"));
assert!(is_valid_model("gpt-4o"));
assert!(is_valid_model("gpt-3.5-turbo"));
assert!(!is_valid_model("invalid-model"));
}
#[test]
fn test_stream_delta_reasoning_parsing() {
let json = r#"{"content":null,"reasoning_content":"Let me think..."}"#;
let delta: StreamDelta = serde_json::from_str(json).unwrap();
assert!(delta.content.is_none());
assert_eq!(delta.reasoning_content, Some("Let me think...".to_string()));
}
#[test]
fn test_stream_delta_content_parsing() {
let json = r#"{"content":"Hello","reasoning_content":null}"#;
let delta: StreamDelta = serde_json::from_str(json).unwrap();
assert_eq!(delta.content, Some("Hello".to_string()));
assert!(delta.reasoning_content.is_none());
}
}

View File

@@ -1,286 +0,0 @@
use super::{LlmProvider, create_http_client};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// OpenRouter API client
pub struct OpenRouterClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
max_tokens: u32,
temperature: f32,
top_p: Option<f32>,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl OpenRouterClient {
/// Create new OpenRouter client
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: "https://openrouter.ai/api/v1".to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
max_tokens: 500,
temperature: 0.7,
top_p: None,
})
}
/// Create with custom base URL
pub fn with_base_url(api_key: &str, model: &str, base_url: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
max_tokens: 500,
temperature: 0.7,
top_p: None,
})
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = temperature;
self
}
pub fn with_top_p(mut self, top_p: f32) -> Self {
self.top_p = Some(top_p);
self
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("HTTP-Referer", "https://quicommit.dev")
.header("X-Title", "QuiCommit")
.send()
.await
.context("Failed to list OpenRouter models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("OpenRouter API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<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)
}
}
}
}
}
#[async_trait]
impl LlmProvider for OpenRouterClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![Message {
role: "user".to_string(),
content: prompt.to_string(),
}];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"openrouter"
}
}
impl OpenRouterClient {
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let request = ChatCompletionRequest {
model: self.model.clone(),
messages,
max_tokens: Some(self.max_tokens),
temperature: Some(self.temperature),
stream: false,
};
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("HTTP-Referer", "https://quicommit.dev")
.header("X-Title", "QuiCommit")
.json(&request)
.send()
.await
.context("Failed to send request to OpenRouter")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
// Try to parse error
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!(
"OpenRouter API error: {} ({})",
error.error.message,
error.error.error_type
);
}
bail!("OpenRouter API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse OpenRouter response")?;
result
.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))
}
}
/// Popular OpenRouter models
pub const OPENROUTER_MODELS: &[&str] = &[
"openai/gpt-3.5-turbo",
"openai/gpt-4",
"openai/gpt-4-turbo",
"anthropic/claude-3-opus",
"anthropic/claude-3-sonnet",
"anthropic/claude-3-haiku",
"google/gemini-pro",
"meta-llama/llama-2-70b-chat",
"mistralai/mixtral-8x7b-instruct",
"01-ai/yi-34b-chat",
];
/// Check if a model name is valid
pub fn is_valid_model(_model: &str) -> bool {
// Since OpenRouter supports many models, we'll allow any model name
// but provide some popular ones as suggestions
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation() {
assert!(is_valid_model("openai/gpt-4"));
assert!(is_valid_model("custom/model"));
}
}

281
src/llm/parsing.rs Normal file
View File

@@ -0,0 +1,281 @@
use anyhow::{Result, bail};
/// Parse commit response from LLM
pub(crate) fn parse_commit_response(
response: &str,
format: crate::config::CommitFormat,
) -> Result<GeneratedCommit> {
// Clean markdown code fences from the response
let cleaned = strip_code_fences(response);
let lines: Vec<&str> = cleaned
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
let preview: String = response.chars().take(200).collect();
bail!(
"LLM returned empty or whitespace-only response. \
Raw response preview: '{}'. \
Hint: If using DeepSeek/Kimi with thinking enabled, \
the model may have returned reasoning_content only. \
Try disabling thinking mode or switching models.",
preview
);
}
// Find the line most likely to be the commit subject
let first_line = find_commit_subject_line(&lines, format);
// Parse based on format
match format {
crate::config::CommitFormat::Conventional => {
parse_conventional_commit(first_line, &lines, response)
}
crate::config::CommitFormat::Commitlint => {
parse_commitlint_commit(first_line, &lines, response)
}
}
}
/// Remove surrounding markdown code fences (```) from LLM output
fn strip_code_fences(response: &str) -> String {
let mut lines: Vec<&str> = response.lines().collect();
// Strip leading fence lines (``` or ```lang)
while lines.first().map_or(false, |l| l.trim().starts_with("```")) {
lines.remove(0);
}
// Strip trailing fence lines
while lines.last().map_or(false, |l| l.trim() == "```") {
lines.pop();
}
lines.join("\n")
}
/// Find the line that is most likely the commit subject among extracted lines
fn find_commit_subject_line<'a>(
lines: &[&'a str],
format: crate::config::CommitFormat,
) -> &'a str {
let valid_types = crate::utils::validators::get_commit_types(matches!(
format,
crate::config::CommitFormat::Commitlint
));
// First pass: line starting with a known type that also has proper syntax
// (e.g. "type:", "type(scope):", "type!:")
for &line in lines {
let trimmed = line.trim();
for &t in valid_types {
if let Some(rest) = trimmed.strip_prefix(t) {
if rest.starts_with(':') || rest.starts_with('(') || rest.starts_with("!:") {
return trimmed;
}
}
}
}
// Second pass: any line containing a colon (generic "prefix: description")
for &line in lines {
if line.contains(':') {
return line.trim();
}
}
// Fallback: return the first line as-is
lines[0].trim()
}
fn parse_conventional_commit(
first_line: &str,
lines: &[&str],
raw_response: &str,
) -> Result<GeneratedCommit> {
// Parse: type(scope)!: description
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
let preview: String = raw_response.chars().take(300).collect();
bail!(
"Invalid conventional commit format: missing colon.\n\
Parsed subject line: '{}'\n\
Raw response preview: '{}'\n\
Expected: <type>[optional scope]: <description>",
first_line,
preview
);
}
let type_part = parts[0];
let description = parts[1].trim();
// Extract type, scope, and breaking indicator
let breaking = type_part.ends_with('!');
let type_part = type_part.trim_end_matches('!');
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
if let Some(end) = type_part.find(')') {
let t = &type_part[..start];
let s = &type_part[start + 1..end];
(t.to_string(), Some(s.to_string()))
} else {
bail!("Invalid scope format: missing closing parenthesis");
}
} else {
(type_part.to_string(), None)
};
// Extract body and footer
let (body, footer) = extract_body_footer(lines);
Ok(GeneratedCommit {
commit_type,
scope,
description: description.to_string(),
body,
footer,
breaking,
})
}
fn parse_commitlint_commit(
first_line: &str,
lines: &[&str],
raw_response: &str,
) -> Result<GeneratedCommit> {
// Similar parsing but with commitlint rules
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
let preview: String = raw_response.chars().take(300).collect();
bail!(
"Invalid commit format: missing colon.\n\
Parsed subject line: '{}'\n\
Raw response preview: '{}'\n\
Expected: <type>[optional scope]: <subject>",
first_line,
preview
);
}
let type_part = parts[0];
let subject = parts[1].trim();
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
if let Some(end) = type_part.find(')') {
let t = &type_part[..start];
let s = &type_part[start + 1..end];
(t.to_string(), Some(s.to_string()))
} else {
(type_part.to_string(), None)
}
} else {
(type_part.to_string(), None)
};
let (body, footer) = extract_body_footer(&lines);
Ok(GeneratedCommit {
commit_type,
scope,
description: subject.to_string(),
body,
footer,
breaking: false,
})
}
fn extract_body_footer(lines: &[&str]) -> (Option<String>, Option<String>) {
if lines.len() <= 1 {
return (None, None);
}
let rest: Vec<&str> = lines[1..]
.iter()
.skip_while(|l| l.trim().is_empty())
.copied()
.collect();
if rest.is_empty() {
return (None, None);
}
// Look for footer markers
let footer_markers = [
"BREAKING CHANGE:",
"Closes",
"Fixes",
"Refs",
"Co-authored-by:",
];
let mut body_lines = vec![];
let mut footer_lines = vec![];
let mut in_footer = false;
for line in &rest {
if footer_markers.iter().any(|m| line.starts_with(m)) {
in_footer = true;
}
if in_footer {
footer_lines.push(*line);
} else {
body_lines.push(*line);
}
}
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
};
let footer = if footer_lines.is_empty() {
None
} else {
Some(footer_lines.join("\n"))
};
(body, footer)
}
/// Generated commit structure
#[derive(Debug, Clone)]
pub struct GeneratedCommit {
pub commit_type: String,
pub scope: Option<String>,
pub description: String,
pub body: Option<String>,
pub footer: Option<String>,
pub breaking: bool,
}
impl GeneratedCommit {
/// Format as conventional commit
pub fn to_conventional(&self) -> String {
crate::utils::formatter::format_conventional_commit(
&self.commit_type,
self.scope.as_deref(),
&self.description,
self.body.as_deref(),
self.footer.as_deref(),
self.breaking,
)
}
/// Format as commitlint commit
pub fn to_commitlint(&self) -> String {
crate::utils::formatter::format_commitlint_commit(
&self.commit_type,
self.scope.as_deref(),
&self.description,
self.body.as_deref(),
self.footer.as_deref(),
None,
)
}
}

618
src/llm/prompts.rs Normal file
View File

@@ -0,0 +1,618 @@
use crate::config::Language;
/// Get commit system prompt based on format and language
pub(crate) 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,
}
}
pub(crate) 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,
}
}
pub(crate) 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
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates conventional commit messages.
Analyze the git diff provided and generate a commit message following the Conventional Commits specification.
Format: <type>[optional scope]: <description>
Types:
- feat: A new feature
- fix: A bug fix
- docs: Documentation only changes
- style: Changes that don't affect code meaning (formatting, semicolons, etc.)
- refactor: Code change that neither fixes a bug nor adds a feature
- perf: Code change that improves performance
- test: Adding or correcting tests
- build: Changes to build system or dependencies
- ci: Changes to CI configuration
- chore: Other changes that don't modify src or test files
- revert: Reverts a previous commit
Rules:
1. Use lowercase for type and scope
2. Keep description under 100 characters
3. Use imperative mood ("add" not "added")
4. Don't capitalize first letter
5. No period at the end
6. Include scope if the change is specific to a module/component
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. 如果更改特定于模块/组件,请包含作用域
7. 仅输出提交消息,不要输出其他内容。
"#;
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. 変更がモジュール/コンポーネントに固有の場合はスコープを含める
7. コミットメッセージのみを出力し、それ以外は出力しないでください。
"#;
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. 변경 사항이 모듈/구성 요소에 특정한 경우 범위 포함
7. 커밋 메시지만 출력하고 다른 내용은 출력하지 마세요.
"#;
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
7. 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
7. 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
7. 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.
Analyze the git diff and generate a commit message.
Format: <type>[optional scope]: <subject>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Rules:
1. Subject should not start with uppercase
2. Subject should not end with period
3. Subject should be 4-100 characters
4. Use imperative mood
5. Be concise but descriptive
6. 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. 简洁但描述性强
6. 仅输出提交消息,不要输出其他额外内容。
"#;
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. 簡潔ですが説明的であること
6. コミットメッセージのみを出力し、それ以外は出力しないでください。
"#;
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. 간결하지만 설명적이어야 합니다
6. 커밋 메시지만 출력하고 다른 내용은 출력하지 마세요.
"#;
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
6. 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
6. 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
6. 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.
Given a version number and a list of commits, generate a concise but informative tag message.
The message should:
1. Start with a brief summary of the release
2. Group changes by type (features, fixes, etc.)
3. Be suitable for a git annotated tag
Format:
<version> Release
Summary of changes...
Changes:
- Feature: description
- Fix: description
...
"#;
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.
Given a version and a list of commits, generate a well-formatted changelog section.
Group commits by:
- Features (feat)
- Bug Fixes (fix)
- Documentation (docs)
- Other Changes
Format in markdown with proper headings and bullet points.
"#;
const CHANGELOG_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成变更日志条目的助手。
给定版本和提交列表,生成格式良好的变更日志部分。
按以下方式分组提交:
- 功能 (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.
"#;

1528
src/llm/rig/mod.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -64,18 +64,10 @@ impl Default for ThinkingStateManager {
/// 线程安全的思考状态管理器引用
pub type SharedThinkingState = Arc<ThinkingStateManager>;
/// 创建带有默认控制台输出的思考状态管理器
/// 在思考开始时打印 "thinking...",在思考结束时清除该标识
/// 创建 LLM 流式使用的共享思考状态
/// 进度显示由 AI 生成 spinner 负责issue 21此处不再附加控制台输出。
pub fn create_console_thinking_state() -> SharedThinkingState {
Arc::new(
ThinkingStateManager::new()
.on_thinking_start(|| {
eprint!("\rthinking...");
})
.on_thinking_end(|| {
eprint!("\r \r");
}),
)
Arc::new(ThinkingStateManager::new())
}
#[cfg(test)]

View File

@@ -1,5 +1,3 @@
#![allow(dead_code)]
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
@@ -22,7 +20,7 @@ use quicommit::commands::{
#[command(propagate_version = true)]
#[command(arg_required_else_help = true)]
struct Cli {
/// Enable verbose output
/// Increase verbosity (-v: info, -vv: debug, -vvv: trace)
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
@@ -31,9 +29,17 @@ struct Cli {
config: Option<String>,
/// Disable colored output
#[arg(long, global = true, env = "NO_COLOR")]
#[arg(long, global = true)]
no_color: bool,
/// Force emoji decorations on (overrides config)
#[arg(long, global = true, conflicts_with = "no_emoji")]
emoji: bool,
/// Disable emoji/symbol decorations
#[arg(long, global = true)]
no_emoji: bool,
#[command(subcommand)]
command: Commands,
}
@@ -73,6 +79,31 @@ enum Commands {
async fn main() -> Result<()> {
let cli = Cli::parse();
// Apply the global color decision before any output is produced.
// --no-color disables colors; NO_COLOR follows the no-color.org spec
// (any presence, regardless of value, disables color).
let no_color = cli.no_color || std::env::var_os("NO_COLOR").is_some();
colored::control::set_override(!no_color);
// Resolve the decoration switch (issue 14): explicit flag > config >
// default; --no-color disables decorations too (ADR-0003).
let emoji_from_config = cli
.config
.as_deref()
.map(PathBuf::from)
.or_else(|| quicommit::config::AppConfig::default_path().ok())
.and_then(|path| quicommit::config::AppConfig::load(&path).ok())
.map(|config| config.output.emoji)
.unwrap_or(true);
let emoji_enabled = if no_color || cli.no_emoji {
false
} else if cli.emoji {
true
} else {
emoji_from_config
};
quicommit::utils::set_emoji_enabled(emoji_enabled);
let log_level = match cli.verbose {
0 => "warn",
1 => "info",
@@ -80,8 +111,11 @@ async fn main() -> Result<()> {
_ => "trace",
};
// RUST_LOG takes precedence when set; otherwise -v decides (issue 23).
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level));
tracing_subscriber::fmt()
.with_env_filter(log_level)
.with_env_filter(filter)
.with_target(false)
.init();

View File

@@ -232,9 +232,7 @@ impl KeyringManager {
"Keyring is available".to_string()
}
}
KeyringStatus::Unavailable => {
"Keyring is not available. Set QUICOMMIT_API_KEY environment variable.".to_string()
}
KeyringStatus::Unavailable => "Keyring is not available on this system.".to_string(),
}
}
}

View File

@@ -6,48 +6,144 @@ pub mod validators;
use anyhow::{Context, Result};
use colored::Colorize;
use std::io::{self, Write};
use std::io;
use std::sync::OnceLock;
use std::time::Duration;
/// Process-global decoration switch (issue 14).
static EMOJI_ENABLED: OnceLock<bool> = OnceLock::new();
/// Set whether emoji/symbol decorations are shown. Called once from `main`
/// after resolving config + flags.
pub fn set_emoji_enabled(enabled: bool) {
let _ = EMOJI_ENABLED.set(enabled);
}
/// Whether emoji/symbol decorations are shown.
pub fn emoji_enabled() -> bool {
*EMOJI_ENABLED.get_or_init(|| true)
}
/// Styled decoration prefix for status lines; empty when decorations are off
/// (ADR-0003: message text never embeds decorations).
pub fn success_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".green().bold()
} else {
"".normal()
}
}
pub fn error_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".red().bold()
} else {
"".normal()
}
}
pub fn warning_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".yellow().bold()
} else {
"".normal()
}
}
pub fn info_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".blue().bold()
} else {
"".normal()
}
}
pub fn progress_prefix() -> colored::ColoredString {
if emoji_enabled() {
"".cyan()
} else {
"".normal()
}
}
fn print_decorated(prefix: colored::ColoredString, msg: &str, to_stderr: bool) {
if emoji_enabled() {
if to_stderr {
eprintln!("{} {}", prefix, msg);
} else {
println!("{} {}", prefix, msg);
}
} else if to_stderr {
eprintln!("{}", msg);
} else {
println!("{}", msg);
}
}
/// Print success message
pub fn print_success(msg: &str) {
println!("{} {}", "".green().bold(), msg);
print_decorated(success_prefix(), msg, false);
}
/// Print error message
/// Print error message to stderr
pub fn print_error(msg: &str) {
eprintln!("{} {}", "".red().bold(), msg);
print_decorated(error_prefix(), msg, true);
}
/// Print warning message
pub fn print_warning(msg: &str) {
println!("{} {}", "".yellow().bold(), msg);
print_decorated(warning_prefix(), msg, false);
}
/// Print warning message to stderr (stdout must stay clean, e.g. exports)
pub fn eprint_warning(msg: &str) {
print_decorated(warning_prefix(), msg, true);
}
/// Print info message
pub fn print_info(msg: &str) {
println!("{} {}", "".blue().bold(), msg);
print_decorated(info_prefix(), msg, false);
}
/// Confirm action with user
pub fn confirm(prompt: &str) -> Result<bool> {
print!("{} [y/N] ", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_lowercase().starts_with('y'))
/// Print progress/status message (→ prefix when decorations are on)
pub fn print_progress(msg: &str) {
print_decorated(progress_prefix(), msg, false);
}
/// Get user input
pub fn input(prompt: &str) -> Result<String> {
print!("{}: ", prompt);
io::stdout().flush()?;
/// Progress spinner for long-running operations (issue 21).
/// Degrades to a static line when stdout is not a terminal or when
/// decorations are disabled.
pub struct Spinner {
bar: Option<indicatif::ProgressBar>,
}
let mut input = String::new();
io::stdin().read_line(&mut input)?;
impl Spinner {
pub fn start(msg: &str) -> Self {
if emoji_enabled() && std::io::IsTerminal::is_terminal(&io::stdout()) {
let bar = indicatif::ProgressBar::new_spinner();
bar.set_message(msg.to_string());
bar.enable_steady_tick(Duration::from_millis(100));
Self { bar: Some(bar) }
} else {
println!("{}", msg);
Self { bar: None }
}
}
Ok(input.trim().to_string())
/// Stop the spinner, erasing it from the terminal (no completion line).
pub fn finish_clear(&self) {
if let Some(bar) = &self.bar {
bar.finish_and_clear();
}
}
/// Stop the spinner and print a completion line.
pub fn finish_with(&self, msg: &str) {
if let Some(bar) = &self.bar {
bar.finish_and_clear();
}
println!("{}", msg);
}
}
/// Get password input (hidden)
@@ -59,19 +155,3 @@ pub fn password_input(prompt: &str) -> Result<String> {
.interact()
.context("Failed to read password")
}
/// Check if running in a terminal
pub fn is_terminal() -> bool {
atty::is(atty::Stream::Stdout)
}
/// Format duration in human-readable format
pub fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}

View File

@@ -1,6 +1,6 @@
use anyhow::{Result, bail};
use lazy_static::lazy_static;
use regex::Regex;
use std::sync::LazyLock;
/// Conventional commit types
pub const CONVENTIONAL_TYPES: &[&str] = &[
@@ -37,32 +37,33 @@ pub const COMMITLINT_TYPES: &[&str] = &[
"security", // Security-related changes
];
lazy_static! {
/// Regex for conventional commit format
static ref CONVENTIONAL_COMMIT_REGEX: Regex = Regex::new(
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<description>.+)$"
).unwrap();
/// Regex for conventional commit format
static CONVENTIONAL_COMMIT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<description>.+)$",
)
.unwrap()
});
/// Regex for scope validation
static ref SCOPE_REGEX: Regex = Regex::new(
r"^[a-z0-9-]+$"
).unwrap();
/// Regex for scope validation
static SCOPE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-z0-9-]+$").unwrap());
/// Regex for version tag validation (semver)
static ref SEMVER_REGEX: Regex = Regex::new(
r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
).unwrap();
/// Regex for version tag validation (semver)
static SEMVER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
)
.unwrap()
});
/// Regex for email validation
static ref EMAIL_REGEX: Regex = Regex::new(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
).unwrap();
/// Regex for email validation
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap()
});
/// Regex for GPG key ID validation
static ref GPG_KEY_ID_REGEX: Regex = Regex::new(
r"^[A-F0-9]{16,40}$"
).unwrap();
}
/// Regex for GPG key ID validation
static GPG_KEY_ID_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-F0-9]{16,40}$").unwrap());
/// Validate conventional commit message
pub fn validate_conventional_commit(message: &str) -> Result<()> {

View File

@@ -115,7 +115,8 @@ fn test_stage_all_removes_ignored_tracked_files() {
write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let removed = repo.stage_all().expect("stage_all should succeed");
let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English);
let removed = repo.stage_all(&messages).expect("stage_all should succeed");
assert!(
removed.iter().any(|f| f == "__pycache__/foo.pyc"),
@@ -142,7 +143,8 @@ fn test_stage_all_no_ignored_files_returns_empty() {
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let removed = repo.stage_all().expect("stage_all should succeed");
let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English);
let removed = repo.stage_all(&messages).expect("stage_all should succeed");
assert!(
removed.is_empty(),

View File

@@ -258,6 +258,190 @@ mod config_command {
.success()
.stdout(predicate::str::contains("config.toml"));
}
#[test]
fn test_no_color_disables_ansi() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"show",
"--no-color",
"--config",
config_path.to_str().unwrap(),
]);
let output = cmd.output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("\x1b["),
"ANSI color codes present despite --no-color"
);
}
#[test]
fn test_emoji_switch_precedence() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let set_format = |extra: &[&str], cfg: &std::path::Path| {
let mut c = cargo_bin_cmd!("quicommit");
c.env_remove("NO_COLOR");
c.args(&["config", "set", "commit.format", "conventional"])
.args(extra)
.args(&["--config", cfg.to_str().unwrap()]);
c.output().unwrap()
};
// Default (config true): decorations shown
let out = set_format(&[], &config_path);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains(""));
// Config output.emoji=false: no decorations
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"output.emoji",
"false",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let out = set_format(&[], &config_path);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stdout).contains(""));
// Explicit --emoji overrides config false
let out = set_format(&["--emoji"], &config_path);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains(""));
// Config back to true, then --no-emoji overrides
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"output.emoji",
"true",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let out = set_format(&["--no-emoji"], &config_path);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stdout).contains(""));
}
#[test]
fn test_chinese_output_smoke() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "content");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"language.output_language",
"zh",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"commit",
"--manual",
"-m",
"feat: 中文冒烟",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("试运行"));
}
#[test]
fn test_rust_log_env() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.env("RUST_LOG", "debug");
cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Starting quicommit"));
}
#[test]
fn test_auto_generate_defaults_true() {
// Documents the unified --yes semantics (issue 05): with default
// config, --yes still AI-generates; deterministic output needs
// --no-generate (changelog) or -m (tag).
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
for key in [
"commit.auto_generate",
"tag.auto_generate",
"changelog.auto_generate",
] {
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"get",
key,
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("true"));
}
}
}
mod commit_command {
@@ -438,6 +622,8 @@ mod tag_command {
"tag",
"--name",
"v0.1.0",
"-m",
"Release v0.1.0",
"--dry-run",
"--yes",
"--config",
@@ -469,6 +655,8 @@ mod tag_command {
"--think",
"--name",
"v0.2.0",
"-m",
"Release v0.2.0",
"--dry-run",
"--yes",
"--config",
@@ -528,6 +716,7 @@ mod changelog_command {
"changelog",
"--dry-run",
"--yes",
"--no-generate",
"--config",
config_path.to_str().unwrap(),
])