anodizer-core 0.28.1

Core configuration, context, and template engine for the anodizer release tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
//! Single source of truth for archive asset naming.
//!
//! The archive stage names every release asset by rendering a `name_template`
//! against a set of per-target template variables (`Os`, `Arch`, `Target`, plus
//! the micro-architecture variants `Arm` / `Arm64` / `Amd64` / `Mips` / `I386`)
//! and appending the archive format as the file extension. Several other
//! features must compute the *same* filename without producing an archive on
//! disk — most notably cargo-binstall metadata derivation, which has to emit a
//! `pkg_url` pointing at an asset whose name exactly matches what the archive
//! stage will later upload.
//!
//! Centralising the default templates, the per-target variant seeding, and the
//! format→extension / format→`pkg_fmt` mappings here guarantees those derived
//! names cannot drift from the archive stage's own output: a `pkg_url` derived
//! through [`render_archive_asset_name`] resolves to byte-identical bytes as the
//! archive the release uploads, eliminating the "binstall 404" class by
//! construction.

use anyhow::{Context as _, Result};

use crate::context::Context;
use crate::target::map_target;

/// The micro-architecture variant suffix as a literal, so a default template
/// that ends in it can be built with `concat!` instead of re-typing the clause.
///
/// `concat!` takes literals only, which is why this is a macro rather than a
/// second `const`; [`MICRO_ARCH_VARIANT_SUFFIX`] is the value every non-literal
/// consumer should read.
macro_rules! micro_arch_variant_suffix {
    () => {
        "{% if Arm %}v{{ Arm }}{% endif %}{% if Mips %}_{{ Mips }}{% endif %}{% if Amd64 and Amd64 != \"v1\" %}{{ Amd64 }}{% endif %}"
    };
}

/// The `format: binary` default name template as a literal, so a default that
/// appends an extension to it (the SBOM document path) can be built with
/// `concat!` rather than re-typing the stem.
///
/// [`DEFAULT_BINARY_NAME_TEMPLATE`] is the value every non-literal consumer
/// should read.
macro_rules! default_binary_name_template {
    () => {
        concat!(
            "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}",
            $crate::archive_name::micro_arch_variant_suffix!()
        )
    };
}

pub(crate) use {default_binary_name_template, micro_arch_variant_suffix};

/// Canonical archive name template used when a crate sets no
/// `archive.name_template:`. The default
/// (`{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}…`) with the
/// micro-architecture variant suffixes appended.
pub const DEFAULT_NAME_TEMPLATE: &str = concat!(
    "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}",
    micro_arch_variant_suffix!()
);

/// Multi-crate variant of [`DEFAULT_NAME_TEMPLATE`]. Identical in shape; the
/// archive stage rebinds `ProjectName` to the per-crate name so each crate's
/// stem is distinct without forcing users to hand-author `archive.name_template:`.
pub const DEFAULT_NAME_TEMPLATE_MULTI_CRATE: &str = DEFAULT_NAME_TEMPLATE;

/// Default name template for `format: binary` archives (uses `{{ .Binary }}`
/// rather than `{{ .ProjectName }}` so each binary is named individually).
pub const DEFAULT_BINARY_NAME_TEMPLATE: &str = default_binary_name_template!();

/// The full micro-architecture variant suffix — the `Arm` / `Mips` / `Amd64`
/// tail shared by the Linux-capable asset namers: the archive stage's
/// [`DEFAULT_NAME_TEMPLATE`] / [`DEFAULT_BINARY_NAME_TEMPLATE`] and the
/// `makeself` `.run` default.
///
/// Those namers can target Linux/embedded triples, so all three dimensions can
/// occur: 32-bit ARM (`armv7`/`armv6`, carried in `Arm`), MIPS variants
/// (carried in `Mips`), and the x86-64 micro-architecture level (`Amd64`).
/// Centralising the clause keeps the *suffix* byte-identical across every
/// default that appends it — only the suffix is shared, not the whole template:
/// the archive defaults prefix it with the dotted `{{ .ProjectName }}…` stem
/// while the makeself default uses the bare `{{ ProjectName }}…` form, so the
/// prefixes differ by design. Each consumer carries a drift test pinning its own
/// default to this const so the shared tail cannot drift between them.
pub const MICRO_ARCH_VARIANT_SUFFIX: &str = micro_arch_variant_suffix!();

/// The baseline x86-64 microarchitecture level.
///
/// An ordinary amd64 build carries this level, and every default template
/// guards against emitting it (`Amd64 != "v1"`) so the common single-variant
/// build keeps its historical, suffix-free name. Only the optimized levels
/// (`v2`/`v3`/`v4`) reach an asset name or a package's architecture field.
pub const AMD64_BASELINE_VARIANT: &str = "v1";

