use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn mentions_changelog_stage_skip(s: &str) -> bool {
s.contains("skipped changelog — ")
}
fn anodizer() -> Command {
Command::new(env!("CARGO_BIN_EXE_anodizer"))
}
fn run_git(dir: &Path, args: &[&str]) {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir).args(args);
cmd
},
"git",
);
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn git_init(dir: &Path) {
run_git(dir, &["init", "-q", "-b", "master"]);
run_git(dir, &["config", "user.email", "test@test.com"]);
run_git(dir, &["config", "user.name", "Test"]);
run_git(dir, &["config", "commit.gpgsign", "false"]);
}
fn git_add_commit(dir: &Path, message: &str) {
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", message]);
}
fn read(dir: &Path, rel: &str) -> String {
fs::read_to_string(dir.join(rel)).unwrap()
}
struct RunResult {
stdout: String,
stderr: String,
success: bool,
}
fn changelog(dir: &Path, args: &[&str]) -> RunResult {
let out = anodizer()
.current_dir(dir)
.arg("changelog")
.args(args)
.output()
.unwrap();
RunResult {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
}
fn assert_no_subsection_graft(text: &str, allowed_group_titles: &[&str]) {
let mut in_unreleased = false;
for line in text.lines() {
if line.starts_with("## ") {
in_unreleased = line.contains("Unreleased");
continue;
}
if in_unreleased && line.starts_with("### ") {
let title = line.trim_start_matches("### ").trim();
assert!(
allowed_group_titles.contains(&title),
"unexpected `### {title}` subsection grafted into [Unreleased] \
(allowed group titles: {allowed_group_titles:?}):\n{text}"
);
}
}
}
fn single_crate_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/app\"]\nresolver = \"2\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/app/src")).unwrap();
fs::write(
root.join("crates/app/Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(root.join("crates/app/src/lib.rs"), "").unwrap();
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: single
changelog: {}
crates:
- name: app
path: crates/app
tag_template: "v{{ .Version }}"
version_sync:
enabled: true
"#,
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/app/src/lib.rs"), "// touched\n").unwrap();
git_add_commit(root, "feat: add a thing");
tmp
}
#[test]
fn single_crate_preview_shows_unreleased_only() {
let tmp = single_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q"]);
assert!(r.success, "preview failed: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("Unreleased"),
"preview must show the [Unreleased] heading: {}",
r.stdout
);
assert!(
r.stdout.contains("add a thing"),
"preview must show the new commit: {}",
r.stdout
);
assert!(
!root.join("CHANGELOG.md").exists(),
"preview must not write CHANGELOG.md"
);
}
#[test]
fn single_crate_write_refreshes_file() {
let tmp = single_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "--write"]);
assert!(r.success, "write failed: {}\n{}", r.stdout, r.stderr);
let cl = read(root, "CHANGELOG.md");
assert!(cl.contains("Unreleased"), "expected [Unreleased]: {cl}");
assert!(cl.contains("add a thing"), "expected the commit: {cl}");
let status = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(root)
.args(["status", "--porcelain", "CHANGELOG.md"]);
cmd
},
"git",
);
let out = String::from_utf8_lossy(&status.stdout);
assert!(
out.contains("CHANGELOG.md"),
"CHANGELOG.md must be an uncommitted working-tree edit, status: {out:?}"
);
}
#[test]
fn single_crate_write_preserves_released_history() {
let tmp = single_crate_repo();
let root = tmp.path();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n## [0.1.0] - 2026-01-01\n\n- first release\n\n[Unreleased]: http://x/compare/v0.1.0...HEAD\n",
)
.unwrap();
let r = changelog(root, &["-q", "--write"]);
assert!(r.success, "write failed: {}\n{}", r.stdout, r.stderr);
let cl = read(root, "CHANGELOG.md");
assert!(cl.contains("## [0.1.0]"), "released history dropped: {cl}");
assert!(cl.contains("first release"), "released body dropped: {cl}");
assert!(cl.contains("add a thing"), "new commit missing: {cl}");
assert!(cl.contains("compare/v0.1.0"), "footer dropped: {cl}");
}
#[test]
fn lockstep_write_refreshes_root_changelog() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n\n[workspace.package]\nversion = \"0.1.0\"\n",
)
.unwrap();
for name in ["core", "cli"] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion.workspace = true\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
"project_name: lockstep\nchangelog: {}\n",
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// touched\n").unwrap();
git_add_commit(root, "feat: lockstep change");
let r = changelog(root, &["-q", "--write"]);
assert!(
r.success,
"lockstep write failed: {}\n{}",
r.stdout, r.stderr
);
let cl = read(root, "CHANGELOG.md");
assert!(cl.contains("Unreleased"), "expected [Unreleased]: {cl}");
assert!(cl.contains("lockstep change"), "expected the commit: {cl}");
assert!(
!root.join("crates/core/CHANGELOG.md").exists(),
"lockstep refresh must not write per-crate files"
);
}
#[test]
fn lockstep_honors_custom_tag_prefix_for_range() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\"]\nresolver = \"2\"\n\n[workspace.package]\nversion = \"0.1.0\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/core/src")).unwrap();
fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion.workspace = true\nedition = \"2024\"\n",
)
.unwrap();
fs::write(root.join("crates/core/src/lib.rs"), "").unwrap();
fs::write(
root.join(".anodizer.yaml"),
"project_name: lockstep\nchangelog: {}\ntag:\n tag_prefix: \"release-v\"\n",
)
.unwrap();
git_init(root);
fs::write(root.join("crates/core/src/lib.rs"), "// pre-tag\n").unwrap();
git_add_commit(root, "feat: before the release tag");
run_git(root, &["tag", "release-v0.1.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// post-tag\n").unwrap();
git_add_commit(root, "feat: after the release tag");
let r = changelog(root, &["-q", "--write"]);
assert!(
r.success,
"lockstep custom-prefix write failed: {}\n{}",
r.stdout, r.stderr
);
let cl = read(root, "CHANGELOG.md");
assert!(
cl.contains("after the release tag"),
"post-tag commit missing: {cl}"
);
assert!(
!cl.contains("before the release tag"),
"range degraded to full history — pre-tag commit leaked (prefix \"release-v\" was ignored): {cl}"
);
}
fn per_crate_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n",
)
.unwrap();
for (name, ver) in [("core", "0.1.0"), ("cli", "0.2.0")] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion = \"{ver}\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: percrate
changelog:
files:
per_crate: true
crates:
- name: core
path: crates/core
tag_template: "core-v{{ .Version }}"
version_sync:
enabled: true
- name: cli
path: crates/cli
tag_template: "cli-v{{ .Version }}"
version_sync:
enabled: true
"#,
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "core-v0.1.0"]);
run_git(root, &["tag", "cli-v0.2.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// core touched\n").unwrap();
git_add_commit(root, "feat: core change");
fs::write(root.join("crates/cli/src/lib.rs"), "// cli touched\n").unwrap();
git_add_commit(root, "fix: cli change");
tmp
}
#[test]
fn per_crate_write_refreshes_each_crate_file() {
let tmp = per_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "--write"]);
assert!(
r.success,
"per-crate write failed: {}\n{}",
r.stdout, r.stderr
);
let core = read(root, "crates/core/CHANGELOG.md");
let cli = read(root, "crates/cli/CHANGELOG.md");
assert!(core.contains("core change"), "core section missing: {core}");
assert!(cli.contains("cli change"), "cli section missing: {cli}");
assert!(
!core.contains("cli change"),
"cli commit leaked into core: {core}"
);
assert!(
!cli.contains("core change"),
"core commit leaked into cli: {cli}"
);
}
#[test]
fn per_crate_preview_separates_multiple_targets() {
let tmp = per_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q"]);
assert!(r.success, "preview failed: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("--- crates/core/CHANGELOG.md ---"),
"missing core separator: {}",
r.stdout
);
assert!(
r.stdout.contains("--- crates/cli/CHANGELOG.md ---"),
"missing cli separator: {}",
r.stdout
);
}
#[test]
fn per_crate_filter_restricts_to_one_crate() {
let tmp = per_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "--write", "--crate", "core"]);
assert!(
r.success,
"filtered write failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
root.join("crates/core/CHANGELOG.md").exists(),
"--crate core must refresh core"
);
assert!(
!root.join("crates/cli/CHANGELOG.md").exists(),
"--crate core must not touch cli"
);
}
#[test]
fn single_tag_resolves_owning_crate_and_predecessor() {
let tmp = per_crate_repo();
let root = tmp.path();
run_git(root, &["tag", "core-v0.2.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// core 0.3\n").unwrap();
git_add_commit(root, "feat: core toward 0.3");
run_git(root, &["tag", "core-v0.3.0"]);
let r = changelog(root, &["-q", "core-v0.3.0", "--format", "json"]);
assert!(
r.success,
"single-tag json failed: {}\n{}",
r.stdout, r.stderr
);
let v: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let arr = v.as_array().unwrap();
assert_eq!(arr.len(), 1, "single tag pins to one crate: {}", r.stdout);
assert_eq!(arr[0]["crate"], "core");
assert_eq!(
arr[0]["from"], "core-v0.2.0",
"predecessor wrong: {}",
r.stdout
);
assert_eq!(arr[0]["to"], "core-v0.3.0");
}
#[test]
fn release_notes_format_emits_grouped_bullets() {
let tmp = single_crate_repo();
let root = tmp.path();
run_git(root, &["tag", "v0.2.0"]);
fs::write(root.join("crates/app/src/lib.rs"), "// moved past v0.2.0\n").unwrap();
git_add_commit(root, "chore: move HEAD past the tag");
let r = changelog(root, &["-q", "v0.2.0", "--format", "release-notes"]);
assert!(
r.success,
"release-notes failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
!r.stderr.contains("does not point at HEAD"),
"the standalone preview must NOT require HEAD at the tag: {}",
r.stderr
);
assert!(
r.stdout.contains("add a thing"),
"release notes must list the commit: {}",
r.stdout
);
}
#[test]
fn release_notes_excludes_version_sync_bump_by_default() {
let tmp = single_crate_repo();
let root = tmp.path();
fs::write(root.join("crates/app/src/lib.rs"), "// bumped\n").unwrap();
git_add_commit(root, "chore(release): bump workspace → 0.2.0 [skip ci]");
fs::write(root.join("crates/app/src/lib.rs"), "// reverted\n").unwrap();
git_add_commit(root, "Revert \"chore(release): bump workspace → 0.2.0\"");
let r = changelog(root, &["-q", "--format", "release-notes"]);
assert!(
r.success,
"release-notes failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("add a thing"),
"the real commit must still appear: {}",
r.stdout
);
assert!(
!r.stdout.contains("[skip ci]"),
"the version-sync bump commit (carrying [skip ci]) must be excluded: {}",
r.stdout
);
assert!(
r.stdout.contains("Revert"),
"a Revert of a bump is a real correction and must be kept: {}",
r.stdout
);
}
#[test]
fn release_notes_keeps_version_sync_bump_when_opted_out() {
let tmp = single_crate_repo();
let root = tmp.path();
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: single
changelog:
filters:
exclude_version_sync_commits: false
crates:
- name: app
path: crates/app
tag_template: "v{{ .Version }}"
version_sync:
enabled: true
"#,
)
.unwrap();
git_add_commit(root, "chore: opt out of version-sync exclusion");
fs::write(root.join("crates/app/src/lib.rs"), "// bumped\n").unwrap();
git_add_commit(root, "chore(release): bump workspace → 0.2.0 [skip ci]");
let r = changelog(root, &["-q", "--format", "release-notes"]);
assert!(
r.success,
"release-notes failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("bump workspace"),
"opt-out must keep the version-sync bump commit: {}",
r.stdout
);
}
#[test]
fn keep_a_changelog_and_json_also_exclude_version_sync_bump() {
let tmp = single_crate_repo();
let root = tmp.path();
fs::write(root.join("crates/app/src/lib.rs"), "// bumped\n").unwrap();
git_add_commit(root, "chore(release): bump workspace → 0.2.0 [skip ci]");
let kac = changelog(root, &["-q"]);
assert!(kac.success, "kac failed: {}\n{}", kac.stdout, kac.stderr);
assert!(
kac.stdout.contains("add a thing"),
"kac must show the real commit: {}",
kac.stdout
);
assert!(
!kac.stdout.contains("[skip ci]"),
"kac must exclude the version-sync bump commit: {}",
kac.stdout
);
let json = changelog(root, &["-q", "--format", "json"]);
assert!(
json.success,
"json failed: {}\n{}",
json.stdout, json.stderr
);
assert!(
!json.stdout.contains("[skip ci]"),
"json must exclude the version-sync bump commit: {}",
json.stdout
);
}
#[test]
fn real_single_crate_tag_bump_subject_matches_exclude_prefix() {
let tmp = single_crate_repo();
let root = tmp.path();
let out = anodizer()
.current_dir(root)
.args(["tag", "--crate", "app"])
.output()
.unwrap();
assert!(
out.status.success(),
"tag failed: {}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let log = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(root).args(["log", "-1", "--format=%s"]);
cmd
},
"git",
);
let subject = String::from_utf8_lossy(&log.stdout);
let subject = subject.trim();
assert!(
subject.starts_with("chore(release): bump "),
"the single-crate version-sync bump subject must carry the auto-exclude \
prefix, got: {subject:?}"
);
}
#[test]
fn bare_release_notes_renders_pending_window_no_guards() {
let tmp = single_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "--format", "release-notes"]);
assert!(
r.success,
"bare release-notes failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
!r.stderr.contains("does not point at HEAD"),
"bare preview must NOT require a checkout: {}",
r.stderr
);
assert!(
!mentions_changelog_stage_skip(&r.stdout) && !mentions_changelog_stage_skip(&r.stderr),
"bare preview must NOT hit the snapshot-skip gate: {}\n{}",
r.stdout,
r.stderr
);
assert!(
r.stdout.contains("add a thing"),
"bare preview must show the pending commit: {}",
r.stdout
);
}
#[test]
fn lockstep_release_notes_renders_pending_window_no_guards() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n\n[workspace.package]\nversion = \"0.1.0\"\n",
)
.unwrap();
for name in ["core", "cli"] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion.workspace = true\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
"project_name: lockstep\nchangelog: {}\n",
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// touched\n").unwrap();
git_add_commit(root, "feat: lockstep pending change");
let r = changelog(root, &["-q", "--format", "release-notes"]);
assert!(
r.success,
"lockstep release-notes failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
!r.stderr.contains("does not point at HEAD"),
"lockstep preview must NOT require a checkout: {}",
r.stderr
);
assert!(
!mentions_changelog_stage_skip(&r.stdout) && !mentions_changelog_stage_skip(&r.stderr),
"lockstep preview must NOT hit the snapshot-skip gate: {}\n{}",
r.stdout,
r.stderr
);
assert!(
r.stdout.contains("lockstep pending change"),
"lockstep preview must show the pending aggregate commit: {}",
r.stdout
);
}
#[test]
fn snapshot_release_notes_renders_without_config_opt_in() {
let tmp = single_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "--snapshot", "--format", "release-notes"]);
assert!(
r.success,
"snapshot release-notes failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
!mentions_changelog_stage_skip(&r.stdout) && !mentions_changelog_stage_skip(&r.stderr),
"the standalone command must bypass the `changelog.snapshot` gate: {}\n{}",
r.stdout,
r.stderr
);
assert!(
r.stdout.contains("add a thing"),
"snapshot preview must show the pending commit: {}",
r.stdout
);
}
#[test]
fn json_format_emits_sorted_array_with_crate_field() {
let tmp = per_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "--format", "json"]);
assert!(r.success, "json failed: {}\n{}", r.stdout, r.stderr);
let v: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let arr = v.as_array().expect("json output must be an array");
assert_eq!(arr.len(), 2, "one element per crate: {}", r.stdout);
assert_eq!(arr[0]["crate"], "cli");
assert_eq!(arr[1]["crate"], "core");
for elem in arr {
assert!(elem.get("crate").is_some());
assert!(elem.get("to").is_some());
assert!(elem.get("groups").is_some());
}
}
#[test]
fn write_with_release_notes_format_is_rejected() {
let tmp = single_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "--write", "--format", "release-notes"]);
assert!(!r.success, "--write + release-notes must fail");
assert!(
r.stderr.contains("--write is only valid"),
"expected the write/format error: {}",
r.stderr
);
}
#[test]
fn explicit_range_overrides_auto_discovery() {
let tmp = single_crate_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "v0.1.0..HEAD", "--format", "json"]);
assert!(r.success, "range json failed: {}\n{}", r.stdout, r.stderr);
let v: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let arr = v.as_array().unwrap();
assert_eq!(arr[0]["from"], "v0.1.0");
assert_eq!(arr[0]["to"], "HEAD");
}
fn two_release_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/app\"]\nresolver = \"2\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/app/src")).unwrap();
fs::write(
root.join("crates/app/Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.2.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(root.join("crates/app/src/lib.rs"), "").unwrap();
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: two-release
changelog:
snapshot: true
crates:
- name: app
path: crates/app
tag_template: "v{{ .Version }}"
version_sync:
enabled: true
"#,
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/app/src/lib.rs"), "// early\n").unwrap();
git_add_commit(root, "feat: early feature");
run_git(root, &["tag", "v0.2.0"]);
fs::write(root.join("crates/app/src/lib.rs"), "// late\n").unwrap();
git_add_commit(root, "fix: late fix");
tmp
}
fn json_summaries(elem: &serde_json::Value) -> Vec<String> {
fn walk(group: &serde_json::Value, out: &mut Vec<String>) {
if let Some(entries) = group.get("entries").and_then(|e| e.as_array()) {
for e in entries {
if let Some(s) = e.get("summary").and_then(|s| s.as_str()) {
out.push(s.to_string());
}
}
}
if let Some(subs) = group.get("subgroups").and_then(|g| g.as_array()) {
for sub in subs {
walk(sub, out);
}
}
}
let mut out = Vec::new();
if let Some(groups) = elem.get("groups").and_then(|g| g.as_array()) {
for g in groups {
walk(g, &mut out);
}
}
out
}
fn json_summaries_for_range(root: &Path, range: Option<&str>) -> Vec<String> {
let mut args = vec!["-q"];
if let Some(r) = range {
args.push(r);
}
args.extend(["--format", "json"]);
let r = changelog(root, &args);
assert!(
r.success,
"json {range:?} failed: {}\n{}",
r.stdout, r.stderr
);
let v: serde_json::Value = serde_json::from_str(&r.stdout).unwrap();
let arr = v.as_array().unwrap();
assert_eq!(arr.len(), 1, "single-crate repo yields one element");
json_summaries(&arr[0])
}
fn release_notes_for_range(root: &Path, range: Option<&str>) -> String {
let mut args = vec!["-q"];
if let Some(r) = range {
args.push(r);
}
args.extend(["--snapshot", "--format", "release-notes"]);
let r = changelog(root, &args);
assert!(
r.success,
"release-notes {range:?} failed: {}\n{}",
r.stdout, r.stderr
);
r.stdout
}
#[test]
fn full_history_dotdot_and_dotdot_head_converge() {
let tmp = two_release_repo();
let root = tmp.path();
let mut a = json_summaries_for_range(root, Some(".."));
let mut b = json_summaries_for_range(root, Some("..HEAD"));
a.sort();
b.sort();
assert_eq!(
a, b,
"`..` and `..HEAD` json must be identical: {a:?} vs {b:?}"
);
assert!(
a.iter().any(|s| s.contains("early feature")),
"full history must include the pre-v0.2.0 commit: {a:?}"
);
assert!(
a.iter().any(|s| s.contains("late fix")),
"full history must include the post-v0.2.0 commit: {a:?}"
);
for range in [Some(".."), Some("..HEAD")] {
let notes = release_notes_for_range(root, range);
assert!(
notes.contains("early feature"),
"release-notes {range:?} (full history) must include the early commit:\n{notes}"
);
assert!(
notes.contains("late fix"),
"release-notes {range:?} (full history) must include the late commit:\n{notes}"
);
}
}
#[test]
fn omitted_range_is_pending_not_full_history() {
let tmp = two_release_repo();
let root = tmp.path();
let omitted = json_summaries_for_range(root, None);
assert!(
omitted.iter().all(|s| !s.contains("early feature")),
"omitted (pending) range must exclude the pre-v0.2.0 commit: {omitted:?}"
);
assert!(
omitted.iter().any(|s| s.contains("late fix")),
"omitted (pending) range must include the post-v0.2.0 commit: {omitted:?}"
);
let full = json_summaries_for_range(root, Some(".."));
assert!(
full.len() > omitted.len(),
"full history must cover strictly more than the pending window: full={full:?} pending={omitted:?}"
);
}
#[test]
fn cross_format_commit_set_is_consistent() {
let tmp = two_release_repo();
let root = tmp.path();
let pending_json = json_summaries_for_range(root, None);
let pending_notes = release_notes_for_range(root, None);
assert!(
pending_json.iter().any(|s| s.contains("late fix")),
"json pending must include the post-tag commit: {pending_json:?}"
);
assert!(
pending_json.iter().all(|s| !s.contains("early feature")),
"json pending must EXCLUDE the pre-tag commit: {pending_json:?}"
);
assert!(
pending_notes.contains("late fix"),
"release-notes pending must include the post-tag commit:\n{pending_notes}"
);
assert!(
!pending_notes.contains("early feature"),
"release-notes pending must EXCLUDE the pre-tag commit (full-history leak):\n{pending_notes}"
);
let full_json = json_summaries_for_range(root, Some("..HEAD"));
let full_notes = release_notes_for_range(root, Some("..HEAD"));
for needle in ["early feature", "late fix"] {
assert!(
full_json.iter().any(|s| s.contains(needle)),
"json full history missing {needle:?}: {full_json:?}"
);
assert!(
full_notes.contains(needle),
"release-notes full history missing {needle:?}:\n{full_notes}"
);
}
let exp_json = json_summaries_for_range(root, Some("v0.1.0..HEAD"));
let exp_notes = release_notes_for_range(root, Some("v0.1.0..HEAD"));
for needle in ["early feature", "late fix"] {
assert!(
exp_json.iter().any(|s| s.contains(needle)),
"json v0.1.0..HEAD missing {needle:?}: {exp_json:?}"
);
assert!(
exp_notes.contains(needle),
"release-notes v0.1.0..HEAD missing {needle:?}:\n{exp_notes}"
);
}
}
#[test]
fn snapshot_release_notes_pending_matches_engine_formats() {
let tmp = two_release_repo();
let root = tmp.path();
let notes = release_notes_for_range(root, None);
assert!(
notes.contains("late fix"),
"release-notes pending must include the post-tag commit:\n{notes}"
);
assert!(
!notes.contains("early feature"),
"release-notes pending leaked the pre-tag commit (full-history bug):\n{notes}"
);
let json = json_summaries_for_range(root, None);
assert!(json.iter().any(|s| s.contains("late fix")), "{json:?}");
assert!(
json.iter().all(|s| !s.contains("early feature")),
"{json:?}"
);
let kac = changelog(root, &["-q"]);
assert!(kac.success, "kac failed: {}\n{}", kac.stdout, kac.stderr);
assert!(
kac.stdout.contains("late fix") && !kac.stdout.contains("early feature"),
"kac pending mismatch:\n{}",
kac.stdout
);
}
fn per_crate_snapshot_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n",
)
.unwrap();
for (name, ver) in [("core", "0.2.0"), ("cli", "0.3.0")] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion = \"{ver}\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: percrate-snap
changelog:
snapshot: true
files:
per_crate: true
crates:
- name: core
path: crates/core
tag_template: "core-v{{ .Version }}"
version_sync:
enabled: true
- name: cli
path: crates/cli
tag_template: "cli-v{{ .Version }}"
version_sync:
enabled: true
"#,
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "core-v0.1.0"]);
run_git(root, &["tag", "cli-v0.1.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// core early\n").unwrap();
git_add_commit(root, "feat: core early");
run_git(root, &["tag", "core-v0.2.0"]);
fs::write(root.join("crates/cli/src/lib.rs"), "// cli early\n").unwrap();
git_add_commit(root, "feat: cli early");
run_git(root, &["tag", "cli-v0.3.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// core late\n").unwrap();
git_add_commit(root, "fix: core late");
fs::write(root.join("crates/cli/src/lib.rs"), "// cli late\n").unwrap();
git_add_commit(root, "fix: cli late");
tmp
}
#[test]
fn per_crate_snapshot_release_notes_bounds_at_each_crate_tag() {
let tmp = per_crate_snapshot_repo();
let root = tmp.path();
let core = release_notes_for_crate(root, "core");
assert!(
core.contains("core late"),
"core pending must include its post-tag commit:\n{core}"
);
assert!(
!core.contains("core early"),
"core pending leaked its pre-tag commit (full-history bug):\n{core}"
);
assert!(
!core.contains("cli late") && !core.contains("cli early"),
"cli commits leaked into core's notes:\n{core}"
);
let cli = release_notes_for_crate(root, "cli");
assert!(
cli.contains("cli late"),
"cli pending must include its post-tag commit:\n{cli}"
);
assert!(
!cli.contains("cli early"),
"cli pending leaked its pre-tag commit (full-history bug):\n{cli}"
);
assert!(
!cli.contains("core late") && !cli.contains("core early"),
"core commits leaked into cli's notes:\n{cli}"
);
}
fn release_notes_for_crate(root: &Path, crate_name: &str) -> String {
let r = changelog(
root,
&[
"-q",
"--crate",
crate_name,
"--snapshot",
"--format",
"release-notes",
],
);
assert!(
r.success,
"release-notes --crate {crate_name} failed: {}\n{}",
r.stdout, r.stderr
);
r.stdout
}
fn same_prefix_shared_root_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n",
)
.unwrap();
for (name, ver) in [("core", "0.1.0"), ("cli", "0.1.0")] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion = \"{ver}\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: aggregate
changelog:
groups:
- title: Features
regexp: "^feat"
crates:
- name: core
path: crates/core
tag_template: "v{{ .Version }}"
- name: cli
path: crates/cli
tag_template: "v{{ .Version }}"
"#,
)
.unwrap();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n### Docs\n\n- hand-written prose\n\n### Fixes\n\n- curated fix note\n",
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// core\n").unwrap();
git_add_commit(root, "feat: aggregate change in core");
fs::write(root.join("crates/cli/src/lib.rs"), "// cli\n").unwrap();
git_add_commit(root, "feat: aggregate change in cli");
tmp
}
#[test]
fn same_prefix_shared_root_collapses_to_one_flat_unreleased() {
let tmp = same_prefix_shared_root_repo();
let root = tmp.path();
let r = changelog(root, &["-q"]);
assert!(r.success, "preview failed: {}\n{}", r.stdout, r.stderr);
assert_eq!(
r.stdout.matches("## [Unreleased]").count(),
1,
"expected exactly one [Unreleased] section: {}",
r.stdout
);
assert!(
!r.stdout.contains("---"),
"flat aggregate must not emit per-crate separators: {}",
r.stdout
);
assert!(
!r.stdout.contains("### core") && !r.stdout.contains("### cli"),
"flat aggregate must not graft a `### <crate>` subsection: {}",
r.stdout
);
assert_no_subsection_graft(&r.stdout, &["Features"]);
assert!(
r.stdout.contains("aggregate change in core")
&& r.stdout.contains("aggregate change in cli"),
"regenerated flat body must aggregate every member's commits: {}",
r.stdout
);
}
#[test]
fn same_prefix_shared_root_write_is_flat() {
let tmp = same_prefix_shared_root_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "--write"]);
assert!(r.success, "write failed: {}\n{}", r.stdout, r.stderr);
let cl = read(root, "CHANGELOG.md");
assert_eq!(
cl.matches("## [Unreleased]").count(),
1,
"written file must keep a single [Unreleased]: {cl}"
);
assert!(
!cl.contains("### core") && !cl.contains("### cli"),
"written file must not graft a `### <crate>` subsection: {cl}"
);
assert_no_subsection_graft(&cl, &["Features"]);
assert!(
cl.contains("aggregate change in core") && cl.contains("aggregate change in cli"),
"written flat body must aggregate every member's commits: {cl}"
);
assert!(
!root.join("crates/core/CHANGELOG.md").exists()
&& !root.join("crates/cli/CHANGELOG.md").exists(),
"flat aggregate must not write per-crate files"
);
}
#[test]
fn same_prefix_shared_root_release_notes_is_one_aggregate() {
let tmp = same_prefix_shared_root_repo();
let root = tmp.path();
run_git(root, &["tag", "v0.2.0"]);
let r = changelog(root, &["-q", "v0.1.0..v0.2.0", "--format", "release-notes"]);
assert!(
r.success,
"release-notes failed: {}\n{}",
r.stdout, r.stderr
);
let out = &r.stdout;
assert!(
!out.contains("---\ncore\n---") && !out.contains("---\ncli\n---"),
"release-notes flat aggregate must not emit per-crate separators: {out}"
);
for c in ["### core", "### cli", "### aggregate"] {
assert!(
!out.contains(c),
"spurious `{c}` graft in release-notes: {out}"
);
}
assert!(
out.contains("aggregate change in core") && out.contains("aggregate change in cli"),
"release-notes aggregate body must span every member's commits: {out}"
);
}
#[test]
fn distinct_prefix_multitrack_keeps_crate_subsections() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n",
)
.unwrap();
for (name, ver) in [("core", "0.1.0"), ("cli", "0.2.0")] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion = \"{ver}\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: multitrack
changelog:
groups:
- title: Features
regexp: "^feat"
crates:
- name: core
path: crates/core
tag_template: "core-v{{ .Version }}"
- name: cli
path: crates/cli
tag_template: "cli-v{{ .Version }}"
"#,
)
.unwrap();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n### core\n\n- old core note\n\n### cli\n\n- old cli note\n",
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "core-v0.1.0"]);
run_git(root, &["tag", "cli-v0.2.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// core\n").unwrap();
git_add_commit(root, "feat: distinct core change");
fs::write(root.join("crates/cli/src/lib.rs"), "// cli\n").unwrap();
git_add_commit(root, "feat: distinct cli change");
let r = changelog(root, &["-q", "--write"]);
assert!(
r.success,
"multitrack write failed: {}\n{}",
r.stdout, r.stderr
);
let cl = read(root, "CHANGELOG.md");
assert!(
cl.contains("### core") && cl.contains("### cli"),
"genuine multi-track must keep both `### <crate>` subsections: {cl}"
);
assert!(
cl.contains("distinct core change"),
"core subsection must regenerate from core's commits: {cl}"
);
assert!(
cl.contains("distinct cli change"),
"cli subsection must regenerate from cli's commits: {cl}"
);
}
#[test]
fn dogfood_flat_aggregate_render_is_clean() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\", \"crates/api\"]\nresolver = \"2\"\n",
)
.unwrap();
for name in ["core", "cli", "api"] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion = \"0.5.0\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: dogfood
changelog:
use: github-native
format: "* {{ .SHA }} {{ .Message }} ({{ .AuthorUsername }})"
abbrev: 12
groups:
- title: Features
regexp: "^feat"
order: 0
- title: Bug Fixes
regexp: "^fix"
order: 1
crates:
- name: core
path: crates/core
tag_template: "v{{ .Version }}"
- name: cli
path: crates/cli
tag_template: "v{{ .Version }}"
- name: api
path: crates/api
tag_template: "v{{ .Version }}"
"#,
)
.unwrap();
fs::write(
root.join("CHANGELOG.md"),
"# Changelog\n\n## [Unreleased]\n\n### CI / Workflows\n\n- curated CI note\n\n### Docs\n\n- curated docs note\n",
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.5.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// core\n").unwrap();
git_add_commit(root, "feat: dogfood core capability");
fs::write(root.join("crates/cli/src/lib.rs"), "// cli\n").unwrap();
git_add_commit(root, "fix: dogfood cli bug");
let r = changelog(root, &["-q"]);
assert!(
r.success,
"dogfood preview failed: {}\n{}",
r.stdout, r.stderr
);
let out = &r.stdout;
assert_eq!(
out.matches("## [Unreleased]").count(),
1,
"expected one flat [Unreleased]: {out}"
);
for c in ["### core", "### cli", "### api", "### dogfood"] {
assert!(!out.contains(c), "spurious `{c}` graft: {out}");
}
assert_no_subsection_graft(out, &["Features", "Bug Fixes"]);
assert!(!out.contains("* *"), "double bullet emitted: {out}");
assert!(
out.contains("(Test)"),
"author must render as the committer name: {out}"
);
assert!(!out.contains("()"), "empty author parens emitted: {out}");
assert!(
out.contains("dogfood core capability") && out.contains("dogfood cli bug"),
"regenerated body must reflect since-tag commits: {out}"
);
}
fn github_native_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/app\"]\nresolver = \"2\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/app/src")).unwrap();
fs::write(
root.join("crates/app/Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(root.join("crates/app/src/lib.rs"), "").unwrap();
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: gh-native
changelog:
use: github-native
crates:
- name: app
path: crates/app
tag_template: "v{{ .Version }}"
release:
github:
owner: octocat
name: app
"#,
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/app/src/lib.rs"), "// touched\n").unwrap();
git_add_commit(root, "feat: github-native local preview");
tmp
}
#[test]
fn github_native_release_notes_previews_from_local_git() {
let tmp = github_native_repo();
let root = tmp.path();
let mut cmd = anodizer();
cmd.current_dir(root)
.arg("changelog")
.args(["-q", "--format", "release-notes"])
.env_remove("GITHUB_TOKEN")
.env_remove("ANODIZER_GITHUB_TOKEN");
let out = cmd.output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"github-native preview failed: {stdout}\n{stderr}"
);
assert!(
!stderr.contains("requires a GitHub token"),
"github-native preview must NOT require a token: {stderr}"
);
assert!(
stderr.contains("previewing changelog from local git"),
"github-native preview must emit the one-line fallback note: {stderr}"
);
assert!(
stdout.contains("github-native local preview"),
"github-native preview must render the local commit (non-empty): {stdout}"
);
}
#[test]
fn multitrack_release_notes_preview_bounds_at_each_crate_tag() {
let tmp = per_crate_repo();
let root = tmp.path();
let core = changelog(
root,
&["-q", "--crate", "core", "--format", "release-notes"],
);
assert!(
core.success,
"core release-notes preview failed: {}\n{}",
core.stdout, core.stderr
);
assert!(
!core.stderr.contains("does not point at HEAD"),
"multitrack preview must NOT require a checkout: {}",
core.stderr
);
assert!(
core.stdout.contains("core change"),
"core preview must show its pending commit: {}",
core.stdout
);
assert!(
!core.stdout.contains("cli change"),
"cli commit leaked into core's preview: {}",
core.stdout
);
let cli = changelog(root, &["-q", "--crate", "cli", "--format", "release-notes"]);
assert!(
cli.success,
"cli release-notes preview failed: {}\n{}",
cli.stdout, cli.stderr
);
assert!(
cli.stdout.contains("cli change"),
"cli preview must show its pending commit: {}",
cli.stdout
);
assert!(
!cli.stdout.contains("core change"),
"core commit leaked into cli's preview: {}",
cli.stdout
);
}
fn flat_aggregate_versions_repo(core_ver: &str, cli_ver: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n",
)
.unwrap();
for (name, ver) in [("core", core_ver), ("cli", cli_ver)] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion = \"{ver}\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: agg
changelog: {}
crates:
- name: core
path: crates/core
tag_template: "v{{ .Version }}"
- name: cli
path: crates/cli
tag_template: "v{{ .Version }}"
"#,
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
tmp
}
fn assert_coherence_error(stderr: &str) {
assert!(
stderr.contains("core") && stderr.contains("cli"),
"{stderr}"
);
assert!(
stderr.contains("0.5.0") && stderr.contains("0.1.0"),
"names differing versions: {stderr}"
);
assert!(
stderr.contains("prefix 'v'"),
"names shared prefix: {stderr}"
);
assert!(
stderr.contains("[workspace.package].version"),
"steers toward lockstep: {stderr}"
);
assert!(
stderr.contains("distinct tag_template prefix"),
"steers toward independent prefixes: {stderr}"
);
}
#[test]
fn changelog_rejects_divergent_flat_aggregate_versions() {
let tmp = flat_aggregate_versions_repo("0.5.0", "0.1.0");
let r = changelog(tmp.path(), &["-q"]);
assert!(
!r.success,
"divergent flat aggregate must error: {}",
r.stdout
);
assert_coherence_error(&r.stderr);
}
#[test]
fn changelog_accepts_agreeing_flat_aggregate_versions() {
let tmp = flat_aggregate_versions_repo("0.2.0", "0.2.0");
let r = changelog(tmp.path(), &["-q"]);
assert!(
r.success,
"all-agree flat aggregate must work: {}\n{}",
r.stdout, r.stderr
);
}
#[test]
fn tag_rejects_divergent_flat_aggregate_versions() {
let tmp = flat_aggregate_versions_repo("0.5.0", "0.1.0");
let out = anodizer()
.current_dir(tmp.path())
.args(["tag", "--dry-run", "-q"])
.output()
.unwrap();
assert!(
!out.status.success(),
"tag must error on divergent versions"
);
assert_coherence_error(&String::from_utf8_lossy(&out.stderr));
}
#[test]
fn bump_rejects_divergent_flat_aggregate_versions() {
let tmp = flat_aggregate_versions_repo("0.5.0", "0.1.0");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--workspace", "--dry-run", "-q"])
.output()
.unwrap();
assert!(
!out.status.success(),
"bump must error on divergent versions"
);
assert_coherence_error(&String::from_utf8_lossy(&out.stderr));
}
fn changelog_preview_from(run_dir: &Path, config: &Path) -> RunResult {
changelog(
run_dir,
&["-q", "--config", config.to_str().expect("utf8 config path")],
)
}
fn assert_subdir_matches_root(fixture: &TempDir, subdir: &str) {
let root = fixture.path();
let config = root.join(".anodizer.yaml");
let from_root = changelog_preview_from(root, &config);
assert!(
from_root.success,
"root invocation failed: {}\n{}",
from_root.stdout, from_root.stderr
);
let from_subdir = changelog_preview_from(&root.join(subdir), &config);
assert!(
from_subdir.success,
"subdir invocation failed: {}\n{}",
from_subdir.stdout, from_subdir.stderr
);
assert_eq!(
from_subdir.stdout, from_root.stdout,
"changelog preview from {subdir} must match the repo-root preview \
(workspace root resolved against cwd instead of the config)"
);
}
#[test]
fn single_crate_changelog_from_subdir_matches_root() {
let tmp = single_crate_repo();
assert_subdir_matches_root(&tmp, "crates/app");
}
#[test]
fn lockstep_changelog_from_subdir_matches_root() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n\n[workspace.package]\nversion = \"0.1.0\"\n",
)
.unwrap();
for name in ["core", "cli"] {
fs::create_dir_all(root.join(format!("crates/{name}/src"))).unwrap();
fs::write(
root.join(format!("crates/{name}/Cargo.toml")),
format!("[package]\nname = \"{name}\"\nversion.workspace = true\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(format!("crates/{name}/src/lib.rs")), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
"project_name: lockstep\nchangelog: {}\n",
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// touched\n").unwrap();
git_add_commit(root, "feat: lockstep change");
assert_subdir_matches_root(&tmp, "crates/core");
}
#[test]
fn per_crate_changelog_from_subdir_matches_root() {
let tmp = per_crate_repo();
assert_subdir_matches_root(&tmp, "crates/cli");
}
struct ShapeCase {
name: &'static str,
yaml: &'static str,
members: &'static [&'static str],
crates: &'static [(&'static str, &'static str, &'static str)],
tags: &'static [&'static str],
post_tag_commits: &'static [(&'static str, &'static str)],
must_contain: &'static [&'static str],
json_len: usize,
}
fn build_shape_repo(case: &ShapeCase) -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let members = case
.members
.iter()
.map(|m| format!("\"{m}\""))
.collect::<Vec<_>>()
.join(", ");
let ws_pkg = if case.crates.iter().any(|(_, _, v)| *v == "workspace") {
"\n[workspace.package]\nversion = \"0.1.0\"\n"
} else {
""
};
fs::write(
root.join("Cargo.toml"),
format!("[workspace]\nmembers = [{members}]\nresolver = \"2\"\n{ws_pkg}"),
)
.unwrap();
for (dir, name, ver) in case.crates {
fs::create_dir_all(root.join(dir).join("src")).unwrap();
let ver_line = if *ver == "workspace" {
"version.workspace = true".to_string()
} else {
format!("version = \"{ver}\"")
};
fs::write(
root.join(dir).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\n{ver_line}\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(dir).join("src/lib.rs"), "").unwrap();
}
fs::write(root.join(".anodizer.yaml"), case.yaml).unwrap();
git_init(root);
git_add_commit(root, "initial");
for tag in case.tags {
run_git(root, &["tag", tag]);
}
for (dir, subject) in case.post_tag_commits {
fs::write(root.join(dir).join("src/lib.rs"), format!("// {subject}\n")).unwrap();
git_add_commit(root, subject);
}
tmp
}
fn shape_matrix() -> Vec<ShapeCase> {
vec![
ShapeCase {
name: "single",
yaml: "project_name: single\nchangelog: {}\ncrates:\n - name: app\n path: crates/app\n tag_template: \"v{{ Version }}\"\n",
members: &["crates/app"],
crates: &[("crates/app", "app", "0.1.0")],
tags: &["v0.1.0"],
post_tag_commits: &[("crates/app", "feat: single change")],
must_contain: &["single change"],
json_len: 1,
},
ShapeCase {
name: "lockstep",
yaml: "project_name: lockstep\nchangelog: {}\n",
members: &["crates/core", "crates/cli"],
crates: &[
("crates/core", "core", "workspace"),
("crates/cli", "cli", "workspace"),
],
tags: &["v0.1.0"],
post_tag_commits: &[("crates/core", "feat: lockstep change")],
must_contain: &["lockstep change"],
json_len: 1,
},
ShapeCase {
name: "flat-aggregate",
yaml: "project_name: aggregate\nchangelog: {}\ncrates:\n - name: core\n path: crates/core\n tag_template: \"v{{ Version }}\"\n - name: cli\n path: crates/cli\n tag_template: \"v{{ Version }}\"\n",
members: &["crates/core", "crates/cli"],
crates: &[
("crates/core", "core", "0.1.0"),
("crates/cli", "cli", "0.1.0"),
],
tags: &["v0.1.0"],
post_tag_commits: &[
("crates/core", "feat: aggregate core change"),
("crates/cli", "feat: aggregate cli change"),
],
must_contain: &["aggregate core change", "aggregate cli change"],
json_len: 1,
},
ShapeCase {
name: "percrate-flat",
yaml: "project_name: percrate\nchangelog:\n files:\n per_crate: true\ncrates:\n - name: core\n path: crates/core\n tag_template: \"core-v{{ Version }}\"\n - name: cli\n path: crates/cli\n tag_template: \"cli-v{{ Version }}\"\n",
members: &["crates/core", "crates/cli"],
crates: &[
("crates/core", "core", "0.1.0"),
("crates/cli", "cli", "0.2.0"),
],
tags: &["core-v0.1.0", "cli-v0.2.0"],
post_tag_commits: &[
("crates/core", "feat: flat core change"),
("crates/cli", "fix: flat cli change"),
],
must_contain: &["flat core change", "flat cli change"],
json_len: 2,
},
ShapeCase {
name: "percrate-workspaces-ghnative",
yaml: "project_name: cfgd\nchangelog:\n use: github-native\nworkspaces:\n - name: core\n crates:\n - name: cfgd-core\n path: crates/core\n tag_template: \"core-v{{ Version }}\"\n - name: cli\n crates:\n - name: cfgd\n path: crates/cli\n tag_template: \"v{{ Version }}\"\n",
members: &["crates/core", "crates/cli"],
crates: &[
("crates/core", "cfgd-core", "0.1.0"),
("crates/cli", "cfgd", "0.1.0"),
],
tags: &["core-v0.1.0", "v0.1.0"],
post_tag_commits: &[
("crates/core", "feat: ws core change"),
("crates/cli", "fix: ws cli change"),
],
must_contain: &["ws core change", "ws cli change"],
json_len: 2,
},
]
}
fn assert_no_dist_changelog(root: &Path, label: &str) {
assert!(
!root.join("dist/CHANGELOG.md").exists(),
"[{label}] preview must not write dist/CHANGELOG.md"
);
}
#[test]
fn config_shape_matrix_renders_all_formats() {
for case in shape_matrix() {
let tmp = build_shape_repo(&case);
let root = tmp.path();
let label = case.name;
let is_ghnative = case.yaml.contains("use: github-native");
let kac = changelog(root, &["-q"]);
assert!(
kac.success,
"[{label}] kac failed: {}\n{}",
kac.stdout, kac.stderr
);
assert!(
kac.stdout.contains("Unreleased"),
"[{label}] kac must render [Unreleased]: {}",
kac.stdout
);
assert_no_dist_changelog(root, label);
let rn = changelog(root, &["-q", "--format", "release-notes"]);
assert!(
rn.success,
"[{label}] release-notes failed: {}\n{}",
rn.stdout, rn.stderr
);
assert!(
!rn.stdout.trim().is_empty(),
"[{label}] release-notes body must be non-empty"
);
for needle in case.must_contain {
assert!(
rn.stdout.contains(needle),
"[{label}] release-notes missing {needle:?}:\n{}",
rn.stdout
);
}
assert!(
!mentions_changelog_stage_skip(&rn.stdout)
&& !mentions_changelog_stage_skip(&rn.stderr),
"[{label}] release-notes must not hit the snapshot-skip gate: {}\n{}",
rn.stdout,
rn.stderr
);
if is_ghnative {
assert!(
rn.stderr.contains("previewing changelog from local git"),
"[{label}] github-native release-notes must emit the local-git note: {}",
rn.stderr
);
assert!(
!rn.stderr.contains("requires a GitHub token"),
"[{label}] github-native preview must not require a token: {}",
rn.stderr
);
}
assert_no_dist_changelog(root, label);
let js = changelog(root, &["-q", "--format", "json"]);
assert!(
js.success,
"[{label}] json failed: {}\n{}",
js.stdout, js.stderr
);
let v: serde_json::Value = serde_json::from_str(&js.stdout)
.unwrap_or_else(|e| panic!("[{label}] json parse failed: {e}\n{}", js.stdout));
let arr = v
.as_array()
.unwrap_or_else(|| panic!("[{label}] json must be an array"));
assert_eq!(
arr.len(),
case.json_len,
"[{label}] json element count: {}",
js.stdout
);
assert_no_dist_changelog(root, label);
let snap = changelog(root, &["-q", "--snapshot", "--format", "release-notes"]);
assert!(
snap.success,
"[{label}] snapshot release-notes failed: {}\n{}",
snap.stdout, snap.stderr
);
assert!(
!mentions_changelog_stage_skip(&snap.stdout)
&& !mentions_changelog_stage_skip(&snap.stderr),
"[{label}] snapshot release-notes must bypass the config gate: {}\n{}",
snap.stdout,
snap.stderr
);
for needle in case.must_contain {
assert!(
snap.stdout.contains(needle),
"[{label}] snapshot release-notes missing {needle:?}:\n{}",
snap.stdout
);
}
assert_no_dist_changelog(root, label);
}
}
fn build_multitrack_scope_repo(paths_block: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n",
)
.unwrap();
for (dir, name) in [("crates/core", "core"), ("crates/cli", "cli")] {
fs::create_dir_all(root.join(dir).join("src")).unwrap();
fs::write(
root.join(dir).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(dir).join("src/lib.rs"), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
format!(
"project_name: cfgd\nchangelog:{paths_block}\ncrates:\n \
- name: core\n path: crates/core\n tag_template: \"core-v{{{{ Version }}}}\"\n \
- name: cli\n path: crates/cli\n tag_template: \"cli-v{{{{ Version }}}}\"\n"
),
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "core-v0.1.0"]);
run_git(root, &["tag", "cli-v0.1.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// c\n").unwrap();
git_add_commit(root, "feat: core only change");
fs::write(root.join("crates/cli/src/lib.rs"), "// c\n").unwrap();
git_add_commit(root, "fix: cli only change");
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n# bump\n",
)
.unwrap();
git_add_commit(root, "chore: workspace manifest bump");
tmp
}
fn json_summaries_for_crate(root: &Path, name: &str) -> String {
let r = changelog(root, &["-q", "--crate", name, "--format", "json"]);
assert!(
r.success,
"json --crate {name} failed: {}\n{}",
r.stdout, r.stderr
);
let v: serde_json::Value = serde_json::from_str(&r.stdout)
.unwrap_or_else(|e| panic!("json parse failed: {e}\n{}", r.stdout));
v.to_string()
}
fn assert_track_isolation(root: &Path, name: &str, own: &str, foreign: &str) {
let kac = changelog(root, &["-q", "--crate", name]);
assert!(
kac.success,
"kac --crate {name} failed: {}\n{}",
kac.stdout, kac.stderr
);
let rn = changelog(root, &["-q", "--crate", name, "--format", "release-notes"]);
assert!(
rn.success,
"rn --crate {name} failed: {}\n{}",
rn.stdout, rn.stderr
);
let js = json_summaries_for_crate(root, name);
for (fmt, body) in [
("kac", &kac.stdout),
("release-notes", &rn.stdout),
("json", &js),
] {
assert!(
body.contains(own),
"[{fmt}] --crate {name} must contain its own commit {own:?}:\n{body}"
);
assert!(
!body.contains(foreign),
"[{fmt}] --crate {name} must NOT contain the other track's commit {foreign:?}:\n{body}"
);
}
}
#[test]
fn multitrack_each_track_scopes_to_its_own_dir_all_formats() {
let tmp = build_multitrack_scope_repo("");
let root = tmp.path();
assert_track_isolation(root, "core", "core only change", "cli only change");
assert_track_isolation(root, "cli", "cli only change", "core only change");
}
#[test]
fn multitrack_superset_paths_match_no_paths_proving_intersect_not_replace() {
let paths = "\n paths:\n - \"crates/**\"\n - Cargo.toml\n - Cargo.lock";
let tmp = build_multitrack_scope_repo(paths);
let root = tmp.path();
assert_track_isolation(root, "core", "core only change", "cli only change");
assert_track_isolation(root, "cli", "cli only change", "core only change");
}
#[test]
fn aggregate_includes_manifest_only_commit() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\"]\nresolver = \"2\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/core/src")).unwrap();
fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(root.join("crates/core/src/lib.rs"), "").unwrap();
fs::write(
root.join(".anodizer.yaml"),
"project_name: agg\nchangelog: {}\ncrates:\n \
- name: core\n path: crates/core\n tag_template: \"v{{ Version }}\"\n",
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\"]\nresolver = \"2\"\n# dep bump\n",
)
.unwrap();
git_add_commit(root, "chore: workspace manifest only bump");
let rn = changelog(root, &["-q", "--format", "release-notes"]);
assert!(rn.success, "rn failed: {}\n{}", rn.stdout, rn.stderr);
assert!(
rn.stdout.contains("workspace manifest only bump"),
"aggregate must include the Cargo.toml-only commit:\n{}",
rn.stdout
);
let js = changelog(root, &["-q", "--format", "json"]);
assert!(
js.stdout.contains("workspace manifest only bump"),
"aggregate json must include the Cargo.toml-only commit:\n{}",
js.stdout
);
}
#[test]
fn narrowing_paths_drop_an_excluded_crates_commits_from_aggregate() {
let paths = "\n paths:\n - \"crates/core/**\"";
let tmp = build_multitrack_scope_repo(paths);
let root = tmp.path();
let rn = changelog(root, &["-q", "--crate", "cli", "--format", "release-notes"]);
assert!(rn.success, "rn failed: {}\n{}", rn.stdout, rn.stderr);
assert!(
!rn.stdout.contains("cli only change"),
"narrowing paths (crates/core/**) must drop cli's commit:\n{}",
rn.stdout
);
let core = changelog(
root,
&["-q", "--crate", "core", "--format", "release-notes"],
);
assert!(
core.stdout.contains("core only change"),
"core's commit must survive crates/core/** narrowing:\n{}",
core.stdout
);
}
fn build_narrowing_body_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n",
)
.unwrap();
for (dir, name) in [("crates/core", "core"), ("crates/cli", "cli")] {
fs::create_dir_all(root.join(dir).join("src")).unwrap();
fs::write(
root.join(dir).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(root.join(dir).join("src/lib.rs"), "").unwrap();
}
fs::write(
root.join(".anodizer.yaml"),
"project_name: aggregate\nchangelog:\n paths:\n - \"crates/core/**\"\ncrates:\n \
- name: core\n path: crates/core\n tag_template: \"v{{ Version }}\"\n \
- name: cli\n path: crates/cli\n tag_template: \"v{{ Version }}\"\n",
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/core/src/lib.rs"), "// c\n").unwrap();
run_git(root, &["add", "-A"]);
run_git(
root,
&[
"commit",
"-q",
"-m",
"feat: core narrowed change\n\nbody line one\nbody line two\n\nCo-Authored-By: Charlie <charlie@c.com>",
],
);
fs::write(root.join("crates/cli/src/lib.rs"), "// c\n").unwrap();
git_add_commit(root, "fix: cli narrowed-out change");
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\nresolver = \"2\"\n# bump\n",
)
.unwrap();
git_add_commit(root, "chore: manifest narrowed-out change");
tmp
}
#[test]
fn narrowed_aggregate_preserves_full_body_and_coauthor_and_drops_excluded() {
let tmp = build_narrowing_body_repo();
let root = tmp.path();
let js = changelog(root, &["-q", "--format", "json"]);
assert!(js.success, "json failed: {}\n{}", js.stdout, js.stderr);
let v: serde_json::Value = serde_json::from_str(&js.stdout)
.unwrap_or_else(|e| panic!("json parse failed: {e}\n{}", js.stdout));
let blob = v.to_string();
assert!(
blob.contains("core narrowed change"),
"surviving core commit subject missing:\n{}",
js.stdout
);
assert!(
blob.contains("Charlie"),
"narrowed render dropped the co-author from the multi-line body \
(body truncation regression):\n{}",
js.stdout
);
assert!(
!blob.contains("cli narrowed-out change"),
"cli commit outside crates/core/** must drop:\n{}",
js.stdout
);
assert!(
!blob.contains("manifest narrowed-out change"),
"manifest-only commit outside crates/core/** must drop:\n{}",
js.stdout
);
let rn = changelog(root, &["-q", "--format", "release-notes"]);
assert!(rn.success, "rn failed: {}\n{}", rn.stdout, rn.stderr);
assert!(
rn.stdout.contains("core narrowed change")
&& !rn.stdout.contains("cli narrowed-out change"),
"release-notes narrowed render disagrees with json:\n{}",
rn.stdout
);
}
fn bounded_range_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/app\"]\nresolver = \"2\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/app/src")).unwrap();
fs::write(
root.join("crates/app/Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.3.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(root.join("crates/app/src/lib.rs"), "").unwrap();
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: bounded
changelog: {}
crates:
- name: app
path: crates/app
tag_template: "v{{ .Version }}"
version_sync:
enabled: true
"#,
)
.unwrap();
git_init(root);
git_add_commit(root, "initial");
run_git(root, &["tag", "v0.1.0"]);
fs::write(root.join("crates/app/src/lib.rs"), "// a\n").unwrap();
git_add_commit(root, "feat: WINDOW_ONE change");
run_git(root, &["tag", "v0.2.0"]);
fs::write(root.join("crates/app/src/lib.rs"), "// b\n").unwrap();
git_add_commit(root, "feat: WINDOW_TWO change");
run_git(root, &["tag", "v0.3.0"]);
fs::write(root.join("crates/app/src/lib.rs"), "// c\n").unwrap();
git_add_commit(root, "feat: POST_TAG change");
tmp
}
#[test]
fn release_notes_explicit_range_honors_upper_bound() {
let tmp = bounded_range_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "v0.1.0..v0.2.0", "--format", "release-notes"]);
assert!(r.success, "range render failed: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("WINDOW_ONE change"),
"in-range commit (v0.1.0..v0.2.0) must be present:\n{}",
r.stdout
);
assert!(
!r.stdout.contains("WINDOW_TWO change"),
"v0.2.0..v0.3.0 commit is above the upper bound v0.2.0 and must be absent:\n{}",
r.stdout
);
assert!(
!r.stdout.contains("POST_TAG change"),
"post-v0.3.0 HEAD commit leaked past the upper bound v0.2.0 \
(release-notes ignored `<to>` and walked to HEAD):\n{}",
r.stdout
);
}
#[test]
fn release_notes_single_tag_excludes_post_tag_commits() {
let tmp = bounded_range_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "v0.3.0", "--format", "release-notes"]);
assert!(
r.success,
"single-tag render failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("WINDOW_TWO change"),
"the v0.2.0..v0.3.0 commit must be present for `changelog v0.3.0`:\n{}",
r.stdout
);
assert!(
!r.stdout.contains("POST_TAG change"),
"post-v0.3.0 HEAD commit leaked into `changelog v0.3.0` \
(single-tag upper bound v0.3.0 ignored, walked to HEAD):\n{}",
r.stdout
);
assert!(
!r.stdout.contains("WINDOW_ONE change"),
"the v0.1.0..v0.2.0 commit is below the lower bound (predecessor v0.2.0) \
and must be absent:\n{}",
r.stdout
);
}
#[test]
fn release_notes_open_lower_bounded_upper_walks_ancestors_of_to() {
let tmp = bounded_range_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "..v0.2.0", "--format", "release-notes"]);
assert!(
r.success,
"open-lower render failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("WINDOW_ONE change"),
"commit reachable from v0.2.0 must be present for `..v0.2.0`:\n{}",
r.stdout
);
assert!(
!r.stdout.contains("WINDOW_TWO change"),
"the v0.2.0..v0.3.0 commit is NOT an ancestor of v0.2.0 and must be absent:\n{}",
r.stdout
);
assert!(
!r.stdout.contains("POST_TAG change"),
"post-v0.3.0 HEAD commit is not an ancestor of v0.2.0 and must be absent \
(open-lower path walked to HEAD instead of bounding at v0.2.0):\n{}",
r.stdout
);
}
#[test]
fn release_notes_open_upper_bound_still_reaches_head() {
let tmp = bounded_range_repo();
let root = tmp.path();
let r = changelog(root, &["-q", "v0.2.0..", "--format", "release-notes"]);
assert!(
r.success,
"open-range render failed: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("POST_TAG change"),
"open upper bound `v0.2.0..` must still reach HEAD (pending window):\n{}",
r.stdout
);
assert!(
r.stdout.contains("WINDOW_TWO change"),
"the v0.2.0..v0.3.0 commit must be present in the open `v0.2.0..` range:\n{}",
r.stdout
);
}
fn workspaces_member_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"tools/member\"]\nresolver = \"2\"\n",
)
.unwrap();
fs::create_dir_all(root.join("tools/member/src")).unwrap();
fs::write(
root.join("tools/member/Cargo.toml"),
"[package]\nname = \"member\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(root.join("tools/member/src/lib.rs"), "").unwrap();
fs::write(
root.join(".anodizer.yaml"),
r#"project_name: mono
workspaces:
- name: tools
changelog:
groups:
- title: Workspace Features
regexp: "^feat"
order: 0
crates:
- name: member
path: tools/member
tag_template: "member-v{{ .Version }}"
"#,
)
.unwrap();
git_init(root);
git_add_commit(root, "feat: initial member");
run_git(root, &["tag", "member-v0.1.0"]);
fs::write(root.join("tools/member/src/lib.rs"), "// gadget\n").unwrap();
git_add_commit(root, "feat: add gadget");
tmp
}
#[test]
fn release_notes_crate_filter_applies_workspace_overlay() {
let tmp = workspaces_member_repo();
let stdout = release_notes_for_crate(tmp.path(), "member");
assert!(
stdout.contains("Workspace Features"),
"the member's workspace-level changelog.groups must shape the notes \
(overlay applied), got:\n{stdout}"
);
assert!(
stdout.contains("add gadget"),
"the member's pending commit must appear in its notes, got:\n{stdout}"
);
}
#[test]
fn changelog_unknown_crate_hard_errors_on_every_format() {
let tmp = workspaces_member_repo();
for format_args in [
&["--crate", "nope"][..],
&["--crate", "nope", "--format", "release-notes"][..],
&["--crate", "nope", "--format", "json"][..],
] {
let r = changelog(tmp.path(), format_args);
assert!(
!r.success,
"changelog {format_args:?} must fail on an unknown crate, got stdout:\n{}",
r.stdout
);
assert!(
r.stderr.contains("nope") && r.stderr.contains("member"),
"error must name the unknown crate and the known ones, got:\n{}",
r.stderr
);
}
}