Files
QuiCommit/src/commands/changelog.rs
SidneyZhang a40c88c768 feat(changelog): 添加 --no-generate 标志以支持纯模板生成
- 新增 `--no-generate` 参数,强制使用模板生成而非 AI
- 调整 `--yes` 参数仅跳过交互提示,不改变生成行为
- 统一使用工具函数替代 println 输出
2026-08-21 13:57:25 +08:00

284 lines
9.0 KiB
Rust

use anyhow::{Result, bail};
use chrono::Utc;
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input};
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::generator::ContentGenerator;
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)]
#[command(disable_version_flag = true, disable_help_flag = false)]
pub struct ChangelogCommand {
/// Output file path
#[arg(short, long)]
output: Option<PathBuf>,
/// Version to generate changelog for
#[arg(long)]
version: Option<String>,
/// Generate from specific tag
#[arg(short, long)]
from: Option<String>,
/// Generate to specific ref
#[arg(short, long, default_value = "HEAD")]
to: String,
/// Initialize new changelog file
#[arg(short, long)]
init: bool,
/// Generate with AI
#[arg(short, long)]
generate: bool,
/// Include commit hashes
#[arg(long)]
include_hashes: bool,
/// Include authors
#[arg(long)]
include_authors: bool,
/// Format (keep-a-changelog, github-releases)
#[arg(long)]
format: Option<String>,
/// Dry run (output to stdout)
#[arg(long)]
dry_run: bool,
/// Enable thinking mode for this changelog (override config)
#[arg(long)]
think: bool,
/// 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,
}
impl ChangelogCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
let repo = find_repo(std::env::current_dir()?.as_path())?;
let manager = if let Some(ref path) = config_path {
ConfigManager::with_path(path)?
} else {
ConfigManager::new()?
};
let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
// Initialize changelog if requested
if self.init {
let path = self
.output
.clone()
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
init_changelog(&path)?;
print_success(&messages.initialized_changelog(&path.display().to_string()));
return Ok(());
}
// Determine output path
let output_path = self
.output
.clone()
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
// Determine format
let format = match self.format.as_deref() {
Some("github") | Some("github-releases") => ChangelogFormat::GitHubReleases,
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
Some("custom") => ChangelogFormat::Custom,
None => ChangelogFormat::KeepAChangelog,
Some(f) => bail!("{}", messages.unknown_changelog_format(f)),
};
// Get version
let version = if let Some(ref v) = self.version {
v.clone()
} else if !self.yes {
Input::new()
.with_prompt(messages.version())
.default(messages.unreleased().to_string())
.interact_text()?
} else {
messages.unreleased().to_string()
};
// Get 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);
let commits = generate_from_history(&repo, from_tag.as_deref(), Some(&self.to))?;
if commits.is_empty() {
bail!("{}", messages.no_commits_found());
}
print_success(&messages.found_commits(commits.len()));
// Generate changelog
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)?
};
// Output or write
if self.dry_run {
println!("\n{}", "─".repeat(60));
println!("{}", changelog);
println!("{}", "─".repeat(60));
return Ok(());
}
// Preview
if !self.yes {
println!("\n{}", "─".repeat(60));
println!("{}", messages.changelog_preview().bold());
println!("{}", "─".repeat(60));
// Show first 20 lines
let preview: String = changelog.lines().take(20).collect::<Vec<_>>().join("\n");
println!("{}", preview);
if changelog.lines().count() > 20 {
println!("\n... ({} more lines)", changelog.lines().count() - 20);
}
println!("{}", "─".repeat(60));
let confirm = Confirm::new()
.with_prompt(messages.write_to_file(&output_path.display().to_string()))
.default(true)
.interact()?;
if !confirm {
print_warning(messages.cancelled());
return Ok(());
}
}
// Write to file (always prepend new entry before existing versions)
if output_path.exists() {
let existing = std::fs::read_to_string(&output_path)?;
let new_content = if existing.is_empty() {
format!("{}{}", CHANGELOG_HEADER, changelog)
} else {
insert_changelog_entry(&existing, &changelog)
};
std::fs::write(&output_path, new_content)?;
} else {
let content = format!("{}{}", CHANGELOG_HEADER, changelog);
std::fs::write(&output_path, content)?;
}
print_success(&format!(
"{} {}",
messages.changelog_written(),
output_path.display()
));
Ok(())
}
fn resolve_from_tag(
&self,
repo: &GitRepo,
output_path: &PathBuf,
messages: &Messages,
) -> Option<String> {
// Explicit --from always wins
if self.from.is_some() {
return self.from.clone();
}
// Auto-detect: find highest version already in changelog
let existing = std::fs::read_to_string(output_path).ok()?;
let versions = parse_changelog_versions(&existing);
let highest = versions.first()?;
// Match highest version to a git tag
let tag = repo.find_tag_by_version(highest)?;
println!(" {}: {}", messages.version(), tag.name);
Some(tag.name)
}
async fn generate_with_ai(
&self,
version: &str,
commits: &[CommitInfo],
messages: &Messages,
) -> Result<String> {
let manager = ConfigManager::new()?;
let language = manager.get_language().unwrap_or(Language::English);
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
let spinner = crate::utils::Spinner::start(messages.ai_generating_changelog());
let result = generator
.generate_changelog_entry(version, commits, language)
.await;
spinner.finish_clear();
result
}
fn generate_with_template(
&self,
format: ChangelogFormat,
version: &str,
commits: &[CommitInfo],
language: Language,
) -> Result<String> {
let manager = ConfigManager::new()?;
let generator = ChangelogGenerator::new()
.format(format)
.include_hashes(self.include_hashes)
.include_authors(self.include_authors);
let changelog = generator.generate(version, Utc::now(), commits)?;
// Translate changelog categories if configured
if !manager.keep_changelog_types_english() {
Ok(self.translate_changelog_categories(&changelog, language))
} else {
Ok(changelog)
}
}
fn translate_changelog_categories(&self, changelog: &str, language: Language) -> String {
changelog
.lines()
.map(|line| {
if line.starts_with("## ") || line.starts_with("### ") {
let category = line.trim_start_matches("## ").trim_start_matches("### ");
let translated_category =
translate_changelog_category(category, language, false);
if line.starts_with("## ") {
format!("## {}", translated_category)
} else {
format!("### {}", translated_category)
}
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
}