/// The amd64-only subset of [`MICRO_ARCH_VARIANT_SUFFIX`] — the suffix the
/// macOS/Windows OS-installer family (`app_bundles`, `dmgs`, `pkgs`, `msis`,
/// `nsis`) default name templates append.
///
/// That amd64 is the lone micro-architecture dimension this family
/// disambiguates is structural to Rust, not arbitrary special-casing: 32-bit
/// ARM and MIPS variants are encoded in the **target triple** (`armv7-…` vs
/// `armv6-…`, `mips-…` vs `mipsel-…`), so they already render a distinct
/// `Arch`; only the x86-64 micro-architecture levels share one triple
/// (`x86_64-…`, differing solely by `-Ctarget-cpu=x86-64-v{2,3}`) and therefore
/// one `Arch`. The build stage consequently only detects `amd64_variant`
/// (stored in `Artifact.metadata["amd64_variant"]`), and these OSes have no
/// 32-bit-ARM/MIPS targets at all — so amd64 is the only clause this family
/// needs. Appending it lets two amd64 builds of the same target (a baseline
/// `v1` and a `v3` tuned with `-Ctarget-cpu=x86-64-v3`) render distinct
/// installer names instead of one silently clobbering the other; `v1` (the
/// baseline) renders no suffix so the common single-variant build keeps its
/// historical name.
pub const INSTALLER_AMD64_VARIANT_SUFFIX: &str =
    "{% if Amd64 and Amd64 != \"v1\" %}{{ Amd64 }}{% endif %}";

/// Seed the per-target template variables a `name_template` reads.
///
/// Sets `Os`, `Arch`, and `Target` from [`map_target`], plus the
/// micro-architecture variant vars (`Arm`, `Arm64`, `Amd64`, `Mips`, `I386`),
/// all reset every call so a prior target's value can never leak.
///
/// The default `name_template` concatenates `{{ .Arch }}{% if Arm %}v{{ Arm }}…`,
/// so the ARM micro-architecture must be carried in `Arm` with `Arch` reduced to
/// the bare `"arm"` — otherwise `{{ .Arch }}v{{ .Arm }}` would double to
/// `armv7v7`. This mirrors the project's tested invariant in
/// `stage-snapcraft::compute_snap_filename`
/// (`tests::test_armv7_target_splits_arch_and_arm_for_default_template`:
/// `linux_armv7`, not `linux_armv7v7`).
///
/// For every other architecture the default template's `{% if Arm %}` /
/// `{% if Mips %}` guards must emit NOTHING (the go-arch `Arch` token alone is
/// the asset suffix), so `Arm64` / `Mips` / `I386` are left empty. `Amd64` is
/// the exception: an x86-64 target seeds the `"v1"` baseline — the value every
/// seeding policy gives an untagged x86-64 binary — so `{{ Amd64 }}` renders
/// identically in an archive name and in a build/installer name for the same
/// binary. The default templates guard the clause with `Amd64 != "v1"`, so the
/// rendered asset names stay byte-identical to what the archive stage has
/// always produced — the contract every consumer of a derived name
/// (binstall, nix, …) depends on.
pub fn seed_target_vars(ctx: &mut Context, target: &str) {
    seed_target_vars_in(ctx.template_vars_mut(), target);
}

/// [`seed_target_vars`] against the variable map directly, for a caller that
/// renders a name off a CLONED variable set rather than the live context —
/// the sign stage derives a binary signature's asset name without disturbing
/// the vars the run's other templates render under.
pub fn seed_target_vars_in(vars: &mut crate::template::TemplateVars, target: &str) {
    let (os, arch) = map_target(target);
    vars.set("Os", &os);
    vars.set("Target", target);

    reset_variant_vars(vars);

    // ARM is the only architecture whose default-template suffix lives in a
    // variant var: split `armv7`/`armv6` into `Arch="arm"` + `Arm="7"/"6"` so
    // `{{ .Arch }}v{{ .Arm }}` renders `armv7` rather than `armv7v7`. Every
    // other go-arch is carried whole in `Arch` with no variant suffix.
    if let Some(version) = arch.strip_prefix("armv") {
        vars.set("Arch", "arm");
        vars.set("Arm", version);
    } else {
        if arch == "amd64" {
            vars.set("Amd64", AMD64_BASELINE_VARIANT);
        }
        vars.set("Arch", &arch);
    }
}

/// Reset every micro-architecture variant template var (`Arm`, `Arm64`,
/// `Amd64`, `Mips`, `I386`) to the empty string.
///
/// The single reset behind [`seed_target_vars`] and [`seed_variant_vars`],
/// also called directly by stages whose host-build (no-triple) paths must
/// clear the variant vars a previous target seeded — resetting a subset is
/// how a stale `Arm64="v8"` leaks into the next render.
pub fn reset_variant_vars(vars: &mut crate::template::TemplateVars) {
    vars.set("Arm", "");
    vars.set("Arm64", "");
    vars.set("Amd64", "");
    vars.set("Mips", "");
    vars.set("I386", "");
}

