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    // Arch-specific assets are inserted first (first-writer-wins within them
242    // is unambiguous — only `all` could alias two triples onto one key);
243    // universal (`all`) assets then fan out into any amd64/arm64 keys still
244    // missing, since no `uname -m` can ever produce "all".
245    let mut arms: BTreeMap<String, String> = BTreeMap::new();
246    let mut released_os: BTreeSet<String> = BTreeSet::new();
247    let mut released_arch: BTreeSet<String> = BTreeSet::new();
248    for (target, asset) in &assets {
249        let (os, arch) = map_target(target);
250        if arch == "all" {
251            continue;
252        }
253        released_os.insert(os.clone());
254        released_arch.insert(arch.clone());
255        arms.entry(format!("{os}-{arch}"))
256            .or_insert_with(|| asset.asset_name.clone());
257    }
258    for (target, asset) in &assets {
259        let (os, arch) = map_target(target);
260        if arch != "all" {
261            continue;
262        }
263        released_os.insert(os.clone());
264        for cpu in ["amd64", "arm64"] {
265            released_arch.insert(cpu.to_string());
266            arms.entry(format!("{os}-{cpu}"))
267                .or_insert_with(|| asset.asset_name.clone());
268        }
269    }
270
271    // Which released tokens the generated detect arms can actually emit. A
272    // token with no row in the detection tables (the mips family, illumos)
273    // makes every asset arm keyed by it unreachable — the release ships the
274    // asset, but no host can ever select it.
275    let detectable_os: BTreeSet<&str> = UNAME_OS_CASES.iter().map(|(_, t)| *t).collect();
276    let detectable_arch: BTreeSet<&str> = UNAME_ARCH_CASES.iter().map(|(_, t)| *t).collect();
277    let key_is_reachable = |key: &str| -> bool {
278        // Keys are `{os}-{arch}` and neither token contains `-`.
279        key.split_once('-')
280            .is_some_and(|(os, arch)| detectable_os.contains(os) && detectable_arch.contains(arch))
281    };
282
283    let stranded: Vec<&str> = assets
284        .iter()
285        .filter(|(target, _)| {
286            let (os, arch) = map_target(target);
287            arch != "all" && !key_is_reachable(&format!("{os}-{arch}"))
288        })
289        .map(|(target, _)| target.as_str())
290        .collect();
291    if !stranded.is_empty() {
292        ctx.logger("templatefiles").warn(&format!(
293            "installer script cannot detect released target(s) {}: their uname \
294             tokens are ambiguous or unmapped (`uname -m` reports the same \
295             token for both mips endiannesses; illumos reports `SunOS`), so \
296             their asset arms are unreachable — matching hosts get the \
297             unsupported-platform error",
298            stranded.join(", ")
299        ));
300    }
301
302    let supported: Vec<&str> = arms
303        .keys()
304        .filter(|key| key_is_reachable(key))
305        .map(String::as_str)
306        .collect();
307
308    let lines: Vec<String> = arms
309        .iter()
310        .map(|(key, asset)| format!("    {key})\n        ARCHIVE=\"{asset}\"\n        ;;"))
311        .collect();
312    Ok(InstallerCases {
313        asset_cases: lines.join("\n"),
314        detect_os_cases: render_uname_cases(UNAME_OS_CASES, &released_os),
315        detect_arch_cases: render_uname_cases(UNAME_ARCH_CASES, &released_arch),
316        supported_platforms: supported.join(" "),
317    })
318}
319
320/// Render the `uname` case arms for the released tokens only, in the fixed
321/// table order (deterministic output for the determinism harness).
322fn render_uname_cases(table: &[(&str, &str)], released: &BTreeSet<String>) -> String {
323    table
324        .iter()
325        .filter(|(_, token)| released.contains(*token))
326        .map(|(pattern, token)| format!("        {pattern}) echo \"{token}\" ;;"))
327        .collect::<Vec<_>>()
328        .join("\n")
329}
330
331/// The crate whose primary archive the remote installer downloads: the crate
332/// that builds a binary named after the project (`config.project_name`) — the
333/// binary the `curl | sh` script installs as `${PROJECT}` — and exposes a
334/// binstallable (`tar.gz` / `zip` / …) archive.
335///
336/// Searches top-level `crates:` first, then every `workspaces[].crates[]`, and
337/// returns the first match. `None` when no such crate exists (e.g. a pure
338/// library workspace), in which case the installer renders no asset arms.
339/// Public so emission validation can tell whether a crate's derived asset
340/// names feed the installer's case table.
341pub fn installer_crate(config: &Config) -> Option<CrateConfig> {
342    let project = config.project_name.as_str();
343    let produces_project_binary = |c: &CrateConfig| -> bool {
344        crate::build_plan::planned_builds(c)
345            .map(|builds| builds.iter().any(|b| b.binary.as_deref() == Some(project)))
346            .unwrap_or(false)
347    };
348
349    config
350        .crate_universe()
351        .into_iter()
352        .find(|c| produces_project_binary(c) && crate::binstall::binstallable_archive(c).is_some())
353        .cloned()
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::archive_name::render_archive_asset_name;
360    use crate::config::{
361        ArchiveConfig, ArchivesConfig, BuildConfig, Config, Defaults, FormatOverride,
362    };
363    use crate::context::{Context, ContextOptions};
364
365    /// The six lockstep triples anodize releases, paired with the installer
366    /// `os-arch` key `map_target` reduces each to.
367    const ANODIZE_TARGETS: &[&str] = &[
368        "x86_64-unknown-linux-gnu",
369        "aarch64-unknown-linux-gnu",
370        "x86_64-apple-darwin",
371        "aarch64-apple-darwin",
372        "x86_64-pc-windows-msvc",
373        "aarch64-pc-windows-msvc",
374    ];
375
376    /// Build a context shaped like anodize's own (lockstep) config: one crate
377    /// named after the project that builds the `anodize` binary, with a single
378    /// primary archive carrying `name_template` + a `windows → zip` override,
379    /// plus the second `-extra` archive that must be ignored (binstallable
380    /// archive selection picks the first tar.gz/zip entry).
381    fn anodize_ctx(name_template: Option<&str>) -> Context {
382        let primary = ArchiveConfig {
383            id: Some("default".to_string()),
384            name_template: name_template.map(str::to_string),
385            formats: Some(vec!["tar.gz".to_string()]),
386            format_overrides: Some(vec![FormatOverride {
387                os: "windows".to_string(),
388                formats: Some(vec!["zip".to_string()]),
389            }]),
390            ids: Some(vec!["anodizer".to_string()]),
391            ..Default::default()
392        };
393        let extra = ArchiveConfig {
394            id: Some("extra".to_string()),
395            name_template: Some(
396                "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}-extra".to_string(),
397            ),
398            formats: Some(vec!["tar.xz".to_string(), "tar.zst".to_string()]),
399            ids: Some(vec!["anodizer".to_string()]),
400            ..Default::default()
401        };
402        let crate_cfg = CrateConfig {
403            name: "anodizer".to_string(),
404            path: "crates/cli".to_string(),
405            builds: Some(vec![BuildConfig {
406                id: Some("anodizer".to_string()),
407                binary: Some("anodizer".to_string()),
408                ..Default::default()
409            }]),
410            archives: ArchivesConfig::Configs(vec![primary, extra]),
411            ..Default::default()
412        };
413        let config = Config {
414            project_name: "anodizer".to_string(),
415            defaults: Some(Defaults {
416                targets: Some(ANODIZE_TARGETS.iter().map(|s| s.to_string()).collect()),
417                ..Default::default()
418            }),
419            crates: vec![crate_cfg],
420            ..Default::default()
421        };
422        let mut ctx = Context::new(config, ContextOptions::default());
423        // Variant derivation consults the process env at every cargo flags
424        // tier; seal so an exported RUSTFLAGS cannot leak into fixtures.
425        ctx.set_env_source(crate::MapEnvSource::new());
426        ctx.template_vars_mut().set("ProjectName", "anodizer");
427        ctx.template_vars_mut().set("Version", "0.13.0");
428        ctx
429    }
430
431    /// [`InstallerCases::bind`] must write exactly the keys listed in
432    /// [`INSTALLER_TEMPLATE_VARS`] — the const is the consumption probe's
433    /// view of the binding surface, so drift between the two silently blinds
434    /// the probe.
435    #[test]
436    fn bind_writes_exactly_the_installer_template_vars() {
437        let cases = InstallerCases {
438            asset_cases: "a".to_string(),
439            detect_os_cases: "b".to_string(),
440            detect_arch_cases: "c".to_string(),
441            supported_platforms: "d".to_string(),
442        };
443        let mut vars = crate::template::TemplateVars::new();
444        cases.bind(&mut vars);
445        for k in INSTALLER_TEMPLATE_VARS {
446            assert!(
447                vars.get(k).is_some_and(|v| !v.is_empty()),
448                "{k} must be bound"
449            );
450        }
451        assert_eq!(
452            vars.all().len(),
453            INSTALLER_TEMPLATE_VARS.len(),
454            "bind must write no keys beyond INSTALLER_TEMPLATE_VARS"
455        );
456    }
457
458    /// The consumption probe: an entry that interpolates an installer var
459    /// consumes; one that renders none of them does not; a missing src file
460    /// stays conservatively "consuming" (fail-loud). The caller's template
461    /// scope survives the probe.
462    #[test]
463    fn template_files_consumption_probe_detects_real_bindings() {
464        use crate::config::TemplateFileConfig;
465        let tmp = tempfile::tempdir().unwrap();
466        let installer = tmp.path().join("install.sh.tera");
467        std::fs::write(&installer, "{{ InstallerAssetCases }}\n").unwrap();
468        let plain = tmp.path().join("notes.md.tera");
469        std::fs::write(&plain, "release {{ Version }} of {{ ProjectName }}\n").unwrap();
470
471        let entry = |src: &std::path::Path| TemplateFileConfig {
472            src: src.to_string_lossy().into_owned(),
473            dst: "out.txt".to_string(),
474            ..Default::default()
475        };
476
477        let mut ctx = anodize_ctx(None);
478        ctx.template_vars_mut()
479            .set("InstallerAssetCases", "stale-from-stage");
480        ctx.config.template_files = Some(vec![entry(&plain)]);
481        assert!(
482            !template_files_consume_installer_vars(&mut ctx),
483            "a template binding no Installer* var must not count as a consumer"
484        );
485
486        ctx.config.template_files = Some(vec![entry(&plain), entry(&installer)]);
487        assert!(
488            template_files_consume_installer_vars(&mut ctx),
489            "a template interpolating an installer var is a consumer"
490        );
491
492        ctx.config.template_files = Some(vec![entry(&tmp.path().join("missing.tera"))]);
493        assert!(
494            template_files_consume_installer_vars(&mut ctx),
495            "an unreadable entry cannot prove non-consumption — stay loud"
496        );
497
498        assert_eq!(
499            ctx.template_vars()
500                .get("InstallerAssetCases")
501                .map(String::as_str),
502            Some("stale-from-stage"),
503            "the probe must restore the caller's bindings"
504        );
505    }
506
507    /// Parse the rendered `case` arms back into `os-arch -> ARCHIVE` so a test
508    /// can compare each arm against the engine's own asset name.
509    fn parse_arms(table: &str) -> BTreeMap<String, String> {
510        let mut out = BTreeMap::new();
511        let mut key: Option<String> = None;
512        for line in table.lines() {
513            let t = line.trim();
514            if let Some(k) = t.strip_suffix(')') {
515                key = Some(k.to_string());
516            } else if let Some(rest) = t.strip_prefix("ARCHIVE=\"") {
517                let asset = rest.trim_end_matches('"');
518                if let Some(k) = key.take() {
519                    out.insert(k, asset.to_string());
520                }
521            }
522        }
523        out
524    }
525
526    /// Every rendered installer arm must equal `render_archive_asset_name` for
527    /// the target it serves — the agreement that keeps a `curl | sh` URL from
528    /// 404ing. Exercised against anodize's real hyphen `name_template` (the
529    /// current shipping config: R8 here is hardening, the names already match).
530    #[test]
531    fn installer_arms_match_engine_asset_names_hyphen_template() {
532        let name_template = "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}";
533        let mut ctx = anodize_ctx(Some(name_template));
534        let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
535        let arms = parse_arms(&table);
536
537        assert_eq!(arms.len(), ANODIZE_TARGETS.len(), "one arm per target");
538        for target in ANODIZE_TARGETS {
539            let (os, arch) = map_target(target);
540            let key = format!("{os}-{arch}");
541            let format = if os == "windows" { "zip" } else { "tar.gz" };
542            let expected =
543                render_archive_asset_name(&mut ctx, name_template, target, format).unwrap();
544            assert_eq!(
545                arms.get(&key),
546                Some(&expected),
547                "installer arm '{key}' must equal the archive stage's asset name"
548            );
549        }
550        // Concrete proof of the shipping names.
551        assert_eq!(
552            arms.get("linux-amd64").map(String::as_str),
553            Some("anodizer-0.13.0-linux-amd64.tar.gz")
554        );
555        assert_eq!(
556            arms.get("windows-amd64").map(String::as_str),
557            Some("anodizer-0.13.0-windows-amd64.zip")
558        );
559    }
560
561    /// The whole point of engine-derivation: with the DEFAULT (underscore)
562    /// `name_template`, the installer follows the engine to underscore asset
563    /// names — it does NOT keep emitting the old hardcoded hyphen form. A future
564    /// `name_template` change can never silently leave the installer 404ing.
565    #[test]
566    fn installer_arms_follow_engine_default_underscore_template() {
567        let mut ctx = anodize_ctx(None);
568        let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
569        let arms = parse_arms(&table);
570
571        for target in ANODIZE_TARGETS {
572            let (os, arch) = map_target(target);
573            let key = format!("{os}-{arch}");
574            let format = if os == "windows" { "zip" } else { "tar.gz" };
575            let expected = render_archive_asset_name(
576                &mut ctx,
577                crate::archive_name::DEFAULT_NAME_TEMPLATE,
578                target,
579                format,
580            )
581            .unwrap();
582            assert_eq!(arms.get(&key), Some(&expected));
583        }
584        // Underscores, not the hardcoded hyphen form the old shell carried.
585        assert_eq!(
586            arms.get("linux-amd64").map(String::as_str),
587            Some("anodizer_0.13.0_linux_amd64.tar.gz")
588        );
589    }
590
591    /// A v3-tuned linux build (`RUSTFLAGS -Ctarget-cpu=x86-64-v3` in the
592    /// per-target build env) must surface the SAME `amd64v3` suffix in the
593    /// installer's asset arm the archive stage bakes into the uploaded name —
594    /// with zero overrides. Untuned targets keep the baseline names.
595    #[test]
596    fn installer_arms_carry_config_declared_amd64_variant() {
597        let mut ctx = anodize_ctx(None);
598        let mut env = std::collections::HashMap::new();
599        env.insert(
600            "x86_64-unknown-linux-gnu".to_string(),
601            std::collections::HashMap::from([(
602                "RUSTFLAGS".to_string(),
603                "-Ctarget-cpu=x86-64-v3".to_string(),
604            )]),
605        );
606        ctx.config.crates[0].builds.as_mut().unwrap()[0].env = Some(env);
607
608        let table = render_installer_cases(&mut ctx).unwrap().asset_cases;
609        let arms = parse_arms(&table);
610        assert_eq!(
611            arms.get("linux-amd64").map(String::as_str),
612            Some("anodizer_0.13.0_linux_amd64v3.tar.gz"),
613            "tuned target's arm must carry the micro-arch suffix"
614        );
615        assert_eq!(
616            arms.get("darwin-amd64").map(String::as_str),
617            Some("anodizer_0.13.0_darwin_amd64.tar.gz"),
618            "untuned amd64 target stays baseline"
619        );
620    }
621
622    /// Rendering the table must not leak per-target seed vars (`Os`/`Arch`/…)
623    /// into the surrounding template_files render.
624    #[test]
625    fn render_restores_seed_vars() {
626        let mut ctx = anodize_ctx(Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"));
627        ctx.template_vars_mut().set("Os", "sentinel-os");
628        let _ = render_installer_cases(&mut ctx).unwrap();
629        assert_eq!(
630            ctx.template_vars().get("Os").map(String::as_str),
631            Some("sentinel-os"),
632            "Os must be restored after rendering the case table"
633        );
634        // `Target` was never set before the render, so it must be unset again.
635        assert!(
636            ctx.template_vars().get("Target").is_none(),
637            "Target must be cleared back to unset after rendering"
638        );
639    }
640
641    /// A pure-library workspace (no crate builds the project binary) yields no
642    /// arms — the installer template falls through to its own error arm.
643    #[test]
644    fn no_installer_crate_yields_empty_table() {
645        let config = Config {
646            project_name: "anodizer".to_string(),
647            crates: vec![CrateConfig {
648                name: "anodizer-core".to_string(),
649                path: "crates/core".to_string(),
650                ..Default::default()
651            }],
652            ..Default::default()
653        };
654        let mut ctx = Context::new(config, ContextOptions::default());
655        ctx.template_vars_mut().set("ProjectName", "anodizer");
656        ctx.template_vars_mut().set("Version", "0.13.0");
657        let cases = render_installer_cases(&mut ctx).unwrap();
658        assert_eq!(cases.asset_cases, "");
659        assert_eq!(cases.detect_os_cases, "");
660        assert_eq!(cases.detect_arch_cases, "");
661        assert_eq!(cases.supported_platforms, "");
662    }
663
664    /// Every `uname -m` alias must round-trip through `map_target` to the
665    /// exact arch token its case arm echoes — the coupling that keeps a
666    /// generated asset arm reachable (a vocabulary rename in `map_target`
667    /// moves the detect arms with it, or this test names the alias that
668    /// stopped matching).
669    #[test]
670    fn uname_arch_aliases_round_trip_through_map_target() {
671        for (pattern, token) in UNAME_ARCH_CASES {
672            for alias in pattern.split('|') {
673                let (_, arch) = map_target(&format!("{alias}-unknown-linux-gnu"));
674                assert_eq!(
675                    &arch, token,
676                    "uname alias '{alias}' must map_target to its case token '{token}'"
677                );
678            }
679        }
680    }
681
682    /// Every OS token a detect arm echoes must be exactly what `map_target`
683    /// derives for a triple of that OS — the other half of the key coupling.
684    #[test]
685    fn uname_os_tokens_round_trip_through_map_target() {
686        for (_, token) in UNAME_OS_CASES {
687            let triple = match *token {
688                "darwin" => "x86_64-apple-darwin".to_string(),
689                "windows" => "x86_64-pc-windows-msvc".to_string(),
690                "aix" => "powerpc64-ibm-aix".to_string(),
691                other => format!("x86_64-unknown-{other}"),
692            };
693            let (os, _) = map_target(&triple);
694            assert_eq!(
695                &os, token,
696                "uname OS token '{token}' must equal map_target's OS for {triple}"
697            );
698        }
699    }
700
701    /// Detect arms are restricted to the released targets and rendered as
702    /// ready-to-paste case arms; every asset arm key must be reachable through
703    /// them (no released target stranded behind "unsupported platform").
704    #[test]
705    fn detect_cases_cover_every_asset_arm_key() {
706        let mut ctx = anodize_ctx(None);
707        let cases = render_installer_cases(&mut ctx).unwrap();
708
709        assert_eq!(
710            cases.detect_os_cases,
711            "        Linux*) echo \"linux\" ;;\n\
712             \x20       Darwin*) echo \"darwin\" ;;\n\
713             \x20       MINGW*|MSYS*|CYGWIN*) echo \"windows\" ;;"
714        );
715        assert_eq!(
716            cases.detect_arch_cases,
717            "        x86_64|amd64) echo \"amd64\" ;;\n\
718             \x20       aarch64|arm64) echo \"arm64\" ;;"
719        );
720
721        let os_tokens: Vec<&str> = cases
722            .detect_os_cases
723            .lines()
724            .filter_map(|l| l.split('"').nth(1))
725            .collect();
726        let arch_tokens: Vec<&str> = cases
727            .detect_arch_cases
728            .lines()
729            .filter_map(|l| l.split('"').nth(1))
730            .collect();
731        for key in parse_arms(&cases.asset_cases).keys() {
732            let (os, arch) = key.split_once('-').expect("key is os-arch");
733            assert!(os_tokens.contains(&os), "asset arm OS '{os}' undetectable");
734            assert!(
735                arch_tokens.contains(&arch),
736                "asset arm arch '{arch}' undetectable"
737            );
738        }
739    }
740
741    /// A universal (darwin-all) asset fans out to the real `uname -m` keys —
742    /// amd64/arm64 hosts can install it — while arch-specific assets keep
743    /// precedence for their own key.
744    #[test]
745    fn universal_asset_fans_out_to_amd64_and_arm64_keys() {
746        let mut ctx = anodize_ctx(Some("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"));
747        ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
748            "darwin-universal".to_string(),
749            "aarch64-apple-darwin".to_string(),
750        ]);
751        let cases = render_installer_cases(&mut ctx).unwrap();
752        let arms = parse_arms(&cases.asset_cases);
753
754        assert!(!arms.contains_key("darwin-all"), "no unmatchable 'all' key");
755        assert_eq!(
756            arms.get("darwin-amd64").map(String::as_str),
757            Some("anodizer-0.13.0-darwin-all.tar.gz"),
758            "amd64 hosts fall back to the universal asset"
759        );
760        assert_eq!(
761            arms.get("darwin-arm64").map(String::as_str),
762            Some("anodizer-0.13.0-darwin-arm64.tar.gz"),
763            "the arch-specific asset wins its own key over the universal"
764        );
765    }
766
767    /// `supported_platforms` lists every reachable arm key, space-joined in
768    /// arm (BTreeMap) order — the value the script's error paths print.
769    #[test]
770    fn supported_platforms_lists_reachable_keys() {
771        let mut ctx = anodize_ctx(None);
772        let cases = render_installer_cases(&mut ctx).unwrap();
773        assert_eq!(
774            cases.supported_platforms,
775            "darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 \
776             windows-amd64 windows-arm64"
777        );
778    }
779
780    /// A released mips-family target has an asset arm but no detect arm
781    /// (`uname -m` is endian-ambiguous): the render warns naming the stranded
782    /// target and excludes its key from `supported_platforms`.
783    #[test]
784    fn undetectable_mips_target_warns_and_is_not_listed_supported() {
785        let mut ctx = anodize_ctx(None);
786        ctx.config.defaults.as_mut().unwrap().targets = Some(vec![
787            "x86_64-unknown-linux-gnu".to_string(),
788            "mips64el-unknown-linux-gnuabi64".to_string(),
789        ]);
790        let capture = crate::log::LogCapture::new();
791        ctx.with_log_capture(capture.clone());
792
793        let cases = render_installer_cases(&mut ctx).unwrap();
794
795        // The asset arm exists (the release ships it) …
796        assert!(
797            parse_arms(&cases.asset_cases).contains_key("linux-mips64el"),
798            "asset arm for the mips target must still be emitted"
799        );
800        // … but it is unreachable, so it is not advertised as supported …
801        assert_eq!(cases.supported_platforms, "linux-amd64");
802        // … and the operator is told which target is stranded.
803        assert_eq!(capture.warn_count(), 1, "exactly one stranded-target warn");
804        assert!(
805            capture
806                .warn_messages()
807                .iter()
808                .any(|m| m.contains("mips64el-unknown-linux-gnuabi64")),
809            "warn must name the stranded target: {:?}",
810            capture.warn_messages()
811        );
812    }
813
814    /// A fully-detectable release (the standard 6-triple matrix) renders no
815    /// stranded-target warning.
816    #[test]
817    fn detectable_targets_render_no_stranded_warning() {
818        let mut ctx = anodize_ctx(None);
819        let capture = crate::log::LogCapture::new();
820        ctx.with_log_capture(capture.clone());
821        let _ = render_installer_cases(&mut ctx).unwrap();
822        assert_eq!(
823            capture.warn_count(),
824            0,
825            "no warning for detectable targets: {:?}",
826            capture.warn_messages()
827        );
828    }
829}