♻️ refactor(changelog.rs):移除prepend参数,改为自动前置到现有changelog ♻️ refactor(formatter.rs):移除未使用的日期和格式化函数 ♻️ refactor(validators.rs):移除未使用的SSH密钥验证功能
257 lines
8.3 KiB
Rust
257 lines
8.3 KiB
Rust
use anyhow::{bail, Result};
|
|
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::find_repo;
|
|
use crate::git::{changelog::*, CommitInfo};
|
|
use crate::i18n::{Messages, translate_changelog_category};
|
|
|
|
/// Generate changelog
|
|
#[derive(Parser)]
|
|
#[command(disable_version_flag = true, disable_help_flag = false)]
|
|
pub struct ChangelogCommand {
|
|
/// Output file path
|
|
#[arg(short, long)]
|
|
output: Option<PathBuf>,
|
|
|
|
/// Version to generate changelog for
|
|
#[arg(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,
|
|
|
|
/// Skip interactive prompts
|
|
#[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.as_ref()
|
|
.map(|p| p.clone())
|
|
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
|
|
|
|
init_changelog(&path)?;
|
|
println!("{}", messages.initialized_changelog(&format!("{:?}", path)));
|
|
return Ok(());
|
|
}
|
|
|
|
// Determine output path
|
|
let output_path = self.output.as_ref()
|
|
.map(|p| p.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!("Unknown format: {}. Use: keep-a-changelog, github-releases", 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
|
|
println!("{}", messages.fetching_commits());
|
|
let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?;
|
|
|
|
if commits.is_empty() {
|
|
bail!("{}", messages.no_commits_found());
|
|
}
|
|
|
|
println!("{}", messages.found_commits(commits.len()));
|
|
|
|
// Generate changelog
|
|
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) {
|
|
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(&format!("{:?}", output_path)))
|
|
.default(true)
|
|
.interact()?;
|
|
|
|
if !confirm {
|
|
println!("{}", messages.cancelled().yellow());
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
// Write to file (always prepend to preserve history)
|
|
if output_path.exists() {
|
|
let existing = std::fs::read_to_string(&output_path)?;
|
|
let new_content = if existing.is_empty() {
|
|
format!("# Changelog\n\n{}", changelog)
|
|
} else {
|
|
let lines: Vec<&str> = existing.lines().collect();
|
|
let mut header_end = 0;
|
|
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if i == 0 && line.starts_with('#') {
|
|
header_end = i + 1;
|
|
} else if line.trim().is_empty() {
|
|
header_end = i + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let header = lines[..header_end].join("\n");
|
|
let rest = lines[header_end..].join("\n");
|
|
|
|
format!("{}\n{}\n{}", header, changelog, rest)
|
|
};
|
|
std::fs::write(&output_path, new_content)?;
|
|
} else {
|
|
let content = format!("# Changelog\n\n{}", changelog);
|
|
std::fs::write(&output_path, content)?;
|
|
}
|
|
|
|
println!("{} {:?}", messages.changelog_written(), output_path);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn generate_with_ai(
|
|
&self,
|
|
version: &str,
|
|
commits: &[CommitInfo],
|
|
messages: &Messages,
|
|
) -> Result<String> {
|
|
let manager = ConfigManager::new()?;
|
|
let config = manager.config();
|
|
let language = manager.get_language().unwrap_or(Language::English);
|
|
|
|
println!("{}", messages.ai_generating_changelog());
|
|
|
|
let generator = ContentGenerator::new(&config.llm).await?;
|
|
generator.generate_changelog_entry(version, commits, language).await
|
|
}
|
|
|
|
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 {
|
|
let translated = changelog
|
|
.lines()
|
|
.map(|line| {
|
|
if line.starts_with("## ") || line.starts_with("### ") {
|
|
let category = line.trim_start_matches("## ").trim_start_matches("### ");
|
|
let translated_category = translate_changelog_category(category, language, false);
|
|
if line.starts_with("## ") {
|
|
format!("## {}", translated_category)
|
|
} else {
|
|
format!("### {}", translated_category)
|
|
}
|
|
} else {
|
|
line.to_string()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
translated
|
|
}
|
|
}
|