yah_qed/platform.rs
1//! Host platform self-detection (R531-T1, W222).
2//!
3//! QED today models *where* (Local vs Remote) and *runtime* (Native vs
4//! Container) but has no concept of **architecture**. W222 introduces three
5//! triples per step — `host` (where commands actually execute), `target`
6//! (what the step produces), and `container_platform` (the arch of the base
7//! image it pulls) — and a `resolve(host, target, container_platform)`
8//! decision table that picks cross-compile over emulation.
9//!
10//! This module is the foundation that lands first: the **host** triple is
11//! cheap and reliable to self-detect at runner start, so the planner can
12//! reason about portability instead of discovering an arch mismatch three
13//! waves into a run (the mesofact `x86_64-unknown-linux-musl`-on-arm64
14//! faceplant in W222's frame).
15//!
16//! The host triple is derived from the compiled binary's own
17//! [`std::env::consts`] — `ARCH` (`uname -m`) plus `OS` mapped to the Rust
18//! vendor/os/env convention. This is exactly the "uname -m + OS →
19//! `aarch64-apple-darwin`" detection W222 calls for, and it needs no
20//! subprocess: the QED runner *is* a host-native binary, so its own build
21//! target is the host.
22//!
23//! F2 builds the structured `Platform { host, target, container_platform }`
24//! field on steps atop [`detect_host_triple`]; F3 builds the `resolve(...)`
25//! decision table that consumes the host triple this module produces.
26//!
27//! @arch:see(.yah/docs/working/W235-remote-qed.md)
28//!
29//! @yah:relay(R631, "Placement mesh-tags carry no OS dimension — a darwin target routes to Linux build-workers")
30//! @yah:at(2026-07-23T03:13:00Z)
31//! @yah:status(open)
32//! @yah:next("Surfaced 2026-07-22 by enrolling us-west-015, the fleet's first macOS node. Until then every build-worker was Linux, so arch alone was an adequate proxy for capability and the gap could not manifest.")
33//! @yah:next("Start at build_worker_mesh_tags (this file, ~line 464): it maps arch to a tier only — aarch64 yields [tag:build-worker, tier:arm] with no OS term. Extend it to emit os:<os> from the target triple's OS segment, then teach the machine inventory and any placement spec that consumes it.")
34//! @yah:gotcha("The failure is silent and picks the WRONG node rather than none. An aarch64-apple-darwin offload requests exactly the tag set the Raspberry Pi 5s (us-west-011/013/014) already carry; candidates are filtered by tag superset and ties break on declaration order, so a Linux Pi wins and then cannot emit Mach-O.")
35//! @yah:gotcha("qed already knows darwin cannot be cross-built from Linux — platform.rs resolve() sends such a target to Offload (see resolve_darwin_target_from_linux_host_offloads). So the placement decision is correct in isolation; it is only the TAG DERIVATION that loses the OS, which is why this survived.")
36//! @yah:gotcha("us-west-015 already declares os:darwin and tag:mac-builder, but nothing selects on them — they are descriptive until this lands. Its inventory file says so explicitly; update that note when the gap closes.")
37//! @arch:see(.yah/infra/machines/us-west-015.toml)
38
39use serde::{Deserialize, Serialize};
40
41/// The TOML-declared portion of a step's platform intent (R531-F2, W222).
42///
43/// `host` is deliberately *not* here — it's self-detected per runner
44/// (R531-T1) and composed in at plan time, so a pipeline file never hard-codes
45/// the machine it runs on. A step declares only what it *produces* (`target`)
46/// and, when it pulls a foreign-arch base image, that image's docker platform
47/// (`container_platform`). Both default to `None`, so the overwhelming
48/// majority of steps (host-native builds, checks, typechecks) need no
49/// `[platform]` block at all.
50///
51/// On the TOML side this is an inline table on a step:
52///
53/// ```toml
54/// [[steps]]
55/// name = "build-musl"
56/// platform = { target = "x86_64-unknown-linux-musl" }
57/// ```
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
60pub struct PlatformSpec {
61 /// Rust target triple this step produces, e.g.
62 /// `x86_64-unknown-linux-musl`. `None` = host-native build / nothing
63 /// cross-compiled.
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub target: Option<String>,
66 /// Docker platform of the toolchain / base image this step pulls, e.g.
67 /// `linux/amd64`. `None` = no container, or the host-platform default.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub container_platform: Option<String>,
70 /// R590-F4: when `true`, this step's cross-arch target MUST be built on a
71 /// real machine of that arch. QED disables the cross-compile / emulate
72 /// tiers for the step and routes it to an arch-matched build-worker
73 /// (`Offload`) instead. Defaults `false`, so ordinary steps keep the
74 /// cross-first ladder. Set it only for builds that genuinely can't cross or
75 /// emulate here — e.g. `rusty-v8-musl`, a gn/ninja C++ build that OOMs under
76 /// QEMU on an arm64 host. On the TOML side:
77 /// `platform = { target = "x86_64-unknown-linux-musl", native = true }`.
78 #[serde(default, skip_serializing_if = "is_false")]
79 pub native: bool,
80}
81
82/// `skip_serializing_if` predicate for a `bool` field that defaults to `false`
83/// — so an all-default [`PlatformSpec`] still emits no TOML keys.
84fn is_false(b: &bool) -> bool {
85 !*b
86}
87
88/// A step's fully-composed platform triple-set (R531-F2, W222): where it runs
89/// (`host`), what it produces (`target`), and the arch of the image it pulls
90/// (`container_platform`).
91///
92/// Built at plan time by [`Platform::compose`] from the runner's self-detected
93/// host (R531-T1) plus the step's declared [`PlatformSpec`] — falling back to
94/// the legacy per-kind `triple` field so existing `package-native-tarball`
95/// TOML keeps producing the right target without a `[platform]` block. F3's
96/// `resolve(host, target, container_platform)` decision table consumes this
97/// directly.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Platform {
100 /// Where the step's commands actually execute — the runner host triple.
101 pub host: String,
102 /// What the step produces. `None` = host-native, nothing cross-built.
103 pub target: Option<String>,
104 /// Arch of the base image the step pulls (`linux/amd64`). `None` = no
105 /// container, or the host-platform default.
106 pub container_platform: Option<String>,
107}
108
109impl Platform {
110 /// Compose the triple-set for a step.
111 ///
112 /// - `host` — the runner's self-detected triple (R531-T1).
113 /// - `declared` — the step's `[platform]` block, if any.
114 /// - `triple_field` — the legacy per-kind `triple`
115 /// (`package-native-tarball` / `sign-native-tarball`), used as the
116 /// `target` fallback so existing TOML keeps working: an explicit
117 /// `[platform].target` always wins over it.
118 pub fn compose(
119 host: impl Into<String>,
120 declared: Option<&PlatformSpec>,
121 triple_field: Option<&str>,
122 ) -> Self {
123 let target = declared
124 .and_then(|d| d.target.clone())
125 .or_else(|| triple_field.map(str::to_string));
126 let container_platform = declared.and_then(|d| d.container_platform.clone());
127 Platform {
128 host: host.into(),
129 target,
130 container_platform,
131 }
132 }
133
134 /// True when the step builds for an arch other than the host's. A
135 /// `target` of `None` (host-native) is never cross. Compared on the arch
136 /// segment only — `x86_64-apple-darwin` on an `x86_64-unknown-linux-gnu`
137 /// host is *not* a cross *arch* even though the full triples differ (the
138 /// OS/cross distinction is F3's resolution concern, not this predicate's).
139 pub fn is_cross_arch(&self) -> bool {
140 match &self.target {
141 None => false,
142 Some(t) => arch_of(t) != arch_of(&self.host),
143 }
144 }
145
146 /// True when the step pulls a container image whose arch differs from the
147 /// host's — the exact host ≠ container_platform mismatch that produced the
148 /// mesofact `no matching manifest for linux/arm64` faceplant in W222. The
149 /// `linux/amd64` docker-platform vocabulary is normalized to a bare arch
150 /// for the comparison.
151 pub fn container_is_foreign_arch(&self) -> bool {
152 match &self.container_platform {
153 None => false,
154 Some(p) => docker_platform_arch(p) != Some(arch_of(&self.host)),
155 }
156 }
157}
158
159/// The verdict of [`resolve`] for one step's platform triple-set (R531-F3,
160/// W222): *how* QED should satisfy a "build for target T" / "pull image P"
161/// step on the host it actually runs on.
162///
163/// The ordering encodes W222's **cross-compile first, emulate last** ladder:
164/// a target that can be built with a host-native linker always is; emulation
165/// is an explicit, named fallback for the residue, never the silent default.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum Resolution {
168 /// Tier 1 — host-native cross-compile (`cargo-zigbuild` / musl-cross), no
169 /// container, no emulation. The default path for the overwhelming
170 /// majority of Rust targets (W222: ~99%). Also covers a plain host-native
171 /// build (target absent or host-arch).
172 NativeCross,
173 /// Tier 2 — `cross` via a **host-arch** toolchain container. The toolchain
174 /// runs in a container, but the container's arch matches the host so this
175 /// is genuinely emulation-free (unlike a foreign-arch `cross` image, which
176 /// resolves to [`Emulate`](Self::Emulate)). Chosen when the target isn't
177 /// host-native crossable but a host-arch cross image can build it.
178 CrossDocker,
179 /// Tier 3 — QEMU / platform virtualization. The step pulls a foreign-arch
180 /// image (`docker_platform`) and runs it under emulation. This is the
181 /// W222 mesofact case (`linux/amd64` cross-rs image on an arm64 host) and
182 /// multi-arch `buildx` image builds, where there is no cross-compile for
183 /// an image. Slow and explicit — the preflight (T4) flags it so the
184 /// operator sees the cost before the run.
185 Emulate { docker_platform: String },
186 /// No local path: the target can't be host-native crossed and no container
187 /// can build it here (e.g. `*-apple-darwin` from a Linux host) — it needs
188 /// a real runner capable of building `target`. The scheduler picks the
189 /// concrete remote (P2+); the verdict just names what's needed.
190 Offload { target: String },
191 /// Nothing resolvable: a foreign target with an unrecognized arch and no
192 /// container — QED can neither cross it, emulate it, nor name a runner for
193 /// it. Carries a human-readable reason for the preflight.
194 Skip { reason: String },
195}
196
197impl Resolution {
198 /// Short human label with the cost/mechanism parenthetical, for the T4
199 /// portability preflight and the QED detail pane.
200 pub fn label(&self) -> String {
201 match self {
202 Resolution::NativeCross => "NativeCross (cargo-zigbuild)".into(),
203 Resolution::CrossDocker => "CrossDocker (cross-rs container)".into(),
204 Resolution::Emulate { docker_platform } => {
205 format!("Emulate (QEMU {docker_platform}, slow)")
206 }
207 Resolution::Offload { target } => format!("Offload (needs {target} runner)"),
208 Resolution::Skip { reason } => format!("Skip ({reason})"),
209 }
210 }
211
212 /// True for the emulation / offload tiers — the verdicts that mean "this
213 /// step will *not* run fast (or at all) on this host". The preflight uses
214 /// this to flag divergence; tier 1/2 (NativeCross / CrossDocker) are the
215 /// emulation-free happy path.
216 pub fn is_slow_or_unsatisfiable(&self) -> bool {
217 matches!(
218 self,
219 Resolution::Emulate { .. } | Resolution::Offload { .. } | Resolution::Skip { .. }
220 )
221 }
222}
223
224/// Render one portability-preflight line for a step (R531-T4, W222): its name,
225/// what it targets/builds, the host it runs on, and the resolution verdict —
226/// so an operator sees where mac and linux diverge (and what it costs) *before*
227/// a run, not after a faceplant. Format mirrors W222's example:
228///
229/// ```text
230/// mesofact-dev-build · targets x86_64-unknown-linux-musl · host aarch64-apple-darwin · resolution = NativeCross (cargo-zigbuild)
231/// ```
232pub fn preflight_line(name: &str, platform: &Platform, resolution: &Resolution) -> String {
233 let what = match (&platform.target, &platform.container_platform) {
234 (Some(t), _) => format!("targets {t}"),
235 (None, Some(c)) => format!("builds {c} image"),
236 (None, None) => "host-native".to_string(),
237 };
238 format!(
239 "{name} · {what} · host {host} · resolution = {res}",
240 host = platform.host,
241 res = resolution.label(),
242 )
243}
244
245/// Total resolution function (R531-F3, W222) — a decision-table-as-spec over
246/// (host-arch × target × container-arch). Pure and total: every input maps to
247/// exactly one [`Resolution`], so the mac-vs-linux behaviour is *specified and
248/// tested* rather than emergent.
249///
250/// The decision order (cross-first):
251/// 1. **Host-native crossable target → [`NativeCross`](Resolution::NativeCross).**
252/// Wins even if the recipe declares a foreign container — that container is
253/// the slow path P2/T6 should replace, not what *should* happen.
254/// 2. **Foreign-arch container → [`Emulate`](Resolution::Emulate).** A
255/// foreign image can't be cross-compiled away; it's pulled and run under
256/// QEMU.
257/// 3. **Foreign non-crossable target** → [`CrossDocker`](Resolution::CrossDocker)
258/// if a host-arch toolchain container is present, else
259/// [`Offload`](Resolution::Offload) (known arch) or
260/// [`Skip`](Resolution::Skip) (unknown arch).
261/// 4. **No target / host-arch target** → host-native
262/// [`NativeCross`](Resolution::NativeCross).
263pub fn resolve(host: &str, target: Option<&str>, container_platform: Option<&str>) -> Resolution {
264 let host_arch = arch_of(host);
265 let foreign_target = target
266 .map(str::trim)
267 .filter(|t| !t.is_empty())
268 .filter(|t| arch_of(t) != host_arch);
269
270 // 1. Cross-first: a host-native crossable target always wins.
271 if let Some(t) = foreign_target {
272 if host_native_crossable(host, t) {
273 return Resolution::NativeCross;
274 }
275 }
276
277 // 2. A foreign-arch container forces emulation (mesofact case + buildx).
278 if let Some(p) = container_platform {
279 if docker_platform_arch(p) != Some(host_arch) {
280 return Resolution::Emulate {
281 docker_platform: p.to_string(),
282 };
283 }
284 }
285
286 // 3. Foreign target we can't host-native cross. Any container that reaches
287 // here is host-arch (a foreign one returned Emulate above), so it's a
288 // cross-rs-style toolchain image that can build the target.
289 if let Some(t) = foreign_target {
290 if container_platform.is_some() {
291 return Resolution::CrossDocker;
292 }
293 if is_known_arch(arch_of(t)) {
294 return Resolution::Offload {
295 target: t.to_string(),
296 };
297 }
298 return Resolution::Skip {
299 reason: format!(
300 "cannot build `{t}` on host `{host}`: target arch is unrecognized, \
301 no host-native cross path, and no toolchain container declared"
302 ),
303 };
304 }
305
306 // 4. No target, or a host-arch target → plain native build on the host.
307 Resolution::NativeCross
308}
309
310/// R590-F4 native-placement policy — the entry point the runner resolves each
311/// step through. Placement is *derived from what the step declares*, not an
312/// imperative `--where` flag.
313///
314/// When `native` is `false` this defers entirely to the cross-first decision
315/// table [`resolve`] (the ~99% path: cross-compile beats emulation).
316///
317/// When `native` is `true` the step demands a real machine of its target arch,
318/// so the cross-compile and emulate tiers are *disabled* and the decision
319/// collapses to a binary:
320/// - host-arch (or absent) target → build locally ([`NativeCross`]);
321/// - foreign-arch target → [`Offload`] to an arch-matched build-worker.
322///
323/// `native = true` deliberately overrides even a declared foreign
324/// `container_platform` (which [`resolve`] would send to [`Emulate`] at its
325/// branch 2): the whole point is "no emulation — put it on real silicon." This
326/// is the `rusty-v8-musl` forcing case — a gn/ninja C++ build that OOMs under
327/// QEMU, so it must land on the x86 build-worker (`us-west-002`) rather than
328/// emulate on an arm64 host.
329pub fn resolve_placement(
330 host: &str,
331 target: Option<&str>,
332 container_platform: Option<&str>,
333 native: bool,
334) -> Resolution {
335 if !native {
336 return resolve(host, target, container_platform);
337 }
338 let host_arch = arch_of(host);
339 match target.map(str::trim).filter(|t| !t.is_empty()) {
340 Some(t) if arch_of(t) != host_arch => Resolution::Offload {
341 target: t.to_string(),
342 },
343 // Host-arch target or no target: a plain native build on this host.
344 _ => Resolution::NativeCross,
345 }
346}
347
348/// Can `target` be built on `host` with a host-native linker
349/// (`cargo-zigbuild` / musl-cross), i.e. no container and no emulation?
350///
351/// The spec (refinable as real cross builds surface, the same discipline as
352/// [`crate::preflight::KNOWN_GLIBC_ONLY_CRATES`]):
353/// - **Linux** (`-gnu` / `-musl`, any arch): yes from any host — zig provides
354/// the sysroot + linker for both libc flavors.
355/// - **Windows `-gnu`**: yes from any host (zig). **Windows `-msvc`**: no —
356/// needs the MSVC toolchain.
357/// - **Apple/Darwin**: yes only from a macOS host (the SDK + codesign aren't
358/// redistributable), no from Linux/Windows.
359/// - **Unknown OS**: no.
360pub fn host_native_crossable(host: &str, target: &str) -> bool {
361 match target_os(target) {
362 TargetOs::Linux => true,
363 TargetOs::Windows { msvc } => !msvc,
364 TargetOs::Darwin => matches!(target_os(host), TargetOs::Darwin),
365 TargetOs::Unknown => false,
366 }
367}
368
369/// Coarse OS classification of a target triple, for [`host_native_crossable`].
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371enum TargetOs {
372 Linux,
373 Windows { msvc: bool },
374 Darwin,
375 Unknown,
376}
377
378fn target_os(triple: &str) -> TargetOs {
379 if triple.contains("linux") {
380 TargetOs::Linux
381 } else if triple.contains("windows") {
382 TargetOs::Windows {
383 msvc: triple.ends_with("msvc"),
384 }
385 } else if triple.contains("darwin") || triple.contains("apple") {
386 TargetOs::Darwin
387 } else {
388 TargetOs::Unknown
389 }
390}
391
392/// Recognized CPU arch tokens — the set [`resolve`] can name a runner for when
393/// it has to [`Offload`](Resolution::Offload). An unrecognized arch resolves
394/// to [`Skip`](Resolution::Skip) instead.
395fn is_known_arch(arch: &str) -> bool {
396 matches!(
397 arch,
398 "x86_64" | "aarch64" | "arm64" | "x86" | "i686" | "arm" | "riscv64" | "powerpc64" | "s390x"
399 )
400}
401
402/// Map a docker `--platform` value (`linux/amd64`, `linux/arm64/v8`, or a bare
403/// `arm64`) to the Rust arch token used in target triples (`x86_64`,
404/// `aarch64`). Returns `None` for an unrecognized arch so callers can decide
405/// how to treat the unknown rather than silently matching.
406pub fn docker_platform_arch(platform: &str) -> Option<&'static str> {
407 // `os/arch[/variant]` — the arch is the middle (or only) segment.
408 let arch = platform.split('/').nth(1).unwrap_or(platform);
409 match arch {
410 "amd64" | "x86_64" => Some("x86_64"),
411 "arm64" | "aarch64" => Some("aarch64"),
412 "386" | "x86" => Some("x86"),
413 "arm" => Some("arm"),
414 _ => None,
415 }
416}
417
418/// Detect the host's Rust target triple (e.g. `aarch64-apple-darwin`,
419/// `x86_64-unknown-linux-gnu`).
420///
421/// Composed from [`std::env::consts::ARCH`] and [`std::env::consts::OS`] —
422/// the arch is taken verbatim (it already matches the Rust triple's first
423/// segment) and the OS is mapped to the canonical `<vendor>-<os>[-<env>]`
424/// tail. A Linux host is reported as `-gnu`: a musl *host* is vanishingly
425/// rare for our runners, and `target` (what a step builds) — where musl
426/// actually matters — is a separate triple F2 carries per step.
427///
428/// Unknown OSes fall back to `unknown-<os>` so the output is still a
429/// well-formed, greppable triple rather than a panic.
430pub fn detect_host_triple() -> String {
431 let arch = std::env::consts::ARCH;
432 let tail = match std::env::consts::OS {
433 "macos" => "apple-darwin",
434 "linux" => "unknown-linux-gnu",
435 "windows" => "pc-windows-msvc",
436 other => return format!("{arch}-unknown-{other}"),
437 };
438 format!("{arch}-{tail}")
439}
440
441/// Normalize a Rust arch token (the first segment of a target triple) to the
442/// GitHub Actions `runner.arch` vocabulary (`X86` / `X64` / `ARM` / `ARM64`).
443///
444/// GHA workflows gate on `runner.arch`, so when QED threads the detected host
445/// into the GHA expression context (see `yah_qed_gha::Executor::runner_arch`) it
446/// must speak that vocabulary, not Rust's. An unrecognized arch is upcased
447/// verbatim — a forward-compatible, debuggable default rather than a wrong
448/// guess.
449pub fn gha_runner_arch(arch: &str) -> String {
450 match arch {
451 "x86_64" => "X64".into(),
452 "aarch64" | "arm64" => "ARM64".into(),
453 "x86" | "i686" => "X86".into(),
454 "arm" => "ARM".into(),
455 other => other.to_ascii_uppercase(),
456 }
457}
458
459/// The arch segment (first `-`-delimited token) of a target triple.
460/// `arch_of("aarch64-apple-darwin") == "aarch64"`.
461pub fn arch_of(triple: &str) -> &str {
462 triple.split('-').next().unwrap_or(triple)
463}
464
465/// R594: mesh tags that select an arch-matched build-worker for a remote image
466/// build. `arch` is an arch token (as from [`arch_of`]); the returned tags are a
467/// *superset requirement* — a candidate node must carry all of them.
468///
469/// The fleet nodes are tagged in `.yah/infra/machines/*.toml` with
470/// `mesh_tags = ["tag:build-worker", "tag:qed", "tier:x86" | "tier:arm"]`, so an
471/// amd64 image build routes to `us-west-002` (x86) and an arm64 build to the
472/// Pi5s (arm). yubaba admission consumes this set (see
473/// `velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION`).
474pub fn build_worker_mesh_tags(arch: &str) -> Vec<String> {
475 let arch_tag = match arch {
476 "x86_64" | "x86" | "i686" | "amd64" => "tier:x86",
477 "aarch64" | "arm64" | "arm" => "tier:arm",
478 // Unknown arch: fall back to the build-worker pool without an arch pin;
479 // yubaba admission picks any build-worker (may emulate).
480 _ => return vec!["tag:build-worker".into()],
481 };
482 vec!["tag:build-worker".into(), arch_tag.into()]
483}
484
485#[cfg(test)]
486mod build_worker_tag_tests {
487 use super::build_worker_mesh_tags;
488
489 #[test]
490 fn amd64_selects_x86_build_worker() {
491 assert_eq!(
492 build_worker_mesh_tags("x86_64"),
493 vec!["tag:build-worker".to_string(), "tier:x86".to_string()]
494 );
495 }
496
497 #[test]
498 fn arm64_selects_arm_build_worker() {
499 assert_eq!(
500 build_worker_mesh_tags("aarch64"),
501 vec!["tag:build-worker".to_string(), "tier:arm".to_string()]
502 );
503 }
504
505 #[test]
506 fn unknown_arch_falls_back_to_pool() {
507 assert_eq!(
508 build_worker_mesh_tags("riscv64"),
509 vec!["tag:build-worker".to_string()]
510 );
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn detect_host_triple_is_a_wellformed_triple() {
520 let t = detect_host_triple();
521 // Always at least arch + vendor + os (3 segments), arch first.
522 let segs: Vec<&str> = t.split('-').collect();
523 assert!(
524 segs.len() >= 3,
525 "host triple should have >=3 segments: {t:?}"
526 );
527 assert_eq!(segs[0], std::env::consts::ARCH, "arch segment leads: {t:?}");
528 }
529
530 #[test]
531 fn detect_host_triple_matches_known_os_tails() {
532 // The detected triple's tail must match the running OS's convention,
533 // so this test pins the mapping on whatever host CI/dev runs it.
534 let t = detect_host_triple();
535 match std::env::consts::OS {
536 "macos" => assert!(t.ends_with("-apple-darwin"), "{t:?}"),
537 "linux" => assert!(t.ends_with("-unknown-linux-gnu"), "{t:?}"),
538 "windows" => assert!(t.ends_with("-pc-windows-msvc"), "{t:?}"),
539 _ => {} // unknown-OS fallback covered by the wellformed test
540 }
541 }
542
543 #[test]
544 fn gha_runner_arch_maps_the_rust_vocabulary() {
545 assert_eq!(gha_runner_arch("x86_64"), "X64");
546 assert_eq!(gha_runner_arch("aarch64"), "ARM64");
547 assert_eq!(gha_runner_arch("arm64"), "ARM64");
548 assert_eq!(gha_runner_arch("x86"), "X86");
549 assert_eq!(gha_runner_arch("i686"), "X86");
550 assert_eq!(gha_runner_arch("arm"), "ARM");
551 }
552
553 #[test]
554 fn gha_runner_arch_upcases_unknown_arch() {
555 // Forward-compatible: a new arch we haven't mapped is upcased, not
556 // mis-guessed.
557 assert_eq!(gha_runner_arch("riscv64"), "RISCV64");
558 }
559
560 #[test]
561 fn arch_of_takes_the_first_segment() {
562 assert_eq!(arch_of("aarch64-apple-darwin"), "aarch64");
563 assert_eq!(arch_of("x86_64-unknown-linux-musl"), "x86_64");
564 assert_eq!(arch_of("nodashes"), "nodashes");
565 }
566
567 // ── Platform / PlatformSpec composition (R531-F2) ───────────────────────
568
569 #[test]
570 fn compose_prefers_declared_target_over_triple_field() {
571 let spec = PlatformSpec {
572 target: Some("x86_64-unknown-linux-musl".into()),
573 container_platform: None,
574 native: false,
575 };
576 let p = Platform::compose("aarch64-apple-darwin", Some(&spec), Some("legacy-triple"));
577 assert_eq!(p.target.as_deref(), Some("x86_64-unknown-linux-musl"));
578 assert_eq!(p.host, "aarch64-apple-darwin");
579 }
580
581 #[test]
582 fn compose_falls_back_to_triple_field_when_undeclared() {
583 // package-native-tarball back-compat: a step with no `[platform]`
584 // block still lifts its legacy `triple` into the target.
585 let p = Platform::compose(
586 "aarch64-apple-darwin",
587 None,
588 Some("x86_64-unknown-linux-musl"),
589 );
590 assert_eq!(p.target.as_deref(), Some("x86_64-unknown-linux-musl"));
591 assert!(p.container_platform.is_none());
592 }
593
594 #[test]
595 fn compose_host_native_when_nothing_declared() {
596 let p = Platform::compose("aarch64-apple-darwin", None, None);
597 assert!(p.target.is_none());
598 assert!(!p.is_cross_arch(), "no target is never cross-arch");
599 }
600
601 #[test]
602 fn is_cross_arch_compares_arch_segment_only() {
603 // Same arch, different OS → not a cross *arch*.
604 let same_arch = Platform::compose(
605 "x86_64-unknown-linux-gnu",
606 None,
607 Some("x86_64-apple-darwin"),
608 );
609 assert!(!same_arch.is_cross_arch());
610 // Different arch → cross.
611 let cross = Platform::compose(
612 "aarch64-apple-darwin",
613 None,
614 Some("x86_64-unknown-linux-musl"),
615 );
616 assert!(cross.is_cross_arch());
617 }
618
619 #[test]
620 fn container_foreign_arch_catches_the_mesofact_case() {
621 // The W222 motivating failure: arm64 host pulling a linux/amd64
622 // toolchain image → foreign-arch container.
623 let spec = PlatformSpec {
624 target: Some("x86_64-unknown-linux-musl".into()),
625 container_platform: Some("linux/amd64".into()),
626 native: false,
627 };
628 let p = Platform::compose("aarch64-apple-darwin", Some(&spec), None);
629 assert!(p.container_is_foreign_arch());
630
631 // Same arch image on an amd64 host → not foreign.
632 let native = Platform::compose("x86_64-unknown-linux-gnu", Some(&spec), None);
633 assert!(!native.container_is_foreign_arch());
634 }
635
636 #[test]
637 fn docker_platform_arch_normalizes_os_arch_variant() {
638 assert_eq!(docker_platform_arch("linux/amd64"), Some("x86_64"));
639 assert_eq!(docker_platform_arch("linux/arm64/v8"), Some("aarch64"));
640 assert_eq!(docker_platform_arch("arm64"), Some("aarch64"));
641 assert_eq!(docker_platform_arch("linux/riscv64"), None);
642 }
643
644 #[test]
645 fn platform_spec_round_trips_through_toml() {
646 let spec = PlatformSpec {
647 target: Some("x86_64-unknown-linux-musl".into()),
648 container_platform: Some("linux/amd64".into()),
649 native: false,
650 };
651 let toml = toml::to_string(&spec).unwrap();
652 let back: PlatformSpec = toml::from_str(&toml).unwrap();
653 assert_eq!(spec, back);
654 }
655
656 #[test]
657 fn empty_platform_spec_serializes_to_nothing() {
658 // Both fields skip_serializing_if None → an all-default spec emits no
659 // keys, so a step that declares `platform = {}` stays inert.
660 let spec = PlatformSpec::default();
661 assert_eq!(toml::to_string(&spec).unwrap(), "");
662 }
663
664 // ── resolve() decision table (R531-F3) ──────────────────────────────────
665
666 const ARM_MAC: &str = "aarch64-apple-darwin";
667 const X64_LINUX: &str = "x86_64-unknown-linux-gnu";
668
669 #[test]
670 fn resolve_no_target_is_native() {
671 assert_eq!(resolve(ARM_MAC, None, None), Resolution::NativeCross);
672 }
673
674 #[test]
675 fn resolve_host_arch_target_is_native() {
676 // Same arch (different OS doesn't matter to this tier) → native.
677 assert_eq!(
678 resolve(ARM_MAC, Some("aarch64-unknown-linux-musl"), None),
679 Resolution::NativeCross
680 );
681 }
682
683 #[test]
684 fn resolve_foreign_linux_target_cross_compiles_natively() {
685 // The mesofact target itself, sans foreign container: zig cross-builds
686 // x86_64 musl from an arm64 mac with no emulation.
687 assert_eq!(
688 resolve(ARM_MAC, Some("x86_64-unknown-linux-musl"), None),
689 Resolution::NativeCross
690 );
691 }
692
693 #[test]
694 fn resolve_crossable_target_wins_over_foreign_container() {
695 // Cross-first: even though the recipe declares a foreign cross-rs
696 // image, a host-native crossable target resolves to NativeCross (the
697 // slow container is what P2/T6 should replace).
698 assert_eq!(
699 resolve(
700 ARM_MAC,
701 Some("x86_64-unknown-linux-musl"),
702 Some("linux/amd64")
703 ),
704 Resolution::NativeCross
705 );
706 }
707
708 #[test]
709 fn resolve_foreign_container_with_non_crossable_emulates() {
710 // A non-crossable foreign target (windows-msvc) pulling a foreign-arch
711 // image on an arm64 host → Emulate, carrying the platform to pull.
712 // (For the *crossable* mesofact target the verdict is NativeCross —
713 // "use zigbuild, not the container" — see the test above.)
714 let r = resolve(ARM_MAC, Some("x86_64-pc-windows-msvc"), Some("linux/amd64"));
715 assert_eq!(
716 r,
717 Resolution::Emulate {
718 docker_platform: "linux/amd64".into()
719 }
720 );
721 }
722
723 #[test]
724 fn resolve_multiarch_image_build_with_no_target_emulates() {
725 // A buildx image job (no Rust target) pulling a foreign-arch image:
726 // no cross-compile for an image → Emulate.
727 assert_eq!(
728 resolve(ARM_MAC, None, Some("linux/amd64")),
729 Resolution::Emulate {
730 docker_platform: "linux/amd64".into()
731 }
732 );
733 }
734
735 #[test]
736 fn resolve_host_arch_container_is_not_emulation() {
737 // A host-arch image (no target) is just a native containerized build.
738 assert_eq!(
739 resolve(ARM_MAC, None, Some("linux/arm64")),
740 Resolution::NativeCross
741 );
742 }
743
744 #[test]
745 fn resolve_darwin_target_from_linux_host_offloads() {
746 // Can't host-native cross macOS off Linux, no container → needs a mac.
747 assert_eq!(
748 resolve(X64_LINUX, Some("aarch64-apple-darwin"), None),
749 Resolution::Offload {
750 target: "aarch64-apple-darwin".into()
751 }
752 );
753 }
754
755 #[test]
756 fn resolve_non_crossable_with_host_arch_container_uses_cross_docker() {
757 // macOS target off Linux, but a host-arch (linux/amd64) cross toolchain
758 // image is declared → CrossDocker (genuinely emulation-free).
759 assert_eq!(
760 resolve(X64_LINUX, Some("aarch64-apple-darwin"), Some("linux/amd64")),
761 Resolution::CrossDocker
762 );
763 }
764
765 #[test]
766 fn resolve_windows_msvc_off_linux_offloads_but_gnu_cross_compiles() {
767 // -msvc needs MSVC → offload; -gnu zig-cross-compiles → native.
768 assert_eq!(
769 resolve(X64_LINUX, Some("aarch64-pc-windows-msvc"), None),
770 Resolution::Offload {
771 target: "aarch64-pc-windows-msvc".into()
772 }
773 );
774 assert_eq!(
775 resolve(X64_LINUX, Some("aarch64-pc-windows-gnu"), None),
776 Resolution::NativeCross
777 );
778 }
779
780 #[test]
781 fn resolve_unknown_foreign_arch_with_no_container_skips() {
782 // An unrecognizable arch we can't name a runner for → Skip with reason.
783 let r = resolve(X64_LINUX, Some("sparc64-unknown-linux-gnu"), None);
784 // sparc64 linux is technically zig-crossable per our coarse rule
785 // (linux ⇒ true), so this resolves NativeCross — assert that, and use a
786 // genuinely unknown-OS triple for the Skip path below.
787 assert_eq!(r, Resolution::NativeCross);
788
789 let skip = resolve(X64_LINUX, Some("mos-unknown-none"), None);
790 match skip {
791 Resolution::Skip { reason } => {
792 assert!(reason.contains("mos-unknown-none"), "reason: {reason}");
793 }
794 other => panic!("expected Skip, got {other:?}"),
795 }
796 }
797
798 /// Exhaustive sweep: every (host, target, container) class combination maps
799 /// to exactly one Resolution and the function never panics. This is the
800 /// decision-table-as-spec guarantee — totality over the input space.
801 #[test]
802 fn resolve_is_total_over_the_class_space() {
803 let hosts = [ARM_MAC, X64_LINUX, "x86_64-pc-windows-msvc"];
804 let targets = [
805 None,
806 Some("x86_64-unknown-linux-musl"),
807 Some("aarch64-unknown-linux-gnu"),
808 Some("aarch64-apple-darwin"),
809 Some("x86_64-pc-windows-msvc"),
810 Some("mos-unknown-none"),
811 Some(""),
812 ];
813 let containers = [
814 None,
815 Some("linux/amd64"),
816 Some("linux/arm64"),
817 Some("linux/riscv64"),
818 ];
819 for h in hosts {
820 for t in targets {
821 for c in containers {
822 // Just exercising every cell — the assertion is "doesn't
823 // panic and returns a value"; specific cells are pinned by
824 // the named tests above.
825 let _r = resolve(h, t, c);
826 }
827 }
828 }
829 }
830
831 // ── resolve_placement() native policy (R590-F4) ─────────────────────────
832
833 #[test]
834 fn native_false_defers_to_the_full_decision_table() {
835 // native=false must be byte-identical to resolve() across the board:
836 // the mesofact target still cross-compiles, a foreign container still
837 // emulates a non-crossable target.
838 assert_eq!(
839 resolve_placement(ARM_MAC, Some("x86_64-unknown-linux-musl"), None, false),
840 resolve(ARM_MAC, Some("x86_64-unknown-linux-musl"), None),
841 );
842 assert_eq!(
843 resolve_placement(ARM_MAC, Some("x86_64-pc-windows-msvc"), Some("linux/amd64"), false),
844 resolve(ARM_MAC, Some("x86_64-pc-windows-msvc"), Some("linux/amd64")),
845 );
846 }
847
848 #[test]
849 fn native_foreign_arch_offloads_instead_of_cross_compiling() {
850 // The rusty-v8-musl forcing case: x86_64 musl from an arm64 mac. Without
851 // native this is NativeCross (zig); WITH native it MUST Offload to the
852 // x86 build-worker (the C++ build can't cross/emulate here).
853 assert_eq!(
854 resolve_placement(ARM_MAC, Some("x86_64-unknown-linux-musl"), None, true),
855 Resolution::Offload {
856 target: "x86_64-unknown-linux-musl".into()
857 }
858 );
859 }
860
861 #[test]
862 fn native_forces_past_the_foreign_container_emulate_branch() {
863 // A foreign container_platform would send resolve() to Emulate (branch
864 // 2). native=true overrides that — no emulation, offload to real silicon.
865 assert_eq!(
866 resolve_placement(
867 ARM_MAC,
868 Some("x86_64-unknown-linux-musl"),
869 Some("linux/amd64"),
870 true
871 ),
872 Resolution::Offload {
873 target: "x86_64-unknown-linux-musl".into()
874 }
875 );
876 }
877
878 #[test]
879 fn native_host_arch_target_builds_locally() {
880 // Same arch (different OS is irrelevant) → the native build runs right
881 // here; no offload even under native=true.
882 assert_eq!(
883 resolve_placement(ARM_MAC, Some("aarch64-unknown-linux-musl"), None, true),
884 Resolution::NativeCross
885 );
886 // The x86 build-worker running its own x86 musl build: local native.
887 assert_eq!(
888 resolve_placement(X64_LINUX, Some("x86_64-unknown-linux-musl"), None, true),
889 Resolution::NativeCross
890 );
891 }
892
893 #[test]
894 fn native_absent_or_empty_target_builds_locally() {
895 assert_eq!(
896 resolve_placement(ARM_MAC, None, None, true),
897 Resolution::NativeCross
898 );
899 assert_eq!(
900 resolve_placement(ARM_MAC, Some(" "), None, true),
901 Resolution::NativeCross
902 );
903 }
904
905 #[test]
906 fn native_spec_round_trips_through_toml() {
907 let spec = PlatformSpec {
908 target: Some("x86_64-unknown-linux-musl".into()),
909 container_platform: None,
910 native: true,
911 };
912 let toml = toml::to_string(&spec).unwrap();
913 assert!(toml.contains("native = true"), "toml: {toml:?}");
914 let back: PlatformSpec = toml::from_str(&toml).unwrap();
915 assert_eq!(spec, back);
916 assert!(back.native);
917 }
918
919 // ── preflight rendering (R531-T4) ────────────────────────────────────────
920
921 #[test]
922 fn resolution_labels_carry_the_cost_parenthetical() {
923 assert_eq!(
924 Resolution::NativeCross.label(),
925 "NativeCross (cargo-zigbuild)"
926 );
927 assert_eq!(
928 Resolution::CrossDocker.label(),
929 "CrossDocker (cross-rs container)"
930 );
931 assert_eq!(
932 Resolution::Emulate {
933 docker_platform: "linux/amd64".into()
934 }
935 .label(),
936 "Emulate (QEMU linux/amd64, slow)"
937 );
938 assert_eq!(
939 Resolution::Offload {
940 target: "aarch64-apple-darwin".into()
941 }
942 .label(),
943 "Offload (needs aarch64-apple-darwin runner)"
944 );
945 }
946
947 #[test]
948 fn is_slow_or_unsatisfiable_partitions_the_tiers() {
949 assert!(!Resolution::NativeCross.is_slow_or_unsatisfiable());
950 assert!(!Resolution::CrossDocker.is_slow_or_unsatisfiable());
951 assert!(Resolution::Emulate {
952 docker_platform: "linux/amd64".into()
953 }
954 .is_slow_or_unsatisfiable());
955 assert!(Resolution::Offload { target: "x".into() }.is_slow_or_unsatisfiable());
956 assert!(Resolution::Skip { reason: "x".into() }.is_slow_or_unsatisfiable());
957 }
958
959 #[test]
960 fn preflight_line_matches_w222_format() {
961 // The motivating example from W222, verbatim shape.
962 let p = Platform::compose(ARM_MAC, None, Some("x86_64-unknown-linux-musl"));
963 let r = resolve(
964 &p.host,
965 p.target.as_deref(),
966 p.container_platform.as_deref(),
967 );
968 let line = preflight_line("mesofact-dev-build", &p, &r);
969 assert_eq!(
970 line,
971 "mesofact-dev-build · targets x86_64-unknown-linux-musl · \
972 host aarch64-apple-darwin · resolution = NativeCross (cargo-zigbuild)"
973 );
974 }
975
976 #[test]
977 fn preflight_line_describes_image_builds_and_host_native() {
978 let img = Platform {
979 host: ARM_MAC.into(),
980 target: None,
981 container_platform: Some("linux/amd64".into()),
982 };
983 let r = resolve(&img.host, None, img.container_platform.as_deref());
984 assert!(preflight_line("image-yah-base", &img, &r).contains("builds linux/amd64 image"),);
985
986 let native = Platform::compose(ARM_MAC, None, None);
987 let rn = resolve(&native.host, None, None);
988 assert!(preflight_line("check", &native, &rn).contains("· host-native ·"));
989 }
990
991 #[test]
992 fn host_native_crossable_spec() {
993 // Linux from anywhere.
994 assert!(host_native_crossable(ARM_MAC, "x86_64-unknown-linux-musl"));
995 assert!(host_native_crossable(
996 X64_LINUX,
997 "aarch64-unknown-linux-gnu"
998 ));
999 // Windows gnu yes, msvc no.
1000 assert!(host_native_crossable(X64_LINUX, "x86_64-pc-windows-gnu"));
1001 assert!(!host_native_crossable(X64_LINUX, "x86_64-pc-windows-msvc"));
1002 // Darwin only from a darwin host.
1003 assert!(host_native_crossable(ARM_MAC, "x86_64-apple-darwin"));
1004 assert!(!host_native_crossable(X64_LINUX, "aarch64-apple-darwin"));
1005 // Unknown OS never.
1006 assert!(!host_native_crossable(X64_LINUX, "mos-unknown-none"));
1007 }
1008
1009 #[test]
1010 fn host_arch_round_trips_through_gha_vocabulary() {
1011 // The host we detect must always normalize to a non-empty GHA arch.
1012 let host = detect_host_triple();
1013 let gha = gha_runner_arch(arch_of(&host));
1014 assert!(!gha.is_empty(), "host {host:?} → gha arch {gha:?}");
1015 }
1016}