/// Seed the micro-architecture variant template vars (`Arm`, `Arm64`, `Amd64`,
/// `Mips`, `I386`) from a target triple's first component — the build/installer
/// naming policy, distinct from [`seed_target_vars`]'s archive-asset policy.
///
/// Where [`seed_target_vars`] arm-splits `Arch` for archive-asset names, this
/// policy never touches `Arch`; it seeds each family's GoReleaser-default
/// micro-architecture level so a user template referencing `{{ .Amd64 }}` /
/// `{{ .Arm64 }}` / `{{ .I386 }}` renders the same value in a build binary
/// name and in a makeself/AppImage filename for the same binary:
/// `aarch64` → `Arm64="v8"`, `x86_64` → `Amd64=<variant>` (the binary's
/// `amd64_variant` metadata, defaulting to the `"v1"` baseline when untagged),
/// `i686` → `I386="sse2"`.
///
/// `Arm` and `Mips` are NEVER seeded: every consumer of this policy carries
/// the whole `map_target` arch token (`armv7`, `mips64el`, …) in `Arch`, so a
/// non-empty `Arm`/`Mips` doubles every default filename that appends
/// [`MICRO_ARCH_VARIANT_SUFFIX`]'s `{% if Arm %}v{{ Arm }}` /
/// `{% if Mips %}_{{ Mips }}` clauses (`…_armv7v7.run`,
/// `…_mips64el_mips64el`) — the same contract [`seed_target_vars`] pins for
/// archive names, where the composite token instead splits into
/// `Arch="arm"` + `Arm="7"`.
///
/// All five vars are reset every call so a prior target's value cannot leak.
pub fn seed_variant_vars(
    vars: &mut crate::template::TemplateVars,
    target: &str,
    amd64_variant: Option<&str>,
) {
    reset_variant_vars(vars);
    match target.split('-').next().unwrap_or("") {
        "aarch64" => vars.set("Arm64", "v8"),
        "x86_64" => vars.set("Amd64", amd64_variant.unwrap_or(AMD64_BASELINE_VARIANT)),
        "i686" | "i386" | "i586" => vars.set("I386", "sse2"),
        _ => {}
    }
}

/// Seed the `Amd64` micro-architecture variant template var from a built
/// binary's `amd64_variant` metadata.
///
/// Installer stages call this per `(target, variant)` so a `name` template —
/// the stage default (which appends [`INSTALLER_AMD64_VARIANT_SUFFIX`]) or a
/// user override referencing `{{ Amd64 }}` — can disambiguate two amd64 builds
/// of the same target.
///
/// `arch` is the binary's [`map_target`]-mapped arch token: an amd64 binary
/// with no `amd64_variant` metadata seeds the `"v1"` baseline — the same
/// value [`seed_variant_vars`] and [`seed_target_vars`] give an untagged
/// x86-64 binary, so `{{ Amd64 }}` renders one value everywhere for the same
/// binary — while a non-amd64 binary seeds the empty string (the level is an
/// x86-64 dimension; every policy leaves it empty for other arches). The
/// default templates' `Amd64 != "v1"` guard keeps the baseline suffix-free,
/// preserving the single-variant historical name.
///
/// Takes the [`TemplateVars`](crate::template::TemplateVars) directly rather
/// than a [`Context`] so both the ctx-render installer stages
/// (appbundle/pkg/msi/dmg, which pass `ctx.template_vars_mut()`) and the
/// clone-render stage (nsis, which renders against a cloned `name_vars`) share
/// one helper.
pub fn seed_amd64_variant_var(
    vars: &mut crate::template::TemplateVars,
    arch: &str,
    amd64_variant: Option<&str>,
) {
    let value = match amd64_variant {
        Some(v) => v,
        None if arch == "amd64" => AMD64_BASELINE_VARIANT,
        None => "",
    };
    vars.set("Amd64", value);
}

/// Seed the COMPLETE per-target naming scope an archive `name_template`
/// renders under: the target vars ([`seed_target_vars_in`]), the group's
/// amd64 micro-architecture level overlaid on top, `CrateName`, and the
/// `Binary` the entry names its output after.
///
/// The one spelling of that scope. The archive stage seeds it before
/// rendering each entry's name, and the sign stage seeds the same scope on a
/// cloned variable set to derive a binary signature's asset name — a second
/// spelling on either side is a signature named after an archive the release
/// never uploads.
pub fn seed_archive_name_vars(
    vars: &mut crate::template::TemplateVars,
    target: &str,
    crate_name: &str,
    binary: &str,
    amd64_variant: Option<&str>,
) {
    seed_target_vars_in(vars, target);
    let (_, arch) = map_target(target);
    seed_amd64_variant_var(vars, &arch, amd64_variant);
    vars.set("CrateName", crate_name);
    vars.set("Binary", binary);
}

/// The default `name_template` the archive stage applies to a crate that sets
/// none: the multi-crate default when this run archives more than one crate,
/// the single-crate default otherwise.
pub fn default_archive_name_template(ctx: &Context) -> String {
    if archives_more_than_one_crate(ctx) {
        DEFAULT_NAME_TEMPLATE_MULTI_CRATE.to_string()
    } else {
        DEFAULT_NAME_TEMPLATE.to_string()
    }
}

