use std::path::Path;
use anyhow::{Context, Result};
use regex::Regex;
use crate::utils::git;
const CHANGELOG_HEADER: &str = "\
# 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.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
";
pub fn generate_section(cwd: &Path, version: &str, date: &str, range: &str) -> Result<String> {
let subjects = git::log_subjects(cwd, range)?;
let relevant: Vec<&str> = subjects
.iter()
.map(|s| s.as_str())
.filter(|s| !s.starts_with("chore: release "))
.collect();
if relevant.is_empty() {
return Ok(String::new());
}
let feat = filter(&relevant, "feat");
let fix = filter(&relevant, "fix");
let perf = filter(&relevant, "perf");
let refactor = filter(&relevant, "refactor");
let docs = filter(&relevant, "docs");
let other = filter_other(&relevant);
let mut out = String::new();
out.push_str(&format!("## [{version}] - {date}\n\n"));
append_bullets(&mut out, "Added", &feat, Some("feat"));
append_bullets(&mut out, "Fixed", &fix, Some("fix"));
append_bullets(&mut out, "Performance", &perf, Some("perf"));
append_bullets(&mut out, "Changed", &refactor, Some("refactor"));
append_bullets(&mut out, "Documentation", &docs, Some("docs"));
append_bullets(&mut out, "Other", &other, None);
Ok(out)
}
fn filter<'a>(subjects: &[&'a str], prefix: &str) -> Vec<&'a str> {
let re = Regex::new(&format!(r"^{prefix}(\([^)]+\))?:")).expect("static regex");
subjects
.iter()
.copied()
.filter(|s| re.is_match(s))
.collect()
}
fn filter_other<'a>(subjects: &[&'a str]) -> Vec<&'a str> {
let re = Regex::new(r"^(feat|fix|perf|refactor|docs|chore|style|test|build|ci)(\([^)]+\))?:")
.expect("static regex");
subjects
.iter()
.copied()
.filter(|s| !re.is_match(s))
.collect()
}
fn append_bullets(out: &mut String, heading: &str, items: &[&str], strip_prefix: Option<&str>) {
if items.is_empty() {
return;
}
out.push_str(&format!("### {heading}\n\n"));
for item in items {
if let Some(prefix) = strip_prefix {
let re = Regex::new(&format!(r"^{prefix}(\([^)]+\))?:\s*")).expect("static regex");
out.push_str(&format!("- {}\n", re.replace(item, "")));
} else {
out.push_str(&format!("- {item}\n"));
}
}
out.push('\n');
}
pub fn insert_section(changelog_path: &Path, section: &str) -> Result<()> {
if section.is_empty() {
return Ok(());
}
let content = if changelog_path.exists() {
std::fs::read_to_string(changelog_path)
.with_context(|| format!("failed to read {}", changelog_path.display()))?
} else {
CHANGELOG_HEADER.to_string()
};
let new_content = splice_after_unreleased(&content, section);
std::fs::write(changelog_path, new_content)
.with_context(|| format!("failed to write {}", changelog_path.display()))?;
Ok(())
}
pub fn regenerate(cwd: &Path, changelog_path: &Path) -> Result<()> {
let output = std::process::Command::new("git")
.args([
"log",
"--reverse",
"--pretty=format:%H%x09%ad%x09%s",
"--date=short",
"--grep=^chore: release v[0-9]",
])
.current_dir(cwd)
.output()
.context("failed to run git log for changelog regeneration")?;
if !output.status.success() {
anyhow::bail!("git log failed during changelog regeneration");
}
let raw = String::from_utf8_lossy(&output.stdout);
let mut releases: Vec<(String, String, String)> = Vec::new(); for line in raw.lines() {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() != 3 {
continue;
}
let version = parts[2].trim_start_matches("chore: release v").to_string();
releases.push((parts[0].to_string(), parts[1].to_string(), version));
}
if releases.is_empty() {
std::fs::write(changelog_path, CHANGELOG_HEADER)
.with_context(|| format!("failed to write {}", changelog_path.display()))?;
return Ok(());
}
let first_sha = git::first_commit_sha(cwd)?;
let mut sections = String::new();
for i in (0..releases.len()).rev() {
let (sha, date, version) = &releases[i];
let range = if i == 0 {
format!("{first_sha}..{sha}")
} else {
let prev_sha = &releases[i - 1].0;
format!("{prev_sha}..{sha}")
};
let sec = generate_section(cwd, version, date, &range)?;
sections.push_str(&sec);
}
let mut content = String::from(CHANGELOG_HEADER);
content.push_str(§ions);
std::fs::write(changelog_path, content)
.with_context(|| format!("failed to write {}", changelog_path.display()))?;
Ok(())
}
fn splice_after_unreleased(content: &str, section: &str) -> String {
let mut out = String::new();
let mut inserted = false;
for line in content.lines() {
out.push_str(line);
out.push('\n');
if !inserted && line.trim_start().starts_with("## [Unreleased]") {
out.push('\n');
out.push_str(section);
inserted = true;
}
}
if !inserted {
let mut prefix = String::from(CHANGELOG_HEADER);
prefix.push_str(section);
prefix.push_str(&out);
return prefix;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_matches_prefix_with_and_without_scope() {
let items = vec![
"feat: add thing",
"feat(doc): add doxygen",
"fix: typo",
"chore: bump",
];
let out = filter(&items, "feat");
assert_eq!(out, vec!["feat: add thing", "feat(doc): add doxygen"]);
}
#[test]
fn filter_other_drops_standard_types() {
let items = vec![
"feat: a",
"chore: b",
"refactor(x): c",
"something else",
"WIP hack",
];
let out = filter_other(&items);
assert_eq!(out, vec!["something else", "WIP hack"]);
}
#[test]
fn append_bullets_strips_prefix() {
let mut out = String::new();
let items = vec!["feat: add thing", "feat(doc): more"];
append_bullets(&mut out, "Added", &items, Some("feat"));
assert_eq!(out, "### Added\n\n- add thing\n- more\n\n");
}
#[test]
fn append_bullets_keeps_other_verbatim() {
let mut out = String::new();
let items = vec!["random commit", "WIP"];
append_bullets(&mut out, "Other", &items, None);
assert_eq!(out, "### Other\n\n- random commit\n- WIP\n\n");
}
#[test]
fn splice_inserts_after_unreleased() {
let existing = "\
# Changelog
## [Unreleased]
## [1.0.0] - 2025-01-01
### Added
- old stuff
";
let section = "## [1.1.0] - 2025-02-01\n\n### Added\n\n- new stuff\n\n";
let out = splice_after_unreleased(existing, section);
let unrel_idx = out.find("## [Unreleased]").unwrap();
let new_idx = out.find("## [1.1.0]").unwrap();
let old_idx = out.find("## [1.0.0]").unwrap();
assert!(unrel_idx < new_idx);
assert!(
new_idx < old_idx,
"new section must land before the older one"
);
}
#[test]
fn splice_handles_missing_unreleased() {
let existing = "no header here\n";
let section = "## [1.1.0] - 2025-02-01\n\n";
let out = splice_after_unreleased(existing, section);
assert!(out.contains("## [Unreleased]"));
assert!(out.contains("## [1.1.0]"));
assert!(out.contains("no header here"));
}
}