use anodizer_core::config::{ChangelogConfig, Config};
use anodizer_core::log::StageLogger;
use anyhow::{Context as _, Result};
use std::path::{Path, PathBuf};
pub(crate) fn resolve_changelog_enabled(config: Option<&Config>, opt_in: bool) -> bool {
if !opt_in {
return false;
}
let Some(cl) = config.and_then(|c| c.changelog.as_ref()) else {
return false;
};
match cl.skip.as_ref() {
Some(skip) if !skip.is_template() => !skip.as_bool(),
_ => true,
}
}
#[derive(Clone)]
pub(crate) struct ChangelogTarget {
pub crate_name: String,
pub crate_dir: PathBuf,
pub from_tag: Option<String>,
pub to_version: String,
pub full_tag: String,
}
pub(crate) struct ChangelogRouting<'a> {
pub root_enabled: bool,
pub per_crate: bool,
pub chronology: anodizer_core::config::Chronology,
pub root_crates: Option<&'a [String]>,
pub single_track: bool,
pub multitrack: bool,
pub root_crate_names: Vec<String>,
}
impl<'a> ChangelogRouting<'a> {
pub fn from_config(cfg: &'a ChangelogConfig) -> Self {
let dest = cfg.resolved_destination();
Self {
root_enabled: dest.root_enabled,
per_crate: dest.per_crate,
chronology: cfg.resolved_chronology(),
root_crates: cfg.root_crates_filter(),
single_track: false,
multitrack: false,
root_crate_names: Vec::new(),
}
}
}
pub(crate) fn crate_in_root(crate_name: &str, filter: Option<&[String]>) -> bool {
filter.is_none_or(|names| names.iter().any(|n| n == crate_name))
}
pub(crate) fn config_root_crate_names(
config: &Config,
root_crates: Option<&[String]>,
) -> Vec<String> {
let mut names: Vec<String> = config.crates.iter().map(|c| c.name.clone()).collect();
if let Some(workspaces) = config.workspaces.as_deref() {
for ws in workspaces {
names.extend(ws.crates.iter().map(|c| c.name.clone()));
}
}
names.retain(|n| crate_in_root(n, root_crates));
names
}
pub(crate) fn render_and_stage_changelogs(
workspace_root: &Path,
targets: &[ChangelogTarget],
routing: &ChangelogRouting<'_>,
dry_run: bool,
log: &StageLogger,
) -> Result<Vec<String>> {
let mut written: Vec<String> = Vec::new();
for t in targets {
if routing.per_crate {
let update = anodizer_stage_changelog::render_crate_section(
workspace_root,
&t.crate_name,
&t.crate_dir,
t.from_tag.as_deref(),
&t.to_version,
)
.with_context(|| format!("failed to render changelog for {}", t.crate_name))?;
persist_update(workspace_root, t, update, dry_run, log, &mut written)?;
}
if routing.root_enabled && crate_in_root(&t.crate_name, routing.root_crates) {
let update = anodizer_stage_changelog::render_root_section(
workspace_root,
&t.crate_name,
&t.crate_dir,
t.from_tag.as_deref(),
&t.to_version,
&t.full_tag,
routing.chronology,
routing.multitrack,
&routing.root_crate_names,
)
.with_context(|| format!("failed to render root changelog for {}", t.crate_name))?;
persist_update(workspace_root, t, update, dry_run, log, &mut written)?;
}
}
Ok(written)
}
fn persist_update(
workspace_root: &Path,
t: &ChangelogTarget,
update: Option<anodizer_stage_changelog::ChangelogUpdate>,
dry_run: bool,
log: &StageLogger,
written: &mut Vec<String>,
) -> Result<()> {
let Some(update) = update else {
return Ok(());
};
match update.insertion_mode {
anodizer_stage_changelog::InsertionMode::Replace => {
if dry_run {
log.status(&format!(
"(dry-run) changelog: would write section for {} → {} in {}",
t.crate_name,
t.to_version,
update.file_path.display()
));
return Ok(());
}
if let Some(parent) = update.file_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&update.file_path, &update.rendered_text).with_context(|| {
format!(
"failed to write changelog at {}",
update.file_path.display()
)
})?;
}
}
log.verbose(&format!(
"bundled changelog section for {} → {}",
t.crate_name, t.to_version
));
let rel = rel_display(workspace_root, &update.file_path);
if !written.contains(&rel) {
written.push(rel);
}
Ok(())
}
pub(crate) struct RefreshTarget {
pub crate_name: String,
pub crate_dir: PathBuf,
pub from_tag: Option<String>,
pub to_ref: Option<String>,
}
pub(crate) struct RefreshOutput {
pub file_path: PathBuf,
pub rel_path: String,
pub rendered_text: String,
}
pub(crate) fn refresh_changelogs(
workspace_root: &Path,
targets: &[RefreshTarget],
routing: &ChangelogRouting<'_>,
write: bool,
log: &StageLogger,
) -> Result<Vec<RefreshOutput>> {
let mut outputs: Vec<RefreshOutput> = Vec::new();
let root_file = workspace_root.join("CHANGELOG.md");
let mut root_working: Option<String> = None;
for t in targets {
if routing.per_crate {
let update = anodizer_stage_changelog::refresh_crate_unreleased(
workspace_root,
&t.crate_name,
&t.crate_dir,
t.from_tag.as_deref(),
t.to_ref.as_deref(),
)
.with_context(|| format!("failed to refresh changelog for {}", t.crate_name))?;
collect_refresh(workspace_root, update, write, log, &mut outputs)?;
}
if routing.root_enabled && crate_in_root(&t.crate_name, routing.root_crates) {
let update = anodizer_stage_changelog::refresh_root_unreleased(
workspace_root,
&t.crate_name,
&t.crate_dir,
t.from_tag.as_deref(),
t.to_ref.as_deref(),
routing.chronology,
routing.multitrack,
&routing.root_crate_names,
root_working.as_deref(),
)
.with_context(|| format!("failed to refresh root changelog for {}", t.crate_name))?;
if let Some(ref u) = update
&& u.file_path == root_file
{
root_working = Some(u.rendered_text.clone());
}
collect_refresh(workspace_root, update, write, log, &mut outputs)?;
}
}
Ok(outputs)
}
fn collect_refresh(
workspace_root: &Path,
update: Option<anodizer_stage_changelog::ChangelogUpdate>,
write: bool,
log: &StageLogger,
outputs: &mut Vec<RefreshOutput>,
) -> Result<()> {
let Some(update) = update else {
return Ok(());
};
let rel = rel_display(workspace_root, &update.file_path);
if write {
if let Some(parent) = update.file_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&update.file_path, &update.rendered_text).with_context(|| {
format!(
"failed to write changelog at {}",
update.file_path.display()
)
})?;
log.status(&format!("refreshed {}", rel));
}
if let Some(existing) = outputs.iter_mut().find(|o| o.file_path == update.file_path) {
existing.rendered_text = update.rendered_text;
return Ok(());
}
outputs.push(RefreshOutput {
file_path: update.file_path,
rel_path: rel,
rendered_text: update.rendered_text,
});
Ok(())
}
fn rel_display(workspace_root: &Path, file_path: &Path) -> String {
file_path
.strip_prefix(workspace_root)
.unwrap_or(file_path)
.to_string_lossy()
.replace('\\', "/")
}
pub(crate) fn extract_unreleased_section(rendered: &str) -> String {
let mut lines = rendered.lines();
let mut section: Vec<&str> = Vec::new();
let mut found = false;
for line in lines.by_ref() {
if line.starts_with("## ") && line.contains("Unreleased") {
section.push(line);
found = true;
break;
}
}
if !found {
return rendered.trim_end().to_string();
}
for line in lines {
let is_ref_def = line.starts_with('[')
&& line
.split_once("]:")
.is_some_and(|(label, _)| !label.is_empty() && !label.contains(' '));
if line.starts_with("## ") || is_ref_def {
break;
}
section.push(line);
}
let mut out = section.join("\n");
out.truncate(out.trim_end().len());
out
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::{ChangelogFilesConfig, Chronology, RootChangelogConfig};
#[test]
fn extract_unreleased_grabs_only_the_section() {
let file = "# Changelog\n\n## [Unreleased]\n\n### Features\n\n- a thing\n\n## [0.1.0] - 2026-01-01\n\n- old\n\n[Unreleased]: http://x/compare\n";
let section = extract_unreleased_section(file);
assert!(section.starts_with("## [Unreleased]"), "{section}");
assert!(section.contains("a thing"));
assert!(
!section.contains("0.1.0"),
"released history leaked: {section}"
);
assert!(!section.contains("compare"), "footer leaked: {section}");
}
#[test]
fn extract_unreleased_no_heading_returns_input() {
let file = "# Changelog\n\nsome free text\n";
let section = extract_unreleased_section(file);
assert_eq!(section, "# Changelog\n\nsome free text");
}
#[test]
fn extract_unreleased_keeps_bracketed_bullets() {
let file = "# Changelog\n\n## [Unreleased]\n\n### Fixes\n\n- [#42](https://x/42) fix the thing\n- later bullet\n\n[Unreleased]: http://x/compare\n";
let section = extract_unreleased_section(file);
assert!(
section.contains("#42"),
"bracketed-link bullet dropped: {section}"
);
assert!(
section.contains("later bullet"),
"entries after the bracketed bullet dropped: {section}"
);
assert!(!section.contains("compare"), "footer leaked: {section}");
}
#[test]
fn crate_in_root_no_filter_includes_all() {
assert!(crate_in_root("core", None));
assert!(crate_in_root("anything", None));
}
#[test]
fn crate_in_root_filter_includes_named() {
let filter = vec!["core".to_string(), "cli".to_string()];
assert!(crate_in_root("core", Some(&filter)));
assert!(crate_in_root("cli", Some(&filter)));
}
#[test]
fn crate_in_root_filter_excludes_unnamed() {
let filter = vec!["core".to_string()];
assert!(!crate_in_root("cli", Some(&filter)));
}
#[test]
fn crate_in_root_empty_filter_excludes_all() {
let filter: Vec<String> = Vec::new();
assert!(!crate_in_root("core", Some(&filter)));
}
#[test]
fn routing_bare_config_is_root_only() {
let cfg = ChangelogConfig::default();
let routing = ChangelogRouting::from_config(&cfg);
assert!(
routing.root_enabled,
"bare changelog routes to the root file"
);
assert!(!routing.per_crate);
assert_eq!(routing.chronology, Chronology::Date);
assert!(routing.root_crates.is_none());
}
#[test]
fn routing_per_crate_only() {
let cfg = ChangelogConfig {
files: Some(ChangelogFilesConfig {
per_crate: Some(true),
..Default::default()
}),
..Default::default()
};
let routing = ChangelogRouting::from_config(&cfg);
assert!(!routing.root_enabled, "per_crate: true drops the root file");
assert!(routing.per_crate);
}
#[test]
fn routing_both_with_filter_and_chronology() {
let cfg = ChangelogConfig {
files: Some(ChangelogFilesConfig {
per_crate: Some(true),
root: Some(RootChangelogConfig {
chronology: Some(Chronology::Tag),
crates: Some(vec!["core".to_string()]),
}),
}),
..Default::default()
};
let routing = ChangelogRouting::from_config(&cfg);
assert!(routing.root_enabled);
assert!(routing.per_crate);
assert_eq!(routing.chronology, Chronology::Tag);
assert_eq!(routing.root_crates, Some(["core".to_string()].as_slice()));
}
#[test]
fn target_carries_full_tag() {
let t = ChangelogTarget {
crate_name: "core".to_string(),
crate_dir: PathBuf::from("/ws/crates/core"),
from_tag: Some("core-v0.1.0".to_string()),
to_version: "0.2.0".to_string(),
full_tag: "core-v0.2.0".to_string(),
};
assert_eq!(t.full_tag, "core-v0.2.0");
}
}