use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
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 {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn git_init(dir: &Path) {
run_git(dir, &["init", "-q"]);
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 git_commit_empty_on_path(dir: &Path, relpath: &str, content: &str, message: &str) {
let full = dir.join(relpath);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&full, content).unwrap();
run_git(dir, &["add", "-A"]);
run_git(dir, &["commit", "-q", "-m", message]);
}
fn single_crate_workspace(tmp: &Path) {
fs::write(
tmp.join("Cargo.toml"),
r#"[package]
name = "demo"
version = "0.1.0"
edition = "2024"
"#,
)
.unwrap();
}
fn two_crate_workspace(tmp: &Path) {
fs::write(
tmp.join("Cargo.toml"),
r#"[workspace]
members = ["crates/core", "crates/cli"]
resolver = "2"
"#,
)
.unwrap();
fs::create_dir_all(tmp.join("crates/core")).unwrap();
fs::create_dir_all(tmp.join("crates/cli")).unwrap();
fs::write(
tmp.join("crates/core/Cargo.toml"),
r#"[package]
name = "core"
version = "0.1.0"
edition = "2024"
"#,
)
.unwrap();
fs::write(
tmp.join("crates/cli/Cargo.toml"),
r#"[package]
name = "cli"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core", version = "0.1.0" }
"#,
)
.unwrap();
}
fn inheriting_workspace(tmp: &Path) {
fs::write(
tmp.join("Cargo.toml"),
r#"[workspace]
members = ["crates/a", "crates/b"]
resolver = "2"
[workspace.package]
version = "0.3.0"
"#,
)
.unwrap();
fs::create_dir_all(tmp.join("crates/a")).unwrap();
fs::create_dir_all(tmp.join("crates/b")).unwrap();
fs::write(
tmp.join("crates/a/Cargo.toml"),
r#"[package]
name = "a"
version.workspace = true
edition = "2024"
"#,
)
.unwrap();
fs::write(
tmp.join("crates/b/Cargo.toml"),
r#"[package]
name = "b"
version.workspace = true
edition = "2024"
"#,
)
.unwrap();
}
fn workspace_with_private_member(tmp: &Path) {
fs::write(
tmp.join("Cargo.toml"),
r#"[workspace]
members = ["crates/pub", "crates/priv"]
resolver = "2"
"#,
)
.unwrap();
fs::create_dir_all(tmp.join("crates/pub")).unwrap();
fs::create_dir_all(tmp.join("crates/priv")).unwrap();
fs::write(
tmp.join("crates/pub/Cargo.toml"),
r#"[package]
name = "pub"
version = "0.1.0"
edition = "2024"
"#,
)
.unwrap();
fs::write(
tmp.join("crates/priv/Cargo.toml"),
r#"[package]
name = "priv"
version = "0.1.0"
edition = "2024"
publish = false
"#,
)
.unwrap();
}
fn read_version(manifest: &Path) -> String {
let text = fs::read_to_string(manifest).unwrap();
for line in text.lines() {
if let Some(rest) = line.trim().strip_prefix("version")
&& let Some(eq) = rest.find('=')
{
let raw = rest[eq + 1..].trim();
if let Some(s) = raw
.trim_start_matches('"')
.split('"')
.next()
.filter(|s| !s.is_empty() && s.chars().next().unwrap().is_ascii_digit())
{
return s.to_string();
}
}
}
panic!("no version in {}", manifest.display());
}
#[test]
fn patch_bumps_single_crate() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(read_version(&tmp.path().join("Cargo.toml")), "0.1.1");
}
#[test]
fn minor_explicit_then_major() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
anodizer()
.current_dir(tmp.path())
.args(["bump", "minor", "-y"])
.status()
.unwrap();
assert_eq!(read_version(&tmp.path().join("Cargo.toml")), "0.2.0");
anodizer()
.current_dir(tmp.path())
.args(["bump", "major", "-y", "--allow-dirty"])
.status()
.unwrap();
assert_eq!(read_version(&tmp.path().join("Cargo.toml")), "1.0.0");
}
#[test]
fn dry_run_writes_nothing() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let before = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "minor", "--dry-run"])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("0.1.0"), "stdout missing 0.1.0: {stdout}");
assert!(stdout.contains("0.2.0"), "stdout missing 0.2.0: {stdout}");
let after = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert_eq!(before, after, "--dry-run should not touch the manifest");
}
#[test]
fn dry_run_json_is_parseable() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "minor", "--dry-run", "--output", "json"])
.output()
.unwrap();
assert!(
out.status.success(),
"json dry-run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout must be JSON");
let arr = v.as_array().expect("json root must be an array");
assert_eq!(arr.len(), 1);
let row = &arr[0];
assert_eq!(row["crate"], "demo");
assert_eq!(row["current"], "0.1.0");
assert_eq!(row["next"], "0.2.0");
assert_eq!(row["level"], "minor");
}
#[test]
fn dirty_tree_refused_without_allow_dirty() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
fs::write(tmp.path().join("dirty.txt"), "hello").unwrap();
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "-y"])
.output()
.unwrap();
assert!(
!out.status.success(),
"bump should refuse a dirty tree; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("uncommitted") || err.contains("dirty"),
"error should mention uncommitted changes: {err}"
);
}
#[test]
fn publish_false_skipped_from_workspace() {
let tmp = TempDir::new().unwrap();
workspace_with_private_member(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--workspace", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump --workspace failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
read_version(&tmp.path().join("crates/pub/Cargo.toml")),
"0.1.1"
);
assert_eq!(
read_version(&tmp.path().join("crates/priv/Cargo.toml")),
"0.1.0"
);
}
#[test]
fn workspace_package_inheritance_bumps_root_only() {
let tmp = TempDir::new().unwrap();
inheriting_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "minor", "--workspace", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump --workspace failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let root = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
root.contains("version = \"0.4.0\""),
"root should be bumped to 0.4.0: {root}"
);
let a = fs::read_to_string(tmp.path().join("crates/a/Cargo.toml")).unwrap();
assert!(a.contains("version.workspace = true"), "member a: {a}");
}
#[test]
fn exact_skips_dep_propagation() {
let tmp = TempDir::new().unwrap();
two_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "-p", "core", "--exact", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"{:?}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
read_version(&tmp.path().join("crates/core/Cargo.toml")),
"0.1.1"
);
let cli = fs::read_to_string(tmp.path().join("crates/cli/Cargo.toml")).unwrap();
assert!(
cli.contains("version = \"0.1.0\""),
"cli dep on core should NOT be rewritten under --exact: {cli}"
);
}
#[test]
fn propagation_rewrites_sibling_dep() {
let tmp = TempDir::new().unwrap();
two_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "-p", "core", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
read_version(&tmp.path().join("crates/core/Cargo.toml")),
"0.1.1"
);
assert_eq!(
read_version(&tmp.path().join("crates/cli/Cargo.toml")),
"0.1.0"
);
let cli = fs::read_to_string(tmp.path().join("crates/cli/Cargo.toml")).unwrap();
assert!(
cli.contains("version = \"0.1.1\""),
"cli dep on core should be rewritten: {cli}"
);
}
#[test]
fn bump_heals_stale_floor_on_unbumped_sibling() {
for (args, expected) in [
(vec!["bump", "patch", "-p", "core", "-y"], "0.5.0"),
(
vec!["bump", "patch", "-p", "core", "--exact", "-y"],
"0.1.0",
),
(
vec!["bump", "patch", "-p", "core", "-y", "--commit"],
"0.5.0",
),
] {
let tmp = TempDir::new().unwrap();
two_crate_workspace(tmp.path());
fs::create_dir_all(tmp.path().join("crates/util")).unwrap();
fs::write(
tmp.path().join("crates/util/Cargo.toml"),
"[package]\nname = \"util\"\nversion = \"0.5.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\", \"crates/cli\", \"crates/util\"]\nresolver = \"2\"\n",
)
.unwrap();
let cli_manifest = fs::read_to_string(tmp.path().join("crates/cli/Cargo.toml")).unwrap();
fs::write(
tmp.path().join("crates/cli/Cargo.toml"),
format!("{cli_manifest}util = {{ path = \"../util\", version = \"0.1.0\" }}\n"),
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(&args)
.output()
.unwrap();
assert!(
out.status.success(),
"{args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let cli = fs::read_to_string(tmp.path().join("crates/cli/Cargo.toml")).unwrap();
assert!(
cli.contains(&format!("path = \"../util\", version = \"{expected}\"")),
"{args:?}: util floor should read {expected}: {cli}"
);
if !args.contains(&"--commit") {
continue;
}
let status = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path()).args(["status", "--porcelain"]);
cmd
},
"git",
);
assert_eq!(
String::from_utf8_lossy(&status.stdout).trim(),
"",
"--commit must leave no manifest unstaged"
);
let show = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path())
.args(["show", "--name-only", "--format=", "HEAD"]);
cmd
},
"git",
);
let files = String::from_utf8_lossy(&show.stdout);
assert!(
files.lines().any(|l| l == "crates/cli/Cargo.toml"),
"the healed manifest must be inside the bump commit: {files}"
);
}
}
#[test]
fn commit_flag_creates_single_commit() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let before = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path())
.args(["rev-list", "--count", "HEAD"]);
cmd
},
"git",
);
let before_n: u32 = String::from_utf8_lossy(&before.stdout)
.trim()
.parse()
.unwrap();
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--commit", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump --commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let after = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path())
.args(["rev-list", "--count", "HEAD"]);
cmd
},
"git",
);
let after_n: u32 = String::from_utf8_lossy(&after.stdout)
.trim()
.parse()
.unwrap();
assert_eq!(after_n, before_n + 1, "exactly one new commit expected");
let msg = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path())
.args(["log", "-1", "--pretty=%B"]);
cmd
},
"git",
);
let msg = String::from_utf8_lossy(&msg.stdout);
assert!(
msg.contains("0.1.1"),
"commit message missing version: {msg}"
);
assert!(
msg.contains("demo"),
"commit message missing crate name: {msg}"
);
}
#[test]
fn infer_picks_per_crate_level_from_commits() {
let tmp = TempDir::new().unwrap();
two_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
run_git(tmp.path(), &["tag", "core-v0.1.0"]);
run_git(tmp.path(), &["tag", "cli-v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"crates/core/new_feature.rs",
"pub fn f() {}",
"feat(core): add new feature",
);
git_commit_empty_on_path(
tmp.path(),
"crates/cli/bugfix.rs",
"pub fn g() {}",
"fix(cli): correct bug",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "--workspace", "--dry-run", "--output", "json"])
.output()
.unwrap();
assert!(
out.status.success(),
"infer dry-run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let rows = v.as_array().unwrap();
let by_name: std::collections::HashMap<&str, &serde_json::Value> = rows
.iter()
.map(|r| (r["crate"].as_str().unwrap(), r))
.collect();
assert_eq!(by_name["core"]["level"], "minor");
assert_eq!(by_name["core"]["next"], "0.2.0");
assert_eq!(by_name["cli"]["level"], "patch");
assert_eq!(by_name["cli"]["next"], "0.1.1");
}
#[test]
fn infer_previews_minor_for_root_level_crate() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
git_commit_empty_on_path(
tmp.path(),
"feature.rs",
"pub fn f() {}",
"feat: add feature",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "--dry-run", "--output", "json"])
.output()
.unwrap();
assert!(
out.status.success(),
"root-crate infer dry-run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let row = &v.as_array().unwrap()[0];
assert_eq!(row["crate"], "demo");
assert_eq!(row["level"], "minor", "feat commit must preview minor: {v}");
assert_eq!(row["next"], "0.2.0");
}
#[test]
fn bump_warns_on_cargo_toml_defaults_fallback() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--dry-run"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump dry-run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no anodizer config found; using defaults from Cargo.toml"),
"missing Cargo.toml-fallback warn on stderr: {stderr}"
);
}
#[test]
fn multi_crate_without_selection_errors() {
let tmp = TempDir::new().unwrap();
two_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "-y"])
.output()
.unwrap();
assert!(
!out.status.success(),
"multi-crate bump without selection should error"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("-p") || err.contains("--workspace"),
"error should suggest -p or --workspace: {err}"
);
}
#[test]
fn release_strips_prerelease() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
r#"[package]
name = "demo"
version = "1.0.0-rc.1"
edition = "2024"
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "release", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"release bump failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(read_version(&tmp.path().join("Cargo.toml")), "1.0.0");
}
#[test]
fn pre_appends_prerelease() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "minor", "--pre", "rc.1", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"pre bump failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
text.contains("version = \"0.2.0-rc.1\""),
"expected 0.2.0-rc.1: {text}"
);
}
#[test]
fn commit_bundles_changelog_when_configured() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
fs::write(
tmp.path().join(".anodizer.yaml"),
r#"version: 2
project_name: demo
crates:
- name: demo
path: .
tag_template: "v{{ Version }}"
changelog:
sort: asc
groups:
- title: Features
regexp: "^feat"
order: 0
- title: Bug Fixes
regexp: "^fix"
order: 1
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "feat: groundwork before the release");
run_git(tmp.path(), &["tag", "v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"src/feature.rs",
"pub fn f() {}",
"feat: add a sparkly new feature",
);
let before = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path())
.args(["rev-list", "--count", "HEAD"]);
cmd
},
"git",
);
let before_n: u32 = String::from_utf8_lossy(&before.stdout)
.trim()
.parse()
.unwrap();
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--commit", "--changelog", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump --commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let after = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path())
.args(["rev-list", "--count", "HEAD"]);
cmd
},
"git",
);
let after_n: u32 = String::from_utf8_lossy(&after.stdout)
.trim()
.parse()
.unwrap();
assert_eq!(
after_n,
before_n + 1,
"exactly one new commit must include the bundled changelog"
);
let diff = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path()).args([
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
"HEAD",
]);
cmd
},
"git",
);
let names = String::from_utf8_lossy(&diff.stdout);
assert!(
names.lines().any(|l| l == "Cargo.toml"),
"commit must touch Cargo.toml: {names}"
);
assert!(
names.lines().any(|l| l == "CHANGELOG.md"),
"commit must touch CHANGELOG.md: {names}"
);
let cl = fs::read_to_string(tmp.path().join("CHANGELOG.md")).unwrap();
assert!(cl.contains("[0.1.1]"), "changelog missing version: {cl}");
assert!(
cl.contains("sparkly new feature"),
"changelog missing feat description: {cl}"
);
assert!(
!cl.contains("groundwork before the release"),
"section must exclude pre-v0.1.0 history (from_tag resolved wrong): {cl}"
);
let msg = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path())
.args(["log", "-1", "--pretty=%B"]);
cmd
},
"git",
);
let msg = String::from_utf8_lossy(&msg.stdout);
assert!(
msg.contains("changelog regenerated for demo@0.1.1"),
"bump commit must record the changelog provenance marker: {msg}"
);
}
#[test]
fn commit_default_no_flag_skips_changelog() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
fs::write(
tmp.path().join(".anodizer.yaml"),
r#"version: 2
project_name: demo
crates:
- name: demo
path: .
tag_template: "v{{ Version }}"
changelog:
sort: asc
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
run_git(tmp.path(), &["tag", "v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"src/feature.rs",
"pub fn f() {}",
"feat: add a sparkly new feature",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--commit", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump --commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!tmp.path().join("CHANGELOG.md").exists(),
"without --changelog, bump --commit must not write a CHANGELOG.md"
);
let diff = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path()).args([
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
"HEAD",
]);
cmd
},
"git",
);
let names = String::from_utf8_lossy(&diff.stdout);
assert!(
names.lines().any(|l| l == "Cargo.toml"),
"commit must still touch Cargo.toml: {names}"
);
assert!(
!names.lines().any(|l| l == "CHANGELOG.md"),
"without --changelog, commit must NOT touch CHANGELOG.md: {names}"
);
}
#[test]
fn bump_changelog_v_template_resolves_prefix_and_range() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
fs::write(
tmp.path().join(".anodizer.yaml"),
r#"version: 2
project_name: demo
crates:
- name: demo
path: .
tag_template: "v{{ Version }}"
changelog:
sort: asc
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "feat: pre-release groundwork");
run_git(tmp.path(), &["tag", "v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"src/added.rs",
"pub fn g() {}",
"feat: shiny post-tag addition",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--commit", "--changelog", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump --commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let cl = fs::read_to_string(tmp.path().join("CHANGELOG.md")).unwrap();
assert!(
cl.contains("## [0.1.1]"),
"expected a bare-version heading `## [0.1.1]`: {cl}"
);
assert!(
!cl.contains("## [demo-v"),
"heading must not carry the {{crate}}-v prefix: {cl}"
);
assert!(
cl.contains("shiny post-tag addition"),
"post-tag feat must be in the section: {cl}"
);
assert!(
!cl.contains("pre-release groundwork"),
"pre-v0.1.0 history must be excluded (prefix resolved to `v`): {cl}"
);
}
#[test]
fn bump_changelog_unset_template_resolves_name_v_prefix_and_range() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
fs::write(
tmp.path().join(".anodizer.yaml"),
r#"version: 2
project_name: demo
crates:
- name: demo
path: .
changelog:
sort: asc
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "feat: pre-release groundwork");
run_git(tmp.path(), &["tag", "demo-v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"src/added.rs",
"pub fn g() {}",
"feat: shiny post-tag addition",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--commit", "--changelog", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump --commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let cl = fs::read_to_string(tmp.path().join("CHANGELOG.md")).unwrap();
assert!(
cl.contains("## [0.1.1]"),
"expected a bare-version heading `## [0.1.1]`: {cl}"
);
assert!(
cl.contains("shiny post-tag addition"),
"post-tag feat must be in the section: {cl}"
);
assert!(
!cl.contains("pre-release groundwork"),
"pre-demo-v0.1.0 history must be excluded — an UNSET tag_template \
must resolve the {{name}}-v prefix family (matching tag/changelog), \
not silently collapse to the bare 'v' built-in default: {cl}"
);
}
#[test]
fn commit_skips_changelog_when_skip_true() {
let tmp = TempDir::new().unwrap();
single_crate_workspace(tmp.path());
fs::write(
tmp.path().join(".anodizer.yaml"),
r#"version: 2
project_name: demo
crates:
- name: demo
path: .
tag_template: "v{{ Version }}"
changelog:
skip: true
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
run_git(tmp.path(), &["tag", "demo-v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"src/feature.rs",
"pub fn f() {}",
"feat: add a sparkly new feature",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "patch", "--commit", "--changelog", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"bump --commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!tmp.path().join("CHANGELOG.md").exists(),
"changelog.skip: true must not write a CHANGELOG.md"
);
let diff = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(tmp.path()).args([
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
"HEAD",
]);
cmd
},
"git",
);
let names = String::from_utf8_lossy(&diff.stdout);
assert!(
names.lines().any(|l| l == "Cargo.toml"),
"commit must still touch Cargo.toml: {names}"
);
assert!(
!names.lines().any(|l| l == "CHANGELOG.md"),
"commit must NOT touch CHANGELOG.md when skipped: {names}"
);
}
#[test]
fn inference_respects_tag_template_from_anodizer_yaml() {
let tmp = TempDir::new().unwrap();
two_crate_workspace(tmp.path());
fs::write(
tmp.path().join(".anodizer.yaml"),
r#"version: 2
project_name: tag-template-fixture
crates:
- name: core
path: crates/core
tag_template: "core-v{{ Version }}"
- name: cli
path: crates/cli
tag_template: "v{{ Version }}"
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
run_git(tmp.path(), &["tag", "core-v0.1.0"]);
run_git(tmp.path(), &["tag", "v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"crates/core/feature.rs",
"pub fn f() {}",
"feat(core): add feature",
);
git_commit_empty_on_path(
tmp.path(),
"crates/cli/notes.rs",
"// notes",
"chore(cli): housekeeping",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "--workspace", "--dry-run", "--output", "json"])
.output()
.unwrap();
assert!(
out.status.success(),
"infer dry-run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let by_name: std::collections::HashMap<&str, &serde_json::Value> = v
.as_array()
.unwrap()
.iter()
.map(|r| (r["crate"].as_str().unwrap(), r))
.collect();
assert_eq!(
by_name["cli"]["level"], "skip",
"cli should skip — no feat/fix since v0.1.0"
);
let cli_reason = by_name["cli"]["reason"].as_str().unwrap();
assert!(
cli_reason.contains("since v0.1.0"),
"cli reason must reference the actual v0.1.0 tag, not <name>-v fallback: {cli_reason}"
);
assert_eq!(by_name["core"]["level"], "minor");
assert_eq!(by_name["core"]["next"], "0.2.0");
let core_reason = by_name["core"]["reason"].as_str().unwrap();
assert!(
core_reason.contains("since core-v0.1.0"),
"core reason must reference core-v0.1.0: {core_reason}"
);
}
#[test]
fn inference_unset_template_resolves_name_v_prefix_not_bare_v() {
let tmp = TempDir::new().unwrap();
two_crate_workspace(tmp.path());
fs::write(
tmp.path().join(".anodizer.yaml"),
r#"version: 2
project_name: tag-template-fixture
crates:
- name: core
path: crates/core
- name: cli
path: crates/cli
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
run_git(tmp.path(), &["tag", "core-v0.1.0"]);
run_git(tmp.path(), &["tag", "cli-v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"crates/core/feature.rs",
"pub fn f() {}",
"feat(core): add feature",
);
git_commit_empty_on_path(
tmp.path(),
"crates/cli/notes.rs",
"// notes",
"chore(cli): housekeeping",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "--workspace", "--dry-run", "--output", "json"])
.output()
.unwrap();
assert!(
out.status.success(),
"infer dry-run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let by_name: std::collections::HashMap<&str, &serde_json::Value> = v
.as_array()
.unwrap()
.iter()
.map(|r| (r["crate"].as_str().unwrap(), r))
.collect();
assert_eq!(by_name["core"]["level"], "minor");
assert_eq!(by_name["core"]["next"], "0.2.0");
let core_reason = by_name["core"]["reason"].as_str().unwrap();
assert!(
core_reason.contains("since core-v0.1.0"),
"core reason must reference core-v0.1.0 (the {{name}}-v convention \
family), not fall back to a bare-v scan of full history: {core_reason}"
);
assert_eq!(
by_name["cli"]["level"], "skip",
"cli should skip — no feat/fix since cli-v0.1.0"
);
let cli_reason = by_name["cli"]["reason"].as_str().unwrap();
assert!(
cli_reason.contains("since cli-v0.1.0"),
"cli reason must reference cli-v0.1.0, not a bare-v fallback: {cli_reason}"
);
}
#[test]
fn bump_discovers_non_dot_anodizer_yaml_config() {
let tmp = TempDir::new().unwrap();
two_crate_workspace(tmp.path());
fs::write(
tmp.path().join("anodizer.yaml"),
r#"version: 2
project_name: non-dot-config-fixture
crates:
- name: cli
path: crates/cli
tag_template: "v{{ Version }}"
"#,
)
.unwrap();
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
run_git(tmp.path(), &["tag", "v0.1.0"]);
git_commit_empty_on_path(
tmp.path(),
"crates/cli/feature.rs",
"pub fn f() {}",
"feat(cli): add feature",
);
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "--workspace", "--dry-run", "--output", "json"])
.output()
.unwrap();
assert!(
out.status.success(),
"infer dry-run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let cli_row = v
.as_array()
.unwrap()
.iter()
.find(|r| r["crate"] == "cli")
.expect("cli row");
assert_eq!(cli_row["level"], "minor");
let reason = cli_row["reason"].as_str().unwrap();
assert!(
reason.contains("since v0.1.0"),
"anodizer.yaml's tag_template must be honored (range bounded at \
v0.1.0, not the cli-v fallback family): {reason}"
);
}
fn pinned_two_crate_workspace(tmp: &Path, pin_core: bool) {
two_crate_workspace(tmp);
let core_pin = if pin_core {
" version: \"0.1.0\"\n"
} else {
""
};
let yaml = format!(
r#"version: 2
project_name: pinned
crates:
- name: core
path: crates/core
tag_template: "v{{{{ Version }}}}"
{core_pin} - name: cli
path: crates/cli
tag_template: "v{{{{ Version }}}}"
"#,
core_pin = core_pin,
);
fs::write(tmp.join(".anodizer.yaml"), yaml).unwrap();
}
#[test]
fn strict_refuses_bump_when_version_pinned() {
let tmp = TempDir::new().unwrap();
pinned_two_crate_workspace(tmp.path(), true);
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["--strict", "bump", "minor", "-p", "core", "-y"])
.output()
.unwrap();
assert!(
!out.status.success(),
"strict bump should fail when version is pinned; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("core"), "error must mention crate name: {err}");
assert!(
err.contains("0.1.0"),
"error must mention pinned version: {err}"
);
assert!(
err.contains("0.2.0"),
"error must mention proposed version: {err}"
);
assert_eq!(
read_version(&tmp.path().join("crates/core/Cargo.toml")),
"0.1.0"
);
}
#[test]
fn strict_allows_bump_when_no_pin() {
let tmp = TempDir::new().unwrap();
pinned_two_crate_workspace(tmp.path(), false);
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["--strict", "bump", "minor", "-p", "core", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"strict bump should succeed without a pin; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
read_version(&tmp.path().join("crates/core/Cargo.toml")),
"0.2.0"
);
}
#[test]
fn non_strict_warns_but_proceeds() {
let tmp = TempDir::new().unwrap();
pinned_two_crate_workspace(tmp.path(), true);
git_init(tmp.path());
git_add_commit(tmp.path(), "initial");
let out = anodizer()
.current_dir(tmp.path())
.args(["bump", "minor", "-p", "core", "-y"])
.output()
.unwrap();
assert!(
out.status.success(),
"non-strict bump should proceed despite pin; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.to_lowercase().contains("warn"),
"stderr must include a warning when bumping a pinned crate: {err}"
);
assert_eq!(
read_version(&tmp.path().join("crates/core/Cargo.toml")),
"0.2.0"
);
}