use std::collections::BTreeMap;
use super::StageId;
use anodizer_core::tool_detect::on_path;
#[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::Appimage => Some("linuxdeploy"),
StageId::Flatpak => Some("flatpak-builder"),
StageId::Snapcraft => Some("snapcraft"),
StageId::Dmg => Some(if cfg!(target_os = "macos") {
"hdiutil"
} else {
"genisoimage"
}),
StageId::Pkg => Some(if cfg!(target_os = "macos") {
"pkgbuild"
} else {
"xar"
}),
_ => None,
}
}
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)], require_tools: bool) -> String {
let detail = skipped
.iter()
.map(|(stage, tool)| format!("`{}` (needs `{}`)", stage.as_str(), tool))
.collect::<Vec<_>>()
.join(", ");
let common = "The determinism gate cannot byte-verify the output of these stages, and a \
silent skip would be false coverage (the exact failure mode that hid the \
macOS/Windows installers from every release).";
if require_tools {
format!(
"stage(s) selected by the host-OS default have no backing tool on PATH: {detail}. \
{common} `--require-tools` forbids skipping a host-OS-native producer, so provision \
the missing tool on this shard (e.g. choco install wixtoolset nsis on Windows, \
the action's auto-install for linuxdeploy / flatpak-builder, or upx)."
)
} else {
format!(
"stage(s) requested via --stages but their tool is not on PATH: {detail}. \
{common} 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 {
on_path(tool)
}
pub(super) fn filter_available_with_probe<P>(
requested: &[StageId],
config_tools: &BTreeMap<StageId, Vec<String>>,
probe: P,
) -> InstallerToolGate
where
P: Fn(&str) -> bool,
{
let mut gate = InstallerToolGate::default();
for &stage in requested {
if let Some(required) = config_tools.get(&stage) {
match required.iter().find(|tool| !probe(tool)) {
None => gate.available.push(stage),
Some(missing) => gate.skipped.push((stage, missing.clone())),
}
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::*;
use std::collections::BTreeMap;
fn msi_map(tools: &[&str]) -> BTreeMap<StageId, Vec<String>> {
BTreeMap::from([(StageId::Msi, tools.iter().map(|t| t.to_string()).collect())])
}
#[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, &BTreeMap::new(), 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 gate =
filter_available_with_probe(&req, &msi_map(&["candle", "light"]), 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 gate = filter_available_with_probe(&req, &msi_map(&["candle", "light"]), |_| 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 gate = filter_available_with_probe(&req, &msi_map(&["candle", "light"]), |_| true);
assert_eq!(gate.available, req);
assert!(gate.skipped.is_empty());
}
#[test]
fn appimage_flatpak_route_to_skipped_when_tool_absent() {
let req = vec![StageId::Build, StageId::Appimage, StageId::Flatpak];
let gate = filter_available_with_probe(&req, &BTreeMap::new(), |_| false);
assert_eq!(gate.available, vec![StageId::Build]);
assert_eq!(
gate.skipped,
vec![
(StageId::Appimage, "linuxdeploy".to_string()),
(StageId::Flatpak, "flatpak-builder".to_string()),
],
"appimage/flatpak must skip with their toolchain binary named"
);
}
#[test]
fn appimage_flatpak_available_when_tool_present() {
let req = vec![StageId::Appimage, StageId::Flatpak];
let gate = filter_available_with_probe(&req, &BTreeMap::new(), |_| true);
assert_eq!(gate.available, req);
assert!(gate.skipped.is_empty());
}
#[test]
fn appimage_flatpak_are_not_in_installers_umbrella() {
let umbrella = installer_stages();
assert!(!umbrella.contains(&StageId::Appimage));
assert!(!umbrella.contains(&StageId::Flatpak));
}
#[test]
fn snapcraft_routes_to_skipped_when_tool_absent() {
let req = vec![StageId::Build, StageId::Snapcraft];
let gate = filter_available_with_probe(&req, &BTreeMap::new(), |_| false);
assert_eq!(gate.available, vec![StageId::Build]);
assert_eq!(
gate.skipped,
vec![(StageId::Snapcraft, "snapcraft".to_string())],
"snapcraft must skip with its toolchain binary named"
);
}
#[test]
fn snapcraft_available_when_tool_present() {
let req = vec![StageId::Snapcraft];
let gate = filter_available_with_probe(&req, &BTreeMap::new(), |_| true);
assert_eq!(gate.available, req);
assert!(gate.skipped.is_empty());
}
#[test]
fn snapcraft_is_not_in_installers_umbrella() {
assert!(!installer_stages().contains(&StageId::Snapcraft));
}
#[test]
fn msi_v3_available_when_candle_and_light_present() {
let gate = filter_available_with_probe(
&[StageId::Build, StageId::Msi],
&msi_map(&["candle", "light"]),
|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 gate =
filter_available_with_probe(&[StageId::Msi], &msi_map(&["candle", "light"]), |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 gate = filter_available_with_probe(&[StageId::Msi], &msi_map(&["wix"]), |_| 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], &BTreeMap::new(), |_| false);
assert_eq!(gate.available, vec![StageId::Msi]);
assert!(gate.skipped.is_empty());
}
#[test]
fn upx_is_config_gated_like_a_resolved_stage() {
let config_tools = BTreeMap::from([(StageId::Upx, vec!["upx".to_string()])]);
let absent =
filter_available_with_probe(&[StageId::Build, StageId::Upx], &config_tools, |_| false);
assert_eq!(absent.available, vec![StageId::Build]);
assert_eq!(absent.skipped, vec![(StageId::Upx, "upx".to_string())]);
let present =
filter_available_with_probe(&[StageId::Build, StageId::Upx], &config_tools, |_| true);
assert_eq!(present.available, vec![StageId::Build, StageId::Upx]);
assert!(present.skipped.is_empty());
}
#[test]
fn explicitly_skipped_filters_to_operator_requested_stages() {
let gate =
filter_available_with_probe(&[StageId::Msi, StageId::Dmg], &msi_map(&["wixl"]), |_| {
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 gate = filter_available_with_probe(
&[StageId::Msi, StageId::Nsis],
&msi_map(&["wixl"]),
|_| 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 skipped = [
(StageId::Msi, "candle".to_string()),
(StageId::Nsis, "makensis".to_string()),
];
let explicit = missing_tool_error(&skipped, false);
assert!(
explicit.contains("msi") && explicit.contains("candle"),
"msg: {explicit}"
);
assert!(
explicit.contains("nsis") && explicit.contains("makensis"),
"msg: {explicit}"
);
assert!(
explicit.contains("remove the stage from --stages"),
"operator-typed message must tell the operator how to opt out: {explicit}"
);
let required = missing_tool_error(&skipped, true);
assert!(
required.contains("msi") && required.contains("candle"),
"msg: {required}"
);
assert!(
required.contains("host-OS default") && required.contains("--require-tools"),
"require-tools message must name the host-default selection + flag: {required}"
);
assert!(
!required.contains("remove the stage from --stages"),
"require-tools remedy must NOT tell the operator to drop a stage they never typed: {required}"
);
}
#[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::Upx,
] {
assert!(
!is_installer_stage(stage),
"is_installer_stage({:?}) should be false",
stage
);
}
assert!(is_installer_stage(StageId::Snapcraft));
}
#[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"
);
}
}