use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
use anodizer_core::test_helpers::{create_config, create_test_project, init_git_repo};
fn host_target() -> String {
anodizer_cli::detect_host_target().expect("rustc -vV must succeed in test env")
}
fn minimal_config(host: &str) -> String {
format!(
r#"project_name: matrix-fixture
crates:
- name: matrix-fixture
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: matrix-fixture
targets:
- {host}
archives:
- name_template: "{{{{ .ProjectName }}}}-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
)
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for c2 in chars.by_ref() {
if c2.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn extract_skipped_stages(stderr: &str) -> std::collections::BTreeSet<String> {
stderr
.lines()
.filter_map(|line| {
let line = strip_ansi(line);
let body = line.trim_start().strip_prefix("• ")?;
let (stage, verdict) = body.split_once(' ')?;
if is_stage_skip_message(verdict) {
Some(stage.to_string())
} else {
None
}
})
.collect()
}
fn is_stage_skip_message(verdict: &str) -> bool {
verdict == "skipped" || verdict == "skipped (no binaries)"
}
fn setup_fixture(tmp: &Path) {
create_test_project(tmp);
init_git_repo(tmp);
create_config(tmp, &minimal_config(&host_target()));
}
fn run_anodizer(tmp: &Path, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args(args)
.current_dir(tmp)
.env_remove("COSIGN_KEY")
.env_remove("GPG_PRIVATE_KEY")
.env_remove("GITHUB_TOKEN")
.env_remove("ANODIZER_GITHUB_TOKEN")
.output()
.expect("invoke anodizer")
}
fn assert_skip_matrix(stderr: &str, must_skip: &[&str], must_not_skip: &[&str], label: &str) {
let skipped = extract_skipped_stages(stderr);
for stage in must_skip {
assert!(
skipped.contains(*stage),
"{label}: stage `{stage}` should be reported as skipped but wasn't.\n\
skipped set: {skipped:?}\nstderr:\n{stderr}"
);
}
for stage in must_not_skip {
assert!(
!skipped.contains(*stage),
"{label}: stage `{stage}` should NOT be reported as skipped but was.\n\
skipped set: {skipped:?}\nstderr:\n{stderr}"
);
}
}
#[test]
fn release_snapshot_skips_publish_chain() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
let out = run_anodizer(
tmp.path(),
&[
"release",
"--snapshot",
"--dry-run",
"--skip=build,archive,checksum,docker,sign,nfpm,changelog,sbom",
"--timeout",
"2m",
],
);
assert!(
out.status.success(),
"release --snapshot must succeed.\nstderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_skip_matrix(
&stderr,
&["publish", "blob", "announce"],
&["release"],
"release --snapshot",
);
}
#[test]
fn release_prepare_skips_publish_release_announce() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
let out = run_anodizer(
tmp.path(),
&[
"release",
"--prepare",
"--snapshot",
"--dry-run",
"--skip=build,archive,checksum,docker,sign,nfpm,changelog,sbom",
"--timeout",
"2m",
],
);
assert!(
out.status.success(),
"release --prepare must succeed.\nstderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_skip_matrix(
&stderr,
&[
"release",
"publish",
"blob",
"snapcraft-publish",
"announce",
],
&[],
"release --prepare",
);
}
#[test]
fn release_prepare_only_alias_matches_prepare() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
let out = run_anodizer(
tmp.path(),
&[
"release",
"--prepare-only",
"--snapshot",
"--dry-run",
"--skip=build,archive,checksum,docker,sign,nfpm,changelog,sbom",
"--timeout",
"2m",
],
);
assert!(
out.status.success(),
"release --prepare-only must succeed.\nstderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_skip_matrix(
&stderr,
&[
"release",
"publish",
"blob",
"snapcraft-publish",
"announce",
],
&[],
"release --prepare-only",
);
}
#[test]
fn release_announce_only_bails_without_prior_report() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
Command::new("git")
.current_dir(tmp.path())
.args(["tag", "v0.1.0-test"])
.output()
.expect("git tag");
let out = run_anodizer(
tmp.path(),
&["release", "--announce-only", "--dry-run", "--timeout", "2m"],
);
assert!(
!out.status.success(),
"release --announce-only must fail without a prior report.json"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no prior report found"),
"error must name the missing report; stderr:\n{stderr}"
);
}
#[test]
fn release_announce_only_conflicts_with_publish_only() {
let tmp = TempDir::new().unwrap();
let out = run_anodizer(
tmp.path(),
&["release", "--announce-only", "--publish-only"],
);
assert!(
!out.status.success(),
"clap must reject --announce-only + --publish-only"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("cannot be used with") || stderr.contains("conflict"),
"expected clap conflict error; got:\n{stderr}"
);
}
#[test]
fn release_announce_only_conflicts_with_prepare() {
let tmp = TempDir::new().unwrap();
let out = run_anodizer(tmp.path(), &["release", "--announce-only", "--prepare"]);
assert!(
!out.status.success(),
"clap must reject --announce-only + --prepare"
);
}
#[test]
fn publish_subcommand_dispatches_to_publish_only_pipeline() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
fs::create_dir_all(tmp.path().join("dist")).unwrap();
let out = run_anodizer(tmp.path(), &["publish", "--dry-run", "--timeout", "2m"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("dist directory") || !stderr.contains("not empty"),
"publish subcommand must NOT trip the full-release dist pre-check; got:\n{stderr}"
);
}
#[test]
fn announce_subcommand_dispatches_to_announce_pipeline() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
fs::create_dir_all(tmp.path().join("dist")).unwrap();
let out = run_anodizer(tmp.path(), &["announce", "--dry-run", "--timeout", "2m"]);
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let merged = format!("{stdout}\n{stderr}");
for forbidden in &["building binaries", "archiving", "building nfpm"] {
assert!(
!merged.contains(forbidden),
"announce subcommand must not invoke build/archive/nfpm; \
saw `{forbidden}` in:\n{merged}"
);
}
}
#[test]
fn continue_no_merge_does_not_recompile() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
fs::create_dir_all(tmp.path().join("dist")).unwrap();
let out = run_anodizer(tmp.path(), &["continue", "--dry-run", "--timeout", "2m"]);
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let merged = format!("{stdout}\n{stderr}");
for forbidden in &["building binaries", "archiving", "building nfpm"] {
assert!(
!merged.contains(forbidden),
"continue (no --merge) must not recompile; saw `{forbidden}` in:\n{merged}"
);
}
}
#[test]
fn publish_merge_flag_parses() {
let cli = anodizer_cli::Cli::try_parse_from_with_args([
"anodizer",
"publish",
"--merge",
"--dry-run",
]);
assert!(cli, "publish --merge must parse at the clap level");
}
#[test]
fn announce_merge_flag_parses() {
let cli = anodizer_cli::Cli::try_parse_from_with_args([
"anodizer",
"announce",
"--merge",
"--dry-run",
]);
assert!(cli, "announce --merge must parse at the clap level");
}
#[test]
fn release_snapshot_does_not_skip_release_stage() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
let out = run_anodizer(
tmp.path(),
&[
"release",
"--snapshot",
"--dry-run",
"--skip=build,archive,checksum,docker,sign,nfpm,changelog,sbom",
"--timeout",
"2m",
],
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_skip_matrix(&stderr, &[], &["release"], "release --snapshot");
}
#[test]
fn publish_only_bypasses_dist_precheck_requires_context_json() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
fs::create_dir_all(tmp.path().join("dist")).unwrap();
let out = run_anodizer(
tmp.path(),
&["release", "--publish-only", "--dry-run", "--timeout", "2m"],
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"release --publish-only must fail without a preserved dist tree"
);
assert!(
stderr.contains("context.json") || stderr.contains("publish-only"),
"error must come from the publish-only branch; got:\n{stderr}"
);
assert!(
!stderr.contains("dist directory") || !stderr.contains("not empty"),
"release --publish-only must not trip the full-release dist pre-check; got:\n{stderr}"
);
}
#[test]
fn continue_merge_does_not_trigger_build_pipeline() {
let tmp = TempDir::new().unwrap();
setup_fixture(tmp.path());
fs::create_dir_all(tmp.path().join("dist")).unwrap();
let out = run_anodizer(
tmp.path(),
&["continue", "--merge", "--dry-run", "--timeout", "2m"],
);
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let merged = format!("{stdout}\n{stderr}");
for forbidden in &["building binaries", "archiving", "building nfpm"] {
assert!(
!merged.contains(forbidden),
"continue --merge must not invoke build/archive/nfpm; \
saw `{forbidden}` in:\n{merged}"
);
}
assert!(
!merged.contains("dist directory") || !merged.contains("not empty"),
"continue --merge must not trip the full-release dist pre-check"
);
}
fn config_with_targets(triples: &[&str]) -> String {
let targets = triples
.iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"project_name: matrix-fixture
crates:
- name: matrix-fixture
path: "."
tag_template: "v{{{{ .Version }}}}"
builds:
- binary: matrix-fixture
targets:
{targets}
archives:
- name_template: "{{{{ .ProjectName }}}}-{{{{ .Os }}}}-{{{{ .Arch }}}}"
formats: [tar.gz]
checksum:
name_template: "checksums.txt"
algorithm: sha256
"#,
)
}
fn setup_fixture_with_targets(tmp: &Path, triples: &[&str]) {
create_test_project(tmp);
init_git_repo(tmp);
create_config(tmp, &config_with_targets(triples));
}
#[test]
fn host_targets_requires_snapshot_or_dry_run() {
let tmp = TempDir::new().unwrap();
setup_fixture_with_targets(tmp.path(), &["x86_64-unknown-linux-gnu"]);
let out = run_anodizer(tmp.path(), &["release", "--host-targets", "--force"]);
assert!(
!out.status.success(),
"--host-targets without --snapshot/--dry-run must fail.\nstderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = strip_ansi(&String::from_utf8_lossy(&out.stderr));
assert!(
stderr.contains("--host-targets is only valid with --snapshot or --dry-run"),
"must explain the snapshot/dry-run gate.\nstderr:\n{stderr}"
);
}
#[test]
fn host_targets_allowed_with_dry_run() {
let tmp = TempDir::new().unwrap();
setup_fixture_with_targets(tmp.path(), &["x86_64-unknown-linux-gnu"]);
let out = run_anodizer(
tmp.path(),
&[
"release",
"--dry-run",
"--host-targets",
"--skip=build,archive,sign,checksum,sbom,docker",
],
);
let stderr = strip_ansi(&String::from_utf8_lossy(&out.stderr));
assert!(
!stderr.contains("--host-targets is only valid with"),
"the safety gate must NOT trip under --dry-run.\nstderr:\n{stderr}"
);
}
#[test]
fn host_targets_empty_result_hard_errors_on_linux() {
if anodizer_core::partial::host_is_apple(&host_target()) {
return;
}
let tmp = TempDir::new().unwrap();
setup_fixture_with_targets(tmp.path(), &["x86_64-apple-darwin", "aarch64-apple-darwin"]);
let out = run_anodizer(
tmp.path(),
&["release", "--snapshot", "--host-targets", "--force"],
);
assert!(
!out.status.success(),
"apple-only config on a non-apple host must hard-error.\nstderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = strip_ansi(&String::from_utf8_lossy(&out.stderr));
assert!(
stderr.contains("none of the")
&& stderr.contains("can be built on this host")
&& stderr.contains("macOS host"),
"empty-result guard must name the cause and the macOS-host remedy.\nstderr:\n{stderr}"
);
}
#[test]
fn host_targets_logs_skipped_apple_and_msvc_on_linux() {
let host = host_target();
if anodizer_core::partial::host_is_apple(&host)
|| anodizer_core::partial::host_is_windows(&host)
{
return;
}
let tmp = TempDir::new().unwrap();
setup_fixture_with_targets(
tmp.path(),
&[
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
],
);
let out = run_anodizer(
tmp.path(),
&[
"release",
"--snapshot",
"--host-targets",
"--force",
"--skip=build,archive,sign,checksum,sbom,docker",
],
);
let stderr = strip_ansi(&String::from_utf8_lossy(&out.stderr));
assert!(
stderr.contains("host-targets: skipping 3 target(s)")
&& stderr.contains("x86_64-apple-darwin")
&& stderr.contains("aarch64-apple-darwin")
&& stderr.contains("apple targets require a macOS host")
&& stderr.contains("x86_64-pc-windows-msvc")
&& stderr.contains("windows-msvc targets require a Windows host"),
"must emit one grouped skip line naming both apple + msvc reasons.\nstderr:\n{stderr}"
);
let skip_line = stderr
.lines()
.find(|l| l.contains("host-targets: skipping"))
.expect("a skip line is emitted");
assert!(
!skip_line.contains("x86_64-pc-windows-gnu"),
"windows-gnu must NOT be in the skip set (cross-builds from linux).\nskip line:\n{skip_line}"
);
}
trait CliParse {
fn try_parse_from_with_args(args: impl IntoIterator<Item = &'static str>) -> bool;
}
impl CliParse for anodizer_cli::Cli {
fn try_parse_from_with_args(args: impl IntoIterator<Item = &'static str>) -> bool {
use clap::Parser;
anodizer_cli::Cli::try_parse_from(args).is_ok()
}
}