Skip to main content

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}