/// Whether this run archives more than one crate — the archive stage's
/// `work.len() > 1`, which decides both the default `name_template` and
/// whether `ProjectName` is rebound to the per-crate name while a name is
/// rendered.
pub fn archives_more_than_one_crate(ctx: &Context) -> bool {
    crate::archive_selection::archive_producing_crates(
        &ctx.config,
        &ctx.artifacts,
        &ctx.options.selected_crates,
    )
    .len()
        > 1
}

/// [`archives_more_than_one_crate`] answered from CONFIG alone.
///
/// The registry-aware answer counts a crate that configures archives but no
/// builds only once something archivable is registered for it, so `anodizer
/// build` (an empty registry) and `anodizer release --publish-only` (a registry
/// rehydrated from the preserved manifest) disagree about it. A derivation that
/// must name one asset identically under both commands asks this instead.
pub fn config_archives_more_than_one_crate(ctx: &Context) -> bool {
    crate::archive_selection::archive_producing_crates(
        &ctx.config,
        &crate::artifact::ArtifactRegistry::new(),
        &ctx.options.selected_crates,
    )
    .len()
        > 1
}

/// The project-wide default archive format (`defaults.archives.formats[0]`,
/// falling back to `tar.gz`). Used when an archive entry sets no `formats:`.
pub fn global_default_archive_format(ctx: &Context) -> String {
    ctx.config
        .defaults
        .as_ref()
        .and_then(|d| d.archives.as_ref())
        .and_then(|a| a.formats.as_ref())
        .and_then(|f| f.first())
        .cloned()
        .unwrap_or_else(|| "tar.gz".to_string())
}

/// The project-wide `defaults.archives.format_overrides`, the list an entry
/// that sets none of its own inherits.
pub fn global_format_overrides(ctx: &Context) -> Vec<crate::config::FormatOverride> {
    ctx.config
        .defaults
        .as_ref()
        .and_then(|d| d.archives.as_ref())
        .and_then(|a| a.format_overrides.clone())
        .unwrap_or_default()
}

/// Every format one archive entry produces for `target`, in the order the
/// archive stage writes them: the first `format_overrides[]` entry whose `os:`
/// prefixes the target's OS wins, else the entry's own `formats[]`, else
/// `global_default`.
///
/// An entry's own `format_overrides:` REPLACE the global list rather than
/// extending it, and an override carrying an empty (or absent) `formats:` is a
/// typo the archive stage falls through on rather than honoring — an empty `os:`
/// likewise, since an empty prefix matches every target.
///
/// THE resolver: the archive stage plans its outputs through this, and every
/// derivation that must name the asset the stage will write (binstall's
/// `pkg_url`, a raw binary's signature) reads the same answer.
pub fn archive_formats_for_target(
    archive: &crate::config::ArchiveConfig,
    target: &str,
    global_overrides: &[crate::config::FormatOverride],
    global_default: &str,
) -> Vec<String> {
    let (os, _arch) = map_target(target);
    let overrides: &[crate::config::FormatOverride] = archive
        .format_overrides
        .as_deref()
        .unwrap_or(global_overrides);
    overrides
        .iter()
        .find(|ov| !ov.os.is_empty() && os.starts_with(&ov.os))
        .and_then(|ov| ov.formats.as_ref().filter(|f| !f.is_empty()).cloned())
        .or_else(|| archive.formats.as_ref().filter(|f| !f.is_empty()).cloned())
        .unwrap_or_else(|| vec![global_default.to_string()])
}

/// The first format [`archive_formats_for_target`] resolves — the one an
/// entry's primary output carries.
pub fn archive_format_for_target(
    archive: &crate::config::ArchiveConfig,
    target: &str,
    global_overrides: &[crate::config::FormatOverride],
    global_default: &str,
) -> String {
    archive_formats_for_target(archive, target, global_overrides, global_default)
        .into_iter()
        .next()
        .unwrap_or_else(|| global_default.to_string())
}

/// Render an archive's *stem* (filename without the format extension) for a
/// single target by seeding the per-target vars and rendering `name_template`.
///
/// The caller is responsible for any non-target template vars the template
/// reads (`ProjectName`, `Version`, `CrateName`, `Binary`); this only owns the
/// per-target dimension. Returns the rendered stem.
pub fn render_archive_stem(ctx: &mut Context, name_template: &str, target: &str) -> Result<String> {
    seed_target_vars(ctx, target);
    ctx.render_template(name_template)
        .with_context(|| format!("render archive name template for target '{target}'"))
}

