use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use anodizer_core::test_helpers::{create_config, create_test_project, init_git_repo};
#[test]
fn test_check_valid_config() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"check should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_invalid_config() {
let tmp = TempDir::new().unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("no anodizer config file found"));
}
#[test]
fn test_init_generates_config() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("init")
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"init should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Created .anodizer.yaml"));
let config_content =
fs::read_to_string(tmp.path().join(".anodizer.yaml")).expect(".anodizer.yaml should exist");
assert!(config_content.contains("project_name:"));
assert!(config_content.contains("test-project"));
assert!(config_content.contains("tag_template:"));
let gitignore =
fs::read_to_string(tmp.path().join(".gitignore")).expect(".gitignore should exist");
assert!(
gitignore.contains("dist/"),
".gitignore should contain dist/"
);
}
#[test]
fn test_help_output() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("--help")
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("release"));
assert!(stdout.contains("build"));
assert!(stdout.contains("check"));
assert!(stdout.contains("init"));
assert!(stdout.contains("changelog"));
assert!(
stdout.contains("completion"),
"help should list completion command"
);
assert!(
stdout.contains("healthcheck"),
"help should list healthcheck command"
);
}
#[test]
fn test_version_output() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("--version")
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("anodizer"));
}
#[test]
fn test_check_with_config_flag() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let custom_dir = tmp.path().join("configs");
fs::create_dir_all(&custom_dir).unwrap();
let config_path = custom_dir.join("release.yaml");
fs::write(
&config_path,
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["-f", config_path.to_str().unwrap(), "check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"check -f should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_with_config_flag_long() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let config_path = tmp.path().join("my-anodizer.yaml");
fs::write(
&config_path,
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["--config", config_path.to_str().unwrap(), "check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"check --config should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_with_config_flag_nonexistent() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["-f", "/tmp/does-not-exist-anodizer.yaml", "check", "config"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("config file not found"),
"expected 'config file not found' error, got: {}",
stderr
);
}
#[test]
fn test_release_help_shows_timeout_flag() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--help"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--timeout"),
"release --help should show --timeout flag, got: {}",
stdout
);
}
#[test]
fn test_build_help_shows_timeout_flag() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["build", "--help"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--timeout"),
"build --help should show --timeout flag, got: {}",
stdout
);
}
#[test]
fn test_timeout_kills_long_running_release() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
before:
hooks:
- "sleep 60"
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(tmp.path())
.status()
.ok();
std::process::Command::new("git")
.args(["commit", "--amend", "--no-edit"])
.current_dir(tmp.path())
.status()
.ok();
std::process::Command::new("git")
.args(["tag", "-f", "v0.1.0"])
.current_dir(tmp.path())
.status()
.ok();
let start = Instant::now();
let mut child = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--timeout", "1s"])
.current_dir(tmp.path())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let poll_deadline = Instant::now() + Duration::from_secs(10);
let exit_status = loop {
match child.try_wait().unwrap() {
Some(status) => break status,
None => {
if Instant::now() > poll_deadline {
child.kill().ok();
panic!("anodizer process did not exit within 10s (timeout was 1s)");
}
std::thread::sleep(Duration::from_millis(100));
}
}
};
let elapsed = start.elapsed();
assert!(
!exit_status.success(),
"release with 1s timeout on a 60s sleep should fail"
);
assert_eq!(
exit_status.code(),
Some(124),
"expected exit code 124 for timeout, got {:?}",
exit_status.code()
);
assert!(
elapsed < Duration::from_secs(10),
"process should have been killed by timeout quickly, but took {:?}",
elapsed
);
}
#[test]
fn test_man_renders_roff() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("man")
.output()
.unwrap();
assert!(
output.status.success(),
"man should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.is_empty(), "man output should not be empty");
assert!(
stdout.starts_with(".ie ") || stdout.contains(".TH anodizer"),
"man output should be roff (start with .ie or contain .TH anodizer), got first 200 bytes: {}",
&stdout.chars().take(200).collect::<String>()
);
assert!(
stdout.contains("anodizer"),
"man output should reference the program name 'anodizer'"
);
assert!(
stdout.contains("release"),
"man output should reference the 'release' subcommand"
);
}
#[test]
fn test_completion_bash_produces_output() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["completion", "bash"])
.output()
.unwrap();
assert!(
output.status.success(),
"completion bash should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.is_empty(), "bash completions should not be empty");
assert!(
stdout.contains("anodizer"),
"bash completions should reference 'anodizer'"
);
}
#[test]
fn test_completion_zsh_produces_output() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["completion", "zsh"])
.output()
.unwrap();
assert!(output.status.success(), "completion zsh should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.is_empty(), "zsh completions should not be empty");
}
#[test]
fn test_healthcheck_succeeds() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("healthcheck")
.output()
.unwrap();
assert!(
output.status.success(),
"healthcheck should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Health Check"),
"healthcheck should print header"
);
assert!(stderr.contains("cargo"), "healthcheck should check cargo");
}
#[test]
fn test_release_help_shows_new_flags() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--help"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--parallelism"),
"release --help should show --parallelism: {}",
stdout
);
assert!(
stdout.contains("--auto-snapshot"),
"release --help should show --auto-snapshot: {}",
stdout
);
assert!(
stdout.contains("--single-target"),
"release --help should show --single-target: {}",
stdout
);
assert!(
stdout.contains("--release-notes"),
"release --help should show --release-notes: {}",
stdout
);
}
#[test]
fn test_build_help_shows_new_flags() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["build", "--help"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--parallelism"),
"build --help should show --parallelism: {}",
stdout
);
assert!(
stdout.contains("--single-target"),
"build --help should show --single-target: {}",
stdout
);
}
#[test]
fn test_release_invalid_timeout_value() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--timeout", "notavalidtimeout"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid --timeout value"),
"stderr should report invalid timeout, got: {}",
stderr
);
}
fn detect_host_target() -> String {
anodizer_cli::detect_host_target().expect("failed to detect host target triple")
}
fn create_workspace_project(dir: &Path) {
fs::write(
dir.join("Cargo.toml"),
r#"[workspace]
resolver = "2"
members = ["crates/core-lib", "crates/helper-lib", "crates/myapp"]
"#,
)
.unwrap();
let core_dir = dir.join("crates/core-lib");
fs::create_dir_all(core_dir.join("src")).unwrap();
fs::write(
core_dir.join("Cargo.toml"),
r#"[package]
name = "core-lib"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
fs::write(
core_dir.join("src/lib.rs"),
r#"pub fn core_fn() -> &'static str { "core" }"#,
)
.unwrap();
let helper_dir = dir.join("crates/helper-lib");
fs::create_dir_all(helper_dir.join("src")).unwrap();
fs::write(
helper_dir.join("Cargo.toml"),
r#"[package]
name = "helper-lib"
version = "0.1.0"
edition = "2021"
[dependencies]
core-lib = { path = "../core-lib" }
"#,
)
.unwrap();
fs::write(
helper_dir.join("src/lib.rs"),
r#"pub fn helper_fn() -> String { format!("helper+{}", core_lib::core_fn()) }"#,
)
.unwrap();
let app_dir = dir.join("crates/myapp");
fs::create_dir_all(app_dir.join("src")).unwrap();
fs::write(
app_dir.join("Cargo.toml"),
r#"[package]
name = "myapp"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "myapp"
path = "src/main.rs"
[dependencies]
core-lib = { path = "../core-lib" }
helper-lib = { path = "../helper-lib" }
"#,
)
.unwrap();
fs::write(
app_dir.join("src/main.rs"),
r#"fn main() { println!("{}", helper_lib::helper_fn()); }"#,
)
.unwrap();
}
fn create_single_crate_snapshot_config(host: &str) -> String {
format!(
r#"project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "{{{{ .ProjectName }}}}-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
)
}
fn create_workspace_snapshot_config(host: &str) -> String {
format!(
r#"project_name: my-workspace
crates:
- name: core-lib
path: "crates/core-lib"
tag_template: "core-lib-v{{{{ .Version }}}}"
- name: helper-lib
path: "crates/helper-lib"
tag_template: "helper-lib-v{{{{ .Version }}}}"
depends_on:
- core-lib
- name: myapp
path: "crates/myapp"
tag_template: "myapp-v{{{{ .Version }}}}"
depends_on:
- core-lib
- helper-lib
builds:
- binary: myapp
targets:
- {host}
archives:
- name_template: "myapp-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
)
}
#[test]
fn test_e2e_snapshot_release_produces_artifacts() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = create_single_crate_snapshot_config(&host);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"snapshot release should succeed.\nstderr:\n{}",
stderr
);
let dist_dir = tmp.path().join("dist");
assert!(
dist_dir.exists(),
"dist/ directory should exist after snapshot release"
);
let entries: Vec<_> = fs::read_dir(&dist_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let has_archive = entries.iter().any(|name| name.ends_with(".tar.gz"));
assert!(
has_archive,
"dist/ should contain a .tar.gz archive, found: {:?}",
entries
);
let has_checksum = entries.iter().any(|name| name == "checksums.txt");
assert!(
has_checksum,
"dist/ should contain checksums.txt, found: {:?}",
entries
);
let checksum_content = fs::read_to_string(dist_dir.join("checksums.txt")).unwrap();
assert!(
!checksum_content.trim().is_empty(),
"checksums.txt should not be empty"
);
assert!(
checksum_content.lines().any(|line| line.len() > 64),
"checksums.txt should contain hash lines, got: {}",
checksum_content
);
let has_metadata = entries.iter().any(|name| name == "metadata.json");
assert!(
has_metadata,
"dist/ should contain metadata.json, found: {:?}",
entries
);
}
#[test]
fn test_release_prepare_matches_explicit_skip() {
let host = detect_host_target();
fn extract_skipped_stages(stderr: &str) -> std::collections::BTreeSet<String> {
stderr
.lines()
.filter_map(|line| {
let line = strip_ansi(line);
let after_prefix = line.trim_start().trim_start_matches("[release]").trim();
after_prefix
.strip_suffix(" skipped")
.map(|name| name.to_string())
})
.collect()
}
fn run_release(tmp: &Path, extra_args: &[&str]) -> std::process::Output {
let mut args: Vec<&str> = vec![
"release",
"--snapshot",
"--dry-run",
"--skip=build,archive,checksum,docker,sign,nfpm,changelog,sbom",
"--timeout",
"2m",
];
args.extend_from_slice(extra_args);
Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(&args)
.current_dir(tmp)
.output()
.unwrap()
}
fn setup_fixture(tmp: &Path, host: &str) {
create_test_project(tmp);
init_git_repo(tmp);
let config = create_single_crate_snapshot_config(host);
create_config(tmp, &config);
}
let tmp_prepare = TempDir::new().unwrap();
setup_fixture(tmp_prepare.path(), &host);
let out_prepare = run_release(tmp_prepare.path(), &["--prepare"]);
assert!(
out_prepare.status.success(),
"release --prepare should succeed.\nstderr:\n{}",
String::from_utf8_lossy(&out_prepare.stderr)
);
let stderr_prepare = String::from_utf8_lossy(&out_prepare.stderr).into_owned();
let skipped_prepare = extract_skipped_stages(&stderr_prepare);
let tmp_explicit = TempDir::new().unwrap();
setup_fixture(tmp_explicit.path(), &host);
let out_explicit = run_release(tmp_explicit.path(), &["--skip=release,publish,announce"]);
assert!(
out_explicit.status.success(),
"release --skip=release,publish,announce should succeed.\nstderr:\n{}",
String::from_utf8_lossy(&out_explicit.stderr)
);
let stderr_explicit = String::from_utf8_lossy(&out_explicit.stderr).into_owned();
let skipped_explicit = extract_skipped_stages(&stderr_explicit);
for stage in ["release", "publish", "announce"] {
assert!(
skipped_prepare.contains(stage),
"--prepare run should report '{stage} skipped' in stderr, got skipped set {:?}\nfull stderr:\n{}",
skipped_prepare,
stderr_prepare
);
assert!(
skipped_explicit.contains(stage),
"explicit --skip run should report '{stage} skipped' in stderr, got skipped set {:?}\nfull stderr:\n{}",
skipped_explicit,
stderr_explicit
);
}
assert_eq!(
skipped_prepare, skipped_explicit,
"release --prepare must yield the same skip-stage set as \
--skip=release,publish,announce\n\
--prepare skipped: {:?}\n--skip skipped: {:?}",
skipped_prepare, skipped_explicit
);
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next(); for c in chars.by_ref() {
if ('@'..='~').contains(&c) {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn test_e2e_dry_run_no_side_effects() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = create_single_crate_snapshot_config(&host);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"dry-run release should succeed.\nstderr:\n{}",
stderr
);
let dist_dir = tmp.path().join("dist");
if dist_dir.exists() {
let entries: Vec<_> = fs::read_dir(&dist_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let has_archives = entries
.iter()
.any(|name| name.ends_with(".tar.gz") || name.ends_with(".zip"));
assert!(
!has_archives,
"dist/ should NOT contain archives after dry-run, found: {:?}",
entries
);
let has_checksums = entries
.iter()
.any(|name| name.contains("checksum") || name.ends_with(".txt"));
assert!(
!has_checksums,
"dist/ should NOT contain checksum files after dry-run, found: {:?}",
entries
);
}
assert!(
stderr.contains("dry-run"),
"stderr should mention dry-run, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_check_comprehensive_config() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"project_name: test-project
dist: ./dist
report_sizes: true
env:
- BUILD_ENV=ci
- DEPLOY_TARGET=staging
defaults:
targets:
- x86_64-unknown-linux-gnu
- aarch64-unknown-linux-gnu
cross: auto
archives:
formats: [tar.gz]
format_overrides:
- os: windows
formats: [zip]
checksum:
algorithm: sha256
signs:
- id: gpg-sign
artifacts: checksum
cmd: gpg
args:
- "--detach-sig"
publishers:
- name: custom-upload
cmd: echo
args:
- "published"
artifact_types:
- archive
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: test-project
targets:
- x86_64-unknown-linux-gnu
archives:
- name_template: "test-project-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
docker:
- image_templates:
- "myregistry/test-project:{{ .Version }}"
dockerfile: Dockerfile
nfpm:
- formats:
- deb
package_name: test-project
description: "A test project"
changelog:
sort: asc
groups:
- title: Features
regexp: "^feat"
order: 0
- title: Bug Fixes
regexp: "^fix"
order: 1
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"check with comprehensive config should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("Config is valid"),
"stderr should confirm config is valid, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_init_generates_parseable_yaml() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("init")
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"init should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let config_content = fs::read_to_string(tmp.path().join(".anodizer.yaml"))
.expect(".anodizer.yaml should exist after init");
let parsed: serde_yaml_ng::Value =
serde_yaml_ng::from_str(&config_content).unwrap_or_else(|e| {
panic!(
"init output should be valid YAML.\nParse error: {}\nOutput:\n{}",
e, config_content
);
});
let map = parsed
.as_mapping()
.expect("parsed YAML should be a mapping");
assert!(
map.contains_key(serde_yaml_ng::Value::String("project_name".to_string())),
"YAML should contain project_name"
);
assert!(
map.contains_key(serde_yaml_ng::Value::String("crates".to_string())),
"YAML should contain crates"
);
assert!(
map.contains_key(serde_yaml_ng::Value::String("defaults".to_string())),
"YAML should contain defaults"
);
let project_name = map
.get(serde_yaml_ng::Value::String("project_name".to_string()))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
project_name, "test-project",
"project_name should be test-project"
);
let crates = map
.get(serde_yaml_ng::Value::String("crates".to_string()))
.and_then(|v| v.as_sequence())
.expect("crates should be an array");
assert!(!crates.is_empty(), "crates array should not be empty");
let tmp2 = TempDir::new().unwrap();
create_test_project(tmp2.path());
init_git_repo(tmp2.path());
create_config(tmp2.path(), &config_content);
let check_output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp2.path())
.output()
.unwrap();
assert!(
check_output.status.success(),
"generated config should pass validation.\nstderr:\n{}",
String::from_utf8_lossy(&check_output.stderr)
);
}
#[test]
fn test_e2e_workspace_all_force_detects_crates() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_workspace_project(tmp.path());
init_git_repo(tmp.path());
let config = create_workspace_snapshot_config(&host);
create_config(tmp.path(), &config);
let check_output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
check_output.status.success(),
"workspace config check should succeed.\nstderr:\n{}",
String::from_utf8_lossy(&check_output.stderr)
);
let release_output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--all",
"--force",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&release_output.stderr);
assert!(
release_output.status.success(),
"workspace dry-run release should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("core-lib"),
"stderr should mention core-lib crate, got:\n{}",
stderr
);
assert!(
stderr.contains("helper-lib"),
"stderr should mention helper-lib crate, got:\n{}",
stderr
);
assert!(
stderr.contains("myapp"),
"stderr should mention myapp crate, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_workspace_snapshot_produces_artifacts() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_workspace_project(tmp.path());
init_git_repo(tmp.path());
let config = create_workspace_snapshot_config(&host);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--all",
"--force",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"workspace snapshot release should succeed.\nstderr:\n{}",
stderr
);
let dist_dir = tmp.path().join("dist");
assert!(
dist_dir.exists(),
"dist/ should exist after workspace snapshot release"
);
let entries: Vec<_> = fs::read_dir(&dist_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let has_archive = entries
.iter()
.any(|name| name.contains("myapp") && name.ends_with(".tar.gz"));
assert!(
has_archive,
"dist/ should contain a myapp .tar.gz archive, found: {:?}",
entries
);
let has_checksum = entries.iter().any(|name| name == "checksums.txt");
assert!(
has_checksum,
"dist/ should contain checksums.txt, found: {:?}",
entries
);
let has_metadata = entries.iter().any(|name| name == "metadata.json");
assert!(
has_metadata,
"dist/ should contain metadata.json, found: {:?}",
entries
);
}
#[test]
fn test_e2e_init_workspace_generates_depends_on() {
let tmp = TempDir::new().unwrap();
create_workspace_project(tmp.path());
init_git_repo(tmp.path());
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("init")
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"init on workspace should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let config_content = fs::read_to_string(tmp.path().join(".anodizer.yaml"))
.expect(".anodizer.yaml should exist after init");
assert!(
config_content.contains("core-lib"),
"init output should mention core-lib"
);
assert!(
config_content.contains("helper-lib"),
"init output should mention helper-lib"
);
assert!(
config_content.contains("myapp"),
"init output should mention myapp"
);
assert!(
config_content.contains("depends_on"),
"init output should include depends_on for workspace deps"
);
let core_pos = config_content
.find("name: core-lib")
.expect("core-lib should appear");
let app_pos = config_content
.find("name: myapp")
.expect("myapp should appear");
assert!(
core_pos < app_pos,
"core-lib should appear before myapp (topological order)"
);
let _parsed: serde_yaml_ng::Value =
serde_yaml_ng::from_str(&config_content).unwrap_or_else(|e| {
panic!(
"workspace init output should be valid YAML.\nParse error: {}\nOutput:\n{}",
e, config_content
);
});
}
#[test]
fn test_e2e_check_workspace_invalid_depends_on() {
let tmp = TempDir::new().unwrap();
create_workspace_project(tmp.path());
create_config(
tmp.path(),
r#"project_name: my-workspace
crates:
- name: core-lib
path: "crates/core-lib"
tag_template: "core-lib-v{{ .Version }}"
- name: myapp
path: "crates/myapp"
tag_template: "myapp-v{{ .Version }}"
depends_on:
- nonexistent-crate
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"check should fail for invalid depends_on reference"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("nonexistent-crate") && stderr.contains("does not exist"),
"error should mention the missing dependency, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_workspace_change_detection_without_force() {
let tmp = TempDir::new().unwrap();
create_workspace_project(tmp.path());
let git = |args: &[&str]| {
let output = Command::new("git")
.args(args)
.current_dir(tmp.path())
.output()
.expect("git command failed");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
output
};
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
create_config(
tmp.path(),
r#"project_name: my-workspace
crates:
- name: core-lib
path: "crates/core-lib"
tag_template: "core-lib-v{{ .Version }}"
- name: helper-lib
path: "crates/helper-lib"
tag_template: "helper-lib-v{{ .Version }}"
depends_on:
- core-lib
- name: myapp
path: "crates/myapp"
tag_template: "myapp-v{{ .Version }}"
depends_on:
- core-lib
- helper-lib
"#,
);
git(&["add", "-A"]);
git(&["commit", "-m", "initial workspace"]);
git(&["tag", "core-lib-v0.1.0"]);
git(&["tag", "helper-lib-v0.1.0"]);
git(&["tag", "myapp-v0.1.0"]);
fs::write(
tmp.path().join("crates/core-lib/src/lib.rs"),
r#"pub fn core_fn() -> &'static str { "core-modified" }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "modify core-lib only"]);
let release_output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--snapshot",
"--all",
"--single-target",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&release_output.stderr);
assert!(
release_output.status.success(),
"workspace dry-run release (change detection) should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("core-lib"),
"stderr should mention core-lib (the changed crate), got:\n{}",
stderr
);
assert!(
stderr.contains("helper-lib"),
"stderr should mention helper-lib (depends on changed core-lib), got:\n{}",
stderr
);
assert!(
stderr.contains("myapp"),
"stderr should mention myapp (depends on changed core-lib), got:\n{}",
stderr
);
}
#[test]
fn test_check_malformed_yaml_reports_parse_error() {
let tmp = TempDir::new().unwrap();
create_config(
tmp.path(),
r#"
project_name: test
crates:
- name: a
path: "."
tag_template: [[[invalid yaml
this is broken
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"check with malformed YAML should fail"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("error")
|| stderr.contains("Error")
|| stderr.contains("parse")
|| stderr.contains("invalid"),
"stderr should indicate a parse error, got:\n{}",
stderr
);
}
#[test]
fn test_check_type_mismatch_crates_not_array() {
let tmp = TempDir::new().unwrap();
create_config(
tmp.path(),
r#"
project_name: test
crates: "this should be an array not a string"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"check with type mismatch should fail"
);
}
#[test]
fn test_skip_flag_skips_specified_stages() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let host = detect_host_target();
let config = format!(
r#"project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "{{{{ .ProjectName }}}}-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--skip=build,archive,checksum,release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"30s",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"release with all stages skipped should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("skipped"),
"stderr should mention 'skipped' when stages are skipped, got:\n{}",
stderr
);
}
#[test]
fn test_skip_brew_accepted() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--skip=brew",
"--dry-run",
"--snapshot",
"--single-target",
"--clean",
"--timeout",
"30s",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("invalid --skip value"),
"--skip=brew should be accepted, got:\n{}",
stderr
);
}
#[test]
fn test_skip_choco_accepted() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--skip=choco",
"--dry-run",
"--snapshot",
"--single-target",
"--clean",
"--timeout",
"30s",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("invalid --skip value"),
"--skip=choco should be accepted, got:\n{}",
stderr
);
}
#[test]
fn test_skip_cargo_accepted() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--skip=cargo",
"--dry-run",
"--snapshot",
"--single-target",
"--clean",
"--timeout",
"30s",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("invalid --skip value"),
"--skip=cargo should be accepted, got:\n{}",
stderr
);
}
#[test]
fn test_skip_krew_accepted() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--skip=krew",
"--dry-run",
"--snapshot",
"--single-target",
"--clean",
"--timeout",
"30s",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("invalid --skip value"),
"--skip=krew should be accepted, got:\n{}",
stderr
);
}
#[test]
fn test_skip_homebrew_alias_rejected() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--skip=homebrew",
"--dry-run",
"--snapshot",
"--single-target",
"--clean",
"--timeout",
"30s",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"--skip=homebrew should be rejected (use --skip=brew); command unexpectedly succeeded"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid --skip value"),
"--skip=homebrew rejection should mention invalidity, got:\n{}",
stderr
);
}
#[test]
fn test_skip_chocolatey_alias_rejected() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--skip=chocolatey",
"--dry-run",
"--snapshot",
"--single-target",
"--clean",
"--timeout",
"30s",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"--skip=chocolatey should be rejected (use --skip=choco); command unexpectedly succeeded"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid --skip value"),
"--skip=chocolatey rejection should mention invalidity, got:\n{}",
stderr
);
}
#[test]
fn test_skip_crates_alias_rejected() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--skip=crates",
"--dry-run",
"--snapshot",
"--single-target",
"--clean",
"--timeout",
"30s",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"--skip=crates should be rejected (use --skip=cargo); command unexpectedly succeeded"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid --skip value"),
"--skip=crates rejection should mention invalidity, got:\n{}",
stderr
);
}
#[test]
fn test_check_empty_crate_name_rejected() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: ""
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"check should fail for empty crate name"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("name must not be empty"),
"stderr should mention empty name error, got:\n{}",
stderr
);
}
#[test]
fn test_check_tag_template_missing_version_rejected() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "release-{{ .Tag }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"check should fail for tag_template without Version"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("must contain") && stderr.contains("Version"),
"stderr should explain the Version requirement, got:\n{}",
stderr
);
}
#[test]
fn test_failed_compilation_snapshot() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
fs::write(
tmp.path().join("Cargo.toml"),
r#"
[package]
name = "bad-project"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "bad-project"
path = "src/main.rs"
"#,
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { let x: i32 = "not a number"; }"#,
)
.unwrap();
init_git_repo(tmp.path());
let config = format!(
r#"project_name: bad-project
crates:
- name: bad-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: bad-project
targets:
- {host}
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--single-target",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"2m",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release should fail when Rust code doesn't compile.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_unknown_yaml_fields_rejected() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
future_feature: "this field does not exist yet"
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"check should fail with unknown YAML fields.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown field"),
"error should mention unknown field.\nstderr:\n{}",
stderr
);
}
#[test]
fn test_check_per_crate_checksum_disable_valid() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
checksum:
skip: true
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"check should succeed with per-crate checksum disable.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_archives_disabled_valid() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
archives: false
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"check should succeed with archives: false.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_global_checksum_disable_valid() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
defaults:
checksum:
skip: true
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"check should succeed with global checksum disable.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_changelog_disabled_valid() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
changelog:
skip: true
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"check should succeed with changelog disabled.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_e2e_multi_format_archive() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = format!(
r#"project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
# Q-arch2: each entry needs a unique id (matches GoReleaser's
# ids.New("archives").Validate() requirement). Default-id collision
# would otherwise be caught at config load.
- id: targz
name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}-targz"
formats: [tar.gz]
- id: tarxz
name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}-tarxz"
formats: [tar.xz]
- id: zipped
name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}-zipped"
formats: [zip]
- id: raw
name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}-raw"
formats: [binary]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"multi-format archive release should succeed.\nstderr:\n{}",
stderr
);
let dist_dir = tmp.path().join("dist");
assert!(dist_dir.exists(), "dist/ should exist");
let entries: Vec<_> = fs::read_dir(&dist_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let has_targz = entries
.iter()
.any(|n| n.contains("targz") && n.ends_with(".tar.gz"));
assert!(
has_targz,
"dist/ should contain a tar.gz archive, found: {:?}",
entries
);
let has_tarxz = entries
.iter()
.any(|n| n.contains("tarxz") && n.ends_with(".tar.xz"));
assert!(
has_tarxz,
"dist/ should contain a tar.xz archive, found: {:?}",
entries
);
let has_zip = entries
.iter()
.any(|n| n.contains("zipped") && n.ends_with(".zip"));
assert!(
has_zip,
"dist/ should contain a zip archive, found: {:?}",
entries
);
let has_binary = if cfg!(windows) {
entries
.iter()
.any(|n| n.contains("raw") && n.ends_with(".exe"))
} else {
entries
.iter()
.any(|n| n.contains("raw") && !n.contains('.'))
};
assert!(
has_binary,
"dist/ should contain a raw binary (no extension on unix; .exe on windows), found: {:?}",
entries
);
let checksum_content = fs::read_to_string(dist_dir.join("checksums.txt")).unwrap();
assert!(
checksum_content.contains("targz"),
"checksums should reference tar.gz archive"
);
assert!(
checksum_content.contains("tarxz"),
"checksums should reference tar.xz archive"
);
assert!(
checksum_content.contains("zipped"),
"checksums should reference zip archive"
);
assert!(
checksum_content.contains("raw"),
"checksums should reference binary archive"
);
}
#[test]
fn test_e2e_multi_sign_dry_run() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = format!(
r#"project_name: test-project
signs:
- id: gpg-checksum
artifacts: checksum
cmd: gpg
args:
- "--detach-sig"
- "{{{{ .Artifact }}}}"
- id: cosign-archive
artifacts: archive
cmd: cosign
args:
- "sign-blob"
- "{{{{ .Artifact }}}}"
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--skip=release,publish,docker,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"multi-sign dry-run should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("sign") && stderr.contains("dry-run"),
"stderr should contain dry-run sign output, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_changelog_with_groups() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
let git = |args: &[&str]| {
let output = Command::new("git")
.args(args)
.current_dir(tmp.path())
.output()
.expect("git command failed");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
};
let config = format!(
r#"project_name: test-project
changelog:
snapshot: true
sort: asc
groups:
- title: Features
regexp: "^feat"
order: 0
- title: Bug Fixes
regexp: "^fix"
order: 1
- title: Maintenance
regexp: "^chore"
order: 2
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
git(&["add", "-A"]);
git(&["commit", "-m", "initial"]);
git(&["tag", "v0.1.0"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("feature 1"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "feat: add awesome new feature"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("fix 1"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "fix: resolve critical bug"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("chore 1"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "chore: update dependencies"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("feature 2"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "feat: implement second feature"]);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"changelog release should succeed.\nstderr:\n{}",
stderr
);
let notes_path = tmp.path().join("dist/CHANGELOG.md");
assert!(
notes_path.exists(),
"dist/CHANGELOG.md should exist after changelog stage"
);
let notes = fs::read_to_string(¬es_path).unwrap();
assert!(
notes.contains("## Features"),
"changelog should contain Features group, got:\n{}",
notes
);
assert!(
notes.contains("## Bug Fixes"),
"changelog should contain Bug Fixes group, got:\n{}",
notes
);
assert!(
notes.contains("## Maintenance"),
"changelog should contain Maintenance group, got:\n{}",
notes
);
assert!(
notes.contains("add awesome new feature"),
"changelog should contain feat commit description, got:\n{}",
notes
);
assert!(
notes.contains("resolve critical bug"),
"changelog should contain fix commit description, got:\n{}",
notes
);
assert!(
notes.contains("update dependencies"),
"changelog should contain chore commit description, got:\n{}",
notes
);
let feat_pos = notes.find("## Features").unwrap();
let fix_pos = notes.find("## Bug Fixes").unwrap();
assert!(
feat_pos < fix_pos,
"Features (order 0) should appear before Bug Fixes (order 1)"
);
}
#[test]
fn test_e2e_config_validation_round_trip() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let init_output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("init")
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
init_output.status.success(),
"init should succeed: {}",
String::from_utf8_lossy(&init_output.stderr)
);
let generated_config = std::fs::read_to_string(tmp.path().join(".anodizer.yaml"))
.expect("init should create .anodizer.yaml");
let host = detect_host_target();
let mut parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&generated_config).unwrap();
if let Some(mapping) = parsed.as_mapping_mut() {
if let Some(defaults) = mapping
.get_mut(serde_yaml_ng::Value::String("defaults".to_string()))
.and_then(|d| d.as_mapping_mut())
{
defaults.insert(
serde_yaml_ng::Value::String("targets".to_string()),
serde_yaml_ng::Value::Sequence(vec![serde_yaml_ng::Value::String(host.clone())]),
);
}
if let Some(crates) = mapping
.get_mut(serde_yaml_ng::Value::String("crates".to_string()))
.and_then(|c| c.as_sequence_mut())
{
for krate in crates.iter_mut() {
if let Some(builds) = krate
.as_mapping_mut()
.and_then(|m| m.get_mut(serde_yaml_ng::Value::String("builds".to_string())))
.and_then(|b| b.as_sequence_mut())
{
for build in builds.iter_mut() {
if let Some(m) = build.as_mapping_mut() {
m.insert(
serde_yaml_ng::Value::String("targets".to_string()),
serde_yaml_ng::Value::Sequence(vec![serde_yaml_ng::Value::String(
host.clone(),
)]),
);
}
}
}
}
}
}
let modified_config = serde_yaml_ng::to_string(&parsed).unwrap();
create_config(tmp.path(), &modified_config);
let check_output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
check_output.status.success(),
"check should succeed on init-generated config.\nstderr:\n{}",
String::from_utf8_lossy(&check_output.stderr)
);
let build_output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&build_output.stderr);
assert!(
build_output.status.success(),
"release --snapshot should succeed with init-generated config.\nstderr:\n{}",
stderr
);
let dist_dir = tmp.path().join("dist");
assert!(
dist_dir.exists(),
"dist/ should exist after round-trip release"
);
let entries: Vec<_> = fs::read_dir(&dist_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
!entries.is_empty(),
"dist/ should have at least one artifact after round-trip"
);
}
#[test]
fn test_e2e_workspace_dependency_ordering() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_workspace_project(tmp.path());
init_git_repo(tmp.path());
let config = create_workspace_snapshot_config(&host);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--all",
"--force",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"workspace dry-run release should succeed.\nstderr:\n{}",
stderr
);
let core_pos = stderr
.find("core-lib")
.expect("stderr should mention core-lib");
let helper_pos = stderr
.find("helper-lib")
.expect("stderr should mention helper-lib");
let app_pos = stderr.find("myapp").expect("stderr should mention myapp");
assert!(
core_pos < helper_pos,
"core-lib should be processed before helper-lib (depends_on). \
core-lib at {}, helper-lib at {}",
core_pos,
helper_pos
);
assert!(
helper_pos < app_pos,
"helper-lib should be processed before myapp (depends_on). \
helper-lib at {}, myapp at {}",
helper_pos,
app_pos
);
}
#[test]
fn test_e2e_skip_archive_and_checksum() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = create_single_crate_snapshot_config(&host);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=archive,checksum,release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"release with skipped archive/checksum should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("build"),
"stderr should mention the build stage, got:\n{}",
stderr
);
assert!(
stderr.contains("archive") && stderr.contains("skipped"),
"stderr should show archive as skipped, got:\n{}",
stderr
);
assert!(
stderr.contains("checksum") && stderr.contains("skipped"),
"stderr should show checksum as skipped, got:\n{}",
stderr
);
let dist_dir = tmp.path().join("dist");
if dist_dir.exists() {
let entries: Vec<_> = fs::read_dir(&dist_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let has_archives = entries
.iter()
.any(|n| n.ends_with(".tar.gz") || n.ends_with(".zip") || n.ends_with(".tar.xz"));
assert!(
!has_archives,
"dist/ should NOT contain archives when archive stage is skipped, found: {:?}",
entries
);
let has_checksums = entries.iter().any(|n| n == "checksums.txt");
assert!(
!has_checksums,
"dist/ should NOT contain checksums when checksum stage is skipped, found: {:?}",
entries
);
}
}
#[test]
fn test_e2e_custom_publishers_dry_run() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = format!(
r#"project_name: test-project
publishers:
- name: s3-upload
cmd: aws
args:
- "s3"
- "cp"
- "{{{{ .ArtifactPath }}}}"
- "s3://my-bucket/"
artifact_types:
- archive
- name: notify
cmd: curl
args:
- "-X"
- "POST"
- "https://example.com/notify"
artifact_types:
- checksum
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--skip=release,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"publisher dry-run should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("publisher") || stderr.contains("s3-upload") || stderr.contains("notify"),
"stderr should mention custom publishers in dry-run, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_docker_staging_dry_run() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
fs::write(tmp.path().join("Dockerfile"), "FROM scratch\nCOPY . /app\n").unwrap();
init_git_repo(tmp.path());
let config = format!(
r#"project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
docker_v2:
- images:
- "myregistry/test-project"
tags:
- "{{{{ .Version }}}}"
dockerfile: Dockerfile
platforms:
- linux/amd64
- linux/arm64
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--skip=release,publish,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"docker staging dry-run should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("docker") && stderr.contains("dry-run"),
"stderr should contain docker dry-run output, got:\n{}",
stderr
);
assert!(
stderr.contains("Dockerfile"),
"stderr should mention Dockerfile in docker staging, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_cross_platform_format_overrides_check() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"project_name: test-project
defaults:
archives:
formats: [tar.gz]
format_overrides:
- os: windows
formats: [zip]
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: test-project
targets:
- x86_64-unknown-linux-gnu
- x86_64-pc-windows-msvc
- aarch64-unknown-linux-gnu
archives:
- name_template: "test-project-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"check with format_overrides should succeed.\nstderr:\n{}",
stderr
);
}
#[test]
fn test_e2e_snapshot_version_in_artifacts() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = format!(
r#"project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Version }}}}-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"snapshot release should succeed.\nstderr:\n{}",
stderr
);
let dist_dir = tmp.path().join("dist");
assert!(dist_dir.exists(), "dist/ should exist");
let entries: Vec<_> = fs::read_dir(&dist_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let has_versioned_archive = entries.iter().any(|name| {
name.starts_with("test-project-") && name.contains("0.1.0") && name.ends_with(".tar.gz")
});
assert!(
has_versioned_archive,
"dist/ should contain versioned archive with 0.1.0, found: {:?}",
entries
);
let checksum_content = fs::read_to_string(dist_dir.join("checksums.txt")).unwrap();
assert!(
checksum_content.contains("0.1.0"),
"checksums should reference versioned artifact, got:\n{}",
checksum_content
);
}
#[test]
fn test_e2e_full_dry_run_all_stages() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
let git = |args: &[&str]| {
let output = Command::new("git")
.args(args)
.current_dir(tmp.path())
.output()
.expect("git command failed");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
};
let config = format!(
r#"project_name: test-project
changelog:
sort: asc
groups:
- title: Changes
regexp: ".*"
order: 0
signs:
- id: gpg
artifacts: checksum
cmd: gpg
args:
- "--detach-sig"
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
git(&["add", "-A"]);
git(&["commit", "-m", "initial"]);
git(&["tag", "v0.1.0"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("updated"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "feat: dry-run test commit"]);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--snapshot",
"--skip=release,publish,docker,announce,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"full dry-run should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("dry-run"),
"stderr should mention dry-run, got:\n{}",
stderr
);
assert!(
stderr.contains("changelog"),
"stderr should mention changelog stage, got:\n{}",
stderr
);
assert!(
stderr.contains("sign"),
"stderr should mention sign stage, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_check_nested_custom_config() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let nested = tmp.path().join("configs").join("release").join("prod");
fs::create_dir_all(&nested).unwrap();
let config_path = nested.join("release-config.yaml");
fs::write(
&config_path,
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: test-project
targets:
- x86_64-unknown-linux-gnu
archives:
- name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["-f", config_path.to_str().unwrap(), "check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"check with nested custom config should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("Config is valid"),
"should confirm config is valid, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_init_yaml_structural_round_trip() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("init")
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"init should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let yaml_str = fs::read_to_string(tmp.path().join(".anodizer.yaml"))
.expect(".anodizer.yaml should exist after init");
let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml_str).unwrap_or_else(|e| {
panic!(
"init output should be valid YAML: {}\nOutput:\n{}",
e, yaml_str
);
});
let map = value.as_mapping().expect("top-level should be a mapping");
let re_serialized = serde_yaml_ng::to_string(&value).unwrap();
let re_parsed: serde_yaml_ng::Value =
serde_yaml_ng::from_str(&re_serialized).unwrap_or_else(|e| {
panic!(
"re-serialized YAML should parse: {}\nOutput:\n{}",
e, re_serialized
);
});
assert_eq!(
map.len(),
re_parsed.as_mapping().unwrap().len(),
"round-trip should preserve number of top-level keys"
);
let has_key = |key: &str| map.contains_key(serde_yaml_ng::Value::String(key.to_string()));
assert!(
has_key("project_name"),
"should have project_name after round-trip"
);
assert!(has_key("crates"), "should have crates after round-trip");
}
#[test]
fn test_e2e_multiple_crates_release() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
fs::write(
tmp.path().join("Cargo.toml"),
r#"[workspace]
resolver = "2"
members = ["crates/app-one", "crates/app-two"]
"#,
)
.unwrap();
let app1_dir = tmp.path().join("crates/app-one");
fs::create_dir_all(app1_dir.join("src")).unwrap();
fs::write(
app1_dir.join("Cargo.toml"),
r#"[package]
name = "app-one"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "app-one"
path = "src/main.rs"
"#,
)
.unwrap();
fs::write(
app1_dir.join("src/main.rs"),
r#"fn main() { println!("app one"); }"#,
)
.unwrap();
let app2_dir = tmp.path().join("crates/app-two");
fs::create_dir_all(app2_dir.join("src")).unwrap();
fs::write(
app2_dir.join("Cargo.toml"),
r#"[package]
name = "app-two"
version = "0.2.0"
edition = "2021"
[[bin]]
name = "app-two"
path = "src/main.rs"
"#,
)
.unwrap();
fs::write(
app2_dir.join("src/main.rs"),
r#"fn main() { println!("app two"); }"#,
)
.unwrap();
init_git_repo(tmp.path());
let config = format!(
r#"project_name: multi-app
crates:
- name: app-one
path: "crates/app-one"
tag_template: "app-one-v{{{{ .Version }}}}"
builds:
- binary: app-one
targets:
- {host}
archives:
- name_template: "app-one-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "app-one-checksums.txt"
algorithm: sha256
- name: app-two
path: "crates/app-two"
tag_template: "app-two-v{{{{ .Version }}}}"
builds:
- binary: app-two
targets:
- {host}
archives:
- name_template: "app-two-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "app-two-checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--all",
"--force",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"multi-crate release should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("app-one"),
"stderr should mention app-one crate, got:\n{}",
stderr
);
assert!(
stderr.contains("app-two"),
"stderr should mention app-two crate, got:\n{}",
stderr
);
let dist_dir = tmp.path().join("dist");
assert!(dist_dir.exists(), "dist/ should exist");
let entries: Vec<_> = fs::read_dir(&dist_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let has_app_one = entries
.iter()
.any(|n| n.starts_with("app-one") && n.ends_with(".tar.gz"));
assert!(
has_app_one,
"dist/ should contain app-one archive, found: {:?}",
entries
);
let has_app_two = entries
.iter()
.any(|n| n.starts_with("app-two") && n.ends_with(".tar.gz"));
assert!(
has_app_two,
"dist/ should contain app-two archive, found: {:?}",
entries
);
}
#[test]
fn test_e2e_healthcheck_detects_tools() {
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.arg("healthcheck")
.output()
.unwrap();
assert!(
output.status.success(),
"healthcheck should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cargo"),
"healthcheck should check cargo availability"
);
assert!(
stderr.contains("git"),
"healthcheck should check git availability"
);
assert!(
stderr.contains("found")
|| stderr.contains("ok")
|| stderr.contains("✓")
|| stderr.contains("available"),
"healthcheck should report tool status, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_changelog_header_footer() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
let git = |args: &[&str]| {
let output = Command::new("git")
.args(args)
.current_dir(tmp.path())
.output()
.expect("git command failed");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
};
let config = format!(
r##"project_name: test-project
changelog:
snapshot: true
sort: asc
header: "# Release Notes"
footer: "Generated by anodizer"
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
"##,
host = host
);
create_config(tmp.path(), &config);
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
git(&["add", "-A"]);
git(&["commit", "-m", "initial"]);
git(&["tag", "v0.1.0"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("v2"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "feat: add header/footer test feature"]);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"changelog header/footer release should succeed.\nstderr:\n{}",
stderr
);
let notes_path = tmp.path().join("dist/CHANGELOG.md");
assert!(notes_path.exists(), "CHANGELOG.md should exist");
let notes = fs::read_to_string(¬es_path).unwrap();
assert!(
notes.contains("# Release Notes"),
"changelog should contain header, got:\n{}",
notes
);
assert!(
notes.contains("Generated by anodizer"),
"changelog should contain footer, got:\n{}",
notes
);
}
#[test]
fn test_strict_mode_cross_axis_smoke() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
let git = |args: &[&str]| {
let output = Command::new("git")
.args(args)
.current_dir(tmp.path())
.output()
.expect("git command failed");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
};
let config = format!(
r##"project_name: test-project
changelog:
snapshot: true
sort: asc
header: "# Cross-Axis Header"
sboms:
- id: archive
documents: ["{{{{ .ArtifactName }}}}.cdx.json"]
milestones:
- close: true
name_template: "{{{{ Tag }}}}"
repo:
owner: cross-axis-owner
name: cross-axis-repo
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
"##,
host = host
);
create_config(tmp.path(), &config);
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
git(&["add", "-A"]);
git(&["commit", "-m", "initial"]);
git(&["tag", "v0.1.0"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("cross-axis"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "feat: cross-axis smoke"]);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"--strict",
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"strict + snapshot release should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("milestone:") && stderr.contains("cross-axis-owner/cross-axis-repo"),
"stderr should log the milestone pre-flight target, got:\n{}",
stderr
);
let dist = tmp.path().join("dist");
let notes = fs::read_to_string(dist.join("CHANGELOG.md"))
.expect("dist/CHANGELOG.md should exist after release");
assert!(
notes.contains("# Cross-Axis Header"),
"release notes should contain changelog.header, got:\n{}",
notes
);
let checksums = fs::read_to_string(dist.join("checksums.txt"))
.expect("dist/checksums.txt should exist after release");
let filenames: std::collections::HashSet<&str> = checksums
.lines()
.filter_map(|l| l.split_once(" ").map(|(_, name)| name))
.collect();
assert!(
filenames.iter().any(|n| n.ends_with(".cdx.json")),
"checksums.txt should list the SBOM artifact (cross-link), got: {:?}",
filenames
);
assert!(
!stderr.contains("strict mode: refusing"),
"strict mode should not reject a well-formed config, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_changelog_exclude_filters() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
let git = |args: &[&str]| {
let output = Command::new("git")
.args(args)
.current_dir(tmp.path())
.output()
.expect("git command failed");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
};
let config = format!(
r#"project_name: test-project
changelog:
snapshot: true
sort: asc
filters:
exclude:
- "^chore"
- "^docs"
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
"#,
host = host
);
create_config(tmp.path(), &config);
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
git(&["add", "-A"]);
git(&["commit", "-m", "initial"]);
git(&["tag", "v0.1.0"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("a"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "feat: visible feature"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("b"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "chore: invisible chore"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("c"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "docs: invisible docs"]);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("d"); }"#,
)
.unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "fix: visible bugfix"]);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"changelog with filters should succeed.\nstderr:\n{}",
stderr
);
let notes_path = tmp.path().join("dist/CHANGELOG.md");
assert!(notes_path.exists(), "CHANGELOG.md should exist");
let notes = fs::read_to_string(¬es_path).unwrap();
assert!(
notes.contains("visible feature"),
"changelog should contain feat commit, got:\n{}",
notes
);
assert!(
notes.contains("visible bugfix"),
"changelog should contain fix commit, got:\n{}",
notes
);
assert!(
!notes.contains("invisible chore"),
"changelog should NOT contain chore commit (excluded), got:\n{}",
notes
);
assert!(
!notes.contains("invisible docs"),
"changelog should NOT contain docs commit (excluded), got:\n{}",
notes
);
}
#[test]
fn test_e2e_auto_snapshot_dirty_repo() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = create_single_crate_snapshot_config(&host);
create_config(tmp.path(), &config);
fs::write(
tmp.path().join("src/main.rs"),
r#"fn main() { println!("dirty change"); }"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--auto-snapshot",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"auto-snapshot on dirty repo should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("snapshot"),
"stderr should mention snapshot (auto-detected from dirty), got:\n{}",
stderr
);
}
#[test]
fn test_e2e_before_hooks_execute() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let marker_path = tmp.path().join("before-hook-ran.txt");
let marker_yaml = marker_path.to_string_lossy().replace('\\', "/");
let config = format!(
r#"project_name: test-project
before:
hooks:
- "echo before-hook-executed > {marker}"
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
"#,
host = host,
marker = marker_yaml
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"release with before hooks should succeed.\nstderr:\n{}",
stderr
);
assert!(
marker_path.exists(),
"before hook should have created marker file at {}",
marker_path.display()
);
let marker_content = fs::read_to_string(&marker_path).unwrap();
assert!(
marker_content.contains("before-hook-executed"),
"marker file should contain expected content, got: {}",
marker_content
);
}
#[test]
fn test_e2e_before_hooks_dry_run() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let marker_path = tmp.path().join("should-not-exist.txt");
let marker_yaml = marker_path.to_string_lossy().replace('\\', "/");
let config = format!(
r#"project_name: test-project
before:
hooks:
- "echo should-not-run > {marker}"
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
"#,
host = host,
marker = marker_yaml
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--dry-run",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"dry-run with before hooks should succeed.\nstderr:\n{}",
stderr
);
assert!(
!marker_path.exists(),
"before hook should NOT have run in dry-run mode"
);
assert!(
stderr.contains("dry-run") && stderr.contains("hook"),
"stderr should mention dry-run hook logging, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_toml_config_check() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let config_path = tmp.path().join("anodizer.toml");
fs::write(
&config_path,
r#"
project_name = "test-project"
[[crates]]
name = "test-project"
path = "."
tag_template = "v{{ .Version }}"
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["-f", config_path.to_str().unwrap(), "check", "config"])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"check with TOML config should succeed.\nstderr:\n{}",
stderr
);
assert!(
stderr.contains("Config is valid"),
"should confirm TOML config is valid, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_report_sizes() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = format!(
r#"project_name: test-project
report_sizes: true
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
archives:
- name_template: "test-project-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
host = host
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--skip=release,publish,docker,sign,announce,changelog,nfpm",
"--timeout",
"5m",
])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"release with report_sizes should succeed.\nstderr:\n{}",
stderr
);
let has_size_info = stderr.contains("bytes")
|| stderr.contains(" B")
|| stderr.contains("KB")
|| stderr.contains("MB")
|| stderr.contains("size")
|| stderr.contains("Size");
assert!(
has_size_info,
"stderr should contain size information when report_sizes is true, got:\n{}",
stderr
);
}
#[test]
fn test_e2e_build_command_matches_goreleaser_pipeline_outputs() {
let tmp = TempDir::new().unwrap();
let host = detect_host_target();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let before_marker = tmp.path().join("before-ran");
let before_marker_str = before_marker.to_string_lossy().replace('\\', "\\\\");
let hook_cmd = if cfg!(windows) {
format!(
"powershell -NoProfile -Command \\\"New-Item -ItemType File -Force -Path '{}'\\\"",
before_marker_str
)
} else {
format!("touch {}", before_marker_str)
};
let config = format!(
r#"project_name: test-project
report_sizes: true
before:
hooks:
- "{hook}"
crates:
- name: test-project
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: test-project
targets:
- {host}
"#,
host = host,
hook = hook_cmd,
);
create_config(tmp.path(), &config);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["build", "--timeout", "5m"])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"anodizer build should succeed.\nstderr:\n{}",
stderr
);
assert!(
before_marker.exists(),
"before hooks should have run and created marker file\nstderr:\n{}",
stderr
);
let dist = tmp.path().join("dist");
assert!(
dist.join("config.yaml").exists(),
"dist/config.yaml should exist after build (effective config dump)"
);
let metadata = dist.join("metadata.json");
assert!(
metadata.exists(),
"dist/metadata.json should exist after build"
);
let metadata_text = fs::read_to_string(&metadata).unwrap();
assert!(
metadata_text.contains("test-project"),
"metadata.json should contain project_name, got: {}",
metadata_text
);
let artifacts = dist.join("artifacts.json");
assert!(
artifacts.exists(),
"dist/artifacts.json should exist after build"
);
let artifacts_text = fs::read_to_string(&artifacts).unwrap();
assert!(
artifacts_text.contains("\"binary\""),
"artifacts.json should contain the built binary, got: {}",
artifacts_text
);
let has_size_info = stderr.contains("bytes")
|| stderr.contains(" B")
|| stderr.contains("KB")
|| stderr.contains("MB")
|| stderr.contains("size")
|| stderr.contains("Size");
assert!(
has_size_info,
"build should print size report when report_sizes is true, got:\n{}",
stderr
);
}
#[test]
fn release_rollback_only_bails_when_prior_report_missing() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--rollback-only", "--from-run", "abc123"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release --rollback-only with no prior report must exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("failed to read prior report") && stderr.contains("run-abc123"),
"stderr should reference the missing prior report path, got: {}",
stderr
);
}
#[test]
fn release_rollback_only_invokes_replay_from_disk() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let run_dir = tmp.path().join("dist").join("run-fixt");
fs::create_dir_all(&run_dir).unwrap();
let report_json = r#"{
"results": [
{
"name": "orphan-mgr",
"group": "Manager",
"required": true,
"outcome": "Succeeded",
"evidence": {
"schema_version": 1,
"publisher": "orphan-mgr",
"primary_ref": null,
"artifact_paths": [],
"nondeterministic": null,
"extra": {}
}
}
],
"submitter_gated": false,
"announce_gated": false
}"#;
fs::write(run_dir.join("report.json"), report_json).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--rollback-only", "--from-run", "fixt"])
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"release --rollback-only must succeed even when a recorded publisher is missing from the current registry (it surfaces as RollbackFailed in rollback.json). stdout:\n{}\nstderr:\n{}",
stdout,
stderr,
);
let rollback_path = run_dir.join("rollback.json");
assert!(
rollback_path.exists(),
"rollback.json must be written to {}",
rollback_path.display(),
);
let rollback_text = fs::read_to_string(&rollback_path).unwrap();
assert!(
rollback_text.contains("RollbackFailed")
&& rollback_text.contains("not found in current registry"),
"rollback.json must carry diagnostic for missing publisher, got:\n{}",
rollback_text,
);
}
#[test]
fn release_from_run_rejects_path_traversal_at_binary_surface() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--rollback-only",
"--from-run",
"../../etc/passwd",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release --from-run=../../etc/passwd must exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--from-run") || stderr.contains("invalid"),
"stderr should explain the rejection, got: {}",
stderr
);
let dist = tmp.path().join("dist");
let has_traversal_leak = dist.exists()
&& std::fs::read_dir(&dist)
.unwrap()
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().starts_with("run-"));
assert!(
!has_traversal_leak,
"no run-* directory should leak into dist/ when parsing fails"
);
}
#[test]
fn release_rejects_invalid_rollback_value() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--rollback", "wat"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release --rollback=wat must exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid --rollback value")
&& stderr.contains("none")
&& stderr.contains("best-effort"),
"stderr should explain valid set, got: {}",
stderr
);
}
#[test]
fn release_simulate_failure_gated_by_env() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--simulate-failure", "cargo"])
.env_remove("ANODIZE_TEST_HARNESS")
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release --simulate-failure without ANODIZE_TEST_HARNESS=1 must exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--simulate-failure") && stderr.contains("ANODIZE_TEST_HARNESS"),
"stderr should explain the env gate, got: {}",
stderr
);
}
#[test]
fn release_required_publisher_failure_gates_exit_code() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
publish:
cargo: {}
"#,
);
init_git_repo(tmp.path());
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--no-preflight",
"--simulate-failure",
"cargo",
"--skip=build,upx,appbundle,dmg,msi,pkg,nsis,notarize,changelog,archive,source,nfpm,srpm,makeself,snapcraft,flatpak,sbom,templatefiles,checksum,sign,release,docker,docker-sign,blob,snapcraft-publish,announce",
])
.env("ANODIZE_TEST_HARNESS", "1")
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("GITHUB_TOKEN")
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"release with required-publisher failure must exit non-zero; stderr: {stderr}"
);
assert!(
stderr.contains("required publisher") && stderr.contains("cargo"),
"stderr must carry the gate bail message; got: {stderr}"
);
let report = tmp
.path()
.join("dist")
.join("run-v0.1.0")
.join("report.json");
assert!(
report.exists(),
"report.json must persist before gate fires; expected at {}",
report.display()
);
}
#[test]
fn release_non_required_publisher_failure_does_not_gate() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
publish:
scoop:
repository:
owner: test
name: scoop-bucket
"#,
);
init_git_repo(tmp.path());
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--no-preflight",
"--simulate-failure",
"scoop",
"--skip=build,upx,appbundle,dmg,msi,pkg,nsis,notarize,changelog,archive,source,nfpm,srpm,makeself,snapcraft,flatpak,sbom,templatefiles,checksum,sign,release,docker,docker-sign,blob,snapcraft-publish,announce",
])
.env("ANODIZE_TEST_HARNESS", "1")
.env_remove("CARGO_REGISTRY_TOKEN")
.env_remove("GITHUB_TOKEN")
.current_dir(tmp.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"release with only non-required publisher failure must exit 0;\nstderr: {stderr}\nstdout: {stdout}"
);
assert!(
!stderr.contains("required publisher(s) failed"),
"gate bail message must NOT appear for non-required failure; got: {stderr}"
);
}
#[test]
fn release_allow_nondeterministic_rejects_no_eq() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--allow-nondeterministic", "barevalue"])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release --allow-nondeterministic barevalue must exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("NAME=REASON"),
"stderr should require NAME=REASON, got: {}",
stderr
);
}
#[test]
fn release_allow_nondeterministic_rejects_empty_reason() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(["release", "--allow-nondeterministic", "foo="])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release --allow-nondeterministic foo= must exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("reason cannot be empty"),
"stderr should reject empty reason, got: {}",
stderr
);
}
#[test]
fn release_strict_conflicts_with_allow_nondeterministic() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
create_config(
tmp.path(),
r#"
project_name: test-project
crates:
- name: test-project
path: "."
tag_template: "v{{ .Version }}"
"#,
);
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"--strict",
"release",
"--allow-nondeterministic",
"foo.rpm=tool-bug",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release --strict --allow-nondeterministic=... must exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--strict") && stderr.contains("--allow-nondeterministic"),
"stderr should name both conflicting flags, got: {}",
stderr
);
}
#[test]
fn test_release_skip_announce_still_writes_summary_json() {
let host = detect_host_target();
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
init_git_repo(tmp.path());
let config = create_single_crate_snapshot_config(&host);
create_config(tmp.path(), &config);
let summary_path = tmp.path().join("summary.json");
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--snapshot",
"--dry-run",
"--skip=build,archive,checksum,docker,sign,nfpm,changelog,sbom,release,publish,announce",
"--summary-json",
summary_path.to_str().unwrap(),
"--timeout",
"2m",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"release with --skip=announce + --summary-json must succeed.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
summary_path.exists(),
"summary.json must be written even when announce is operator-skipped.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr),
);
let summary_text = std::fs::read_to_string(&summary_path)
.expect("read summary.json that the binary just wrote");
let parsed: anodizer_stage_publish::run_summary::RunSummary =
serde_json::from_str(&summary_text).expect("summary.json must parse as RunSummary");
assert_eq!(
parsed.schema_version,
anodizer_stage_publish::run_summary::RunSummary::CURRENT_SCHEMA_VERSION,
"schema_version field must round-trip",
);
}
#[test]
fn test_release_allow_rerun_conflicts_with_rollback_only() {
let tmp = TempDir::new().unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"release",
"--rollback-only",
"--from-run=v0.0.0-test",
"--allow-rerun",
])
.current_dir(tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"release --rollback-only --allow-rerun must be rejected by clap",
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--allow-rerun") && stderr.contains("--rollback-only"),
"stderr must name both conflicting flags, got: {}",
stderr,
);
}