use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
pub const SIDE_EFFECT_STAGES: &[&str] = &[
"release",
"docker",
"docker-sign",
"publish",
"blob",
"snapcraft-publish",
"announce",
];
pub fn compute_skip_arg(extra: &[&str]) -> String {
let mut merged: Vec<&str> = Vec::with_capacity(SIDE_EFFECT_STAGES.len() + extra.len());
for &name in SIDE_EFFECT_STAGES {
if !merged.contains(&name) {
merged.push(name);
}
}
for &name in extra {
if !merged.contains(&name) {
merged.push(name);
}
}
format!("--skip={}", merged.join(","))
}
pub fn run_build_pipeline_subprocess(
anodize_binary: &Path,
worktree_path: &Path,
env: &HashMap<String, String>,
targets: Option<&[String]>,
extra_skip: &[String],
snapshot: bool,
) -> Result<()> {
let mut cmd = build_subprocess_command(
anodize_binary,
worktree_path,
env,
targets,
extra_skip,
snapshot,
);
tracing::debug!(
args = ?cmd.get_args(),
worktree = %worktree_path.display(),
"spawning anodize release child for determinism harness",
);
let status = cmd
.status()
.context("spawning anodize release for determinism harness")?;
anyhow::ensure!(
status.success(),
"harness build pipeline failed in worktree {} (exit {:?})",
worktree_path.display(),
status.code()
);
Ok(())
}
fn build_subprocess_command(
anodize_binary: &Path,
worktree_path: &Path,
env: &HashMap<String, String>,
targets: Option<&[String]>,
extra_skip: &[String],
snapshot: bool,
) -> Command {
let mut cmd = Command::new(anodize_binary);
let extra_refs: Vec<&str> = extra_skip.iter().map(String::as_str).collect();
cmd.arg("release");
if snapshot {
cmd.arg("--snapshot");
}
cmd.arg(compute_skip_arg(&extra_refs));
if let Some(list) = targets
&& !list.is_empty()
{
cmd.arg(format!("--targets={}", list.join(",")));
}
cmd.current_dir(worktree_path);
cmd.env_clear();
for (k, v) in env {
cmd.env(k, v);
}
cmd
}
pub fn current_anodize_binary() -> Result<PathBuf> {
std::env::current_exe().context("locating the currently-running anodize binary")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_binary_resolves_to_a_real_file() {
let p = current_anodize_binary().unwrap();
assert!(p.exists(), "current_exe should point at a real file");
}
#[test]
fn run_build_pipeline_subprocess_fails_when_binary_missing() {
let env = HashMap::new();
let worktree = std::env::temp_dir();
let bogus = PathBuf::from("/nonexistent/anodize-binary-for-tests");
let res = run_build_pipeline_subprocess(&bogus, &worktree, &env, None, &[], true);
assert!(
res.is_err(),
"missing binary should surface as an error, not a panic"
);
}
#[test]
fn subprocess_command_omits_targets_when_none() {
let env = HashMap::new();
let cmd = build_subprocess_command(
&PathBuf::from("/usr/bin/anodize"),
&std::env::temp_dir(),
&env,
None,
&[],
true,
);
let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
assert!(
args.iter().all(|a| !a.starts_with("--targets")),
"expected no --targets argument; got {args:?}"
);
assert!(
args.contains(&"--snapshot"),
"argv missing --snapshot: {args:?}"
);
assert!(
args.iter().any(|a| a.starts_with("--skip=")),
"argv missing --skip=...: {args:?}"
);
}
#[test]
fn subprocess_command_propagates_targets_csv() {
let env = HashMap::new();
let triples = vec![
"x86_64-apple-darwin".to_string(),
"aarch64-apple-darwin".to_string(),
];
let cmd = build_subprocess_command(
&PathBuf::from("/usr/bin/anodize"),
&std::env::temp_dir(),
&env,
Some(&triples),
&[],
true,
);
let args: Vec<String> = cmd
.get_args()
.map(|s| s.to_str().expect("ascii").to_string())
.collect();
assert!(
args.iter()
.any(|a| a == "--targets=x86_64-apple-darwin,aarch64-apple-darwin"),
"expected joined --targets= argument; got {args:?}"
);
}
#[test]
fn subprocess_command_drops_targets_when_list_is_empty() {
let env = HashMap::new();
let empty: Vec<String> = Vec::new();
let cmd = build_subprocess_command(
&PathBuf::from("/usr/bin/anodize"),
&std::env::temp_dir(),
&env,
Some(&empty),
&[],
true,
);
let args: Vec<String> = cmd
.get_args()
.map(|s| s.to_str().expect("ascii").to_string())
.collect();
assert!(
args.iter().all(|a| !a.starts_with("--targets")),
"empty target slice should omit --targets entirely; got {args:?}"
);
}
#[test]
fn subprocess_command_drops_snapshot_when_disabled() {
let env = HashMap::new();
let cmd = build_subprocess_command(
&PathBuf::from("/usr/bin/anodize"),
&std::env::temp_dir(),
&env,
None,
&[],
false,
);
let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
assert!(
!args.contains(&"--snapshot"),
"snapshot=false should drop --snapshot; got {args:?}"
);
assert!(
args.iter().any(|a| a.starts_with("--skip=")),
"argv still needs --skip=...: {args:?}"
);
assert_eq!(args[0], "release", "argv must lead with `release`");
}
#[test]
fn side_effect_stages_covers_every_known_publish_side_effect() {
let expected = [
"release",
"docker",
"docker-sign",
"publish",
"blob",
"snapcraft-publish",
"announce",
];
for name in expected {
assert!(
SIDE_EFFECT_STAGES.contains(&name),
"SIDE_EFFECT_STAGES missing known publish-side stage `{name}`"
);
}
}
#[test]
fn compute_skip_arg_starts_with_skip_flag() {
let arg = compute_skip_arg(&[]);
assert!(
arg.starts_with("--skip="),
"expected --skip= prefix, got `{arg}`"
);
assert!(arg.len() > "--skip=".len(), "skip list must not be empty");
}
#[test]
fn compute_skip_arg_round_trips_through_comma_join() {
let arg = compute_skip_arg(&[]);
let list = arg
.trim_start_matches("--skip=")
.split(',')
.collect::<Vec<_>>();
assert_eq!(list.len(), SIDE_EFFECT_STAGES.len());
for (a, b) in list.iter().zip(SIDE_EFFECT_STAGES.iter()) {
assert_eq!(a, b);
}
}
#[test]
fn compute_skip_arg_includes_side_effects_and_extra() {
let extra = ["nfpm".to_string(), "msi".to_string(), "dmg".to_string()];
let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
let arg = compute_skip_arg(&extra_refs);
let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
for &name in SIDE_EFFECT_STAGES {
assert!(
list.contains(&name),
"merged skip list missing side-effect stage `{name}`: {list:?}"
);
}
for name in ["nfpm", "msi", "dmg"] {
assert!(
list.contains(&name),
"merged skip list missing extra stage `{name}`: {list:?}"
);
}
}
#[test]
fn compute_skip_arg_dedupes_overlap() {
let extra = ["release".to_string(), "nfpm".to_string()];
let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
let arg = compute_skip_arg(&extra_refs);
let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
let release_count = list.iter().filter(|&&s| s == "release").count();
assert_eq!(
release_count, 1,
"expected `release` exactly once in merged skip list, got {release_count} in {list:?}"
);
assert!(
list.contains(&"nfpm"),
"merged list missing extra entry `nfpm`: {list:?}"
);
}
#[test]
fn side_effect_stages_are_all_valid_release_skip_values() {
use crate::context::VALID_RELEASE_SKIPS;
for &name in SIDE_EFFECT_STAGES {
assert!(
VALID_RELEASE_SKIPS.contains(&name),
"SIDE_EFFECT_STAGES contains `{name}` but VALID_RELEASE_SKIPS does not — \
the harness would fail at `anodize release --skip=<list>` invocation. \
Add `{name}` to VALID_RELEASE_SKIPS in crates/core/src/context.rs."
);
}
}
}