/// Render a complete archive *asset filename* (stem + format extension) for a
/// single target.
///
/// `format` is the archive format string as configured (`tar.gz`, `zip`,
/// `tar.xz`, …); it becomes the file extension exactly as the archive stage
/// writes it (`{stem}.{format}`). Returns the full asset filename, e.g.
/// `anodizer-1.2.3-linux-amd64.tar.gz`.
pub fn render_archive_asset_name(
    ctx: &mut Context,
    name_template: &str,
    target: &str,
    format: &str,
) -> Result<String> {
    render_archive_asset_name_with_variant(ctx, name_template, target, format, None)
}

/// [`render_archive_asset_name`] with the group's amd64 micro-architecture
/// level overlaid — the exact seeding sequence the archive stage performs
/// ([`seed_target_vars`] then [`seed_amd64_variant_var`] with the group's
/// `amd64_variant` metadata), so a derived name for a v2/v3-tuned target
/// carries the same `amd64v3`-style suffix the uploaded archive does.
///
/// Config-time callers obtain `amd64_variant` from
/// [`crate::build_env::config_time_amd64_variant`]; `None` renders the
/// baseline (identical to [`render_archive_asset_name`]).
pub fn render_archive_asset_name_with_variant(
    ctx: &mut Context,
    name_template: &str,
    target: &str,
    format: &str,
    amd64_variant: Option<&str>,
) -> Result<String> {
    let stem = if amd64_variant.is_some() {
        seed_target_vars(ctx, target);
        let (_, arch) = map_target(target);
        seed_amd64_variant_var(ctx.template_vars_mut(), &arch, amd64_variant);
        ctx.render_template(name_template)
            .with_context(|| format!("render archive name template for target '{target}'"))?
    } else {
        render_archive_stem(ctx, name_template, target)?
    };
    // `format: binary` publishes the executable itself, so the archive stage
    // writes the rendered stem (plus the Windows `.exe` suffix) with no format
    // extension. Appending `.binary` here would derive an asset name no
    // release ever uploads — the 404 class this module exists to prevent.
    if format == crate::artifact::FORMAT_BINARY {
        return Ok(binary_output_name(stem, target));
    }
    Ok(format!("{stem}.{format}"))
}

/// The file name a `format: binary` archive entry writes for `stem` on
/// `target`: the stem itself, carrying `.exe` on a Windows target unless it
/// already ends in one.
///
/// THE spelling of that rule. The archive stage names the file it produces with
/// it and this module derives the asset name consumers (binstall, the installer
/// script) download with it, so a second spelling on either side is a download
/// URL pointing at a name no release carries.
pub fn binary_output_name(stem: String, target: &str) -> String {
    if crate::target::is_windows(target) && !stem.ends_with(".exe") {
        format!("{stem}.exe")
    } else {
        stem
    }
}

