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}