use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use crate::config::{Config, CrateConfig};
use crate::context::Context;
use crate::target::map_target;
const UNAME_OS_CASES: &[(&str, &str)] = &[
("Linux*", "linux"),
("Darwin*", "darwin"),
("MINGW*|MSYS*|CYGWIN*", "windows"),
("FreeBSD*", "freebsd"),
("NetBSD*", "netbsd"),
("OpenBSD*", "openbsd"),
("SunOS*", "solaris"),
("AIX*", "aix"),
];
const UNAME_ARCH_CASES: &[(&str, &str)] = &[
("x86_64|amd64", "amd64"),
("aarch64|arm64", "arm64"),
("armv7l|armv7", "armv7"),
("armv6l|armv6", "armv6"),
("i686|i586|i386", "386"),
("riscv64", "riscv64"),
("ppc64le", "ppc64le"),
("ppc64", "ppc64"),
("s390x", "s390x"),
("loongarch64", "loong64"),
("sparc64", "sparc64"),
];
const SEEDED_VARS: &[&str] = &[
"Os", "Arch", "Target", "Arm", "Arm64", "Amd64", "Mips", "I386",
];
pub const INSTALLER_TEMPLATE_VARS: &[&str] = &[
"InstallerAssetCases",
"InstallerDetectOsCases",
"InstallerDetectArchCases",
"InstallerSupportedPlatforms",
];
pub struct InstallerCases {
pub asset_cases: String,
pub detect_os_cases: String,
pub detect_arch_cases: String,
pub supported_platforms: String,
}
impl InstallerCases {
pub fn bind(&self, vars: &mut crate::template::TemplateVars) {
vars.set("InstallerAssetCases", &self.asset_cases);
vars.set("InstallerDetectOsCases", &self.detect_os_cases);
vars.set("InstallerDetectArchCases", &self.detect_arch_cases);
vars.set("InstallerSupportedPlatforms", &self.supported_platforms);
}
}
pub fn template_files_consume_installer_vars(ctx: &mut Context) -> bool {
let Some(entries) = ctx.config.template_files.clone() else {
return false;
};
entries
.iter()
.any(|entry| entry_reads_installer_vars(ctx, entry))
}
fn entry_reads_installer_vars(
ctx: &mut Context,
entry: &crate::config::TemplateFileConfig,
) -> bool {
const PROBE: &str = "\u{1}anodizer-installer-consumption-probe\u{1}";
let prior: Vec<(&str, Option<String>)> = INSTALLER_TEMPLATE_VARS
.iter()
.map(|k| (*k, ctx.template_vars().get(k).cloned()))
.collect();
let render_with = |ctx: &mut Context, value: &str| {
for k in INSTALLER_TEMPLATE_VARS {
ctx.template_vars_mut().set(k, value);
}
crate::template_file_render::render_templated_file_entry(
ctx,
entry,
"installer consumption probe",
)
};
let base = render_with(ctx, "");
let probe = render_with(ctx, PROBE);
for (k, value) in prior {
match value {
Some(v) => ctx.template_vars_mut().set(k, &v),
None => {
ctx.template_vars_mut().unset(k);
}
}
}
match (base, probe) {
(Ok(Some(a)), Ok(Some(b))) => {
a.rendered_contents != b.rendered_contents || a.rendered_dst != b.rendered_dst
}
(Ok(a), Ok(b)) => a.is_some() != b.is_some(),
_ => true,
}
}
pub fn render_installer_cases(ctx: &mut Context) -> Result<InstallerCases> {
let empty = || InstallerCases {
asset_cases: String::new(),
detect_os_cases: String::new(),
detect_arch_cases: String::new(),
supported_platforms: String::new(),
};
let Some(crate_cfg) = installer_crate(&ctx.config) else {
return Ok(empty());
};
let default_targets = ctx.config.effective_default_targets();
let prior: Vec<(&str, Option<String>)> = SEEDED_VARS
.iter()
.map(|k| (*k, ctx.template_vars().get(k).cloned()))
.collect();
let assets = crate::binstall::crate_archive_asset_names(&crate_cfg, &default_targets, ctx);
for (key, value) in prior {
match value {
Some(v) => ctx.template_vars_mut().set(key, &v),
None => {
ctx.template_vars_mut().unset(key);
}
}
}
let Some(assets) = assets? else {
return Ok(empty());
};
let mut arms: BTreeMap<String, (u8, String)> = BTreeMap::new();
let mut released_os: BTreeSet<String> = BTreeSet::new();
let mut released_arch: BTreeSet<String> = BTreeSet::new();
for (target, asset) in &assets {
let (os, arch) = map_target(target);
if arch == "all" {
continue;
}
released_os.insert(os.clone());
released_arch.insert(arch.clone());
record_arm(
&mut arms,
format!("{os}-{arch}"),
installer_arm_rank(target),
&asset.asset_name,
);
}
for (target, asset) in &assets {
let (os, arch) = map_target(target);
if arch != "all" {
continue;
}
released_os.insert(os.clone());
for cpu in ["amd64", "arm64"] {
released_arch.insert(cpu.to_string());
record_arm(
&mut arms,
format!("{os}-{cpu}"),
RANK_UNIVERSAL,
&asset.asset_name,
);
}
}
let detectable_os: BTreeSet<&str> = UNAME_OS_CASES.iter().map(|(_, t)| *t).collect();
let detectable_arch: BTreeSet<&str> = UNAME_ARCH_CASES.iter().map(|(_, t)| *t).collect();
let key_is_reachable = |key: &str| -> bool {
key.split_once('-')
.is_some_and(|(os, arch)| detectable_os.contains(os) && detectable_arch.contains(arch))
};
let stranded: Vec<&str> = assets
.iter()
.filter(|(target, _)| {
let (os, arch) = map_target(target);
arch != "all" && !key_is_reachable(&format!("{os}-{arch}"))
})
.map(|(target, _)| target.as_str())
.collect();
if !stranded.is_empty() {
ctx.logger("templatefiles").warn(&format!(
"installer script cannot detect released target(s) {}: their uname \
tokens are ambiguous or unmapped (`uname -m` reports the same \
token for both mips endiannesses; illumos reports `SunOS`), so \
their asset arms are unreachable — matching hosts get the \
unsupported-platform error",
stranded.join(", ")
));
}
let supported: Vec<&str> = arms
.keys()
.filter(|key| key_is_reachable(key))
.map(String::as_str)
.collect();
let lines: Vec<String> = arms
.iter()
.map(|(key, (_, asset))| format!(" {key})\n ARCHIVE=\"{asset}\"\n ;;"))
.collect();
Ok(InstallerCases {
asset_cases: lines.join("\n"),
detect_os_cases: render_uname_cases(UNAME_OS_CASES, &released_os),
detect_arch_cases: render_uname_cases(UNAME_ARCH_CASES, &released_arch),
supported_platforms: supported.join(" "),
})
}
fn render_uname_cases(table: &[(&str, &str)], released: &BTreeSet<String>) -> String {
table
.iter()
.filter(|(_, token)| released.contains(*token))
.map(|(pattern, token)| format!(" {pattern}) echo \"{token}\" ;;"))
.collect::<Vec<_>>()
.join("\n")
}
const RANK_UNIVERSAL: u8 = 2;
fn installer_arm_rank(target: &str) -> u8 {
if target.contains("musl") { 0 } else { 1 }
}
fn record_arm(arms: &mut BTreeMap<String, (u8, String)>, key: String, rank: u8, asset: &str) {
match arms.entry(key) {
std::collections::btree_map::Entry::Vacant(slot) => {
slot.insert((rank, asset.to_string()));
}
std::collections::btree_map::Entry::Occupied(mut slot) if rank < slot.get().0 => {
slot.insert((rank, asset.to_string()));
}
std::collections::btree_map::Entry::Occupied(_) => {}
}
}
pub fn installer_crate(config: &Config) -> Option<CrateConfig> {
let project = config.project_name.as_str();
let produces_project_binary = |c: &CrateConfig| -> bool {
crate::build_plan::planned_builds(c)
.map(|builds| builds.iter().any(|b| b.binary.as_deref() == Some(project)))
.unwrap_or(false)
};
config
.crate_universe()
.into_iter()
.find(|c| produces_project_binary(c) && crate::binstall::binstallable_archive(c).is_some())
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::archive_name::render_archive_asset_name;
use crate::config::{
ArchiveConfig, ArchivesConfig, BuildConfig, Config, Defaults, FormatOverride,
};
use crate::context::{Context, ContextOptions};
const ANODIZE_TARGETS: &[&str] = &[
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
];
fn anodize_ctx(name_template: Option<&str>) -> Context {
let primary = ArchiveConfig {
id: Some("default".to_string()),
name_template: name_template.map(str::to_string),
formats: Some(vec!["tar.gz".to_string()]),
format_overrides: Some(vec![FormatOverride {
os: "windows".to_string(),
formats: Some(vec!["zip".to_string()]),
}]),
ids: Some(vec!["anodizer".to_string()]),
..Default::default()
};
let extra = ArchiveConfig {
id: Some("extra".to_string()),
name_template: Some(
"{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}-extra".to_string(),
),
formats: Some(vec!["tar.xz".to_string(), "tar.zst".to_string()]),
ids: Some(vec!["anodizer".to_string()]),
..Default::default()
};
let crate_cfg = CrateConfig {
name: "anodizer".to_string(),
path: "crates/cli".to_string(),
builds: Some(vec![BuildConfig {
id: Some("anodizer".to_string()),
binary: Some("anodizer".to_string()),
..Default::default()
}]),
archives: ArchivesConfig::Configs(vec![primary, extra]),
..Default::default()
};
let config = Config {
project_name: "anodizer".to_string(),
defaults: Some(Defaults {
targets: Some(ANODIZE_TARGETS.iter().map(|s| s.to_string()).collect()),
..Default::default()
}),
crates: vec![crate_cfg],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
ctx.set_env_source(crate::MapEnvSource::new());
ctx.template_vars_mut().set("ProjectName", "anodizer");
ctx.template_vars_mut().set("Version", "0.13.0");
ctx
}
#[test]
fn bind_writes_exactly_the_installer_template_vars() {
let cases = InstallerCases {
asset_cases: "a".to_string(),
detect_os_cases: "b".to_string(),
detect_arch_cases: "c".to_string(),
supported_platforms: "d".to_string(),
};
let mut vars = crate::template::TemplateVars::new();
cases.bind(&mut vars);
for k in INSTALLER_TEMPLATE_VARS {
assert!(
vars.get(k).is_some_and(|v| !v.is_empty()),
"{k} must be bound"
);
}
assert_eq!(
vars.all().len(),
INSTALLER_TEMPLATE_VARS.len(),
"bind must write no keys beyond INSTALLER_TEMPLATE_VARS"
);
}
#[test]
fn template_files_consumption_probe_detects_real_bindings() {
use crate::config::TemplateFileConfig;
let tmp = tempfile::tempdir().unwrap();
let installer = tmp.path().join("install.sh.tera");
std::fs::write(&installer, "{{ InstallerAssetCases }}\n").unwrap();
let plain = tmp.path().join("notes.md.tera");
std::fs::write(&plain, "release {{ Version }} of {{ ProjectName }}\n").unwrap();
let entry = |src: &std::path::Path| TemplateFileConfig {
src: src.to_string_lossy().into_owned(),
dst: "out.txt".to_string(),
..Default::default()
};
let mut ctx = anodize_ctx(None);
ctx.template_vars_mut()
.set("InstallerAssetCases", "stale-from-stage");
ctx.config.template_files = Some(vec![entry(&plain)]);
assert!(
!template_files_consume_installer_vars(&mut ctx),
"a template binding no Installer* var must not count as a consumer"
);
ctx.config.template_files = Some(vec![entry(&plain), entry(&installer)]);
assert!(
template_files_consume_installer_vars(&mut ctx),
"a template interpolating an installer var is a consumer"
);
ctx.config.template_files = Some(vec![entry(&tmp.path().join("missing.tera"))]);
assert!(
template_files_consume_installer_vars(&mut ctx),
"an unreadable entry cannot prove non-consumption — stay loud"
);
assert_eq!(
ctx.template_vars()
.get("InstallerAssetCases")
.map(String::as_str),
Some("stale-from-stage"),
"the probe must restore the caller's bindings"
);
}
fn parse_arms(table: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut key: Option<String> = None;
for line in table.lines() {
let t = line.trim();
if let Some(k) = t.strip_suffix(')') {
key = Some(k.to_string());
} else if let Some(rest) = t.strip_prefix("ARCHIVE=\"") {
let asset = rest.trim_end_matches('"');
if let Some(k) = key.take() {
out.insert(k, asset.to_string());
}
}
}
out
}
#[test]
fn installer_arms_match_engine_asset_names_hyphen_template() {
let name_template = "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}";
let mut ctx = anodize_ctx(Some(name_template));
let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
let arms = parse_arms(&table);
assert_eq!(arms.len(), ANODIZE_TARGETS.len(), "one arm per target");
for target in ANODIZE_TARGETS {
let (os, arch) = map_target(target);
let key = format!("{os}-{arch}");
let format = if os == "windows" { "zip" } else { "tar.gz" };
let expected =
render_archive_asset_name(&mut ctx, name_template, target, format).unwrap();
assert_eq!(
arms.get(&key),
Some(&expected),
"installer arm '{key}' must equal the archive stage's asset name"
);
}
assert_eq!(
arms.get("linux-amd64").map(String::as_str),
Some("anodizer-0.13.0-linux-amd64.tar.gz")
);
assert_eq!(
arms.get("windows-amd64").map(String::as_str),
Some("anodizer-0.13.0-windows-amd64.zip")
);
}
#[test]
fn installer_arms_follow_engine_default_underscore_template() {
let mut ctx = anodize_ctx(None);
let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
let arms = parse_arms(&table);
for target in ANODIZE_TARGETS {
let (os, arch) = map_target(target);
let key = format!("{os}-{arch}");
let format = if os == "windows" { "zip" } else { "tar.gz" };
let expected = render_archive_asset_name(
&mut ctx,
crate::archive_name::DEFAULT_NAME_TEMPLATE,
target,
format,
)
.unwrap();
assert_eq!(arms.get(&key), Some(&expected));
}
assert_eq!(
arms.get("linux-amd64").map(String::as_str),
Some("anodizer_0.13.0_linux_amd64.tar.gz")
);
}
#[test]
fn installer_arms_carry_config_declared_amd64_variant() {
let mut ctx = anodize_ctx(None);
let mut env = std::collections::HashMap::new();
env.insert(
"x86_64-unknown-linux-gnu".to_string(),
std::collections::HashMap::from([(
"RUSTFLAGS".to_string(),
"-Ctarget-cpu=x86-64-v3".to_string(),
)]),
);
ctx.config.crates[0].builds.as_mut().unwrap()[0].env = Some(env);
let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
let arms = parse_arms(&table);
assert_eq!(
arms.get("linux-amd64").map(String::as_str),
Some("anodizer_0.13.0_linux_amd64v3.tar.gz"),
"tuned target's arm must carry the micro-arch suffix"
);
assert_eq!(
arms.get("darwin-amd64").map(String::as_str),
Some("anodizer_0.13.0_darwin_amd64.tar.gz"),
"untuned amd64 target stays baseline"
);
}
#[test]
fn render_restores_seed_vars() {
let mut ctx = anodize_ctx(Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"));
ctx.template_vars_mut().set("Os", "sentinel-os");
let _ = render_installer_cases(&mut ctx).unwrap();
assert_eq!(
ctx.template_vars().get("Os").map(String::as_str),
Some("sentinel-os"),
"Os must be restored after rendering the case table"
);
assert!(
ctx.template_vars().get("Target").is_none(),
"Target must be cleared back to unset after rendering"
);
}
#[test]
fn no_installer_crate_yields_empty_table() {
let config = Config {
project_name: "anodizer".to_string(),
crates: vec![CrateConfig {
name: "anodizer-core".to_string(),
path: "crates/core".to_string(),
..Default::default()
}],
..Default::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("ProjectName", "anodizer");
ctx.template_vars_mut().set("Version", "0.13.0");
let cases = render_installer_cases(&mut ctx).unwrap();
assert_eq!(cases.asset_cases, "");
assert_eq!(cases.detect_os_cases, "");
assert_eq!(cases.detect_arch_cases, "");
assert_eq!(cases.supported_platforms, "");
}
#[test]
fn uname_arch_aliases_round_trip_through_map_target() {
for (pattern, token) in UNAME_ARCH_CASES {
for alias in pattern.split('|') {
let (_, arch) = map_target(&format!("{alias}-unknown-linux-gnu"));
assert_eq!(
&arch, token,
"uname alias '{alias}' must map_target to its case token '{token}'"
);
}
}
}
#[test]
fn uname_os_tokens_round_trip_through_map_target() {
for (_, token) in UNAME_OS_CASES {
let triple = match *token {
"darwin" => "x86_64-apple-darwin".to_string(),
"windows" => "x86_64-pc-windows-msvc".to_string(),
"aix" => "powerpc64-ibm-aix".to_string(),
other => format!("x86_64-unknown-{other}"),
};
let (os, _) = map_target(&triple);
assert_eq!(
&os, token,
"uname OS token '{token}' must equal map_target's OS for {triple}"
);
}
}
#[test]
fn detect_cases_cover_every_asset_arm_key() {
let mut ctx = anodize_ctx(None);
let cases = render_installer_cases(&mut ctx).unwrap();
assert_eq!(
cases.detect_os_cases,
" Linux*) echo \"linux\" ;;\n\
\x20 Darwin*) echo \"darwin\" ;;\n\
\x20 MINGW*|MSYS*|CYGWIN*) echo \"windows\" ;;"
);
assert_eq!(
cases.detect_arch_cases,
" x86_64|amd64) echo \"amd64\" ;;\n\
\x20 aarch64|arm64) echo \"arm64\" ;;"
);
let os_tokens: Vec<&str> = cases
.detect_os_cases
.lines()
.filter_map(|l| l.split('"').nth(1))
.collect();
let arch_tokens: Vec<&str> = cases
.detect_arch_cases
.lines()
.filter_map(|l| l.split('"').nth(1))
.collect();
for key in parse_arms(&cases.asset_cases).keys() {
let (os, arch) = key.split_once('-').expect("key is os-arch");
assert!(os_tokens.contains(&os), "asset arm OS '{os}' undetectable");
assert!(
arch_tokens.contains(&arch),
"asset arm arch '{arch}' undetectable"
);
}
}
#[test]
fn universal_asset_fans_out_to_amd64_and_arm64_keys() {
let mut ctx = anodize_ctx(Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"));
ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
"darwin-universal".to_string(),
"aarch64-apple-darwin".to_string(),
]);
let cases = render_installer_cases(&mut ctx).unwrap();
let arms = parse_arms(&cases.asset_cases);
assert!(!arms.contains_key("darwin-all"), "no unmatchable 'all' key");
assert_eq!(
arms.get("darwin-amd64").map(String::as_str),
Some("anodizer-0.13.0-darwin-all.tar.gz"),
"amd64 hosts fall back to the universal asset"
);
assert_eq!(
arms.get("darwin-arm64").map(String::as_str),
Some("anodizer-0.13.0-darwin-arm64.tar.gz"),
"the arch-specific asset wins its own key over the universal"
);
}
#[test]
fn gnu_and_musl_same_arch_prefers_static_musl() {
let name_template = "{{ ProjectName }}-{{ Version }}-{{ Target }}";
let mut ctx = anodize_ctx(Some(name_template));
ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
"x86_64-unknown-linux-gnu".to_string(),
"x86_64-unknown-linux-musl".to_string(),
]);
let cases = render_installer_cases(&mut ctx).unwrap();
let arms = parse_arms(&cases.asset_cases);
assert_eq!(
arms.len(),
1,
"both libcs collapse onto the single linux-amd64 key: {arms:?}"
);
assert_eq!(
arms.get("linux-amd64").map(String::as_str),
Some("anodizer-0.13.0-x86_64-unknown-linux-musl.tar.gz"),
"the static musl build must win the shared linux-amd64 arm, not be dropped"
);
}
#[test]
fn gnu_and_musl_collision_resolves_under_workspaces_config() {
let name_template = "{{ ProjectName }}-{{ Version }}-{{ Target }}";
let mut ctx = anodize_ctx(Some(name_template));
ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
"x86_64-unknown-linux-gnu".to_string(),
"x86_64-unknown-linux-musl".to_string(),
]);
let moved: Vec<CrateConfig> = ctx.config.crates.drain(..).collect();
ctx.config.workspaces = Some(vec![crate::config::WorkspaceConfig {
name: "cli".to_string(),
crates: moved,
..Default::default()
}]);
let cases = render_installer_cases(&mut ctx).unwrap();
let arms = parse_arms(&cases.asset_cases);
assert_eq!(
arms.get("linux-amd64").map(String::as_str),
Some("anodizer-0.13.0-x86_64-unknown-linux-musl.tar.gz"),
"per-crate (workspaces) config must resolve the libc collision musl-first too"
);
}
#[test]
fn supported_platforms_lists_reachable_keys() {
let mut ctx = anodize_ctx(None);
let cases = render_installer_cases(&mut ctx).unwrap();
assert_eq!(
cases.supported_platforms,
"darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 \
windows-amd64 windows-arm64"
);
}
#[test]
fn undetectable_mips_target_warns_and_is_not_listed_supported() {
let mut ctx = anodize_ctx(None);
ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
"x86_64-unknown-linux-gnu".to_string(),
"mips64el-unknown-linux-gnuabi64".to_string(),
]);
let capture = crate::log::LogCapture::new();
ctx.with_log_capture(capture.clone());
let cases = render_installer_cases(&mut ctx).unwrap();
assert!(
parse_arms(&cases.asset_cases).contains_key("linux-mips64el"),
"asset arm for the mips target must still be emitted"
);
assert_eq!(cases.supported_platforms, "linux-amd64");
assert_eq!(capture.warn_count(), 1, "exactly one stranded-target warn");
assert!(
capture
.warn_messages()
.iter()
.any(|m| m.contains("mips64el-unknown-linux-gnuabi64")),
"warn must name the stranded target: {:?}",
capture.warn_messages()
);
}
#[test]
fn detectable_targets_render_no_stranded_warning() {
let mut ctx = anodize_ctx(None);
let capture = crate::log::LogCapture::new();
ctx.with_log_capture(capture.clone());
let _ = render_installer_cases(&mut ctx).unwrap();
assert_eq!(
capture.warn_count(),
0,
"no warning for detectable targets: {:?}",
capture.warn_messages()
);
}
}