yah_qed/toolchain.rs
1//! Declarative toolchain pinning (R507, W208 pillar 3).
2//!
3//! QED today pins toolchains *implicitly* — `cargo +nightly` in an `argv`, a
4//! container `image` that happens to carry Xcode 15.4, an `xcrun` that picks up
5//! whatever's selected on the host. None of that is visible to the dashboard or
6//! to dependency analysis, and a host that's missing the right Xcode/NDK/MSVC
7//! doesn't fail until three waves into a multi-hour release.
8//!
9//! This module makes the pin *declarative and plan-time-checked*:
10//!
11//! ```toml
12//! [pipeline.toolchain]
13//! rust = "1.84.0"
14//! xcode = "15.4"
15//! ndk = "r27"
16//! msvc = "2022-17.8"
17//!
18//! [[pipeline.steps]]
19//! name = "build-android"
20//! toolchain.ndk = "r26d" # per-step override beats the pipeline pin
21//! ```
22//!
23//! The new field declares *what's required*; existing infrastructure provides
24//! *how to satisfy it*. Resolution has two arms, mirroring W208's frame:
25//!
26//! - **Host-side tool managers** (rustup, xcrun, ndk) — the step runs natively,
27//! so QED probes the host's installed version and checks it against the pin.
28//! A miss fails the plan fast with an actionable error (install / select the
29//! pinned version), not a confusing mid-build linker/SDK error.
30//! - **Container image** — when the step pulls an explicit `image` (or
31//! `runtime = "container"`), the image *is* the toolchain provider, so the
32//! host-side check is skipped: the pin is satisfied by the image.
33//!
34//! ## Coordination with W222 (R531)
35//!
36//! This is the *toolchain-version* axis of plan-time host-capability checking;
37//! [`crate::platform`]'s `resolve(host, target, container_platform)` is the
38//! *host/target/container-arch* axis. They share the "fail fast at plan time if
39//! the host can't satisfy the pin" shape — kept as two pure, total decision
40//! functions so each is testable in isolation, composed by the runner at plan
41//! time (see [`crate::runner::PipelineRunner::toolchain_preflight`]).
42//!
43//! ## Surface
44//!
45//! - [`ToolchainSpec`] — the TOML-declared pins, pipeline- or step-scoped.
46//! - [`effective_pins`] — overlay step overrides onto the pipeline pins.
47//! - [`Tool`] — the fixed set QED knows how to *probe* (rust/xcode/ndk/msvc);
48//! unknown tool names are still carried, just unverifiable on the host.
49//! - [`resolve_pin`] — the pure, total decision function for one pin.
50//! - [`version_satisfies`] — segment-prefix version matching.
51//! - [`ToolchainPreflight`] — the aggregate verdict the runner gates on.
52
53use indexmap::IndexMap;
54use serde::{Deserialize, Serialize};
55use std::collections::HashMap;
56
57/// Pinned tool versions, declared at pipeline scope (`[pipeline.toolchain]`) or
58/// per-step (`toolchain.<tool> = "..."`). A flat map of tool name → pinned
59/// version string.
60///
61/// Keys are free-form so a new tool needs no schema change — QED knows how to
62/// *probe* the fixed [`Tool`] set, and treats every other key as carried-but-
63/// unverifiable rather than rejecting it. An all-empty spec (`toolchain = {}`)
64/// is inert: it declares no pins and gates nothing.
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct ToolchainSpec {
68 pub pins: IndexMap<String, String>,
69}
70
71impl ToolchainSpec {
72 /// `true` when no pins are declared — treated as "no toolchain block".
73 pub fn is_empty(&self) -> bool {
74 self.pins.is_empty()
75 }
76}
77
78/// Overlay a step's toolchain overrides onto the pipeline-level pins. The
79/// pipeline pins are the base; any tool the step re-declares wins (the
80/// `build-android` → `toolchain.ndk = "r26d"` override case). Declaration
81/// order is pipeline-pins-first, then step-only additions, so the rendered
82/// preflight is deterministic.
83pub fn effective_pins(
84 pipeline: Option<&ToolchainSpec>,
85 step: Option<&ToolchainSpec>,
86) -> IndexMap<String, String> {
87 let mut out: IndexMap<String, String> = IndexMap::new();
88 if let Some(p) = pipeline {
89 for (k, v) in &p.pins {
90 out.insert(k.clone(), v.clone());
91 }
92 }
93 if let Some(s) = step {
94 for (k, v) in &s.pins {
95 out.insert(k.clone(), v.clone()); // step override beats pipeline
96 }
97 }
98 out
99}
100
101/// The fixed set of toolchains QED knows how to probe on the host. An
102/// unrecognized pin key (`zig`, `emsdk`, …) is still carried through
103/// resolution — it just resolves to [`PinResolution::Unverifiable`] when the
104/// step runs host-side, since QED has no probe for it.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
106pub enum Tool {
107 /// Rust toolchain — probed via `rustc --version` (rustup-managed).
108 Rust,
109 /// Apple Xcode — probed via `xcodebuild -version` (`xcrun`-selected).
110 Xcode,
111 /// Android NDK — probed via `$ANDROID_NDK_HOME/source.properties`.
112 Ndk,
113 /// MSVC build tools — probed via `vswhere` (Windows only).
114 Msvc,
115}
116
117impl Tool {
118 /// Map a pin key to a known [`Tool`]. Case-insensitive on the bare tool
119 /// name. Returns `None` for keys outside the probe set.
120 pub fn parse(name: &str) -> Option<Tool> {
121 match name.trim().to_ascii_lowercase().as_str() {
122 "rust" | "rustc" | "rustup" => Some(Tool::Rust),
123 "xcode" => Some(Tool::Xcode),
124 "ndk" | "android-ndk" => Some(Tool::Ndk),
125 "msvc" => Some(Tool::Msvc),
126 _ => None,
127 }
128 }
129
130 /// Probe the host for this tool's installed version, returning `None` when
131 /// the tool isn't found (not installed, wrong platform, or no version
132 /// could be parsed). Best-effort and side-effect-free beyond reading the
133 /// environment / running a `--version` query; the rigor lives in the pure
134 /// [`resolve_pin`] decision function this feeds.
135 pub fn probe_version(&self) -> Option<String> {
136 match self {
137 Tool::Rust => probe_cmd_version("rustc", &["--version"], parse_rustc_version),
138 Tool::Xcode => probe_cmd_version("xcodebuild", &["-version"], parse_xcodebuild_version),
139 Tool::Ndk => probe_ndk_version(),
140 Tool::Msvc => probe_cmd_version(
141 "vswhere",
142 &["-property", "catalog_productDisplayVersion"],
143 |s| {
144 s.lines()
145 .next()
146 .map(|l| l.trim().to_string())
147 .filter(|l| !l.is_empty())
148 },
149 ),
150 }
151 }
152}
153
154/// How one pin resolves against the host (or against the image that provides
155/// the toolchain). The decision is total — every (pin, detected, image) input
156/// maps to exactly one variant via [`resolve_pin`].
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum PinResolution {
159 /// Host has the tool and its version satisfies the pin.
160 Satisfied {
161 tool: String,
162 want: String,
163 got: String,
164 },
165 /// The step pulls a container image, so the image provides the toolchain —
166 /// the host-side check is skipped by design.
167 SatisfiedByImage { tool: String, want: String },
168 /// Host has the tool but at a version that doesn't satisfy the pin.
169 VersionMismatch {
170 tool: String,
171 want: String,
172 got: String,
173 },
174 /// Host doesn't have the tool at all (and no image provides it).
175 Missing { tool: String, want: String },
176 /// QED has no probe for this tool (a key outside the [`Tool`] set) and no
177 /// image provides it — the pin is carried but can't be verified on the
178 /// host. Non-blocking: surfaced in the preflight, never fails the plan.
179 Unverifiable { tool: String, want: String },
180}
181
182impl PinResolution {
183 /// `true` for the verdicts that should fail the plan fast — a host that
184 /// lacks the pinned tool or has the wrong version. [`Unverifiable`] is
185 /// deliberately *not* blocking: "can't verify" is not "known-bad".
186 ///
187 /// [`Unverifiable`]: PinResolution::Unverifiable
188 pub fn is_blocking(&self) -> bool {
189 matches!(
190 self,
191 PinResolution::VersionMismatch { .. } | PinResolution::Missing { .. }
192 )
193 }
194
195 /// One human-readable preflight line, with the actionable remediation baked
196 /// into the blocking verdicts.
197 pub fn line(&self) -> String {
198 match self {
199 PinResolution::Satisfied { tool, want, got } => {
200 format!("{tool} {want} ✓ (host has {got})")
201 }
202 PinResolution::SatisfiedByImage { tool, want } => {
203 format!("{tool} {want} ✓ (provided by container image)")
204 }
205 PinResolution::VersionMismatch { tool, want, got } => format!(
206 "{tool} {want} ✗ — host has {got}; select/install {tool} {want} \
207 (e.g. via rustup/xcode-select/ndk manager) or run this step in a \
208 container image that carries it"
209 ),
210 PinResolution::Missing { tool, want } => format!(
211 "{tool} {want} ✗ — not found on host; install {tool} {want} or run \
212 this step in a container image that carries it"
213 ),
214 PinResolution::Unverifiable { tool, want } => {
215 format!("{tool} {want} ? (no host probe; not verified)")
216 }
217 }
218 }
219}
220
221/// The pure, total decision function for one pin (R507). Given a pin
222/// (`tool` → `want`), the host's detected version (`None` = tool absent), and
223/// whether the step is satisfied by a container image, return exactly one
224/// [`PinResolution`].
225///
226/// Decision order:
227/// 1. **Image-provided → [`SatisfiedByImage`].** A step that pulls an image
228/// delegates its toolchain to that image; QED doesn't introspect the image,
229/// it trusts the declaration (the host-side managers are for the non-image
230/// arm). Wins over everything — the host's own tools are irrelevant when the
231/// step runs in a container.
232/// 2. **Unknown tool → [`Unverifiable`].** No probe exists, so the pin is
233/// carried but not checked (non-blocking).
234/// 3. **Detected + satisfies → [`Satisfied`]; detected + mismatch →
235/// [`VersionMismatch`]; absent → [`Missing`].**
236///
237/// [`SatisfiedByImage`]: PinResolution::SatisfiedByImage
238/// [`Unverifiable`]: PinResolution::Unverifiable
239/// [`Satisfied`]: PinResolution::Satisfied
240/// [`VersionMismatch`]: PinResolution::VersionMismatch
241/// [`Missing`]: PinResolution::Missing
242pub fn resolve_pin(
243 tool: &str,
244 want: &str,
245 detected: Option<&str>,
246 satisfied_by_image: bool,
247) -> PinResolution {
248 // 1. A containerized step's toolchain comes from the image.
249 if satisfied_by_image {
250 return PinResolution::SatisfiedByImage {
251 tool: tool.to_string(),
252 want: want.to_string(),
253 };
254 }
255
256 // 2. No probe for this tool → carried but unverifiable.
257 if Tool::parse(tool).is_none() {
258 return PinResolution::Unverifiable {
259 tool: tool.to_string(),
260 want: want.to_string(),
261 };
262 }
263
264 // 3. Host-side check.
265 match detected {
266 Some(got) if version_satisfies(want, got) => PinResolution::Satisfied {
267 tool: tool.to_string(),
268 want: want.to_string(),
269 got: got.to_string(),
270 },
271 Some(got) => PinResolution::VersionMismatch {
272 tool: tool.to_string(),
273 want: want.to_string(),
274 got: got.to_string(),
275 },
276 None => PinResolution::Missing {
277 tool: tool.to_string(),
278 want: want.to_string(),
279 },
280 }
281}
282
283/// Does the host's `got` version satisfy the pinned `want`? Segment-prefix
284/// match after normalizing a leading non-digit prefix (so NDK's `r27` pin
285/// matches a detected `27.0.12077973`). The pin's dot-segments must be a prefix
286/// of the detected version's:
287///
288/// - `15.4` is satisfied by `15.4`, `15.4.1` — but not `15.3` or `15`.
289/// - `1.84.0` is satisfied by `1.84.0` — but not `1.84`.
290/// - `r27` (→ `27`) is satisfied by `27.0.12077973`.
291///
292/// This is intentionally a *minimum-floor-by-prefix* rule, not full semver
293/// ordering: pins in practice name an exact-or-finer version, and a total,
294/// obvious rule is worth more than guessing `>=` semantics across four tools
295/// with four different version vocabularies. Refinable as real pins surface,
296/// the same discipline as [`crate::platform::host_native_crossable`].
297pub fn version_satisfies(want: &str, got: &str) -> bool {
298 let want_n = normalize_version(want);
299 let got_n = normalize_version(got);
300 if want_n.is_empty() {
301 return true; // a pin with no comparable digits matches anything
302 }
303 let want_segs: Vec<&str> = want_n.split('.').collect();
304 let got_segs: Vec<&str> = got_n.split('.').collect();
305 want_segs.len() <= got_segs.len() && want_segs.iter().zip(got_segs.iter()).all(|(w, g)| w == g)
306}
307
308/// Extract the comparable dotted-numeric core of a version string: trim
309/// surrounding whitespace, skip a leading run of non-digits (the `r` in `r27`,
310/// a `v` prefix), then keep only the leading digit-and-dot run — dropping a
311/// trailing alpha suffix (NDK's `r26d` → `26`, whose `Pkg.Revision` reads
312/// `26.3.11579264`). `15.4` → `15.4`, `1.84.0` → `1.84.0`, `27.0.12077973`
313/// passes through whole.
314fn normalize_version(v: &str) -> &str {
315 let head = v.trim().trim_start_matches(|c: char| !c.is_ascii_digit());
316 let end = head
317 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
318 .unwrap_or(head.len());
319 &head[..end]
320}
321
322/// The aggregate plan-time verdict over every pin of every step (R507). Built
323/// by [`crate::runner::PipelineRunner::toolchain_preflight`]; the runner gates
324/// `run()` on [`Self::is_satisfied`] and fails fast with [`Self::error_report`]
325/// when a host can't satisfy a pin.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct ToolchainPreflight {
328 /// One entry per (step, pin), in step-then-declaration order.
329 pub entries: Vec<PreflightEntry>,
330}
331
332/// One row of a [`ToolchainPreflight`] — which step pinned which tool, and how
333/// it resolved.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct PreflightEntry {
336 pub step: String,
337 pub resolution: PinResolution,
338}
339
340impl ToolchainPreflight {
341 /// The blocking entries — pins the host can't satisfy. Empty ⇒ the plan is
342 /// clear to run.
343 pub fn blocking(&self) -> impl Iterator<Item = &PreflightEntry> {
344 self.entries.iter().filter(|e| e.resolution.is_blocking())
345 }
346
347 /// `true` when no pin is blocking — the plan can proceed.
348 pub fn is_satisfied(&self) -> bool {
349 self.blocking().next().is_none()
350 }
351
352 /// Render every entry as a `step · <pin line>` report — used both for the
353 /// always-on preflight log and (filtered to blocking entries) for the
354 /// fail-fast error message.
355 pub fn report(&self) -> Vec<String> {
356 self.entries
357 .iter()
358 .map(|e| format!("{} · {}", e.step, e.resolution.line()))
359 .collect()
360 }
361
362 /// The actionable fail-fast message: a header plus one line per blocking
363 /// pin. `None` when nothing blocks (the plan is satisfiable).
364 pub fn error_report(&self) -> Option<String> {
365 let blocking: Vec<String> = self
366 .blocking()
367 .map(|e| format!(" {} · {}", e.step, e.resolution.line()))
368 .collect();
369 if blocking.is_empty() {
370 return None;
371 }
372 Some(format!(
373 "toolchain preflight failed — {} pinned toolchain(s) the host can't satisfy:\n{}",
374 blocking.len(),
375 blocking.join("\n"),
376 ))
377 }
378}
379
380// ── host probes (best-effort; the decision rigor is in resolve_pin) ──────────
381
382/// Run `cmd args...`, capture stdout, and parse a version out of it. Returns
383/// `None` on spawn failure, non-zero exit, or a parse miss.
384fn probe_cmd_version(
385 cmd: &str,
386 args: &[&str],
387 parse: impl Fn(&str) -> Option<String>,
388) -> Option<String> {
389 let output = std::process::Command::new(cmd).args(args).output().ok()?;
390 if !output.status.success() {
391 return None;
392 }
393 let stdout = String::from_utf8_lossy(&output.stdout);
394 parse(&stdout)
395}
396
397/// Parse `rustc 1.84.0 (abc123 2024-…)` → `1.84.0`.
398fn parse_rustc_version(stdout: &str) -> Option<String> {
399 stdout.split_whitespace().nth(1).map(str::to_string)
400}
401
402/// Parse `Xcode 15.4\nBuild version 15F31d` → `15.4`.
403fn parse_xcodebuild_version(stdout: &str) -> Option<String> {
404 let first = stdout.lines().next()?;
405 first
406 .strip_prefix("Xcode")
407 .map(|r| r.trim().to_string())
408 .filter(|s| !s.is_empty())
409}
410
411/// Read the NDK version from `$ANDROID_NDK_HOME` (or `$ANDROID_NDK_ROOT`)'s
412/// `source.properties` (`Pkg.Revision = 27.0.12077973`).
413fn probe_ndk_version() -> Option<String> {
414 let root =
415 std::env::var_os("ANDROID_NDK_HOME").or_else(|| std::env::var_os("ANDROID_NDK_ROOT"))?;
416 let props = std::path::Path::new(&root).join("source.properties");
417 let content = std::fs::read_to_string(props).ok()?;
418 parse_ndk_revision(&content)
419}
420
421/// Extract `Pkg.Revision = 27.0.12077973` → `27.0.12077973` from an NDK
422/// `source.properties` body.
423fn parse_ndk_revision(content: &str) -> Option<String> {
424 for line in content.lines() {
425 if let Some((key, val)) = line.split_once('=') {
426 if key.trim() == "Pkg.Revision" {
427 let v = val.trim();
428 if !v.is_empty() {
429 return Some(v.to_string());
430 }
431 }
432 }
433 }
434 None
435}
436
437/// Probe the host for every tool named in `pins`, returning tool-key →
438/// detected-version (`None` = absent). Each known tool is probed at most once
439/// even if several pins reference it. Unknown tool keys are skipped (they
440/// resolve to [`PinResolution::Unverifiable`] without a probe).
441pub fn detect_host_versions<'a, I>(pins: I) -> HashMap<String, Option<String>>
442where
443 I: IntoIterator<Item = &'a str>,
444{
445 let mut cache: HashMap<Tool, Option<String>> = HashMap::new();
446 let mut out: HashMap<String, Option<String>> = HashMap::new();
447 for key in pins {
448 let Some(tool) = Tool::parse(key) else {
449 continue;
450 };
451 let detected = cache
452 .entry(tool)
453 .or_insert_with(|| tool.probe_version())
454 .clone();
455 out.insert(key.to_string(), detected);
456 }
457 out
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 fn spec(pairs: &[(&str, &str)]) -> ToolchainSpec {
465 ToolchainSpec {
466 pins: pairs
467 .iter()
468 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
469 .collect(),
470 }
471 }
472
473 // ── parsing ──────────────────────────────────────────────────────────────
474
475 #[test]
476 fn toolchain_spec_round_trips_through_toml() {
477 // Pipeline-scope: `[pipeline.toolchain]` table of tool = version.
478 let src = r#"
479rust = "1.84.0"
480xcode = "15.4"
481ndk = "r27"
482"#;
483 let s: ToolchainSpec = toml::from_str(src).unwrap();
484 assert_eq!(s.pins.get("rust").map(String::as_str), Some("1.84.0"));
485 assert_eq!(s.pins.get("xcode").map(String::as_str), Some("15.4"));
486 let back = toml::to_string(&s).unwrap();
487 let reparsed: ToolchainSpec = toml::from_str(&back).unwrap();
488 assert_eq!(reparsed, s);
489 }
490
491 #[test]
492 fn step_inline_toolchain_table_parses() {
493 // The per-step `toolchain.ndk = "r26d"` shape is an inline table.
494 #[derive(serde::Deserialize)]
495 struct StepLike {
496 #[serde(default)]
497 toolchain: Option<ToolchainSpec>,
498 }
499 let step: StepLike = toml::from_str(r#"toolchain = { ndk = "r26d" }"#).unwrap();
500 assert_eq!(
501 step.toolchain.unwrap().pins.get("ndk").map(String::as_str),
502 Some("r26d")
503 );
504 }
505
506 #[test]
507 fn empty_toolchain_spec_is_empty() {
508 let s: ToolchainSpec = toml::from_str("").unwrap();
509 assert!(s.is_empty());
510 }
511
512 // ── tool name parsing ────────────────────────────────────────────────────
513
514 #[test]
515 fn tool_parse_maps_known_aliases() {
516 assert_eq!(Tool::parse("rust"), Some(Tool::Rust));
517 assert_eq!(Tool::parse("rustc"), Some(Tool::Rust));
518 assert_eq!(Tool::parse("Xcode"), Some(Tool::Xcode));
519 assert_eq!(Tool::parse("ndk"), Some(Tool::Ndk));
520 assert_eq!(Tool::parse("android-ndk"), Some(Tool::Ndk));
521 assert_eq!(Tool::parse("msvc"), Some(Tool::Msvc));
522 assert_eq!(Tool::parse("emsdk"), None);
523 }
524
525 // ── effective_pins (step override beats pipeline) ────────────────────────
526
527 #[test]
528 fn effective_pins_overlays_step_over_pipeline() {
529 let pipe = spec(&[("rust", "1.84.0"), ("ndk", "r27")]);
530 let step = spec(&[("ndk", "r26d")]);
531 let eff = effective_pins(Some(&pipe), Some(&step));
532 // Step override wins for ndk; pipeline rust carries through.
533 assert_eq!(eff.get("ndk").map(String::as_str), Some("r26d"));
534 assert_eq!(eff.get("rust").map(String::as_str), Some("1.84.0"));
535 }
536
537 #[test]
538 fn effective_pins_handles_missing_either_side() {
539 let pipe = spec(&[("rust", "1.84.0")]);
540 assert_eq!(
541 effective_pins(Some(&pipe), None)
542 .get("rust")
543 .map(String::as_str),
544 Some("1.84.0")
545 );
546 let step = spec(&[("xcode", "15.4")]);
547 assert_eq!(
548 effective_pins(None, Some(&step))
549 .get("xcode")
550 .map(String::as_str),
551 Some("15.4")
552 );
553 assert!(effective_pins(None, None).is_empty());
554 }
555
556 // ── version_satisfies ────────────────────────────────────────────────────
557
558 #[test]
559 fn version_satisfies_segment_prefix() {
560 assert!(version_satisfies("15.4", "15.4"));
561 assert!(version_satisfies("15.4", "15.4.1"));
562 assert!(!version_satisfies("15.4", "15.3"));
563 assert!(!version_satisfies("15.4", "15"));
564 assert!(version_satisfies("1.84.0", "1.84.0"));
565 assert!(!version_satisfies("1.84.0", "1.84"));
566 }
567
568 #[test]
569 fn version_satisfies_normalizes_leading_nondigits() {
570 // NDK `r27` pin against a detected `27.0.12077973`.
571 assert!(version_satisfies("r27", "27.0.12077973"));
572 assert!(!version_satisfies("r27", "26.3.11579264"));
573 // `r26d` pin's comparable head is `26` — a `26.x` detected satisfies.
574 assert!(version_satisfies("r26d", "26.3.11579264"));
575 }
576
577 // ── resolve_pin decision table ───────────────────────────────────────────
578
579 #[test]
580 fn resolve_pin_satisfied_when_host_matches() {
581 let r = resolve_pin("xcode", "15.4", Some("15.4.1"), false);
582 assert_eq!(
583 r,
584 PinResolution::Satisfied {
585 tool: "xcode".into(),
586 want: "15.4".into(),
587 got: "15.4.1".into()
588 }
589 );
590 assert!(!r.is_blocking());
591 }
592
593 #[test]
594 fn resolve_pin_version_mismatch_blocks() {
595 // noisetable's release.apple pins xcode=15.4; a 15.2 host fails fast.
596 let r = resolve_pin("xcode", "15.4", Some("15.2"), false);
597 assert_eq!(
598 r,
599 PinResolution::VersionMismatch {
600 tool: "xcode".into(),
601 want: "15.4".into(),
602 got: "15.2".into()
603 }
604 );
605 assert!(r.is_blocking());
606 }
607
608 #[test]
609 fn resolve_pin_missing_blocks() {
610 let r = resolve_pin("xcode", "15.4", None, false);
611 assert_eq!(
612 r,
613 PinResolution::Missing {
614 tool: "xcode".into(),
615 want: "15.4".into()
616 }
617 );
618 assert!(r.is_blocking());
619 }
620
621 #[test]
622 fn resolve_pin_image_satisfies_regardless_of_host() {
623 // Even with the tool absent on the host, an image-backed step is fine.
624 let r = resolve_pin("xcode", "15.4", None, true);
625 assert_eq!(
626 r,
627 PinResolution::SatisfiedByImage {
628 tool: "xcode".into(),
629 want: "15.4".into()
630 }
631 );
632 assert!(!r.is_blocking());
633 // Image wins even over a host version mismatch.
634 let r2 = resolve_pin("xcode", "15.4", Some("15.2"), true);
635 assert!(matches!(r2, PinResolution::SatisfiedByImage { .. }));
636 }
637
638 #[test]
639 fn resolve_pin_unknown_tool_is_unverifiable_not_blocking() {
640 let r = resolve_pin("emsdk", "3.1.50", None, false);
641 assert_eq!(
642 r,
643 PinResolution::Unverifiable {
644 tool: "emsdk".into(),
645 want: "3.1.50".into()
646 }
647 );
648 assert!(!r.is_blocking());
649 }
650
651 /// Exhaustive sweep: every (tool-class × detected × image) cell maps to
652 /// exactly one resolution and never panics — the decision-table-as-spec
653 /// totality guarantee.
654 #[test]
655 fn resolve_pin_is_total_over_the_class_space() {
656 let tools = ["rust", "xcode", "ndk", "msvc", "emsdk"]; // last is unknown
657 let detected = [None, Some("15.4"), Some("15.2"), Some("27.0.1")];
658 for t in tools {
659 for d in detected {
660 for image in [true, false] {
661 let r = resolve_pin(t, "15.4", d, image);
662 // Image arm always SatisfiedByImage; unknown host-side arm
663 // always Unverifiable; everything else is one of the three
664 // host verdicts.
665 if image {
666 assert!(matches!(r, PinResolution::SatisfiedByImage { .. }));
667 } else if Tool::parse(t).is_none() {
668 assert!(matches!(r, PinResolution::Unverifiable { .. }));
669 }
670 }
671 }
672 }
673 }
674
675 // ── aggregate preflight ──────────────────────────────────────────────────
676
677 fn entry(step: &str, res: PinResolution) -> PreflightEntry {
678 PreflightEntry {
679 step: step.into(),
680 resolution: res,
681 }
682 }
683
684 #[test]
685 fn preflight_is_satisfied_when_nothing_blocks() {
686 let pf = ToolchainPreflight {
687 entries: vec![
688 entry(
689 "build",
690 PinResolution::Satisfied {
691 tool: "rust".into(),
692 want: "1.84.0".into(),
693 got: "1.84.0".into(),
694 },
695 ),
696 entry(
697 "sign",
698 PinResolution::SatisfiedByImage {
699 tool: "xcode".into(),
700 want: "15.4".into(),
701 },
702 ),
703 entry(
704 "wasm",
705 PinResolution::Unverifiable {
706 tool: "emsdk".into(),
707 want: "3.1".into(),
708 },
709 ),
710 ],
711 };
712 assert!(pf.is_satisfied());
713 assert!(pf.error_report().is_none());
714 assert_eq!(pf.report().len(), 3);
715 }
716
717 #[test]
718 fn preflight_fails_fast_with_actionable_report() {
719 let pf = ToolchainPreflight {
720 entries: vec![
721 entry(
722 "build-ios",
723 PinResolution::Missing {
724 tool: "xcode".into(),
725 want: "15.4".into(),
726 },
727 ),
728 entry(
729 "build",
730 PinResolution::Satisfied {
731 tool: "rust".into(),
732 want: "1.84.0".into(),
733 got: "1.84.0".into(),
734 },
735 ),
736 ],
737 };
738 assert!(!pf.is_satisfied());
739 let report = pf.error_report().expect("blocking ⇒ report");
740 assert!(report.contains("build-ios"));
741 assert!(report.contains("xcode"));
742 assert!(report.contains("15.4"));
743 // Only the blocking row is in the failure message.
744 assert!(!report.contains("rust 1.84.0 ✓"));
745 assert_eq!(pf.blocking().count(), 1);
746 }
747
748 // ── probe parsers (pure halves of the host probes) ───────────────────────
749
750 #[test]
751 fn parse_rustc_version_extracts_the_semver() {
752 assert_eq!(
753 parse_rustc_version("rustc 1.84.0 (9fc6b4312 2024-12-04)").as_deref(),
754 Some("1.84.0")
755 );
756 }
757
758 #[test]
759 fn parse_xcodebuild_version_extracts_the_xcode_line() {
760 assert_eq!(
761 parse_xcodebuild_version("Xcode 15.4\nBuild version 15F31d").as_deref(),
762 Some("15.4")
763 );
764 }
765
766 #[test]
767 fn parse_ndk_revision_reads_pkg_revision() {
768 let props = "Pkg.Desc = Android NDK\nPkg.Revision = 27.0.12077973\n";
769 assert_eq!(parse_ndk_revision(props).as_deref(), Some("27.0.12077973"));
770 assert_eq!(parse_ndk_revision("nothing here").as_deref(), None);
771 }
772
773 #[test]
774 fn detect_host_versions_skips_unknown_tools() {
775 // Unknown keys never reach a probe (and can't, deterministically, in a
776 // test) — they're simply absent from the detected map.
777 let detected = detect_host_versions(["emsdk", "zig"]);
778 assert!(detected.is_empty());
779 }
780}