Skip to main content

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}