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, /// Version to generate changelog for #[arg(long)] version: Option, /// Generate from specific tag #[arg(short, long)] from: Option, /// 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, /// 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) -> 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::>().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 { // 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 { 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 { 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::>() .join("\n") } }