Skip to main content

anodizer_core/
installer.rs

1//! Remote `curl | sh` installer support — derive the per-target archive asset
2//! filenames the installer downloads from the engine, so the script never
3//! hardcodes (and silently drifts from) the names the archive stage uploads.
4//!
5//! A remote installer's whole job is to fetch the right release asset for the
6//! user's machine. The machine half (`uname -s`/`uname -m` → `os`/`arch`) must
7//! stay in shell, but the NAME of each asset is something anodize already knows
8//! exactly — it is whatever the archive stage renders from
9//! `archive.name_template` + `format_overrides`. Hand-rolling that name in shell
10//! is the same defect class as a hand-written cargo-binstall `pkg_url`: the
11//! moment the template or a format override changes, every `curl | sh` user
12//! gets a 404. This module renders the names through the one engine SSOT so the
13//! installer's URLs resolve by construction.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use anyhow::Result;
18
19use crate::config::{Config, CrateConfig};
20use crate::context::Context;
21use crate::target::map_target;
22
23/// `uname -s` glob patterns → the OS token [`map_target`] emits, so the
24/// installer's `detect_os` case arms are generated from the same vocabulary
25/// that keys the asset arms — a hand-written shell copy of this mapping is the
26/// drift channel that strands a released target behind an "unsupported
27/// platform" error.
28///
29/// `SunOS` maps to `solaris` only: illumos hosts also report `SunOS`, so an
30/// illumos-only release stays undetectable rather than mislabeling a Solaris
31/// host. Android/iOS have no `curl | sh` story and get no arm.
32const UNAME_OS_CASES: &[(&str, &str)] = &[
33    ("Linux*", "linux"),
34    ("Darwin*", "darwin"),
35    ("MINGW*|MSYS*|CYGWIN*", "windows"),
36    ("FreeBSD*", "freebsd"),
37    ("NetBSD*", "netbsd"),
38    ("OpenBSD*", "openbsd"),
39    ("SunOS*", "solaris"),
40    ("AIX*", "aix"),
41];
42
43/// `uname -m` alias patterns → the arch token [`map_target`] emits.
44///
45/// The mips family is deliberately absent: `uname -m` reports `mips`/`mips64`
46/// on both endiannesses, so a generated arm could fetch the wrong-endian
47/// binary — worse than the explicit "unsupported" fallthrough. `all` (the
48/// darwin-universal synthetic) never appears here either; universal assets are
49/// fanned out to the amd64/arm64 keys at render time instead.
50const UNAME_ARCH_CASES: &[(&str, &str)] = &[
51    ("x86_64|amd64", "amd64"),
52    ("aarch64|arm64", "arm64"),
53    ("armv7l|armv7", "armv7"),
54    ("armv6l|armv6", "armv6"),
55    ("i686|i586|i386", "386"),
56    ("riscv64", "riscv64"),
57    ("ppc64le", "ppc64le"),
58    ("ppc64", "ppc64"),
59    ("s390x", "s390x"),
60    ("loongarch64", "loong64"),
61    ("sparc64", "sparc64"),
62];
63
64/// Template-var keys [`render_installer_cases`] mutates while rendering each
65/// target's asset name (via `seed_target_vars` inside
66/// [`crate::archive_name::render_archive_asset_name`]); snapshotted and restored
67/// so a sibling `template_files:` entry that reads `{{ Os }}` / `{{ Arch }}`
68/// still sees its own values, not the last target's.
69const SEEDED_VARS: &[&str] = &[
70    "Os", "Arch", "Target", "Arm", "Arm64", "Amd64", "Mips", "I386",
71];
72
73/// The template-var keys the templatefiles stage binds [`InstallerCases`] to
74/// ([`InstallerCases::bind`]) — the full surface through which a
75/// `template_files:` entry can consume the engine-derived installer tables.
76pub const INSTALLER_TEMPLATE_VARS: &[&str] = &[
77    "InstallerAssetCases",
78    "InstallerDetectOsCases",
79    "InstallerDetectArchCases",
80    "InstallerSupportedPlatforms",
81];
82
83/// The engine-generated `case` arm snippets a `curl | sh` installer template
84/// consumes — asset names AND the `uname`→token detection vocabulary, all
85/// derived from the release's own targets so neither half can drift from the
86/// other.
87pub struct InstallerCases {
88    /// `case "${OS}-${ARCH}"` arms mapping each released pair to its exact
89    /// asset filename (bind to `InstallerAssetCases`).
90    pub asset_cases: String,
91    /// `case "$(uname -s)"` arms mapping host kernel names to the OS tokens
92    /// the asset arms are keyed by (bind to `InstallerDetectOsCases`).
93    pub detect_os_cases: String,
94    /// `case "$(uname -m)"` arms mapping machine names to the arch tokens the
95    /// asset arms are keyed by (bind to `InstallerDetectArchCases`).
96    pub detect_arch_cases: String,
97    /// The reachable asset-arm keys — the `os-arch` pairs the detect arms can
98    /// actually emit — space-joined in arm order (bind to
99    /// `InstallerSupportedPlatforms`). Lets the script's error paths list the
100    /// prebuilt binaries that DO exist instead of a bare "unsupported
101    /// platform".
102    pub supported_platforms: String,
103}
104
105impl InstallerCases {
106    /// Bind the four case tables to their template vars — the keys listed in
107    /// [`INSTALLER_TEMPLATE_VARS`] — so the templatefiles stage and any
108    /// consumption probe share one binding surface.
109    pub fn bind(&self, vars: &mut crate::template::TemplateVars) {
110        vars.set("InstallerAssetCases", &self.asset_cases);
111        vars.set("InstallerDetectOsCases", &self.detect_os_cases);
112        vars.set("InstallerDetectArchCases", &self.detect_arch_cases);
113        vars.set("InstallerSupportedPlatforms", &self.supported_platforms);
114    }
115}
116
117/// Whether any `template_files:` entry actually READS one of the installer
118/// case vars ([`INSTALLER_TEMPLATE_VARS`]).
119///
120/// Each entry is rendered twice through the real entry renderer
121/// ([`crate::template_file_render::render_templated_file_entry`], so skip
122/// semantics and template-dialect translation match the templatefiles stage
123/// exactly) — once with the installer vars bound empty, once bound to a
124/// sentinel. Differing output means the entry consumes at least one of the
125/// vars; probing through the render replaces any regex over template syntax.
126/// An entry that fails to render (missing src, template error) counts as
127/// consuming: non-consumption cannot be proven, and staying loud is the
128/// fail-safe for a gate that silences a 404-class validation.
129///
130/// All touched vars are restored, so the caller's template scope is
131/// unchanged.
132pub fn template_files_consume_installer_vars(ctx: &mut Context) -> bool {
133    let Some(entries) = ctx.config.template_files.clone() else {
134        return false;
135    };
136    entries
137        .iter()
138        .any(|entry| entry_reads_installer_vars(ctx, entry))
139}
140
141fn entry_reads_installer_vars(
142    ctx: &mut Context,
143    entry: &crate::config::TemplateFileConfig,
144) -> bool {
145    const PROBE: &str = "\u{1}anodizer-installer-consumption-probe\u{1}";
146    let prior: Vec<(&str, Option<String>)> = INSTALLER_TEMPLATE_VARS
147        .iter()
148        .map(|k| (*k, ctx.template_vars().get(k).cloned()))
149        .collect();
150    let render_with = |ctx: &mut Context, value: &str| {
151        for k in INSTALLER_TEMPLATE_VARS {
152            ctx.template_vars_mut().set(k, value);
153        }
154        crate::template_file_render::render_templated_file_entry(
155            ctx,
156            entry,
157            "installer consumption probe",
158        )
159    };
160    let base = render_with(ctx, "");
161    let probe = render_with(ctx, PROBE);
162    for (k, value) in prior {
163        match value {
164            Some(v) => ctx.template_vars_mut().set(k, &v),
165            None => {
166                ctx.template_vars_mut().unset(k);
167            }
168        }
169    }
170    match (base, probe) {
171        (Ok(Some(a)), Ok(Some(b))) => {
172            a.rendered_contents != b.rendered_contents || a.rendered_dst != b.rendered_dst
173        }
174        (Ok(a), Ok(b)) => a.is_some() != b.is_some(),
175        _ => true,
176    }
177}
178
179/// Render the installer's POSIX-`sh` case-arm snippets: the `os-arch` →
180/// asset-filename table plus the `uname -s`/`uname -m` detection arms for
181/// every released target, baked with the version vars currently set on `ctx`.
182///
183/// The asset value is the same one
184/// [`crate::binstall::crate_archive_asset_names`] derives for cargo-binstall's
185/// `pkg_url`, so a `curl | sh` download URL built from it resolves to a real
186/// asset by construction — the installer can never hardcode a name that 404s
187/// when the archive `name_template` / `format_overrides` change. The detection
188/// arms are generated from the SAME target set and the same [`map_target`]
189/// vocabulary that keys the asset arms, so a released target's arm cannot be
190/// stranded behind a hand-written shell mapping that never emits its key, and
191/// a vocabulary rename in [`map_target`] moves both halves together.
192///
193/// Two families are undetectable by construction and get NO detect arm: the
194/// mips family (`uname -m` reports `mips`/`mips64` for both endiannesses, so
195/// an arm could fetch the wrong-endian binary) and illumos (`uname -s` is
196/// `SunOS`, mapped to `solaris` only). Releasing such a target leaves its
197/// asset arm unreachable; a default-visible warning names the stranded
198/// target(s) so the release operator knows those hosts fall through to the
199/// unsupported-platform error.
200///
201/// A `darwin-universal` build's asset is fanned out to the `darwin-amd64` /
202/// `darwin-arm64` keys (real `uname -m` values), with arch-specific assets
203/// taking precedence — a universal-only release is installable on both CPU
204/// families instead of erroring on an unmatchable `darwin-all` key.
205///
206/// No snippet carries a trailing `*)` arm; the installer template owns each
207/// fallback error arm. All three strings are empty when no installer crate /
208/// asset set resolves, leaving non-installer template files unaffected.
209pub fn render_installer_cases(ctx: &mut Context) -> Result<InstallerCases> {
210    let empty = || InstallerCases {
211        asset_cases: String::new(),
212        detect_os_cases: String::new(),
213        detect_arch_cases: String::new(),
214        supported_platforms: String::new(),
215    };
216    let Some(crate_cfg) = installer_crate(&ctx.config) else {
217        return Ok(empty());
218    };
219    let default_targets = ctx.config.effective_default_targets();
220
221    // Snapshot the per-target seed vars so rendering the table cannot leak the
222    // last target's `Os`/`Arch`/… into the surrounding template_files render.
223    let prior: Vec<(&str, Option<String>)> = SEEDED_VARS
224        .iter()
225        .map(|k| (*k, ctx.template_vars().get(k).cloned()))
226        .collect();
227    let assets = crate::binstall::crate_archive_asset_names(&crate_cfg, &default_targets, ctx);
228    for (key, value) in prior {
229        match value {
230            Some(v) => ctx.template_vars_mut().set(key, &v),
231            None => {
232                ctx.template_vars_mut().unset(key);
233            }
234        }
235    }
236    let Some(assets) = assets? else {
237        return Ok(empty());
238    };
239
240    // Collapse target triples to the installer's `os-arch` key vocabulary.
241    // Several triples can alias onto one key: two libc/ABI variants of the same
242    // os+arch (`*-linux-gnu` and `*-linux-musl` both key `linux-amd64`), and the
243    // `all` universal fanning into amd64/arm64. The installer detects only OS +
244    // arch — it has no libc probe — so exactly one asset can answer each key.
245    // [`record_arm`] resolves every collision by [`installer_arm_rank`] (lower
246    // wins) rather than first-writer-wins, so the choice is order-independent
247    // and a libc variant is never silently dropped by iteration order. Universal
248    // (`all`) assets carry the worst rank: they only fill amd64/arm64 keys no
249    // real arch-specific build claimed, since no `uname -m` can ever produce
250    // "all".
251    let mut arms: BTreeMap<String, (u8, String)> = BTreeMap::new();
252    let mut released_os: BTreeSet<String> = BTreeSet::new();
253    let mut released_arch: BTreeSet<String> = BTreeSet::new();
254    for (target, asset) in &assets {
255        let (os, arch) = map_target(target);
256        if arch == "all" {
257            continue;
258        }
259        released_os.insert(os.clone());
260        released_arch.insert(arch.clone());
261        record_arm(
262            &mut arms,
263            format!("{os}-{arch}"),
264            installer_arm_rank(target),
265            &asset.asset_name,
266        );
267    }
268    for (target, asset) in &assets {
269        let (os, arch) = map_target(target);
270        if arch != "all" {
271            continue;
272        }
273        released_os.insert(os.clone());
274        for cpu in ["amd64", "arm64"] {
275            released_arch.insert(cpu.to_string());
276            record_arm(
277                &mut arms,
278                format!("{os}-{cpu}"),
279                RANK_UNIVERSAL,
280                &asset.asset_name,
281            );
282        }
283    }
284
285    // Which released tokens the generated detect arms can actually emit. A
286    // token with no row in the detection tables (the mips family, illumos)
287    // makes every asset arm keyed by it unreachable — the release ships the
288    // asset, but no host can ever select it.
289    let detectable_os: BTreeSet<&str> = UNAME_OS_CASES.iter().map(|(_, t)| *t).collect();
290    let detectable_arch: BTreeSet<&str> = UNAME_ARCH_CASES.iter().map(|(_, t)| *t).collect();
291    let key_is_reachable = |key: &str| -> bool {
292        // Keys are `{os}-{arch}` and neither token contains `-`.
293        key.split_once('-')
294            .is_some_and(|(os, arch)| detectable_os.contains(os) && detectable_arch.contains(arch))
295    };
296
297    let stranded: Vec<&str> = assets
298        .iter()
299        .filter(|(target, _)| {
300            let (os, arch) = map_target(target);
301            arch != "all" && !key_is_reachable(&format!("{os}-{arch}"))
302        })
303        .map(|(target, _)| target.as_str())
304        .collect();
305    if !stranded.is_empty() {
306        ctx.logger("templatefiles").warn(&format!(
307            "installer script cannot detect released target(s) {}: their uname \
308             tokens are ambiguous or unmapped (`uname -m` reports the same \
309             token for both mips endiannesses; illumos reports `SunOS`), so \
310             their asset arms are unreachable — matching hosts get the \
311             unsupported-platform error",
312            stranded.join(", ")
313        ));
314    }
315
316    let supported: Vec<&str> = arms
317        .keys()
318        .filter(|key| key_is_reachable(key))
319        .map(String::as_str)
320        .collect();
321
322    let lines: Vec<String> = arms
323        .iter()
324        .map(|(key, (_, asset))| format!("    {key})\n        ARCHIVE=\"{asset}\"\n        ;;"))
325        .collect();
326    Ok(InstallerCases {
327        asset_cases: lines.join("\n"),
328        detect_os_cases: render_uname_cases(UNAME_OS_CASES, &released_os),
329        detect_arch_cases: render_uname_cases(UNAME_ARCH_CASES, &released_arch),
330        supported_platforms: supported.join(" "),
331    })
332}
333
334/// Render the `uname` case arms for the released tokens only, in the fixed
335/// table order (deterministic output for the determinism harness).
336fn render_uname_cases(table: &[(&str, &str)], released: &BTreeSet<String>) -> String {
337    table
338        .iter()
339        .filter(|(_, token)| released.contains(*token))
340        .map(|(pattern, token)| format!("        {pattern}) echo \"{token}\" ;;"))
341        .collect::<Vec<_>>()
342        .join("\n")
343}
344
345/// Rank the `all` universal asset when it fans out onto an `amd64`/`arm64`
346/// key: strictly worse than any real arch-specific build ([`installer_arm_rank`]
347/// tops out at 1), so a universal only fills a key no native build claimed —
348/// the arch-specific-wins precedence a `curl | sh` user relies on.
349const RANK_UNIVERSAL: u8 = 2;
350
351/// Preference rank (lower wins) for the asset that answers an `os-arch`
352/// installer arm when two release targets collapse onto the same key.
353///
354/// The generated installer keys only on OS + arch — it carries no libc probe —
355/// so when a release ships both a gnu and a musl build of one os+arch, exactly
356/// one asset can serve the shared arm. A statically-linked musl binary runs on
357/// BOTH glibc and musl hosts, whereas a glibc binary fails on musl; preferring
358/// musl therefore hands every host a binary that works, and — being a fixed
359/// rule rather than iteration order — makes the choice deterministic instead of
360/// silently dropping whichever libc a map happened to visit second. Every other
361/// ABI (gnu, msvc, darwin, …) shares rank 1; a residual tie holds the incumbent,
362/// which is the lexicographically-smaller target (assets arrive in sorted-target
363/// order), so single-variant output is byte-identical to before.
364fn installer_arm_rank(target: &str) -> u8 {
365    if target.contains("musl") { 0 } else { 1 }
366}
367
368/// Record `asset` under `key`, keeping the lowest-ranked asset when several
369/// release targets collapse onto one `os-arch` key. Replaces first-writer-wins
370/// (which silently dropped a libc variant by iteration order) with a
371/// deterministic, order-independent choice; an equal-rank collision keeps the
372/// incumbent.
373fn record_arm(arms: &mut BTreeMap<String, (u8, String)>, key: String, rank: u8, asset: &str) {
374    match arms.entry(key) {
375        std::collections::btree_map::Entry::Vacant(slot) => {
376            slot.insert((rank, asset.to_string()));
377        }
378        std::collections::btree_map::Entry::Occupied(mut slot) if rank < slot.get().0 => {
379            slot.insert((rank, asset.to_string()));
380        }
381        std::collections::btree_map::Entry::Occupied(_) => {}
382    }
383}
384
385/// The crate whose primary archive the remote installer downloads: the crate
386/// that builds a binary named after the project (`config.project_name`) — the
387/// binary the `curl | sh` script installs as `${PROJECT}` — and exposes a
388/// binstallable (`tar.gz` / `zip` / …) archive.
389///
390/// Searches top-level `crates:` first, then every `workspaces[].crates[]`, and
391/// returns the first match. `None` when no such crate exists (e.g. a pure
392/// library workspace), in which case the installer renders no asset arms.
393/// Public so emission validation can tell whether a crate's derived asset
394/// names feed the installer's case table.
395pub fn installer_crate(config: &Config) -> Option<CrateConfig> {
396    let project = config.project_name.as_str();
397    let produces_project_binary = |c: &CrateConfig| -> bool {
398        crate::build_plan::planned_builds(c)
399            .map(|builds| builds.iter().any(|b| b.binary.as_deref() == Some(project)))
400            .unwrap_or(false)
401    };
402
403    config
404        .crate_universe()
405        .into_iter()
406        .find(|c| produces_project_binary(c) && crate::binstall::binstallable_archive(c).is_some())
407        .cloned()
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::archive_name::render_archive_asset_name;
414    use crate::config::{
415        ArchiveConfig, ArchivesConfig, BuildConfig, Config, Defaults, FormatOverride,
416    };
417    use crate::context::{Context, ContextOptions};
418
419    /// The six lockstep triples anodize releases, paired with the installer
420    /// `os-arch` key `map_target` reduces each to.
421    const ANODIZE_TARGETS: &[&str] = &[
422        "x86_64-unknown-linux-gnu",
423        "aarch64-unknown-linux-gnu",
424        "x86_64-apple-darwin",
425        "aarch64-apple-darwin",
426        "x86_64-pc-windows-msvc",
427        "aarch64-pc-windows-msvc",
428    ];
429
430    /// Build a context shaped like anodize's own (lockstep) config: one crate
431    /// named after the project that builds the `anodize` binary, with a single
432    /// primary archive carrying `name_template` + a `windows → zip` override,
433    /// plus the second `-extra` archive that must be ignored (binstallable
434    /// archive selection picks the first tar.gz/zip entry).
435    fn anodize_ctx(name_template: Option<&str>) -> Context {
436        let primary = ArchiveConfig {
437            id: Some("default".to_string()),
438            name_template: name_template.map(str::to_string),
439            formats: Some(vec!["tar.gz".to_string()]),
440            format_overrides: Some(vec![FormatOverride {
441                os: "windows".to_string(),
442                formats: Some(vec!["zip".to_string()]),
443            }]),
444            ids: Some(vec!["anodizer".to_string()]),
445            ..Default::default()
446        };
447        let extra = ArchiveConfig {
448            id: Some("extra".to_string()),
449            name_template: Some(
450                "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}-extra".to_string(),
451            ),
452            formats: Some(vec!["tar.xz".to_string(), "tar.zst".to_string()]),
453            ids: Some(vec!["anodizer".to_string()]),
454            ..Default::default()
455        };
456        let crate_cfg = CrateConfig {
457            name: "anodizer".to_string(),
458            path: "crates/cli".to_string(),
459            builds: Some(vec![BuildConfig {
460                id: Some("anodizer".to_string()),
461                binary: Some("anodizer".to_string()),
462                ..Default::default()
463            }]),
464            archives: ArchivesConfig::Configs(vec![primary, extra]),
465            ..Default::default()
466        };
467        let config = Config {
468            project_name: "anodizer".to_string(),
469            defaults: Some(Defaults {
470                targets: Some(ANODIZE_TARGETS.iter().map(|s| s.to_string()).collect()),
471                ..Default::default()
472            }),
473            crates: vec![crate_cfg],
474            ..Default::default()
475        };
476        let mut ctx = Context::new(config, ContextOptions::default());
477        // Variant derivation consults the process env at every cargo flags
478        // tier; seal so an exported RUSTFLAGS cannot leak into fixtures.
479        ctx.set_env_source(crate::MapEnvSource::new());
480        ctx.template_vars_mut().set("ProjectName", "anodizer");
481        ctx.template_vars_mut().set("Version", "0.13.0");
482        ctx
483    }
484
485    /// [`InstallerCases::bind`] must write exactly the keys listed in
486    /// [`INSTALLER_TEMPLATE_VARS`] — the const is the consumption probe's
487    /// view of the binding surface, so drift between the two silently blinds
488    /// the probe.
489    #[test]
490    fn bind_writes_exactly_the_installer_template_vars() {
491        let cases = InstallerCases {
492            asset_cases: "a".to_string(),
493            detect_os_cases: "b".to_string(),
494            detect_arch_cases: "c".to_string(),
495            supported_platforms: "d".to_string(),
496        };
497        let mut vars = crate::template::TemplateVars::new();
498        cases.bind(&mut vars);
499        for k in INSTALLER_TEMPLATE_VARS {
500            assert!(
501                vars.get(k).is_some_and(|v| !v.is_empty()),
502                "{k} must be bound"
503            );
504        }
505        assert_eq!(
506            vars.all().len(),
507            INSTALLER_TEMPLATE_VARS.len(),
508            "bind must write no keys beyond INSTALLER_TEMPLATE_VARS"
509        );
510    }
511
512    /// The consumption probe: an entry that interpolates an installer var
513    /// consumes; one that renders none of them does not; a missing src file
514    /// stays conservatively "consuming" (fail-loud). The caller's template
515    /// scope survives the probe.
516    #[test]
517    fn template_files_consumption_probe_detects_real_bindings() {
518        use crate::config::TemplateFileConfig;
519        let tmp = tempfile::tempdir().unwrap();
520        let installer = tmp.path().join("install.sh.tera");
521        std::fs::write(&installer, "{{ InstallerAssetCases }}\n").unwrap();
522        let plain = tmp.path().join("notes.md.tera");
523        std::fs::write(&plain, "release {{ Version }} of {{ ProjectName }}\n").unwrap();
524
525        let entry = |src: &std::path::Path| TemplateFileConfig {
526            src: src.to_string_lossy().into_owned(),
527            dst: "out.txt".to_string(),
528            ..Default::default()
529        };
530
531        let mut ctx = anodize_ctx(None);
532        ctx.template_vars_mut()
533            .set("InstallerAssetCases", "stale-from-stage");
534        ctx.config.template_files = Some(vec![entry(&plain)]);
535        assert!(
536            !template_files_consume_installer_vars(&mut ctx),
537            "a template binding no Installer* var must not count as a consumer"
538        );
539
540        ctx.config.template_files = Some(vec![entry(&plain), entry(&installer)]);
541        assert!(
542            template_files_consume_installer_vars(&mut ctx),
543            "a template interpolating an installer var is a consumer"
544        );
545
546        ctx.config.template_files = Some(vec![entry(&tmp.path().join("missing.tera"))]);
547        assert!(
548            template_files_consume_installer_vars(&mut ctx),
549            "an unreadable entry cannot prove non-consumption — stay loud"
550        );
551
552        assert_eq!(
553            ctx.template_vars()
554                .get("InstallerAssetCases")
555                .map(String::as_str),
556            Some("stale-from-stage"),
557            "the probe must restore the caller's bindings"
558        );
559    }
560
561    /// Parse the rendered `case` arms back into `os-arch -> ARCHIVE` so a test
562    /// can compare each arm against the engine's own asset name.
563    fn parse_arms(table: &str) -> BTreeMap<String, String> {
564        let mut out = BTreeMap::new();
565        let mut key: Option<String> = None;
566        for line in table.lines() {
567            let t = line.trim();
568            if let Some(k) = t.strip_suffix(')') {
569                key = Some(k.to_string());
570            } else if let Some(rest) = t.strip_prefix("ARCHIVE=\"") {
571                let asset = rest.trim_end_matches('"');
572                if let Some(k) = key.take() {
573                    out.insert(k, asset.to_string());
574                }
575            }
576        }
577        out
578    }
579
580    /// Every rendered installer arm must equal `render_archive_asset_name` for
581    /// the target it serves — the agreement that keeps a `curl | sh` URL from
582    /// 404ing. Exercised against anodize's real hyphen `name_template` (the
583    /// current shipping config: R8 here is hardening, the names already match).
584    #[test]
585    fn installer_arms_match_engine_asset_names_hyphen_template() {
586        let name_template = "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}";
587        let mut ctx = anodize_ctx(Some(name_template));
588        let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
589        let arms = parse_arms(&table);
590
591        assert_eq!(arms.len(), ANODIZE_TARGETS.len(), "one arm per target");
592        for target in ANODIZE_TARGETS {
593            let (os, arch) = map_target(target);
594            let key = format!("{os}-{arch}");
595            let format = if os == "windows" { "zip" } else { "tar.gz" };
596            let expected =
597                render_archive_asset_name(&mut ctx, name_template, target, format).unwrap();
598            assert_eq!(
599                arms.get(&key),
600                Some(&expected),
601                "installer arm '{key}' must equal the archive stage's asset name"
602            );
603        }
604        // Concrete proof of the shipping names.
605        assert_eq!(
606            arms.get("linux-amd64").map(String::as_str),
607            Some("anodizer-0.13.0-linux-amd64.tar.gz")
608        );
609        assert_eq!(
610            arms.get("windows-amd64").map(String::as_str),
611            Some("anodizer-0.13.0-windows-amd64.zip")
612        );
613    }
614
615    /// The whole point of engine-derivation: with the DEFAULT (underscore)
616    /// `name_template`, the installer follows the engine to underscore asset
617    /// names — it does NOT keep emitting the old hardcoded hyphen form. A future
618    /// `name_template` change can never silently leave the installer 404ing.
619    #[test]
620    fn installer_arms_follow_engine_default_underscore_template() {
621        let mut ctx = anodize_ctx(None);
622        let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
623        let arms = parse_arms(&table);
624
625        for target in ANODIZE_TARGETS {
626            let (os, arch) = map_target(target);
627            let key = format!("{os}-{arch}");
628            let format = if os == "windows" { "zip" } else { "tar.gz" };
629            let expected = render_archive_asset_name(
630                &mut ctx,
631                crate::archive_name::DEFAULT_NAME_TEMPLATE,
632                target,
633                format,
634            )
635            .unwrap();
636            assert_eq!(arms.get(&key), Some(&expected));
637        }
638        // Underscores, not the hardcoded hyphen form the old shell carried.
639        assert_eq!(
640            arms.get("linux-amd64").map(String::as_str),
641            Some("anodizer_0.13.0_linux_amd64.tar.gz")
642        );
643    }
644
645    /// A v3-tuned linux build (`RUSTFLAGS -Ctarget-cpu=x86-64-v3` in the
646    /// per-target build env) must surface the SAME `amd64v3` suffix in the
647    /// installer's asset arm the archive stage bakes into the uploaded name —
648    /// with zero overrides. Untuned targets keep the baseline names.
649    #[test]
650    fn installer_arms_carry_config_declared_amd64_variant() {
651        let mut ctx = anodize_ctx(None);
652        let mut env = std::collections::HashMap::new();
653        env.insert(
654            "x86_64-unknown-linux-gnu".to_string(),
655            std::collections::HashMap::from([(
656                "RUSTFLAGS".to_string(),
657                "-Ctarget-cpu=x86-64-v3".to_string(),
658            )]),
659        );
660        ctx.config.crates[0].builds.as_mut().unwrap()[0].env = Some(env);
661
662        let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
663        let arms = parse_arms(&table);
664        assert_eq!(
665            arms.get("linux-amd64").map(String::as_str),
666            Some("anodizer_0.13.0_linux_amd64v3.tar.gz"),
667            "tuned target's arm must carry the micro-arch suffix"
668        );
669        assert_eq!(
670            arms.get("darwin-amd64").map(String::as_str),
671            Some("anodizer_0.13.0_darwin_amd64.tar.gz"),
672            "untuned amd64 target stays baseline"
673        );
674    }
675
676    /// Rendering the table must not leak per-target seed vars (`Os`/`Arch`/…)
677    /// into the surrounding template_files render.
678    #[test]
679    fn render_restores_seed_vars() {
680        let mut ctx = anodize_ctx(Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"));
681        ctx.template_vars_mut().set("Os", "sentinel-os");
682        let _ = render_installer_cases(&mut ctx).unwrap();
683        assert_eq!(
684            ctx.template_vars().get("Os").map(String::as_str),
685            Some("sentinel-os"),
686            "Os must be restored after rendering the case table"
687        );
688        // `Target` was never set before the render, so it must be unset again.
689        assert!(
690            ctx.template_vars().get("Target").is_none(),
691            "Target must be cleared back to unset after rendering"
692        );
693    }
694
695    /// A pure-library workspace (no crate builds the project binary) yields no
696    /// arms — the installer template falls through to its own error arm.
697    #[test]
698    fn no_installer_crate_yields_empty_table() {
699        let config = Config {
700            project_name: "anodizer".to_string(),
701            crates: vec![CrateConfig {
702                name: "anodizer-core".to_string(),
703                path: "crates/core".to_string(),
704                ..Default::default()
705            }],
706            ..Default::default()
707        };
708        let mut ctx = Context::new(config, ContextOptions::default());
709        ctx.template_vars_mut().set("ProjectName", "anodizer");
710        ctx.template_vars_mut().set("Version", "0.13.0");
711        let cases = render_installer_cases(&mut ctx).unwrap();
712        assert_eq!(cases.asset_cases, "");
713        assert_eq!(cases.detect_os_cases, "");
714        assert_eq!(cases.detect_arch_cases, "");
715        assert_eq!(cases.supported_platforms, "");
716    }
717
718    /// Every `uname -m` alias must round-trip through `map_target` to the
719    /// exact arch token its case arm echoes — the coupling that keeps a
720    /// generated asset arm reachable (a vocabulary rename in `map_target`
721    /// moves the detect arms with it, or this test names the alias that
722    /// stopped matching).
723    #[test]
724    fn uname_arch_aliases_round_trip_through_map_target() {
725        for (pattern, token) in UNAME_ARCH_CASES {
726            for alias in pattern.split('|') {
727                let (_, arch) = map_target(&format!("{alias}-unknown-linux-gnu"));
728                assert_eq!(
729                    &arch, token,
730                    "uname alias '{alias}' must map_target to its case token '{token}'"
731                );
732            }
733        }
734    }
735
736    /// Every OS token a detect arm echoes must be exactly what `map_target`
737    /// derives for a triple of that OS — the other half of the key coupling.
738    #[test]
739    fn uname_os_tokens_round_trip_through_map_target() {
740        for (_, token) in UNAME_OS_CASES {
741            let triple = match *token {
742                "darwin" => "x86_64-apple-darwin".to_string(),
743                "windows" => "x86_64-pc-windows-msvc".to_string(),
744                "aix" => "powerpc64-ibm-aix".to_string(),
745                other => format!("x86_64-unknown-{other}"),
746            };
747            let (os, _) = map_target(&triple);
748            assert_eq!(
749                &os, token,
750                "uname OS token '{token}' must equal map_target's OS for {triple}"
751            );
752        }
753    }
754
755    /// Detect arms are restricted to the released targets and rendered as
756    /// ready-to-paste case arms; every asset arm key must be reachable through
757    /// them (no released target stranded behind "unsupported platform").
758    #[test]
759    fn detect_cases_cover_every_asset_arm_key() {
760        let mut ctx = anodize_ctx(None);
761        let cases = render_installer_cases(&mut ctx).unwrap();
762
763        assert_eq!(
764            cases.detect_os_cases,
765            "        Linux*) echo \"linux\" ;;\n\
766             \x20       Darwin*) echo \"darwin\" ;;\n\
767             \x20       MINGW*|MSYS*|CYGWIN*) echo \"windows\" ;;"
768        );
769        assert_eq!(
770            cases.detect_arch_cases,
771            "        x86_64|amd64) echo \"amd64\" ;;\n\
772             \x20       aarch64|arm64) echo \"arm64\" ;;"
773        );
774
775        let os_tokens: Vec<&str> = cases
776            .detect_os_cases
777            .lines()
778            .filter_map(|l| l.split('"').nth(1))
779            .collect();
780        let arch_tokens: Vec<&str> = cases
781            .detect_arch_cases
782            .lines()
783            .filter_map(|l| l.split('"').nth(1))
784            .collect();
785        for key in parse_arms(&cases.asset_cases).keys() {
786            let (os, arch) = key.split_once('-').expect("key is os-arch");
787            assert!(os_tokens.contains(&os), "asset arm OS '{os}' undetectable");
788            assert!(
789                arch_tokens.contains(&arch),
790                "asset arm arch '{arch}' undetectable"
791            );
792        }
793    }
794
795    /// A universal (darwin-all) asset fans out to the real `uname -m` keys —
796    /// amd64/arm64 hosts can install it — while arch-specific assets keep
797    /// precedence for their own key.
798    #[test]
799    fn universal_asset_fans_out_to_amd64_and_arm64_keys() {
800        let mut ctx = anodize_ctx(Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"));
801        ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
802            "darwin-universal".to_string(),
803            "aarch64-apple-darwin".to_string(),
804        ]);
805        let cases = render_installer_cases(&mut ctx).unwrap();
806        let arms = parse_arms(&cases.asset_cases);
807
808        assert!(!arms.contains_key("darwin-all"), "no unmatchable 'all' key");
809        assert_eq!(
810            arms.get("darwin-amd64").map(String::as_str),
811            Some("anodizer-0.13.0-darwin-all.tar.gz"),
812            "amd64 hosts fall back to the universal asset"
813        );
814        assert_eq!(
815            arms.get("darwin-arm64").map(String::as_str),
816            Some("anodizer-0.13.0-darwin-arm64.tar.gz"),
817            "the arch-specific asset wins its own key over the universal"
818        );
819    }
820
821    /// A release shipping BOTH a gnu and a musl build for the same os+arch
822    /// (two distinct assets, one shared `os-arch` installer key) must not
823    /// silently drop a libc: the generated installer detects only OS + arch, so
824    /// exactly one asset answers the `linux-amd64` arm, and it must be the
825    /// statically-linked musl build (runs on glibc AND musl hosts) — chosen by
826    /// rule, independent of target iteration order.
827    #[test]
828    fn gnu_and_musl_same_arch_prefers_static_musl() {
829        let name_template = "{{ ProjectName }}-{{ Version }}-{{ Target }}";
830        let mut ctx = anodize_ctx(Some(name_template));
831        ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
832            "x86_64-unknown-linux-gnu".to_string(),
833            "x86_64-unknown-linux-musl".to_string(),
834        ]);
835        let cases = render_installer_cases(&mut ctx).unwrap();
836        let arms = parse_arms(&cases.asset_cases);
837
838        assert_eq!(
839            arms.len(),
840            1,
841            "both libcs collapse onto the single linux-amd64 key: {arms:?}"
842        );
843        assert_eq!(
844            arms.get("linux-amd64").map(String::as_str),
845            Some("anodizer-0.13.0-x86_64-unknown-linux-musl.tar.gz"),
846            "the static musl build must win the shared linux-amd64 arm, not be dropped"
847        );
848    }
849
850    /// The same libc collision, but with the installer crate reachable only
851    /// through `workspaces[].crates[]` (the per-crate config mode) instead of
852    /// top-level `crates:` — the musl-first resolution must hold there too,
853    /// since the collapse operates on the derived asset map regardless of which
854    /// config mode surfaced the crate.
855    #[test]
856    fn gnu_and_musl_collision_resolves_under_workspaces_config() {
857        let name_template = "{{ ProjectName }}-{{ Version }}-{{ Target }}";
858        let mut ctx = anodize_ctx(Some(name_template));
859        ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
860            "x86_64-unknown-linux-gnu".to_string(),
861            "x86_64-unknown-linux-musl".to_string(),
862        ]);
863        let moved: Vec<CrateConfig> = ctx.config.crates.drain(..).collect();
864        ctx.config.workspaces = Some(vec![crate::config::WorkspaceConfig {
865            name: "cli".to_string(),
866            crates: moved,
867            ..Default::default()
868        }]);
869
870        let cases = render_installer_cases(&mut ctx).unwrap();
871        let arms = parse_arms(&cases.asset_cases);
872        assert_eq!(
873            arms.get("linux-amd64").map(String::as_str),
874            Some("anodizer-0.13.0-x86_64-unknown-linux-musl.tar.gz"),
875            "per-crate (workspaces) config must resolve the libc collision musl-first too"
876        );
877    }
878
879    /// `supported_platforms` lists every reachable arm key, space-joined in
880    /// arm (BTreeMap) order — the value the script's error paths print.
881    #[test]
882    fn supported_platforms_lists_reachable_keys() {
883        let mut ctx = anodize_ctx(None);
884        let cases = render_installer_cases(&mut ctx).unwrap();
885        assert_eq!(
886            cases.supported_platforms,
887            "darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 \
888             windows-amd64 windows-arm64"
889        );
890    }
891
892    /// A released mips-family target has an asset arm but no detect arm
893    /// (`uname -m` is endian-ambiguous): the render warns naming the stranded
894    /// target and excludes its key from `supported_platforms`.
895    #[test]
896    fn undetectable_mips_target_warns_and_is_not_listed_supported() {
897        let mut ctx = anodize_ctx(None);
898        ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
899            "x86_64-unknown-linux-gnu".to_string(),
900            "mips64el-unknown-linux-gnuabi64".to_string(),
901        ]);
902        let capture = crate::log::LogCapture::new();
903        ctx.with_log_capture(capture.clone());
904
905        let cases = render_installer_cases(&mut ctx).unwrap();
906
907        // The asset arm exists (the release ships it) …
908        assert!(
909            parse_arms(&cases.asset_cases).contains_key("linux-mips64el"),
910            "asset arm for the mips target must still be emitted"
911        );
912        // … but it is unreachable, so it is not advertised as supported …
913        assert_eq!(cases.supported_platforms, "linux-amd64");
914        // … and the operator is told which target is stranded.
915        assert_eq!(capture.warn_count(), 1, "exactly one stranded-target warn");
916        assert!(
917            capture
918                .warn_messages()
919                .iter()
920                .any(|m| m.contains("mips64el-unknown-linux-gnuabi64")),
921            "warn must name the stranded target: {:?}",
922            capture.warn_messages()
923        );
924    }
925
926    /// A fully-detectable release (the standard 6-triple matrix) renders no
927    /// stranded-target warning.
928    #[test]
929    fn detectable_targets_render_no_stranded_warning() {
930        let mut ctx = anodize_ctx(None);
931        let capture = crate::log::LogCapture::new();
932        ctx.with_log_capture(capture.clone());
933        let _ = render_installer_cases(&mut ctx).unwrap();
934        assert_eq!(
935            capture.warn_count(),
936            0,
937            "no warning for detectable targets: {:?}",
938            capture.warn_messages()
939        );
940    }
941}