/// Map an archive `format` string to cargo-binstall's `pkg_fmt` value.
///
/// cargo-binstall enumerates a fixed set of package formats; the archive
/// `format` strings anodizer produces map onto them as follows. `None` is
/// returned for formats cargo-binstall cannot binstall (e.g. `binary`, `none`),
/// letting the caller skip emitting an override it could never resolve.
pub fn binstall_pkg_fmt(format: &str) -> Option<&'static str> {
    match format {
        "tar.gz" | "tgz" => Some("tgz"),
        "tar.xz" | "txz" => Some("txz"),
        "tar.zst" | "tzst" => Some("tzstd"),
        "tar.bz2" | "tbz2" => Some("tbz2"),
        "tar" => Some("tar"),
        "zip" => Some("zip"),
        "bin" => Some("bin"),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Config;
    use crate::context::{Context, ContextOptions};

    fn ctx() -> Context {
        let config = Config {
            project_name: "anodizer".to_string(),
            ..Default::default()
        };
        let mut ctx = Context::new(config, ContextOptions::default());
        ctx.template_vars_mut().set("ProjectName", "anodizer");
        ctx.template_vars_mut().set("Version", "1.2.3");
        ctx
    }

    /// The derived asset name is what binstall and the install script fetch;
    /// it has to spell the file the archive stage writes, `.exe` included.
    #[test]
    fn a_binary_format_asset_name_carries_the_windows_suffix() {
        let mut c = ctx();
        let name = render_archive_asset_name_with_variant(
            &mut c,
            "{{ ProjectName }}_{{ Os }}_{{ Arch }}",
            "x86_64-pc-windows-msvc",
            crate::artifact::FORMAT_BINARY,
            None,
        )
        .unwrap();
        assert_eq!(name, "anodizer_windows_amd64.exe");
    }

    #[test]
    fn a_stem_already_ending_in_exe_is_not_doubled() {
        assert_eq!(
            binary_output_name("app.exe".to_string(), "x86_64-pc-windows-msvc"),
            "app.exe"
        );
        assert_eq!(
            binary_output_name("app".to_string(), "x86_64-unknown-linux-gnu"),
            "app"
        );
    }

    #[test]
    fn seeds_os_arch_target() {
        let mut c = ctx();
        seed_target_vars(&mut c, "x86_64-unknown-linux-gnu");
        assert_eq!(c.template_vars().get("Os").unwrap(), "linux");
        assert_eq!(c.template_vars().get("Arch").unwrap(), "amd64");
        assert_eq!(
            c.template_vars().get("Target").unwrap(),
            "x86_64-unknown-linux-gnu"
        );
        // amd64 seeds the unified "v1" baseline; the default template's
        // `Amd64 != "v1"` guard still emits nothing, so the asset name is
        // unchanged while `{{ Amd64 }}` matches the build/installer policies.
        assert_eq!(c.template_vars().get("Amd64").unwrap(), "v1");
        assert_eq!(c.template_vars().get("Arm").unwrap(), "");
    }

    #[test]
    fn armv7_splits_arch_and_arm() {
        // The ARM split: Arch reduces to "arm", Arm carries the digit, so the
        // default template's `{{ .Arch }}v{{ .Arm }}` renders "armv7" (not
        // "armv7v7"). Mirrors stage-snapcraft's tested invariant.
        let mut c = ctx();
        seed_target_vars(&mut c, "armv7-unknown-linux-gnueabihf");
        assert_eq!(c.template_vars().get("Arch").unwrap(), "arm");
        assert_eq!(c.template_vars().get("Arm").unwrap(), "7");
        assert_eq!(c.template_vars().get("Amd64").unwrap(), "");
    }

    #[test]
    fn mips_carries_no_variant_suffix() {
        // mips go-arch lives entirely in Arch; Mips stays empty so the
        // `{% if Mips %}` guard adds no suffix the asset name never had.
        let mut c = ctx();
        seed_target_vars(&mut c, "mips-unknown-linux-gnu");
        assert_eq!(c.template_vars().get("Arch").unwrap(), "mips");
        assert_eq!(c.template_vars().get("Mips").unwrap(), "");
    }

    #[test]
    fn variant_vars_reset_between_targets() {
        let mut c = ctx();
        seed_target_vars(&mut c, "armv7-unknown-linux-gnueabihf");
        assert_eq!(c.template_vars().get("Arm").unwrap(), "7");
        // Re-seed a non-arm target: the stale Arm value AND the reduced Arch
        // must clear back to the new target's go-arch.
        seed_target_vars(&mut c, "x86_64-unknown-linux-gnu");
        assert_eq!(c.template_vars().get("Arm").unwrap(), "");
        assert_eq!(c.template_vars().get("Arch").unwrap(), "amd64");
    }

    #[test]
    fn amd64_variant_var_is_arch_gated() {
        // The untagged fallback is ARCH-GATED, not unconditional: only an
        // amd64 binary falls back to the "v1" baseline; a non-amd64 binary
        // must seed the empty string (the level is an x86-64 dimension), or
        // a template's `{% if Amd64 %}` clause would fire on ARM assets. A
        // "simplified" unconditional `unwrap_or("v1")` fails the arm cases.
        let cases: [(&str, Option<&str>, &str); 4] = [
            ("arm64", None, ""),
            ("armv7", None, ""),
            ("amd64", None, "v1"),
            ("amd64", Some("v3"), "v3"),
        ];
        let mut c = ctx();
        for (arch, variant, expected) in cases {
            seed_amd64_variant_var(c.template_vars_mut(), arch, variant);
            assert_eq!(
                c.template_vars().get("Amd64").unwrap(),
                expected,
                "Amd64 for arch={arch} variant={variant:?}"
            );
        }
    }

    #[test]
    fn default_template_renders_goreleaser_stem() {
        let mut c = ctx();
        let stem =
            render_archive_stem(&mut c, DEFAULT_NAME_TEMPLATE, "aarch64-apple-darwin").unwrap();
        assert_eq!(stem, "anodizer_1.2.3_darwin_arm64");
    }

    #[test]
    fn custom_template_renders_with_format_ext() {
        let mut c = ctx();
        let name = render_archive_asset_name(
            &mut c,
            "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}",
            "x86_64-pc-windows-msvc",
            "zip",
        )
        .unwrap();
        assert_eq!(name, "anodizer-1.2.3-windows-amd64.zip");
    }

    /// Byte-identity regression matrix: the default `name_template` rendered
    /// through `seed_target_vars` must equal the asset names the archive stage
    /// has always produced. The reference column is the historical (HEAD)
    /// behavior — `Os`/`Arch` from `map_target` with EVERY micro-arch variant
    /// var empty at archive time (the build stage reset them before archiving,
    /// and Mips was never set). 32-bit ARM is the regression that motivated this
    /// matrix: it must stay single-`armv7`, never `armv7v7`.
    #[test]
    fn default_template_byte_identical_to_head_for_every_arch() {
        let cases: &[(&str, &str)] = &[
            // 32-bit ARM — the regression. Single armv7/armv6, no doubling.
            (
                "armv7-unknown-linux-gnueabihf",
                "anodizer_1.2.3_linux_armv7",
            ),
            (
                "armv6-unknown-linux-gnueabihf",
                "anodizer_1.2.3_linux_armv6",
            ),
            // MIPS — no added `_mips…` suffix.
            ("mips-unknown-linux-gnu", "anodizer_1.2.3_linux_mips"),
            ("mipsel-unknown-linux-gnu", "anodizer_1.2.3_linux_mipsel"),
            (
                "mips64-unknown-linux-gnuabi64",
                "anodizer_1.2.3_linux_mips64",
            ),
            (
                "mips64el-unknown-linux-gnuabi64",
                "anodizer_1.2.3_linux_mips64el",
            ),
            // i686 — go-arch 386.
            ("i686-unknown-linux-gnu", "anodizer_1.2.3_linux_386"),
            // The 6 standard triples — unchanged baseline.
            ("x86_64-unknown-linux-gnu", "anodizer_1.2.3_linux_amd64"),
            ("aarch64-unknown-linux-gnu", "anodizer_1.2.3_linux_arm64"),
            ("x86_64-apple-darwin", "anodizer_1.2.3_darwin_amd64"),
            ("aarch64-apple-darwin", "anodizer_1.2.3_darwin_arm64"),
            ("x86_64-pc-windows-msvc", "anodizer_1.2.3_windows_amd64"),
            ("aarch64-pc-windows-msvc", "anodizer_1.2.3_windows_arm64"),
        ];
        for (target, expected) in cases {
            let mut c = ctx();
            let stem = render_archive_stem(&mut c, DEFAULT_NAME_TEMPLATE, target).unwrap();
            assert_eq!(
                &stem, expected,
                "default-template asset stem for {target} must match HEAD"
            );
        }
    }

    /// Independent confirmation that the reference column above equals what the
    /// HEAD seeding produced: render the SAME default template with `Os`/`Arch`
    /// from `map_target` and all micro-arch variants forced empty (the literal
    /// HEAD archive-time state), and assert the new `seed_target_vars` path
    /// renders the identical string for every target.
    #[test]
    fn new_seeding_equals_head_seeding_per_target() {
        let targets = [
            "armv7-unknown-linux-gnueabihf",
            "armv6-unknown-linux-gnueabihf",
            "mips-unknown-linux-gnu",
            "mipsel-unknown-linux-gnu",
            "mips64-unknown-linux-gnuabi64",
            "i686-unknown-linux-gnu",
            "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",
        ];
        for target in targets {
            // HEAD reference: map_target's Os/Arch, all variants "".
            let (os, arch) = crate::target::map_target(target);
            let mut head = ctx();
            {
                let v = head.template_vars_mut();
                v.set("Os", &os);
                v.set("Arch", &arch);
                v.set("Target", target);
                v.set("Arm", "");
                v.set("Arm64", "");
                v.set("Amd64", "");
                v.set("Mips", "");
                v.set("I386", "");
            }
            let head_stem = head.render_template(DEFAULT_NAME_TEMPLATE).unwrap();

            // New path.
            let mut new = ctx();
            let new_stem = render_archive_stem(&mut new, DEFAULT_NAME_TEMPLATE, target).unwrap();

            assert_eq!(
                head_stem, new_stem,
                "new seed_target_vars must be byte-identical to HEAD for {target}"
            );
        }
    }

    #[test]
    fn archive_defaults_end_with_full_micro_arch_suffix() {
        // The Linux-capable archive defaults carry the FULL Arm/Mips/Amd64
        // suffix; both must keep it as their literal trailing clause so the
        // makeself stage (which composes from the same const) stays
        // byte-identical to them.
        assert!(DEFAULT_NAME_TEMPLATE.ends_with(MICRO_ARCH_VARIANT_SUFFIX));
        assert!(DEFAULT_BINARY_NAME_TEMPLATE.ends_with(MICRO_ARCH_VARIANT_SUFFIX));
    }

    /// Every default template's "suppress the baseline" guard reads the same
    /// level the packagers compare against, so a change to the baseline moves
    /// the asset names and the deb `arch_variant` together.
    #[test]
    fn the_baseline_amd64_level_is_spelled_once() {
        let guard = format!("Amd64 != \"{AMD64_BASELINE_VARIANT}\"");
        assert!(
            INSTALLER_AMD64_VARIANT_SUFFIX.contains(&guard),
            "installer suffix must guard on the baseline const: {INSTALLER_AMD64_VARIANT_SUFFIX}"
        );
        assert!(
            MICRO_ARCH_VARIANT_SUFFIX.contains(&guard),
            "full suffix must guard on the baseline const: {MICRO_ARCH_VARIANT_SUFFIX}"
        );
    }

    #[test]
    fn installer_amd64_suffix_is_the_tail_of_the_full_suffix() {
        // The macOS/Windows installer family appends only the amd64 clause; it
        // must remain a true subset of the full suffix so the two families
        // disambiguate amd64 identically.
        assert!(MICRO_ARCH_VARIANT_SUFFIX.ends_with(INSTALLER_AMD64_VARIANT_SUFFIX));
    }

    #[test]
    fn variant_vars_mips_stays_empty_for_every_mips_goarch() {
        // Arch already carries the whole mips token, so a seeded Mips would
        // render the doubled `…_mips64el_mips64el` default filename.
        use crate::template::TemplateVars;
        for target in [
            "mips-unknown-linux-gnu",
            "mipsel-unknown-linux-gnu",
            "mips64-unknown-linux-gnuabi64",
            "mips64el-unknown-linux-gnuabi64",
        ] {
            let mut v = TemplateVars::new();
            seed_variant_vars(&mut v, target, None);
            assert_eq!(
                v.get("Mips").map(String::as_str),
                Some(""),
                "Mips must stay empty for {target}"
            );
        }
    }

    #[test]
    fn variant_vars_untagged_x86_64_seeds_amd64_baseline() {
        use crate::template::TemplateVars;
        let mut v = TemplateVars::new();
        seed_variant_vars(&mut v, "x86_64-unknown-linux-gnu", None);
        assert_eq!(v.get("Amd64").map(String::as_str), Some("v1"));
        // A tagged variant overrides the baseline.
        seed_variant_vars(&mut v, "x86_64-unknown-linux-gnu", Some("v3"));
        assert_eq!(v.get("Amd64").map(String::as_str), Some("v3"));
    }

    #[test]
    fn variant_vars_family_levels_and_reset() {
        use crate::template::TemplateVars;
        let mut v = TemplateVars::new();
        seed_variant_vars(&mut v, "aarch64-unknown-linux-gnu", None);
        assert_eq!(v.get("Arm64").map(String::as_str), Some("v8"));
        seed_variant_vars(&mut v, "i686-unknown-linux-gnu", None);
        assert_eq!(v.get("I386").map(String::as_str), Some("sse2"));
        assert_eq!(v.get("Arm64").map(String::as_str), Some(""));
    }

    #[test]
    fn variant_vars_arm_stays_empty_for_every_arm_token() {
        // Consumers of this policy carry the composite armv7/armv6 token in
        // `Arch`, so a seeded Arm would render the doubled `…_armv7v7.run`
        // default filename — the same doubling class the Mips guard pins.
        use crate::template::TemplateVars;
        for target in [
            "armv7-unknown-linux-gnueabihf",
            "armv7l-unknown-linux-gnueabihf",
            "armv6-unknown-linux-gnueabihf",
            "arm-unknown-linux-gnueabi",
        ] {
            let mut v = TemplateVars::new();
            seed_variant_vars(&mut v, target, None);
            assert_eq!(
                v.get("Arm").map(String::as_str),
                Some(""),
                "Arm must stay empty for {target}"
            );
        }
    }

    #[test]
    fn untagged_x86_64_renders_amd64_baseline_identically_across_policies() {
        // The unified baseline: the same untagged x86_64 binary renders
        // `{{ Amd64 }}` as "v1" through every seeding path — the archive
        // policy (seed_target_vars), the build/makeself/appimage policy
        // (seed_variant_vars), and the installer-stage policy
        // (seed_amd64_variant_var, used by msi/dmg/pkg/nsis/flatpak/nfpm/snap).
        use crate::template::TemplateVars;
        let target = "x86_64-unknown-linux-gnu";

        let mut archive = ctx();
        seed_target_vars(&mut archive, target);
        let archive_val = archive.template_vars().get("Amd64").cloned().unwrap();

        let mut build_vars = TemplateVars::new();
        seed_variant_vars(&mut build_vars, target, None);
        let build_val = build_vars.get("Amd64").cloned().unwrap();

        let mut installer_vars = TemplateVars::new();
        seed_amd64_variant_var(&mut installer_vars, "amd64", None);
        let installer_val = installer_vars.get("Amd64").cloned().unwrap();

        assert_eq!(archive_val, "v1");
        assert_eq!(build_val, archive_val);
        assert_eq!(installer_val, archive_val);
    }

    #[test]
    fn untagged_x86_64_default_archive_name_stays_suffix_free() {
        // The "v1" baseline must never surface in a default asset name — the
        // `Amd64 != "v1"` guard suppresses it, so the historical name holds.
        let mut c = ctx();
        let stem =
            render_archive_stem(&mut c, DEFAULT_NAME_TEMPLATE, "x86_64-unknown-linux-gnu").unwrap();
        assert_eq!(stem, "anodizer_1.2.3_linux_amd64");
    }

    #[test]
    fn pkg_fmt_maps_known_formats() {
        assert_eq!(binstall_pkg_fmt("tar.gz"), Some("tgz"));
        assert_eq!(binstall_pkg_fmt("zip"), Some("zip"));
        assert_eq!(binstall_pkg_fmt("tar.xz"), Some("txz"));
        assert_eq!(binstall_pkg_fmt("binary"), None);
        assert_eq!(binstall_pkg_fmt("none"), None);
    }
}