use crate::core::conventional::ConventionalCommit;
use std::path::Path;
pub fn generate_changelog_entry(
version: &str,
date: &str,
commits: &[ConventionalCommit],
) -> String {
let mut breaking = Vec::new();
let mut features = Vec::new();
let mut fixes = Vec::new();
let mut perf = Vec::new();
let mut other = Vec::new();
for commit in commits {
if commit.is_breaking {
breaking.push(commit);
} else {
match commit.commit_type.as_str() {
"feat" => features.push(commit),
"fix" => fixes.push(commit),
"perf" => perf.push(commit),
_ => other.push(commit),
}
}
}
let mut entry = format!("## [{}] - {}\n", version, date);
let sections = [
("Breaking Changes", breaking),
("Features", features),
("Bug Fixes", fixes),
("Performance Improvements", perf),
("Other Changes", other),
];
for (title, list) in sections {
if !list.is_empty() {
entry.push_str(&format!("\n### {}\n\n", title));
for c in list {
if let Some(scope) = &c.scope {
entry.push_str(&format!("- **{}**: {}\n", scope, c.description));
} else {
entry.push_str(&format!("- {}\n", c.description));
}
}
}
}
entry
}
pub fn update_changelog_file(repo_root: &Path, entry: &str) -> std::io::Result<()> {
let changelog_path = repo_root.join("CHANGELOG.md");
let trimmed_entry = entry.trim();
if changelog_path.exists() {
let content = std::fs::read_to_string(&changelog_path)?;
let new_content = if let Some(idx) = content.find("# Changelog") {
let line_end = content[idx..]
.find('\n')
.map(|i| idx + i + 1)
.unwrap_or(content.len());
let prefix = &content[..line_end];
let suffix = &content[line_end..];
let suffix_clean = suffix.trim_start_matches(['\r', '\n']);
if suffix_clean.is_empty() {
format!("{}\n\n{}\n", prefix.trim_end(), trimmed_entry)
} else {
format!(
"{}\n\n{}\n\n{}",
prefix.trim_end(),
trimmed_entry,
suffix_clean
)
}
} else {
format!("{}\n\n{}", trimmed_entry, content)
};
std::fs::write(&changelog_path, new_content)?;
} else {
std::fs::write(
&changelog_path,
format!("# Changelog\n\n{}\n", trimmed_entry),
)?;
}
Ok(())
}
pub fn current_date_ymd() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = (secs / 86400) as i64;
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = (yoe as i64) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}", y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_generate_changelog_entry_grouping() {
let commits = vec![
ConventionalCommit {
commit_type: "fix".to_string(),
scope: None,
is_breaking: true,
description: "break everything".to_string(),
},
ConventionalCommit {
commit_type: "feat".to_string(),
scope: Some("auth".to_string()),
is_breaking: false,
description: "support jwt".to_string(),
},
ConventionalCommit {
commit_type: "fix".to_string(),
scope: None,
is_breaking: false,
description: "null pointer exception".to_string(),
},
ConventionalCommit {
commit_type: "perf".to_string(),
scope: Some("db".to_string()),
is_breaking: false,
description: "index lookup".to_string(),
},
ConventionalCommit {
commit_type: "chore".to_string(),
scope: None,
is_breaking: false,
description: "update license".to_string(),
},
];
let entry = generate_changelog_entry("v1.0.0", "2026-10-17", &commits);
assert!(entry.starts_with("## [v1.0.0] - 2026-10-17\n"));
assert!(entry.contains("### Breaking Changes\n\n- break everything\n"));
assert!(entry.contains("### Features\n\n- **auth**: support jwt\n"));
assert!(entry.contains("### Bug Fixes\n\n- null pointer exception\n"));
assert!(entry.contains("### Performance Improvements\n\n- **db**: index lookup\n"));
assert!(entry.contains("### Other Changes\n\n- update license\n"));
}
#[test]
fn test_update_changelog_file_fresh() {
let dir = tempdir().unwrap();
let entry = "## [v0.1.0] - 2026-10-17\n\n### Features\n\n- init";
update_changelog_file(dir.path(), entry).unwrap();
let content = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
assert_eq!(
content,
"# Changelog\n\n## [v0.1.0] - 2026-10-17\n\n### Features\n\n- init\n"
);
}
#[test]
fn test_update_changelog_file_existing_with_header() {
let dir = tempdir().unwrap();
let initial =
"# Changelog\n\n## [v0.1.0] - 2026-10-16\n\n### Features\n\n- initial release\n";
std::fs::write(dir.path().join("CHANGELOG.md"), initial).unwrap();
let entry = "## [v0.2.0] - 2026-10-17\n\n### Features\n\n- new feature";
update_changelog_file(dir.path(), entry).unwrap();
let content = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
let expected = "# Changelog\n\n## [v0.2.0] - 2026-10-17\n\n### Features\n\n- new feature\n\n## [v0.1.0] - 2026-10-16\n\n### Features\n\n- initial release\n";
assert_eq!(content, expected);
}
#[test]
fn test_current_date_ymd_format() {
let date = current_date_ymd();
assert_eq!(date.len(), 10);
let parts: Vec<&str> = date.split('-').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0].len(), 4);
assert_eq!(parts[1].len(), 2);
assert_eq!(parts[2].len(), 2);
}
}