Skip to main content

yah_qed/
nativecross.rs

1//! The NativeCross tier: host-native cross-compilation (R531-F5, W222).
2//!
3//! F3's [`resolve`](crate::platform::resolve) decides *that* a step should be
4//! satisfied by [`Resolution::NativeCross`](crate::platform::Resolution::NativeCross)
5//! — a host-native cross-compile, no container, no emulation. This module is
6//! the *how*: which host-native cross toolchain carries the build, the
7//! concrete `cargo` invocation it produces, the env it needs, and the
8//! actionable hint when the toolchain isn't installed.
9//!
10//! ## Why this is its own tier
11//!
12//! W222's preference ladder puts host-native cross-compile first for ~99% of
13//! a Rust monorepo's targets — and warns that `cross` on a foreign-arch host
14//! is "tier-3 cost wearing a tier-1 label" (it runs the amd64 cross-rs
15//! container under QEMU). The mesofact faceplant was exactly this: an arm64
16//! mac shelling `cross build --target x86_64-unknown-linux-musl` pulls an
17//! amd64-only image and dies resolving the `FROM`. The fix F3's handoff named
18//! is "stop using the foreign container, use zigbuild" — and *this* module is
19//! what zigbuild-the-verdict routes to.
20//!
21//! ## The two host-native toolchains
22//!
23//! - **`cargo-zigbuild`** ([`CrossTool::CargoZigbuild`]) — zig as the
24//!   linker + sysroot. Cross-compiles musl *and* glibc Linux from any host,
25//!   and Windows-gnu too. This is the default for every foreign-arch Linux /
26//!   Windows-gnu target: one tool, no per-target toolchain install.
27//! - **musl cross-toolchain** ([`CrossTool::MuslCross`]) — a
28//!   `<arch>-linux-musl-gcc` (homebrew `musl-cross`, or the
29//!   `messense/<arch>-linux-musl-cross` packages). A *fallback*: only for
30//!   musl targets, only when zig isn't on the box but the cross-gcc is. It
31//!   needs the `CARGO_TARGET_*_LINKER` / `CC_*` env wired up, which is why
32//!   zigbuild is preferred.
33//!
34//! A host-native or same-OS-arch-cross target (e.g. `x86_64-apple-darwin`
35//! from an arm64 mac, where both SDKs are present) needs neither — plain
36//! [`CrossTool::CargoNative`] (`cargo build --target …`) links it directly.
37//!
38//! ## Discipline
39//!
40//! Selection is a **pure, total decision table** ([`select_cross_tool`])
41//! over (host, target, availability) — the same pure-core / shell-seam split
42//! as [`crate::preflight`] (`check_dep_list` vs `check_musl_compatibility`):
43//! [`ToolAvailability::probe`] is the only impure part, and tests drive the
44//! table with hand-built availability so the mac-vs-linux tool choice is
45//! *specified*, not emergent. T6 wires [`plan_native_cross`] into the
46//! subprocess seam; F5 only defines and tests the mechanism.
47
48use crate::platform::{arch_of, host_native_crossable};
49
50/// A host-native cross-compilation mechanism — the concrete toolchain a
51/// [`NativeCross`](crate::platform::Resolution::NativeCross) verdict runs on.
52///
53/// The program is always `cargo`; the variants differ in the subcommand and
54/// the env they need. Ordered by preference: a plain native build needs the
55/// least, zigbuild covers the most ground, musl-cross is the narrow fallback.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum CrossTool {
58    /// `cargo build [--target T]` with no foreign sysroot — the target is
59    /// host-native, or a same-OS arch-cross the host SDK already covers
60    /// (`x86_64-apple-darwin` from an arm64 mac). Just needs the rustup
61    /// `target add`.
62    CargoNative,
63    /// `cargo zigbuild --target T` — zig provides the linker + sysroot,
64    /// cross-compiling musl/glibc Linux and Windows-gnu from any host. The
65    /// W222 default for the overwhelming majority of foreign-arch targets.
66    CargoZigbuild,
67    /// `cargo build --target T` with `CARGO_TARGET_<T>_LINKER` / `CC_<t>`
68    /// pointed at a `<arch>-linux-musl-gcc` cross toolchain. Fallback for a
69    /// musl target on a host that has the cross-gcc but not zig.
70    MuslCross,
71}
72
73impl CrossTool {
74    /// The cargo subcommand this tool drives (`build` or `zigbuild`). The
75    /// program itself is always `cargo`.
76    pub fn cargo_subcommand(&self) -> &'static str {
77        match self {
78            CrossTool::CargoZigbuild => "zigbuild",
79            CrossTool::CargoNative | CrossTool::MuslCross => "build",
80        }
81    }
82
83    /// Env vars this tool needs for `target`, as `(key, value)` pairs.
84    ///
85    /// Only [`MuslCross`](Self::MuslCross) needs any — it points cargo's
86    /// per-target linker and the `cc` crate's compiler at the
87    /// `<arch>-linux-musl-gcc` cross toolchain. Zigbuild self-contains its
88    /// sysroot, and a native build inherits the host toolchain, so both
89    /// return empty.
90    pub fn env_for(&self, target: &str) -> Vec<(String, String)> {
91        match self {
92            CrossTool::MuslCross => {
93                let prefix = musl_cc_prefix(target);
94                let key = cargo_target_env_key(target);
95                vec![
96                    (
97                        format!("CARGO_TARGET_{key}_LINKER"),
98                        format!("{prefix}-gcc"),
99                    ),
100                    (
101                        format!("CC_{}", target.replace('-', "_")),
102                        format!("{prefix}-gcc"),
103                    ),
104                    (
105                        format!("AR_{}", target.replace('-', "_")),
106                        format!("{prefix}-ar"),
107                    ),
108                ]
109            }
110            CrossTool::CargoNative | CrossTool::CargoZigbuild => Vec::new(),
111        }
112    }
113
114    /// The argv that probes whether this tool is installed (its `--version`).
115    /// [`ToolAvailability::probe`] runs these; the exit status is the signal.
116    pub fn probe_argv(&self) -> Vec<String> {
117        match self {
118            CrossTool::CargoZigbuild => {
119                vec!["cargo".into(), "zigbuild".into(), "--version".into()]
120            }
121            // A native build only needs the rustup target; there's no extra
122            // binary to probe, so its "probe" is `cargo --version` (always
123            // present where qed runs cargo at all).
124            CrossTool::CargoNative => vec!["cargo".into(), "--version".into()],
125            // MuslCross is probed per-target by the linker binary; the bare
126            // probe checks the x86_64 gcc as a representative.
127            CrossTool::MuslCross => {
128                vec!["x86_64-linux-musl-gcc".into(), "--version".into()]
129            }
130        }
131    }
132
133    /// Actionable one-line install hint, surfaced when the tool is selected
134    /// but [unavailable](ToolAvailability). Mirrors the
135    /// [`preflight`](crate::preflight) discipline of routing the operator to
136    /// the fix rather than dying with a raw toolchain error.
137    pub fn install_hint(&self) -> &'static str {
138        match self {
139            CrossTool::CargoZigbuild => {
140                "install cargo-zigbuild + zig: `cargo install cargo-zigbuild` and \
141                 `brew install zig` (or download from ziglang.org)"
142            }
143            CrossTool::MuslCross => {
144                "install a musl cross toolchain: `brew install FiloSottile/musl-cross/musl-cross` \
145                 (macOS) or the `<arch>-linux-musl-cross` package — or install cargo-zigbuild, \
146                 which needs no per-target toolchain"
147            }
148            CrossTool::CargoNative => "add the rustup target: `rustup target add <triple>`",
149        }
150    }
151
152    /// Short label with the mechanism parenthetical, matching the style of
153    /// [`Resolution::label`](crate::platform::Resolution::label) for the T4
154    /// preflight / detail pane.
155    pub fn label(&self) -> &'static str {
156        match self {
157            CrossTool::CargoNative => "native (cargo build)",
158            CrossTool::CargoZigbuild => "cargo-zigbuild",
159            CrossTool::MuslCross => "musl-cross",
160        }
161    }
162}
163
164/// Which host-native toolchains are present on this runner (R531-F5).
165///
166/// The availability-aware half of the decision table: [`select_cross_tool`]
167/// prefers zigbuild but falls back to musl-cross for musl targets when zig
168/// isn't installed. Build it from a real probe ([`Self::probe`]) at runtime,
169/// or by hand in tests so the fallback ladder is *specified*.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub struct ToolAvailability {
172    /// `cargo zigbuild` resolves (cargo-zigbuild + zig installed).
173    pub zigbuild: bool,
174    /// A `<arch>-linux-musl-gcc` cross toolchain is on PATH.
175    pub musl_cross: bool,
176}
177
178impl ToolAvailability {
179    /// Assume the full happy path — both toolchains present. Useful as a
180    /// planning default and in tests that don't exercise the fallback.
181    pub const FULL: ToolAvailability = ToolAvailability {
182        zigbuild: true,
183        musl_cross: true,
184    };
185
186    /// Nothing host-native installed — every cross target errors with an
187    /// install hint. The empty end of the table.
188    pub const NONE: ToolAvailability = ToolAvailability {
189        zigbuild: false,
190        musl_cross: false,
191    };
192
193    /// Probe the host: run each tool's `--version` and record whether it
194    /// exits cleanly. The single impure entry point (shell seam); the rest of
195    /// the module is pure over the result.
196    pub fn probe() -> ToolAvailability {
197        ToolAvailability {
198            zigbuild: probe_ok(&CrossTool::CargoZigbuild.probe_argv()),
199            musl_cross: probe_ok(&CrossTool::MuslCross.probe_argv()),
200        }
201    }
202}
203
204/// A planned host-native cross build (R531-F5): the rewritten argv and the env
205/// it must run under. The terminal output of [`plan_native_cross`] — what T6
206/// hands to the subprocess seam in place of the original `cross build` / bare
207/// `cargo build` argv.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct NativeCrossPlan {
210    /// The toolchain chosen for this target.
211    pub tool: CrossTool,
212    /// The rewritten build argv (`cargo zigbuild --target T …`).
213    pub argv: Vec<String>,
214    /// Env that must be set for the build (non-empty only for musl-cross).
215    pub env: Vec<(String, String)>,
216}
217
218/// Why no host-native cross toolchain could carry a target (R531-F5). Carries
219/// the tool we'd have used and its install hint, so the runner can surface an
220/// actionable error instead of a raw linker failure.
221#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
222#[error(
223    "no host-native cross toolchain for `{target}` on host `{host}`: {} is not installed — {}",
224    .preferred.label(),
225    .preferred.install_hint()
226)]
227pub struct CrossToolUnavailable {
228    pub host: String,
229    pub target: String,
230    /// The tool [`select_cross_tool`] would have used had it been present.
231    pub preferred: CrossTool,
232}
233
234/// Select the host-native cross toolchain for `target` on `host`, honoring
235/// what's installed (R531-F5) — the decision-table-as-spec for the NativeCross
236/// tier. **Total** over the input space; every cell maps to exactly one tool
237/// or one [`CrossToolUnavailable`].
238///
239/// Precondition: only meaningful for a target F3 resolved to
240/// [`NativeCross`](crate::platform::Resolution::NativeCross). A target that
241/// isn't host-native crossable is *not this function's concern* — it falls
242/// back to [`CargoNative`](CrossTool::CargoNative) so the caller still gets a
243/// runnable plan (the rustup target may simply be missing), rather than a
244/// panic.
245///
246/// The table:
247/// 1. **Host-native / same-OS arch-cross** (target absent, host-arch, or a
248///    darwin target from a darwin host) → [`CargoNative`](CrossTool::CargoNative).
249///    No foreign sysroot needed.
250/// 2. **Foreign-arch Linux / Windows-gnu, zig present** →
251///    [`CargoZigbuild`](CrossTool::CargoZigbuild). The W222 default.
252/// 3. **Foreign-arch musl, no zig but musl-cross present** →
253///    [`MuslCross`](CrossTool::MuslCross). The narrow fallback.
254/// 4. **Otherwise** → `Err(CrossToolUnavailable)` naming the preferred tool
255///    and its install hint.
256pub fn select_cross_tool(
257    host: &str,
258    target: Option<&str>,
259    avail: &ToolAvailability,
260) -> Result<CrossTool, CrossToolUnavailable> {
261    let target = match target.map(str::trim).filter(|t| !t.is_empty()) {
262        // No target → host-native build.
263        None => return Ok(CrossTool::CargoNative),
264        Some(t) => t,
265    };
266
267    // 1. Host-native arch, or a cross the host SDK covers without a foreign
268    //    linker (darwin↔darwin) → plain cargo. `host_native_crossable` already
269    //    encodes the darwin-only-from-darwin rule; an arch match is the
270    //    same-arch case. Neither needs zig.
271    let same_arch = arch_of(target) == arch_of(host);
272    if same_arch || needs_no_foreign_linker(host, target) {
273        return Ok(CrossTool::CargoNative);
274    }
275
276    // 2. Foreign-arch crossable target: zig is the preferred carrier.
277    if avail.zigbuild {
278        return Ok(CrossTool::CargoZigbuild);
279    }
280
281    // 3. musl-only fallback when zig is absent but the cross-gcc is present.
282    if is_musl_target(target) && avail.musl_cross {
283        return Ok(CrossTool::MuslCross);
284    }
285
286    // 4. Nothing host-native can carry it. zig is always the recommended fix
287    //    even for a musl target with no cross-gcc — one tool, no per-target
288    //    install — so it's the tool the error names.
289    Err(CrossToolUnavailable {
290        host: host.to_string(),
291        target: target.to_string(),
292        preferred: CrossTool::CargoZigbuild,
293    })
294}
295
296/// Plan a host-native cross build (R531-F5): select the toolchain, rewrite the
297/// original build argv onto it, and gather its env. The top-level F5 API T6
298/// wires into the subprocess seam — given the recipe's original `cross build`
299/// / `cargo build` argv plus the resolved (host, target), it yields the
300/// emulation-free invocation that replaces it.
301pub fn plan_native_cross(
302    original_argv: &[String],
303    host: &str,
304    target: &str,
305    avail: &ToolAvailability,
306) -> Result<NativeCrossPlan, CrossToolUnavailable> {
307    let tool = select_cross_tool(host, Some(target), avail)?;
308    let argv = rewrite_build_argv(original_argv, &tool, target);
309    let env = tool.env_for(target);
310    Ok(NativeCrossPlan { tool, argv, env })
311}
312
313/// Rewrite an existing build argv onto a host-native `tool` for `target`
314/// (R531-F5). This is the concrete "route the cross-rs container to zigbuild"
315/// transform F3's handoff named — it takes the recipe's `["cross", "build",
316/// "--release"]` or `["cargo", "build", "--release", "--target", T]` and:
317///
318/// - rewrites the program (`cross` / `cargo`) to **`cargo`**,
319/// - rewrites the build subcommand to the tool's
320///   ([`zigbuild`](CrossTool::cargo_subcommand) / `build`),
321/// - ensures exactly one `--target T` is present (kept if already there,
322///   appended if not).
323///
324/// An argv that doesn't look like a cargo/cross build (no recognizable
325/// `<cargo|cross> <build|zigbuild>` head) is returned **unchanged** — we can't
326/// know an arbitrary command's flag syntax, so appending `--target` could
327/// corrupt it. The caller's seam ([`plan_native_cross`]) only reaches this for
328/// a step the operator explicitly tagged with a cross target; a non-build argv
329/// there is degenerate and passes through verbatim.
330pub fn rewrite_build_argv(argv: &[String], tool: &CrossTool, target: &str) -> Vec<String> {
331    // Recognize `<cargo|cross> <build|zigbuild>` at the head and normalize it
332    // to `cargo <tool-subcommand>`.
333    let prog = argv.first().map(String::as_str);
334    let sub = argv.get(1).map(String::as_str);
335    let head_is_build = matches!(prog, Some("cargo") | Some("cross"))
336        && matches!(sub, Some("build") | Some("zigbuild"));
337
338    if !head_is_build {
339        return argv.to_vec();
340    }
341
342    let mut out: Vec<String> = Vec::with_capacity(argv.len() + 2);
343    out.push("cargo".to_string());
344    out.push(tool.cargo_subcommand().to_string());
345    out.extend(argv[2..].iter().cloned());
346    ensure_target_flag(&mut out, target);
347    out
348}
349
350/// Ensure `argv` carries exactly one `--target <target>`. If a `--target`
351/// (either `--target T` or `--target=T`) is already present it's left as-is
352/// (the recipe's target wins — they should agree by construction); otherwise
353/// `--target target` is appended.
354fn ensure_target_flag(argv: &mut Vec<String>, target: &str) {
355    let has_target = argv
356        .iter()
357        .any(|a| a == "--target" || a.starts_with("--target="));
358    if !has_target {
359        argv.push("--target".to_string());
360        argv.push(target.to_string());
361    }
362}
363
364/// Does a *cross-arch* `target` link without a foreign linker on `host`? True
365/// only for the darwin→darwin case: the macOS SDK ships both arch slices, so
366/// `cargo build --target x86_64-apple-darwin` links on an arm64 mac with no
367/// zig. Linux/Windows cross-arch always needs a cross linker (zig / musl-gcc),
368/// so this is false for them.
369fn needs_no_foreign_linker(host: &str, target: &str) -> bool {
370    target_is_darwin(target) && target_is_darwin(host)
371}
372
373fn target_is_darwin(triple: &str) -> bool {
374    triple.contains("darwin") || triple.contains("apple")
375}
376
377/// A musl Linux target — the only family the [`MuslCross`](CrossTool::MuslCross)
378/// fallback can carry.
379fn is_musl_target(target: &str) -> bool {
380    target.contains("musl")
381}
382
383/// The `<arch>-linux-musl` toolchain prefix for a musl target triple, e.g.
384/// `x86_64-unknown-linux-musl` → `x86_64-linux-musl` (homebrew `musl-cross` /
385/// `messense/<arch>-linux-musl-cross` naming). Strips the `-unknown` vendor
386/// segment that the cross-gcc package names omit.
387fn musl_cc_prefix(target: &str) -> String {
388    format!("{}-linux-musl", arch_of(target))
389}
390
391/// Cargo's per-target env key: the triple upcased with `-` → `_`
392/// (`x86_64-unknown-linux-musl` → `X86_64_UNKNOWN_LINUX_MUSL`), used in
393/// `CARGO_TARGET_<KEY>_LINKER`.
394fn cargo_target_env_key(target: &str) -> String {
395    target.to_ascii_uppercase().replace('-', "_")
396}
397
398/// Run a probe argv and report whether it exited successfully. Any spawn
399/// failure (binary absent) or non-zero exit reads as "unavailable". The shell
400/// seam [`ToolAvailability::probe`] is built on.
401fn probe_ok(argv: &[String]) -> bool {
402    let Some((prog, args)) = argv.split_first() else {
403        return false;
404    };
405    std::process::Command::new(prog)
406        .args(args)
407        .stdout(std::process::Stdio::null())
408        .stderr(std::process::Stdio::null())
409        .status()
410        .map(|s| s.success())
411        .unwrap_or(false)
412}
413
414/// True when `target` is host-native crossable *and* foreign-arch — the set
415/// this tier exists to carry. A thin predicate over
416/// [`host_native_crossable`](crate::platform::host_native_crossable) for
417/// callers that want to gate on "is this a NativeCross-tier target" without
418/// re-running the full [`resolve`](crate::platform::resolve).
419pub fn is_native_cross_target(host: &str, target: &str) -> bool {
420    arch_of(target) != arch_of(host) && host_native_crossable(host, target)
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    const ARM_MAC: &str = "aarch64-apple-darwin";
428    const X64_LINUX: &str = "x86_64-unknown-linux-gnu";
429    const X64_MUSL: &str = "x86_64-unknown-linux-musl";
430
431    // ── select_cross_tool decision table ────────────────────────────────────
432
433    #[test]
434    fn no_target_is_native() {
435        assert_eq!(
436            select_cross_tool(ARM_MAC, None, &ToolAvailability::FULL),
437            Ok(CrossTool::CargoNative)
438        );
439        // Empty/whitespace target is treated as absent.
440        assert_eq!(
441            select_cross_tool(ARM_MAC, Some("  "), &ToolAvailability::NONE),
442            Ok(CrossTool::CargoNative)
443        );
444    }
445
446    #[test]
447    fn host_arch_target_is_native_even_with_no_toolchains() {
448        // Same arch, different OS → host SDK links it, no zig needed.
449        assert_eq!(
450            select_cross_tool(
451                ARM_MAC,
452                Some("aarch64-unknown-linux-musl"),
453                &ToolAvailability::NONE
454            ),
455            Ok(CrossTool::CargoNative)
456        );
457    }
458
459    #[test]
460    fn darwin_cross_off_darwin_host_is_native() {
461        // x86_64 darwin from arm64 mac: SDK has both slices, plain cargo.
462        assert_eq!(
463            select_cross_tool(
464                ARM_MAC,
465                Some("x86_64-apple-darwin"),
466                &ToolAvailability::NONE
467            ),
468            Ok(CrossTool::CargoNative)
469        );
470    }
471
472    #[test]
473    fn foreign_linux_prefers_zigbuild() {
474        // The mesofact target: x86_64 musl from arm64 mac → zigbuild.
475        assert_eq!(
476            select_cross_tool(ARM_MAC, Some(X64_MUSL), &ToolAvailability::FULL),
477            Ok(CrossTool::CargoZigbuild)
478        );
479        // glibc foreign-arch too.
480        assert_eq!(
481            select_cross_tool(ARM_MAC, Some(X64_LINUX), &ToolAvailability::FULL),
482            Ok(CrossTool::CargoZigbuild)
483        );
484    }
485
486    #[test]
487    fn windows_gnu_foreign_arch_prefers_zigbuild() {
488        assert_eq!(
489            select_cross_tool(
490                ARM_MAC,
491                Some("x86_64-pc-windows-gnu"),
492                &ToolAvailability::FULL
493            ),
494            Ok(CrossTool::CargoZigbuild)
495        );
496    }
497
498    #[test]
499    fn musl_falls_back_to_musl_cross_when_no_zig() {
500        let avail = ToolAvailability {
501            zigbuild: false,
502            musl_cross: true,
503        };
504        assert_eq!(
505            select_cross_tool(ARM_MAC, Some(X64_MUSL), &avail),
506            Ok(CrossTool::MuslCross)
507        );
508    }
509
510    #[test]
511    fn glibc_does_not_fall_back_to_musl_cross() {
512        // musl-cross can't build glibc; with no zig there's no host-native path.
513        let avail = ToolAvailability {
514            zigbuild: false,
515            musl_cross: true,
516        };
517        let err = select_cross_tool(ARM_MAC, Some(X64_LINUX), &avail).unwrap_err();
518        assert_eq!(err.target, X64_LINUX);
519        assert_eq!(err.preferred, CrossTool::CargoZigbuild);
520    }
521
522    #[test]
523    fn nothing_installed_errors_with_install_hint() {
524        let err = select_cross_tool(ARM_MAC, Some(X64_MUSL), &ToolAvailability::NONE).unwrap_err();
525        assert_eq!(err.preferred, CrossTool::CargoZigbuild);
526        let msg = err.to_string();
527        assert!(msg.contains("cargo-zigbuild"), "names the install: {msg}");
528        assert!(msg.contains(X64_MUSL), "names the target: {msg}");
529    }
530
531    /// Totality sweep: every (host, target, availability) class returns a
532    /// value and never panics — the decision-table-as-spec guarantee.
533    #[test]
534    fn select_is_total_over_the_class_space() {
535        let hosts = [ARM_MAC, X64_LINUX, "x86_64-pc-windows-msvc"];
536        let targets = [
537            None,
538            Some(X64_MUSL),
539            Some(X64_LINUX),
540            Some("aarch64-apple-darwin"),
541            Some("x86_64-pc-windows-gnu"),
542            Some(""),
543        ];
544        let avails = [
545            ToolAvailability::FULL,
546            ToolAvailability::NONE,
547            ToolAvailability {
548                zigbuild: true,
549                musl_cross: false,
550            },
551            ToolAvailability {
552                zigbuild: false,
553                musl_cross: true,
554            },
555        ];
556        for h in hosts {
557            for t in targets {
558                for a in avails {
559                    let _ = select_cross_tool(h, t, &a);
560                }
561            }
562        }
563    }
564
565    // ── argv rewriting ──────────────────────────────────────────────────────
566
567    fn argv(parts: &[&str]) -> Vec<String> {
568        parts.iter().map(|s| s.to_string()).collect()
569    }
570
571    #[test]
572    fn rewrites_cross_build_to_cargo_zigbuild() {
573        let original = argv(&["cross", "build", "--release"]);
574        let out = rewrite_build_argv(&original, &CrossTool::CargoZigbuild, X64_MUSL);
575        assert_eq!(
576            out,
577            argv(&["cargo", "zigbuild", "--release", "--target", X64_MUSL])
578        );
579    }
580
581    #[test]
582    fn rewrites_cargo_build_and_keeps_existing_target() {
583        // An explicit --target already present is preserved, not duplicated.
584        let original = argv(&["cargo", "build", "--release", "--target", X64_MUSL]);
585        let out = rewrite_build_argv(&original, &CrossTool::CargoZigbuild, X64_MUSL);
586        assert_eq!(
587            out,
588            argv(&["cargo", "zigbuild", "--release", "--target", X64_MUSL])
589        );
590        assert_eq!(out.iter().filter(|a| *a == "--target").count(), 1);
591    }
592
593    #[test]
594    fn rewrites_equals_form_target_without_duplicating() {
595        let original = argv(&["cargo", "build", &format!("--target={X64_MUSL}")]);
596        let out = rewrite_build_argv(&original, &CrossTool::CargoZigbuild, X64_MUSL);
597        assert_eq!(out.iter().filter(|a| a.starts_with("--target")).count(), 1);
598        assert_eq!(out[1], "zigbuild");
599    }
600
601    #[test]
602    fn native_tool_keeps_build_subcommand() {
603        let original = argv(&["cargo", "build", "--release"]);
604        let out = rewrite_build_argv(&original, &CrossTool::CargoNative, "x86_64-apple-darwin");
605        assert_eq!(out[1], "build");
606        assert_eq!(out.last().unwrap(), "x86_64-apple-darwin");
607    }
608
609    #[test]
610    fn unrecognized_argv_passes_through_unchanged() {
611        // A bare script invocation isn't a cargo/cross build — we can't know
612        // its flag syntax, so leave it verbatim rather than risk corrupting it.
613        let original = argv(&["./build.sh", "--fast"]);
614        let out = rewrite_build_argv(&original, &CrossTool::CargoZigbuild, X64_MUSL);
615        assert_eq!(out, original);
616    }
617
618    // ── env wiring ──────────────────────────────────────────────────────────
619
620    #[test]
621    fn zigbuild_and_native_need_no_env() {
622        assert!(CrossTool::CargoZigbuild.env_for(X64_MUSL).is_empty());
623        assert!(CrossTool::CargoNative.env_for(X64_MUSL).is_empty());
624    }
625
626    #[test]
627    fn musl_cross_wires_linker_cc_and_ar() {
628        let env = CrossTool::MuslCross.env_for(X64_MUSL);
629        let map: std::collections::HashMap<_, _> = env.into_iter().collect();
630        assert_eq!(
631            map.get("CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER")
632                .map(String::as_str),
633            Some("x86_64-linux-musl-gcc")
634        );
635        assert_eq!(
636            map.get("CC_x86_64_unknown_linux_musl").map(String::as_str),
637            Some("x86_64-linux-musl-gcc")
638        );
639        assert_eq!(
640            map.get("AR_x86_64_unknown_linux_musl").map(String::as_str),
641            Some("x86_64-linux-musl-ar")
642        );
643    }
644
645    #[test]
646    fn musl_cc_prefix_strips_vendor() {
647        assert_eq!(
648            musl_cc_prefix("x86_64-unknown-linux-musl"),
649            "x86_64-linux-musl"
650        );
651        assert_eq!(
652            musl_cc_prefix("aarch64-unknown-linux-musl"),
653            "aarch64-linux-musl"
654        );
655    }
656
657    // ── plan_native_cross end-to-end ────────────────────────────────────────
658
659    #[test]
660    fn plan_routes_the_mesofact_step_to_zigbuild() {
661        // The exact W222 motivating case: arm64 mac, the cross-rs musl step.
662        let original = argv(&["cross", "build", "--release", "-p", "almanac-serve"]);
663        let plan =
664            plan_native_cross(&original, ARM_MAC, X64_MUSL, &ToolAvailability::FULL).unwrap();
665        assert_eq!(plan.tool, CrossTool::CargoZigbuild);
666        assert_eq!(
667            plan.argv,
668            argv(&[
669                "cargo",
670                "zigbuild",
671                "--release",
672                "-p",
673                "almanac-serve",
674                "--target",
675                X64_MUSL
676            ])
677        );
678        assert!(plan.env.is_empty(), "zigbuild self-contains its sysroot");
679    }
680
681    #[test]
682    fn plan_falls_back_to_musl_cross_with_env() {
683        let avail = ToolAvailability {
684            zigbuild: false,
685            musl_cross: true,
686        };
687        let original = argv(&["cargo", "build"]);
688        let plan = plan_native_cross(&original, ARM_MAC, X64_MUSL, &avail).unwrap();
689        assert_eq!(plan.tool, CrossTool::MuslCross);
690        assert_eq!(plan.argv, argv(&["cargo", "build", "--target", X64_MUSL]));
691        assert!(!plan.env.is_empty(), "musl-cross needs linker env");
692    }
693
694    #[test]
695    fn plan_errors_when_no_toolchain() {
696        let err = plan_native_cross(
697            &argv(&["cross", "build"]),
698            ARM_MAC,
699            X64_MUSL,
700            &ToolAvailability::NONE,
701        )
702        .unwrap_err();
703        assert_eq!(err.preferred, CrossTool::CargoZigbuild);
704    }
705
706    // ── labels / hints / predicate ──────────────────────────────────────────
707
708    #[test]
709    fn labels_and_hints_are_distinct_and_actionable() {
710        assert_eq!(CrossTool::CargoZigbuild.label(), "cargo-zigbuild");
711        assert_eq!(CrossTool::MuslCross.label(), "musl-cross");
712        assert_eq!(CrossTool::CargoNative.label(), "native (cargo build)");
713        assert!(CrossTool::CargoZigbuild
714            .install_hint()
715            .contains("cargo-zigbuild"));
716        assert!(CrossTool::MuslCross.install_hint().contains("musl-cross"));
717        assert!(CrossTool::CargoNative
718            .install_hint()
719            .contains("rustup target add"));
720    }
721
722    #[test]
723    fn is_native_cross_target_matches_the_zigbuild_set() {
724        // Foreign-arch crossable → yes.
725        assert!(is_native_cross_target(ARM_MAC, X64_MUSL));
726        // Same arch → no (it's a plain native build, not the cross tier).
727        assert!(!is_native_cross_target(
728            ARM_MAC,
729            "aarch64-unknown-linux-gnu"
730        ));
731        // Foreign but non-crossable (darwin off linux) → no.
732        assert!(!is_native_cross_target(X64_LINUX, "aarch64-apple-darwin"));
733    }
734
735    #[test]
736    fn cargo_subcommand_maps_each_tool() {
737        assert_eq!(CrossTool::CargoZigbuild.cargo_subcommand(), "zigbuild");
738        assert_eq!(CrossTool::CargoNative.cargo_subcommand(), "build");
739        assert_eq!(CrossTool::MuslCross.cargo_subcommand(), "build");
740    }
741}