use std::path::PathBuf;
use anodizer_core::EnvRequirement;
use anodizer_core::log::{StageLogger, Verbosity};
use anyhow::Result;
use super::preflight::{PreflightScope, collect_requirements};
#[derive(serde::Serialize, Debug, PartialEq, Eq)]
struct ToolRequirement {
any_of: Vec<String>,
advisory: bool,
}
#[derive(serde::Serialize, Debug)]
struct Tools {
schema_version: u32,
tools: Vec<ToolRequirement>,
}
const SCHEMA_VERSION: u32 = 1;
pub struct ToolsOpts {
pub config_override: Option<PathBuf>,
pub json: bool,
pub publish_only: bool,
pub skip: Vec<String>,
pub publishers: Vec<String>,
pub quiet: bool,
pub verbose: bool,
pub debug: bool,
}
fn tool_requirements(reqs: &[anodizer_core::SourcedRequirement]) -> Vec<ToolRequirement> {
let mut out: Vec<ToolRequirement> = Vec::new();
for sr in reqs {
let any_of = match &sr.requirement {
EnvRequirement::Tool { name } => vec![name.clone()],
EnvRequirement::ToolAnyOf { names } => names.clone(),
_ => continue,
};
match out.iter_mut().find(|r| r.any_of == any_of) {
Some(existing) => existing.advisory = existing.advisory && sr.advisory,
None => out.push(ToolRequirement {
any_of,
advisory: sr.advisory,
}),
}
}
out.sort_by(|a, b| a.any_of.cmp(&b.any_of));
out
}
pub fn run(opts: ToolsOpts) -> Result<()> {
let log = StageLogger::new(
"tools",
Verbosity::from_flags(opts.quiet, opts.verbose, opts.debug),
);
let ctx_opts = anodizer_core::context::ContextOptions {
skip_stages: opts.skip.clone(),
publisher_allowlist: opts.publishers.clone(),
quiet: opts.quiet,
verbose: opts.verbose,
debug: opts.debug,
dry_run: true,
snapshot: true,
..Default::default()
};
let (_config, ctx) =
super::helpers::init_merge_stage_ctx(opts.config_override.as_deref(), ctx_opts, &log)?;
let scope = if opts.publish_only {
PreflightScope::PublishOnly
} else {
PreflightScope::Full
};
let requirements = collect_requirements(&ctx, scope);
let tools = Tools {
schema_version: SCHEMA_VERSION,
tools: tool_requirements(&requirements),
};
if opts.json {
println!("{}", serde_json::to_string(&tools)?);
} else if tools.tools.is_empty() {
println!("(no external tools required by this config)");
} else {
for t in &tools.tools {
let suffix = if t.advisory { " (recommended)" } else { "" };
println!("{}{}", t.any_of.join(" | "), suffix);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::{KeyKind, SourcedRequirement};
fn tool(name: &str) -> EnvRequirement {
EnvRequirement::Tool {
name: name.to_string(),
}
}
#[test]
fn keeps_tool_and_tool_any_of_drops_others() {
let reqs = vec![
SourcedRequirement::new("stage:build", tool("cargo")),
SourcedRequirement::new("stage:nfpm", tool("nfpm")),
SourcedRequirement::new(
"stage:dmg",
EnvRequirement::ToolAnyOf {
names: vec!["genisoimage".into(), "mkisofs".into()],
},
),
SourcedRequirement::new(
"publish:cargo",
EnvRequirement::EnvAllOf {
vars: vec!["CARGO_REGISTRY_TOKEN".into()],
},
),
SourcedRequirement::new(
"stage:sign",
EnvRequirement::KeyEnv {
kind: KeyKind::Cosign,
var: "COSIGN_KEY".into(),
},
),
];
let tools = tool_requirements(&reqs);
let flat: Vec<&str> = tools
.iter()
.flat_map(|t| t.any_of.iter().map(|s| s.as_str()))
.collect();
assert!(flat.contains(&"cargo"));
assert!(flat.contains(&"nfpm"));
assert!(flat.contains(&"genisoimage"));
assert!(flat.contains(&"mkisofs"));
assert!(!flat.contains(&"CARGO_REGISTRY_TOKEN"));
assert!(!flat.contains(&"COSIGN_KEY"));
}
#[test]
fn any_of_group_is_preserved_as_one_requirement() {
let reqs = vec![SourcedRequirement::new(
"stage:dmg",
EnvRequirement::ToolAnyOf {
names: vec!["hdiutil".into(), "genisoimage".into(), "mkisofs".into()],
},
)];
let tools = tool_requirements(&reqs);
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].any_of, vec!["hdiutil", "genisoimage", "mkisofs"]);
}
#[test]
fn duplicate_tool_requirements_are_deduped() {
let reqs = vec![
SourcedRequirement::new("stage:build", tool("cargo")),
SourcedRequirement::new("publish:cargo", tool("cargo")),
];
let tools = tool_requirements(&reqs);
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].any_of, vec!["cargo"]);
}
#[test]
fn output_is_sorted_for_stability() {
let reqs = vec![
SourcedRequirement::new("s", tool("syft")),
SourcedRequirement::new("s", tool("cosign")),
SourcedRequirement::new("s", tool("cargo")),
];
let tools = tool_requirements(&reqs);
let names: Vec<&str> = tools.iter().map(|t| t.any_of[0].as_str()).collect();
assert_eq!(names, vec!["cargo", "cosign", "syft"]);
}
#[test]
fn cross_config_emits_zigbuild_and_zig_in_json() {
use anodizer_core::test_helpers::TestContextBuilder;
if anodizer_core::partial::detect_host_target()
.as_deref()
.unwrap_or_default()
!= "x86_64-unknown-linux-gnu"
{
return;
}
let krate: anodizer_core::config::CrateConfig = serde_yaml_ng::from_str(
r#"
name: app
builds:
- binary: app
targets: [aarch64-unknown-linux-gnu]
"#,
)
.expect("crate config yaml");
let ctx = TestContextBuilder::new().crates(vec![krate]).build();
let reqs = collect_requirements(&ctx, PreflightScope::Full);
let tools = Tools {
schema_version: SCHEMA_VERSION,
tools: tool_requirements(&reqs),
};
let json = serde_json::to_string(&tools).unwrap();
assert!(
json.contains(r#"["cargo-zigbuild"]"#),
"tools JSON must list cargo-zigbuild: {json}"
);
assert!(
json.contains(r#"["zig"]"#),
"tools JSON must list zig: {json}"
);
assert!(
json.contains(r#"["cargo"]"#),
"tools JSON must still list cargo: {json}"
);
let zig = tools
.tools
.iter()
.find(|t| t.any_of == vec!["zig".to_string()])
.expect("zig present");
assert!(zig.advisory, "zig must be advisory in the tools emit");
let cargo = tools
.tools
.iter()
.find(|t| t.any_of == vec!["cargo".to_string()])
.expect("cargo present");
assert!(
!cargo.advisory,
"cargo must stay required in the tools emit"
);
}
#[test]
fn json_envelope_shape() {
let tools = Tools {
schema_version: SCHEMA_VERSION,
tools: vec![ToolRequirement {
any_of: vec!["cargo".into()],
advisory: false,
}],
};
let json = serde_json::to_string(&tools).unwrap();
assert!(json.contains(r#""schema_version":1"#));
assert!(json.contains(r#""tools":[{"any_of":["cargo"],"advisory":false}]"#));
}
#[test]
fn advisory_flag_is_anded_across_sources() {
let reqs = vec![
SourcedRequirement::new_advisory("stage:build", tool("zig")),
SourcedRequirement::new_advisory("stage:build", tool("cc")),
SourcedRequirement::new("stage:other", tool("cc")),
];
let tools = tool_requirements(&reqs);
let advisory_of = |name: &str| {
tools
.iter()
.find(|t| t.any_of == vec![name.to_string()])
.unwrap_or_else(|| panic!("missing tool {name}"))
.advisory
};
assert!(advisory_of("zig"), "zig: only advisory sources ⇒ advisory");
assert!(
!advisory_of("cc"),
"cc: one hard source ⇒ required despite an advisory source"
);
}
}