1use std::collections::{BTreeMap, BTreeSet};
16
17use anyhow::Result;
18
19use crate::config::{Config, CrateConfig};
20use crate::context::Context;
21use crate::target::map_target;
22
23const 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
43const 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
64const SEEDED_VARS: &[&str] = &[
70 "Os", "Arch", "Target", "Arm", "Arm64", "Amd64", "Mips", "I386",
71];
72
73pub const INSTALLER_TEMPLATE_VARS: &[&str] = &[
77 "InstallerAssetCases",
78 "InstallerDetectOsCases",
79 "InstallerDetectArchCases",
80 "InstallerSupportedPlatforms",
81];
82
83pub struct InstallerCases {
88 pub asset_cases: String,
91 pub detect_os_cases: String,
94 pub detect_arch_cases: String,
97 pub supported_platforms: String,
103}
104
105impl InstallerCases {
106 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
117pub 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
179pub 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 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 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 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 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
320fn 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
331pub 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 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 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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 assert!(
636 ctx.template_vars().get("Target").is_none(),
637 "Target must be cleared back to unset after rendering"
638 );
639 }
640
641 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert!(
797 parse_arms(&cases.asset_cases).contains_key("linux-mips64el"),
798 "asset arm for the mips target must still be emitted"
799 );
800 assert_eq!(cases.supported_platforms, "linux-amd64");
802 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 #[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}