use super::StageId;
use anodizer_core::util::find_binary;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(super) struct InstallerToolGate {
pub available: Vec<StageId>,
pub skipped: Vec<(StageId, String)>,
}
fn stage_primary_tool(stage: StageId) -> Option<&'static str> {
match stage {
StageId::Nfpm => Some("nfpm"),
StageId::Makeself => Some("makeself"),
StageId::Srpm => Some("rpmbuild"),
StageId::Nsis => Some("makensis"),
StageId::Dmg => Some(if cfg!(target_os = "macos") {
"hdiutil"
} else {
"genisoimage"
}),
StageId::Pkg => Some(if cfg!(target_os = "macos") {
"pkgbuild"
} else {
"xar"
}),
_ => None,
}
}
fn msi_required_tools(msi_tools: &[String]) -> Vec<&str> {
msi_tools.iter().map(String::as_str).collect()
}
pub fn installer_stages() -> Vec<StageId> {
vec![
StageId::Nfpm,
StageId::Makeself,
StageId::Srpm,
StageId::Msi,
StageId::Nsis,
StageId::Dmg,
StageId::Pkg,
]
}
#[cfg(test)]
pub(super) fn is_installer_stage(stage: StageId) -> bool {
stage == StageId::Msi || stage_primary_tool(stage).is_some()
}
impl InstallerToolGate {
pub(super) fn explicitly_skipped(&self, explicit: &[StageId]) -> Vec<(StageId, String)> {
self.skipped
.iter()
.filter(|(stage, _)| explicit.contains(stage))
.cloned()
.collect()
}
}
pub(super) fn missing_tool_error(skipped: &[(StageId, String)]) -> String {
let detail = skipped
.iter()
.map(|(stage, tool)| format!("`{}` (needs `{}`)", stage.as_str(), tool))
.collect::<Vec<_>>()
.join(", ");
format!(
"installer stage(s) requested via --stages but their tool is not on PATH: {detail}. \
The determinism gate cannot byte-verify these formats, and a silent skip would be \
false coverage (the exact failure mode that hid the macOS/Windows installers from \
every release). Provision the missing tool on this shard (e.g. choco install \
wixtoolset nsis on Windows) or remove the stage from --stages."
)
}
pub(super) fn host_tool_probe(tool: &str) -> bool {
find_binary(tool)
}
pub(super) fn filter_available_with_probe<P>(
requested: &[StageId],
msi_tools: &[String],
probe: P,
) -> InstallerToolGate
where
P: Fn(&str) -> bool,
{
let mut gate = InstallerToolGate::default();
for &stage in requested {
if stage == StageId::Msi {
let required = msi_required_tools(msi_tools);
match required.iter().find(|tool| !probe(tool)) {
None => gate.available.push(stage),
Some(missing) => gate.skipped.push((stage, missing.to_string())),
}
continue;
}
match stage_primary_tool(stage) {
None => gate.available.push(stage),
Some(tool) => {
if probe(tool) {
gate.available.push(stage);
} else {
gate.skipped.push((stage, tool.to_string()));
}
}
}
}
gate
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installer_stages_covers_every_installer_family() {
let stages = installer_stages();
assert_eq!(stages.len(), 7);
for stage in stages {
assert!(
is_installer_stage(stage),
"installer_stages() emitted non-installer stage {:?}",
stage
);
}
}
#[test]
fn non_installer_stages_pass_through() {
let req = vec![StageId::Build, StageId::Archive, StageId::Checksum];
let gate = filter_available_with_probe(&req, &[], host_tool_probe);
assert_eq!(gate.available, req);
assert!(gate.skipped.is_empty());
}
#[test]
fn well_formed_partition_on_every_requested_stage() {
let req = installer_stages();
let msi_tools = vec!["candle".to_string(), "light".to_string()];
let gate = filter_available_with_probe(&req, &msi_tools, host_tool_probe);
assert_eq!(
gate.available.len() + gate.skipped.len(),
req.len(),
"every requested stage must land in exactly one bucket"
);
for (stage, tool) in &gate.skipped {
assert!(
is_installer_stage(*stage),
"skipped entry references non-installer stage {:?}",
stage
);
assert!(!tool.is_empty(), "missing-tool name must be non-empty");
}
}
#[test]
fn missing_tool_routes_every_installer_to_skipped() {
let req = vec![
StageId::Build, StageId::Nfpm,
StageId::Makeself,
StageId::Msi,
StageId::Dmg,
StageId::Pkg,
StageId::Archive, ];
let msi_tools = vec!["candle".to_string(), "light".to_string()];
let gate = filter_available_with_probe(&req, &msi_tools, |_| false);
assert_eq!(
gate.available,
vec![StageId::Build, StageId::Archive],
"non-installer stages must pass through even with missing probe"
);
let skipped_stages: Vec<StageId> = gate.skipped.iter().map(|(s, _)| *s).collect();
assert_eq!(
skipped_stages,
vec![
StageId::Nfpm,
StageId::Makeself,
StageId::Msi,
StageId::Dmg,
StageId::Pkg
],
"installer stages must land in `skipped` when their tool is missing"
);
let dmg_tool = if cfg!(target_os = "macos") {
"hdiutil"
} else {
"genisoimage"
};
let pkg_tool = if cfg!(target_os = "macos") {
"pkgbuild"
} else {
"xar"
};
assert_eq!(
gate.skipped
.iter()
.map(|(_, t)| t.as_str())
.collect::<Vec<_>>(),
vec!["nfpm", "makeself", "candle", dmg_tool, pkg_tool],
"each skipped entry must carry its missing-tool name"
);
}
#[test]
fn linux_installer_gate_probes_the_linux_fallback_tools() {
#[cfg(target_os = "linux")]
{
assert_eq!(stage_primary_tool(StageId::Dmg), Some("genisoimage"));
assert_eq!(stage_primary_tool(StageId::Pkg), Some("xar"));
assert_eq!(stage_primary_tool(StageId::Srpm), Some("rpmbuild"));
assert_eq!(stage_primary_tool(StageId::Nsis), Some("makensis"));
assert_eq!(stage_primary_tool(StageId::Msi), None);
}
}
#[test]
fn present_tool_routes_every_installer_to_available() {
let req = installer_stages();
let msi_tools = vec!["candle".to_string(), "light".to_string()];
let gate = filter_available_with_probe(&req, &msi_tools, |_| true);
assert_eq!(gate.available, req);
assert!(gate.skipped.is_empty());
}
#[test]
fn msi_v3_available_when_candle_and_light_present() {
let msi_tools = vec!["candle".to_string(), "light".to_string()];
let gate = filter_available_with_probe(&[StageId::Build, StageId::Msi], &msi_tools, |t| {
matches!(t, "candle" | "light")
});
assert_eq!(
gate.available,
vec![StageId::Build, StageId::Msi],
"v3 msi must stay available when candle+light are present"
);
assert!(gate.skipped.is_empty(), "no tool is missing");
}
#[test]
fn msi_v3_skips_when_one_resolved_tool_absent() {
let msi_tools = vec!["candle".to_string(), "light".to_string()];
let gate = filter_available_with_probe(&[StageId::Msi], &msi_tools, |t| t == "candle");
assert!(gate.available.is_empty());
assert_eq!(
gate.skipped,
vec![(StageId::Msi, "light".to_string())],
"the first missing resolved tool (`light`) must be reported"
);
}
#[test]
fn msi_v4_skips_when_wix_absent() {
let msi_tools = vec!["wix".to_string()];
let gate = filter_available_with_probe(&[StageId::Msi], &msi_tools, |_| false);
assert_eq!(gate.skipped, vec![(StageId::Msi, "wix".to_string())]);
}
#[test]
fn msi_available_when_no_active_config_yields_empty_tools() {
let gate = filter_available_with_probe(&[StageId::Msi], &[], |_| false);
assert_eq!(gate.available, vec![StageId::Msi]);
assert!(gate.skipped.is_empty());
}
#[test]
fn explicitly_skipped_filters_to_operator_requested_stages() {
let msi_tools = vec!["wixl".to_string()];
let gate =
filter_available_with_probe(&[StageId::Msi, StageId::Dmg], &msi_tools, |_| false);
let explicit = vec![StageId::Msi, StageId::Build];
let hard = gate.explicitly_skipped(&explicit);
let stages: Vec<StageId> = hard.iter().map(|(s, _)| *s).collect();
assert_eq!(
stages,
vec![StageId::Msi],
"only the explicitly-requested missing-tool stage hard-fails"
);
}
#[test]
fn explicitly_skipped_empty_when_nothing_requested() {
let msi_tools = vec!["wixl".to_string()];
let gate =
filter_available_with_probe(&[StageId::Msi, StageId::Nsis], &msi_tools, |_| false);
assert!(
gate.explicitly_skipped(&[]).is_empty(),
"no explicit request => no hard-fail even when tools are missing"
);
}
#[test]
fn missing_tool_error_names_every_stage_and_tool() {
let msg = missing_tool_error(&[
(StageId::Msi, "candle".to_string()),
(StageId::Nsis, "makensis".to_string()),
]);
assert!(msg.contains("msi") && msg.contains("candle"), "msg: {msg}");
assert!(
msg.contains("nsis") && msg.contains("makensis"),
"msg: {msg}"
);
assert!(
msg.contains("--stages"),
"message must tell the operator how to opt out: {msg}"
);
}
#[test]
fn is_installer_stage_matches_primary_tool_table() {
for stage in installer_stages() {
assert!(is_installer_stage(stage));
}
for stage in [
StageId::Build,
StageId::Source,
StageId::Archive,
StageId::Sbom,
StageId::Sign,
StageId::Checksum,
StageId::CargoPackage,
StageId::Docker,
StageId::Snapcraft,
StageId::Upx,
] {
assert!(
!is_installer_stage(stage),
"is_installer_stage({:?}) should be false",
stage
);
}
}
#[cfg(unix)]
#[test]
#[serial_test::serial(path_env)]
fn host_tool_probe_detects_present_tool_that_lacks_version_flag() {
use anodizer_core::test_helpers::fake_tool::FakeToolDir;
let tools = FakeToolDir::new();
tools.tool("hdiutil").exit(1).install();
let _path = tools.activate();
assert!(
host_tool_probe("hdiutil"),
"a tool present on PATH must be detected even when it fails --version"
);
assert!(
!host_tool_probe("anodizer-no-such-tool-zzz"),
"a genuinely absent tool must still report missing"
);
}
}