use super::{CommitInfo, GitRepo}; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::fs; use std::path::Path; pub const CHANGELOG_HEADER: &str = r#"# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). "#; /// Changelog generator pub struct ChangelogGenerator { format: ChangelogFormat, include_hashes: bool, include_authors: bool, group_by_type: bool, custom_categories: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChangelogFormat { KeepAChangelog, GitHubReleases, Custom, } #[derive(Debug, Clone)] pub struct ChangelogCategory { pub title: String, pub types: Vec, } impl ChangelogGenerator { /// Create new changelog generator pub fn new() -> Self { Self { format: ChangelogFormat::KeepAChangelog, include_hashes: false, include_authors: false, group_by_type: true, custom_categories: vec![], } } /// Set format pub fn format(mut self, format: ChangelogFormat) -> Self { self.format = format; self } /// Include commit hashes pub fn include_hashes(mut self, include: bool) -> Self { self.include_hashes = include; self } /// Include authors pub fn include_authors(mut self, include: bool) -> Self { self.include_authors = include; self } /// Group by type pub fn group_by_type(mut self, group: bool) -> Self { self.group_by_type = group; self } /// Add custom category pub fn add_category(mut self, title: impl Into, types: Vec) -> Self { self.custom_categories.push(ChangelogCategory { title: title.into(), types, }); self } /// Generate changelog for version pub fn generate( &self, version: &str, date: DateTime, commits: &[CommitInfo], ) -> Result { match self.format { ChangelogFormat::KeepAChangelog => { self.generate_keep_a_changelog(version, date, commits) } ChangelogFormat::GitHubReleases => { self.generate_github_releases(version, date, commits) } ChangelogFormat::Custom => self.generate_custom(version, date, commits), } } /// Generate changelog entry and prepend to file pub fn generate_and_prepend( &self, changelog_path: &Path, version: &str, date: DateTime, commits: &[CommitInfo], ) -> Result<()> { let entry = self.generate(version, date, commits)?; let existing = if changelog_path.exists() { fs::read_to_string(changelog_path)? } else { String::new() }; let new_content = if existing.is_empty() { format!("{}{}", CHANGELOG_HEADER, entry) } else if existing.starts_with(CHANGELOG_HEADER) { format!("{}{}", CHANGELOG_HEADER, entry) } else if existing.starts_with("# Changelog") { 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, entry, rest) } else { format!("{}{}", CHANGELOG_HEADER, entry) }; fs::write(changelog_path, new_content) .with_context(|| format!("Failed to write changelog: {:?}", changelog_path))?; Ok(()) } fn generate_keep_a_changelog( &self, version: &str, date: DateTime, commits: &[CommitInfo], ) -> Result { let date_str = date.format("%Y-%m-%d").to_string(); let mut output = format!("## [{}] - {}\n\n", version, date_str); if self.group_by_type { let _grouped = self.group_commits(commits); // Standard categories let categories = vec![ ("Added", vec!["feat"]), ("Changed", vec!["refactor", "perf"]), ("Deprecated", vec![]), ("Removed", vec!["remove"]), ("Fixed", vec!["fix"]), ("Security", vec!["security"]), ]; for (title, types) in &categories { let items: Vec<&CommitInfo> = commits .iter() .filter(|c| { if let Some(ref t) = c.commit_type() { types.contains(&t.as_str()) } else { false } }) .collect(); if !items.is_empty() { output.push_str(&format!("### {}\n\n", title)); for commit in items { output.push_str(&self.format_commit(commit)); output.push('\n'); } output.push('\n'); } } // Other changes let categorized: Vec = categories .iter() .flat_map(|(_, types)| types.iter().map(|s| s.to_string())) .collect(); let other: Vec<&CommitInfo> = commits .iter() .filter(|c| { if let Some(ref t) = c.commit_type() { !categorized.contains(t) } else { true } }) .collect(); if !other.is_empty() { output.push_str("### Other\n\n"); for commit in other { output.push_str(&self.format_commit(commit)); output.push('\n'); } output.push('\n'); } } else { for commit in commits { output.push_str(&self.format_commit(commit)); output.push('\n'); } } Ok(output) } fn generate_github_releases( &self, _version: &str, _date: DateTime, commits: &[CommitInfo], ) -> Result { let mut output = "## What's Changed\n\n".to_string(); // Group by type let mut features = vec![]; let mut fixes = vec![]; let mut docs = vec![]; let mut other = vec![]; let mut breaking = vec![]; for commit in commits { if commit.message.contains("BREAKING CHANGE") { breaking.push(commit); } if let Some(ref t) = commit.commit_type() { match t.as_str() { "feat" => features.push(commit), "fix" => fixes.push(commit), "docs" => docs.push(commit), _ => other.push(commit), } } else { other.push(commit); } } if !breaking.is_empty() { output.push_str("### ⚠ Breaking Changes\n\n"); for commit in breaking { output.push_str(&self.format_commit_github(commit)); } output.push('\n'); } if !features.is_empty() { output.push_str("### 🚀 Features\n\n"); for commit in features { output.push_str(&self.format_commit_github(commit)); } output.push('\n'); } if !fixes.is_empty() { output.push_str("### 🐛 Bug Fixes\n\n"); for commit in fixes { output.push_str(&self.format_commit_github(commit)); } output.push('\n'); } if !docs.is_empty() { output.push_str("### 📚 Documentation\n\n"); for commit in docs { output.push_str(&self.format_commit_github(commit)); } output.push('\n'); } if !other.is_empty() { output.push_str("### Other Changes\n\n"); for commit in other { output.push_str(&self.format_commit_github(commit)); } } Ok(output) } fn generate_custom( &self, version: &str, date: DateTime, commits: &[CommitInfo], ) -> Result { // Use custom categories if defined if !self.custom_categories.is_empty() { let date_str = date.format("%Y-%m-%d").to_string(); let mut output = format!("## [{}] - {}\n\n", version, date_str); for category in &self.custom_categories { let items: Vec<&CommitInfo> = commits .iter() .filter(|c| { if let Some(ref t) = c.commit_type() { category.types.contains(t) } else { false } }) .collect(); if !items.is_empty() { output.push_str(&format!("### {}\n\n", category.title)); for commit in items { output.push_str(&self.format_commit(commit)); output.push('\n'); } output.push('\n'); } } Ok(output) } else { // Fall back to keep-a-changelog self.generate_keep_a_changelog(version, date, commits) } } fn format_commit(&self, commit: &CommitInfo) -> String { let mut line = format!("- {}", commit.subject()); if self.include_hashes { line.push_str(&format!(" ({})", &commit.short_id)); } if self.include_authors { line.push_str(&format!(" - @{}", commit.author)); } line } fn format_commit_github(&self, commit: &CommitInfo) -> String { format!( "- {} by @{} in {}\n", commit.subject(), commit.author, &commit.short_id ) } fn group_commits<'a>(&self, commits: &'a [CommitInfo]) -> HashMap> { let mut groups: HashMap> = HashMap::new(); for commit in commits { let commit_type = commit.commit_type().unwrap_or_else(|| "other".to_string()); groups.entry(commit_type).or_default().push(commit); } groups } } impl Default for ChangelogGenerator { fn default() -> Self { Self::new() } } /// Read existing changelog pub fn read_changelog(path: &Path) -> Result { fs::read_to_string(path).with_context(|| format!("Failed to read changelog: {:?}", path)) } /// Initialize new changelog file pub fn init_changelog(path: &Path) -> Result<()> { if path.exists() { anyhow::bail!("Changelog already exists at {:?}", path); } fs::write(path, CHANGELOG_HEADER) .with_context(|| format!("Failed to create changelog: {:?}", path))?; Ok(()) } /// Generate changelog from git history pub fn generate_from_history( repo: &GitRepo, from_tag: Option<&str>, to_ref: Option<&str>, ) -> Result> { let to_ref = to_ref.unwrap_or("HEAD"); if let Some(from) = from_tag { repo.get_commits_between(from, to_ref) } else { // Get all commits from the beginning (no from_tag = initial changelog) repo.get_commits(usize::MAX) } } /// Update version links in changelog pub fn update_version_links(changelog: &str, version: &str, compare_url: &str) -> String { // Add version link at the end of changelog format!("{}\n[{}]: {}\n", changelog, version, compare_url) } /// Parse changelog to extract versions pub fn parse_versions(changelog: &str) -> Vec<(String, String)> { let mut versions = vec![]; for line in changelog.lines() { if line.starts_with("## [") && let Some(start) = line.find('[') && let Some(end) = line.find(']') { let version = &line[start + 1..end]; if version != "Unreleased" && let Some(date_start) = line.find(" - ") { let date = &line[date_start + 3..].trim(); versions.push((version.to_string(), date.to_string())); } } } versions } /// Get unreleased changes pub fn get_unreleased_changes(repo: &GitRepo) -> Result> { let tags = repo.get_tags()?; if let Some(latest_tag) = tags.first() { repo.get_commits_between(&latest_tag.name, "HEAD") } else { repo.get_commits(50) } } /// Changelog entry for a specific version pub struct ChangelogEntry { pub version: String, pub date: DateTime, pub commits: Vec, } impl ChangelogEntry { /// Create new entry pub fn new(version: impl Into, commits: Vec) -> Self { Self { version: version.into(), date: Utc::now(), commits, } } /// Set date pub fn with_date(mut self, date: DateTime) -> Self { self.date = date; self } } /// Extract version strings from Keep a Changelog content. /// Returns versions sorted by semver descending (highest first). /// Excludes "Unreleased". pub fn parse_changelog_versions(content: &str) -> Vec { use semver::Version; let mut versions: Vec = vec![]; for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with("## [") { if let Some(end) = trimmed.find(']') { let version = &trimmed[4..end]; if version != "Unreleased" && !version.is_empty() { versions.push(version.to_string()); } } } } // Sort by semver descending so highest version is first versions.sort_by(|a, b| { match (Version::parse(a), Version::parse(b)) { (Ok(va), Ok(vb)) => vb.cmp(&va), (Ok(_), Err(_)) => std::cmp::Ordering::Less, (Err(_), Ok(_)) => std::cmp::Ordering::Greater, (Err(_), Err(_)) => std::cmp::Ordering::Equal, } }); versions } /// Insert a new changelog entry into existing changelog content. /// The new entry is placed after the header and before the first existing version section. /// If no existing version sections are found, the new entry is appended after the header. pub fn insert_changelog_entry(existing: &str, new_entry: &str) -> String { // Find the first version section (## [x.y.z]) if let Some(first_ver_pos) = existing.find("\n## [") { let (header, rest) = existing.split_at(first_ver_pos); // Ensure new_entry ends with a blank line before the next section let entry = new_entry.trim_end(); format!("{}\n{}\n{}", header.trim_end(), entry, rest) } else { // No existing version sections — append after header format!("{}\n{}", existing.trim_end(), new_entry.trim_end()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_changelog_versions_extracts_versions() { let content = "# Changelog\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; let versions = parse_changelog_versions(content); assert_eq!(versions, vec!["0.2.0", "0.1.0"]); } #[test] fn test_parse_changelog_versions_sorts_highest_first() { // Versions in reversed order — should still return highest first let content = "# Changelog\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n\n## [0.3.0] - 2024-04-01\n### Added\n- feature C\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n"; let versions = parse_changelog_versions(content); assert_eq!(versions, vec!["0.3.0", "0.2.0", "0.1.0"]); } #[test] fn test_parse_changelog_versions_excludes_unreleased() { let content = "# Changelog\n\n## [Unreleased]\n### Added\n- wip\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; let versions = parse_changelog_versions(content); assert_eq!(versions, vec!["0.1.0"]); } #[test] fn test_parse_changelog_versions_empty_content() { let versions = parse_changelog_versions(""); assert!(versions.is_empty()); } #[test] fn test_parse_changelog_versions_no_versions() { let content = "# Changelog\n\nSome description text.\n"; let versions = parse_changelog_versions(content); assert!(versions.is_empty()); } #[test] fn test_insert_changelog_entry_between_header_and_existing() { let existing = "# Changelog\n\nAll notable changes...\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n"; let result = insert_changelog_entry(existing, new_entry); // New entry should appear after header, before 0.1.0 assert!(result.contains("## [0.2.0]")); assert!(result.contains("## [0.1.0]")); let pos_new = result.find("## [0.2.0]").unwrap(); let pos_old = result.find("## [0.1.0]").unwrap(); assert!(pos_new < pos_old, "new version should be before old version"); } #[test] fn test_insert_changelog_entry_preserves_all_existing_content() { let existing = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; let new_entry = "## [0.3.0] - 2024-04-01\n### Added\n- feature C\n"; let result = insert_changelog_entry(existing, new_entry); assert!(result.contains("## [0.3.0]")); assert!(result.contains("## [0.2.0]")); assert!(result.contains("## [0.1.0]")); assert!(result.contains("feature A"), "oldest content preserved"); assert!(result.contains("feature B"), "middle content preserved"); assert!(result.contains("feature C"), "new content present"); } #[test] fn test_insert_changelog_entry_empty_existing() { let existing = "# Changelog\n\nAll notable changes...\n\n"; let new_entry = "## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; let result = insert_changelog_entry(existing, new_entry); assert!(result.contains("## [0.1.0]")); assert!(result.contains("# Changelog")); } #[test] fn test_insert_changelog_entry_extra_blank_lines_in_header() { let existing = "# Changelog\n\n\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n"; let result = insert_changelog_entry(existing, new_entry); let pos_new = result.find("## [0.2.0]").unwrap(); let pos_old = result.find("## [0.1.0]").unwrap(); assert!(pos_new < pos_old, "new version should be before old version"); } }