Skip to main content

lean_host_mcp/
toolchain.rs

1//! Resolve a Lake project's `lean-toolchain` pin to the matching
2//! per-toolchain worker binary on disk.
3//!
4//! Two value types and one error live here:
5//!
6//! - [`ToolchainId`] is the canonical short form of a toolchain pin
7//!   (e.g. `v4.30.0`, `nightly-2026-05-20`). [`ToolchainId::parse`] accepts
8//!   either the bare short form or the elan-style `leanprover/lean4:<id>`.
9//!   [`ToolchainId::from_lake_root`] reads `<root>/lean-toolchain` and parses it.
10//! - [`WorkerBinary`] is the resolved path to a worker binary that links
11//!   the corresponding Lean shared library.
12//!   [`WorkerBinary::resolve_for`] consults (in order) the
13//!   `LEAN_HOST_MCP_WORKERS_DIR` developer override, then
14//!   `<install_root>/<id>/lean-host-mcp-worker`. Missing produces an
15//!   actionable [`ToolchainError::WorkerNotInstalled`] whose `install_cmd`
16//!   field names the exact `install-worker` invocation that will produce it.
17//! - [`ToolchainError`] is the typed failure surface. Project-open code
18//!   maps it into [`crate::error::ServerError::BadProject`] so the install
19//!   command flows through to the client.
20
21use std::fmt;
22use std::path::{Path, PathBuf};
23
24use crate::smoke::SmokeOutcome;
25
26/// Canonical short form of a Lean toolchain pin (e.g. `v4.30.0`,
27/// `nightly-2026-05-20`).
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct ToolchainId(String);
30
31/// File name of the per-toolchain worker binary inside
32/// [`WorkerBinary::install_root`].
33pub const WORKER_FILE_NAME: &str = "lean-host-mcp-worker";
34
35/// Env var that lets a developer running `cargo run` point the parent at
36/// a worker binary outside the standard install layout.
37pub const WORKERS_DIR_ENV: &str = "LEAN_HOST_MCP_WORKERS_DIR";
38
39impl ToolchainId {
40    /// Parse from a `lean-toolchain` line. Accepts the elan-style
41    /// `leanprover/lean4:<id>` and the bare `<id>` short form.
42    ///
43    /// # Errors
44    ///
45    /// [`ToolchainError::UnparseableToolchainString`] if the input is
46    /// empty, contains whitespace or other unexpected characters, or
47    /// names a Lean fork we do not understand.
48    pub fn parse(raw: &str) -> Result<Self, ToolchainError> {
49        let trimmed = raw.trim();
50        if trimmed.is_empty() {
51            return Err(ToolchainError::UnparseableToolchainString(raw.to_owned()));
52        }
53        let short = if let Some(rest) = trimmed.strip_prefix("leanprover/lean4:") {
54            rest
55        } else if trimmed.contains(':') || trimmed.contains('/') {
56            return Err(ToolchainError::UnparseableToolchainString(raw.to_owned()));
57        } else {
58            trimmed
59        };
60        if short.is_empty() || short.chars().any(char::is_whitespace) {
61            return Err(ToolchainError::UnparseableToolchainString(raw.to_owned()));
62        }
63        Ok(Self(short.to_owned()))
64    }
65
66    /// Read `<root>/lean-toolchain` and parse it.
67    ///
68    /// # Errors
69    ///
70    /// [`ToolchainError::LeanToolchainFileMissing`] if the file is absent
71    /// or unreadable. Forwards [`Self::parse`]'s error otherwise.
72    pub fn from_lake_root(root: &Path) -> Result<Self, ToolchainError> {
73        let path = root.join("lean-toolchain");
74        let contents =
75            std::fs::read_to_string(&path).map_err(|_| ToolchainError::LeanToolchainFileMissing(path.clone()))?;
76        Self::parse(&contents)
77    }
78
79    /// Resolved path to the elan toolchain root
80    /// (`~/.elan/toolchains/leanprover--lean4---<id>`).
81    ///
82    /// # Errors
83    ///
84    /// [`ToolchainError::ElanToolchainNotInstalled`] if the directory is
85    /// absent. Returns the path even when present so callers can build
86    /// further on top of it.
87    pub fn elan_dir(&self) -> Result<PathBuf, ToolchainError> {
88        let home = dirs::home_dir().ok_or_else(|| ToolchainError::ElanToolchainNotInstalled {
89            toolchain: self.clone(),
90            elan_dir: PathBuf::from(format!("~/.elan/toolchains/leanprover--lean4---{}", self.0)),
91        })?;
92        let dir = home
93            .join(".elan")
94            .join("toolchains")
95            .join(format!("leanprover--lean4---{}", self.0));
96        if dir.is_dir() {
97            Ok(dir)
98        } else {
99            Err(ToolchainError::ElanToolchainNotInstalled {
100                toolchain: self.clone(),
101                elan_dir: dir,
102            })
103        }
104    }
105
106    #[must_use]
107    pub fn as_str(&self) -> &str {
108        &self.0
109    }
110
111    /// Total-order key for presenting toolchains in a sensible order: numbered
112    /// releases sorted semantically (a release candidate sorts *before* its
113    /// release, e.g. `v4.31.0-rc1 < v4.31.0`), then non-numbered pins
114    /// (`nightly-*`) after, ordered by their string (dates sort naturally).
115    ///
116    /// Reuses [`version_key`] so the ordering matches the supported-window
117    /// logic. Callers (`install-worker` listing/scan) `.cmp()` these keys
118    /// instead of comparing the raw strings, whose lexical order wrongly puts
119    /// `v4.31.0-rc1` *after* `v4.31.0` (the release is a prefix of the rc).
120    #[must_use]
121    pub fn sort_key(&self) -> (u8, (u32, u32, u32, u8, u32), String) {
122        let bare = self.0.strip_prefix('v').unwrap_or(&self.0);
123        match version_key(bare) {
124            Some(k) => (0, k, String::new()),
125            None => (1, (0, 0, 0, 0, 0), self.0.clone()),
126        }
127    }
128
129    /// Classify this pin against the lean-rs supported window.
130    ///
131    /// Pure: reads only [`lean_toolchain::SUPPORTED_TOOLCHAINS`], no IO. The
132    /// single leading `v` is stripped before the query (`v4.31.0-rc1` ⇒
133    /// `4.31.0-rc1`, the bare form lean-rs tabulates). A pin that does not
134    /// parse as `X.Y.Z[-rcN]` (e.g. `nightly-2026-05-20`) is
135    /// [`WindowVerdict::Unknown`] — allowed, never a crash.
136    ///
137    /// Used by `install-worker` *before* it spends minutes building (the
138    /// worker does not exist yet, so the full [`WorkerBinary::resolve_ready_for`]
139    /// gate cannot answer), by `--list`, and internally by the gate.
140    #[must_use]
141    pub fn window_verdict(&self) -> WindowVerdict {
142        let bare = self.0.strip_prefix('v').unwrap_or(&self.0);
143        if lean_toolchain::supported_for(bare).is_some() {
144            return WindowVerdict::Supported;
145        }
146        match version_key(bare) {
147            Some(pin) => {
148                let (window, nearest) = out_of_window_bounds(pin);
149                WindowVerdict::OutOfWindow { window, nearest }
150            }
151            None => WindowVerdict::Unknown,
152        }
153    }
154}
155
156impl fmt::Display for ToolchainId {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.write_str(&self.0)
159    }
160}
161
162/// Resolved path to a per-toolchain worker binary.
163#[derive(Debug, Clone)]
164pub struct WorkerBinary {
165    pub path: PathBuf,
166    pub toolchain: ToolchainId,
167}
168
169impl WorkerBinary {
170    /// Look up the worker binary for `toolchain`.
171    ///
172    /// Resolution order:
173    ///
174    /// 1. If `LEAN_HOST_MCP_WORKERS_DIR` is set and
175    ///    `<dir>/lean-host-mcp-worker` exists, return that.
176    /// 2. If `LEAN_HOST_MCP_WORKERS_DIR` is set and
177    ///    `<dir>/<id>/lean-host-mcp-worker` exists, return that.
178    /// 3. Otherwise look under [`Self::install_root`].
179    ///
180    /// # Errors
181    ///
182    /// [`ToolchainError::WorkerNotInstalled`] when no candidate exists.
183    /// The error carries the exact `install-worker` command needed to fix
184    /// the situation.
185    pub fn resolve_for(toolchain: &ToolchainId) -> Result<Self, ToolchainError> {
186        let override_dir = std::env::var_os(WORKERS_DIR_ENV).map(PathBuf::from);
187        Self::resolve_with_override(toolchain, override_dir.as_deref())
188    }
189
190    /// Resolution variant that lets the caller inject the
191    /// `LEAN_HOST_MCP_WORKERS_DIR` value rather than reading the env.
192    /// Used by the test suite (env mutation is forbidden by the
193    /// workspace `unsafe-code` lint) and by tooling that wants to
194    /// override resolution without touching the process environment.
195    ///
196    /// # Errors
197    ///
198    /// Same as [`Self::resolve_for`].
199    pub fn resolve_with_override(toolchain: &ToolchainId, override_dir: Option<&Path>) -> Result<Self, ToolchainError> {
200        if let Some(dir) = override_dir {
201            let bare = dir.join(WORKER_FILE_NAME);
202            if bare.is_file() {
203                return Ok(Self {
204                    path: bare,
205                    toolchain: toolchain.clone(),
206                });
207            }
208            let with_id = dir.join(toolchain.as_str()).join(WORKER_FILE_NAME);
209            if with_id.is_file() {
210                return Ok(Self {
211                    path: with_id,
212                    toolchain: toolchain.clone(),
213                });
214            }
215            return Err(Self::not_installed(toolchain));
216        }
217        let candidate = Self::install_root().join(toolchain.as_str()).join(WORKER_FILE_NAME);
218        if candidate.is_file() {
219            Ok(Self {
220                path: candidate,
221                toolchain: toolchain.clone(),
222            })
223        } else {
224            Err(Self::not_installed(toolchain))
225        }
226    }
227
228    /// `~/.local/share/lean-host-mcp/workers` (or
229    /// `$XDG_DATA_HOME/lean-host-mcp/workers`).
230    ///
231    /// Falls back to the current directory if no data dir can be located;
232    /// the calling code will then fail soon after with a concrete
233    /// `WorkerNotInstalled`.
234    #[must_use]
235    pub fn install_root() -> PathBuf {
236        dirs::data_local_dir()
237            .unwrap_or_else(|| PathBuf::from("."))
238            .join("lean-host-mcp")
239            .join("workers")
240    }
241
242    fn not_installed(toolchain: &ToolchainId) -> ToolchainError {
243        ToolchainError::WorkerNotInstalled {
244            toolchain: toolchain.clone(),
245            install_cmd: install_cmd(toolchain),
246        }
247    }
248
249    /// The one answer project-open needs about a pinned toolchain: window
250    /// membership, elan + worker install presence, and header-digest
251    /// provenance, folded into a single [`Readiness`] verdict the caller maps
252    /// to one outcome (spawn, warn-and-spawn, or a typed `BadProject`).
253    ///
254    /// This hides five independently-volatile decisions behind one call — the
255    /// window source ([`ToolchainId::window_verdict`]), the elan layout
256    /// ([`ToolchainId::elan_dir`]), the worker install layout
257    /// ([`Self::resolve_with_override`]), the header-digest provenance
258    /// mechanism, and the recorded runtime smoke result (both via the private
259    /// `WorkerSidecar`) — so no call site consults a classifier and a separate
260    /// provenance check. An out-of-window pin short-circuits before any
261    /// filesystem probe, so the caller gets the window message even when the
262    /// bogus toolchain was never installed.
263    ///
264    /// On the happy path it hashes `<elan_dir>/include/lean/lean.h` once (a
265    /// few-KB SHA-256); this belongs on the cold resolve/open path, not the
266    /// warm per-call path. The `LEAN_HOST_MCP_WORKERS_DIR` override is read
267    /// from the environment.
268    #[must_use]
269    pub fn resolve_ready_for(pin: &ToolchainId) -> Readiness {
270        // Window first: an out-of-window pin can never load, and the bogus
271        // toolchain is usually not installed, so checking elan first would
272        // bury the useful "outside the window" message.
273        if let WindowVerdict::OutOfWindow { window, nearest } = pin.window_verdict() {
274            return Readiness::Unsupported { window, nearest };
275        }
276        let elan_dir = match pin.elan_dir() {
277            Ok(dir) => dir,
278            Err(ToolchainError::ElanToolchainNotInstalled { toolchain, elan_dir }) => {
279                return Readiness::ToolchainNotInstalled { toolchain, elan_dir };
280            }
281            Err(_) => {
282                return Readiness::ToolchainNotInstalled {
283                    toolchain: pin.clone(),
284                    elan_dir: PathBuf::new(),
285                };
286            }
287        };
288        let current = hash_lean_header(&elan_dir).ok();
289        let override_dir = std::env::var_os(WORKERS_DIR_ENV).map(PathBuf::from);
290        Self::resolve_ready_with_override(pin, override_dir.as_deref(), elan_dir, current.as_deref())
291    }
292
293    /// Resolution variant that injects the `LEAN_HOST_MCP_WORKERS_DIR` value,
294    /// the resolved `lean_sysroot` (the toolchain's `elan_dir`), and the
295    /// already-computed current `lean.h` digest rather than reading the
296    /// environment and filesystem. Mirrors [`Self::resolve_with_override`];
297    /// the test suite drives the gate's window/install/provenance logic
298    /// through this seam without a real toolchain on disk.
299    #[must_use]
300    pub fn resolve_ready_with_override(
301        pin: &ToolchainId,
302        override_dir: Option<&Path>,
303        lean_sysroot: PathBuf,
304        current_digest: Option<&str>,
305    ) -> Readiness {
306        // Self-contained for direct test callers: re-check the window so the
307        // seam alone produces the full verdict set.
308        let window = pin.window_verdict();
309        if let WindowVerdict::OutOfWindow { window, nearest } = window {
310            return Readiness::Unsupported { window, nearest };
311        }
312        let Ok(worker) = Self::resolve_with_override(pin, override_dir) else {
313            return Readiness::NotInstalled {
314                toolchain: pin.clone(),
315                install_cmd: install_cmd(pin),
316            };
317        };
318        // Provenance, in order of severity:
319        //   1. header drift  → Stale   (rebuild advice trumps everything else)
320        //   2. smoke failed   → Unusable (built + digest-matched, but cannot run)
321        //   3. smoke missing  → Ready + soft note (older host: reinstall to verify)
322        //   4. sidecar absent → Ready + soft note (older host: no provenance at all)
323        let install_dir = worker.path.parent().unwrap_or(&worker.path);
324        let note = match WorkerSidecar::load(install_dir) {
325            Some(sidecar) => {
326                // A recorded build-time digest that no longer matches the
327                // toolchain's current lean.h means the header drifted under the
328                // worker; a rebuild is the right move regardless of any smoke
329                // verdict, so check it first.
330                if let Some(current) = current_digest
331                    && !sidecar.header_matches(current)
332                {
333                    return Readiness::Stale {
334                        toolchain: pin.clone(),
335                        install_cmd: install_cmd(pin),
336                    };
337                }
338                // Worker and host are version-locked; a worker built by a
339                // different host version may speak a different worker protocol,
340                // so surface a rebuild nudge. Soft (not a hard refuse): a
341                // protocol-compatible patch bump would otherwise force needless
342                // rebuilds, and `install-worker --auto` rebuilds skewed workers
343                // anyway. `""` means the sidecar predates the field — unknown,
344                // not skewed.
345                let host_skew = {
346                    let built = sidecar.host_version();
347                    let current = env!("CARGO_PKG_VERSION");
348                    (!built.is_empty() && built != current).then(|| {
349                        format!(
350                            "worker for {pin} was built by lean-host-mcp {built}, but this host is \
351                             {current}; worker and host are version-locked — rebuild it: {}",
352                            install_cmd(pin)
353                        )
354                    })
355                };
356                match sidecar.smoke() {
357                    // A header-digest match does not imply ABI compatibility (a
358                    // toolchain's libleanshared can crash this worker); the
359                    // recorded runtime smoke result is the sound signal.
360                    Some(SmokeOutcome::Failed { detail }) => {
361                        return Readiness::Unusable {
362                            toolchain: pin.clone(),
363                            detail: detail.to_owned(),
364                            install_cmd: install_cmd(pin),
365                        };
366                    }
367                    // Host skew is the more actionable nudge than a missing
368                    // smoke record, so it wins when both apply.
369                    Some(SmokeOutcome::Passed) => host_skew,
370                    // Built by an older host that did not smoke-test: the header
371                    // digest still guards drift, but the worker is unverified at
372                    // runtime — nudge a reinstall to record a smoke result.
373                    None => host_skew.or_else(|| {
374                        Some(format!(
375                            "worker for {pin} has no runtime smoke record (installed by an older host); \
376                             reinstall to verify it can run: {}",
377                            install_cmd(pin)
378                        ))
379                    }),
380                }
381            }
382            // A worker installed by an older host has no sidecar: not an error,
383            // just unknown provenance worth a soft nudge to reinstall.
384            None => Some(format!(
385                "worker for {pin} has no provenance record (installed by an older host); \
386                 reinstall to enable header-drift detection: {}",
387                install_cmd(pin)
388            )),
389        };
390        if matches!(window, WindowVerdict::Unknown) {
391            return Readiness::UnknownPin {
392                pin: pin.as_str().to_owned(),
393                worker,
394                lean_sysroot,
395            };
396        }
397        Readiness::Ready {
398            worker,
399            lean_sysroot,
400            note,
401        }
402    }
403}
404
405/// `lean-host-mcp install-worker --toolchain <id>` — the exact command that
406/// produces a missing or stale worker for `toolchain`.
407fn install_cmd(toolchain: &ToolchainId) -> String {
408    format!("lean-host-mcp install-worker --toolchain {}", toolchain.as_str())
409}
410
411/// Where a pin sits relative to the lean-rs supported window. Pure data
412/// derived from [`lean_toolchain::SUPPORTED_TOOLCHAINS`] — see
413/// [`ToolchainId::window_verdict`].
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum WindowVerdict {
416    /// Pin matches a known supported toolchain exactly.
417    Supported,
418    /// Numbered pin (`X.Y.Z[-rcN]`) with no matching entry. `window` is the
419    /// inclusive `floor ..= head` range; `nearest` is the closest supported
420    /// version to migrate to.
421    OutOfWindow { window: String, nearest: String },
422    /// Pin does not name a numbered release (e.g. `nightly-*`): allowed, but
423    /// the host cannot vouch for it.
424    Unknown,
425}
426
427impl WindowVerdict {
428    /// One-word label for the `install-worker --list` `support` column:
429    /// `supported` (lean-rs supports this version), `unsupported` (a numbered
430    /// release outside the window), or `unknown` (not a numbered release, e.g.
431    /// a nightly).
432    #[must_use]
433    pub fn label(&self) -> &'static str {
434        match self {
435            Self::Supported => "supported",
436            Self::OutOfWindow { .. } => "unsupported",
437            Self::Unknown => "unknown",
438        }
439    }
440}
441
442/// Single verdict for a pinned toolchain at project-open time.
443///
444/// Produced by [`WorkerBinary::resolve_ready_for`]; each variant maps to
445/// exactly one caller outcome (spawn, warn-and-spawn, or a typed
446/// `BadProject`).
447#[derive(Debug)]
448pub enum Readiness {
449    /// Spawn the worker. `lean_sysroot` is the toolchain's `elan_dir` (the
450    /// child's `LEAN_SYSROOT`); `note` carries a soft advisory (e.g. missing
451    /// provenance sidecar) to surface as an envelope warning, `None` when the
452    /// worker is fully vouched-for.
453    Ready {
454        worker: WorkerBinary,
455        lean_sysroot: PathBuf,
456        note: Option<String>,
457    },
458    /// Numbered pin outside the supported window. Carries the window range and
459    /// the nearest supported version.
460    Unsupported { window: String, nearest: String },
461    /// The toolchain's `lean.h` changed since the worker was built: rebuild it.
462    Stale {
463        toolchain: ToolchainId,
464        install_cmd: String,
465    },
466    /// The worker built and its header digest matches, but it failed its
467    /// post-build runtime smoke test — the toolchain's `libleanshared` is
468    /// ABI-incompatible with this lean-rs build and the worker crashes when it
469    /// loads Lean. `detail` is the recorded failure (e.g. `signal: 11
470    /// (SIGSEGV)`). A hard verdict: serving it would only produce per-call
471    /// `runtime_unavailable` crashes.
472    Unusable {
473        toolchain: ToolchainId,
474        detail: String,
475        install_cmd: String,
476    },
477    /// No worker binary installed for this pin.
478    NotInstalled {
479        toolchain: ToolchainId,
480        install_cmd: String,
481    },
482    /// The pinned elan toolchain itself is not installed under `~/.elan`.
483    ToolchainNotInstalled { toolchain: ToolchainId, elan_dir: PathBuf },
484    /// Pin is installed and header-fresh but unrecognized (`nightly-*`):
485    /// proceed, but flag it to the caller. Carries `lean_sysroot` so the caller
486    /// can spawn just like [`Self::Ready`].
487    UnknownPin {
488        pin: String,
489        worker: WorkerBinary,
490        lean_sysroot: PathBuf,
491    },
492}
493
494/// Sortable key for a Lean version string `X.Y.Z` or `X.Y.Z-rcN`.
495///
496/// The fourth slot orders release candidates *before* their release
497/// (`0` = rc, `1` = release), so `4.31.0-rc1 < 4.31.0`. Returns `None` for
498/// anything that is not a numbered release (e.g. `nightly-*`).
499fn version_key(s: &str) -> Option<(u32, u32, u32, u8, u32)> {
500    let (core, rc) = match s.split_once("-rc") {
501        Some((core, rc)) => (core, Some(rc.parse::<u32>().ok()?)),
502        None => (s, None),
503    };
504    let mut parts = core.split('.');
505    let major = parts.next()?.parse::<u32>().ok()?;
506    let minor = parts.next()?.parse::<u32>().ok()?;
507    let patch = parts.next()?.parse::<u32>().ok()?;
508    if parts.next().is_some() {
509        return None;
510    }
511    Some(match rc {
512        Some(n) => (major, minor, patch, 0, n),
513        None => (major, minor, patch, 1, 0),
514    })
515}
516
517/// Collapse a [`version_key`] into a single monotonic scalar, so version
518/// nearness is a difference on a number line rather than a per-component
519/// comparison.
520///
521/// Each field gets a positional weight large enough that a higher field always
522/// dominates a lower one (major ≫ minor ≫ patch ≫ rc/release split ≫ rc
523/// number). This is what makes "nearest" behave: a pin a whole major above the
524/// head is closest to the head (largest scalar), not to whichever lower version
525/// happens to share a digit, and `4.30.0-rc1` is closest to its own release
526/// `4.30.0` (one rc step, weight ~10³) rather than to `4.29.1` (a patch away,
527/// weight ~10⁶). The weights assume the realistic ranges of a Lean version
528/// (each field < 1000); see the supported-window table.
529fn version_scalar((major, minor, patch, rc_flag, rc_num): (u32, u32, u32, u8, u32)) -> u64 {
530    // Saturating throughout (workspace denies unchecked arithmetic); the
531    // realistic field ranges are nowhere near `u64::MAX`, so saturation never
532    // bites — it just keeps the lint happy and the mapping total.
533    u64::from(major)
534        .saturating_mul(1_000_000_000_000)
535        .saturating_add(u64::from(minor).saturating_mul(1_000_000_000))
536        .saturating_add(u64::from(patch).saturating_mul(1_000_000))
537        .saturating_add(u64::from(rc_flag).saturating_mul(1_000))
538        .saturating_add(u64::from(rc_num))
539}
540
541/// The `floor ..= head` window string and the nearest supported version for a
542/// numbered pin outside the window. Both derive from
543/// [`lean_toolchain::SUPPORTED_TOOLCHAINS`] (ordered ascending; each entry's
544/// first version is canonical) — never a hardcoded literal list.
545///
546/// "Nearest" scans every supported version and picks the one whose key is the
547/// smallest [`version_distance`] from the pin, ties broken toward the newer
548/// version to bias migration forward. This is why `v4.30.0-rc1` resolves to
549/// `4.30.0` (one rc step away) rather than the window floor: the old logic only
550/// compared the pin against the head and fell back to the floor for everything
551/// below it.
552fn out_of_window_bounds(pin: (u32, u32, u32, u8, u32)) -> (String, String) {
553    let entries = lean_toolchain::SUPPORTED_TOOLCHAINS;
554    let floor = entries
555        .first()
556        .and_then(|t| t.versions.first())
557        .copied()
558        .unwrap_or_default();
559    let head = entries
560        .last()
561        .and_then(|t| t.versions.first())
562        .copied()
563        .unwrap_or_default();
564    let window = format!("{floor} ..= {head}");
565    let pin_scalar = version_scalar(pin);
566    let nearest = entries
567        .iter()
568        .filter_map(|t| t.versions.first().copied())
569        .filter_map(|v| version_key(v).map(|key| (v, version_scalar(key))))
570        .min_by(|(_, a), (_, b)| {
571            a.abs_diff(pin_scalar)
572                .cmp(&b.abs_diff(pin_scalar))
573                // Equal distance (pin sits exactly between two releases): prefer
574                // the newer supported version, so callers are nudged forward.
575                .then_with(|| b.cmp(a))
576        })
577        .map_or(floor, |(v, _)| v);
578    (window, nearest.to_owned())
579}
580
581/// Full SHA-256 (lowercase hex) of `<elan_dir>/include/lean/lean.h`. The
582/// robust toolchain-identity check: a version string can lie (an rc
583/// republished under the same id), the header digest cannot.
584pub(crate) fn hash_lean_header(elan_dir: &Path) -> std::io::Result<String> {
585    use sha2::{Digest, Sha256};
586    let path = elan_dir.join("include").join("lean").join("lean.h");
587    let bytes = std::fs::read(path)?;
588    let digest = Sha256::digest(&bytes);
589    use std::fmt::Write as _;
590    let mut hex = String::with_capacity(digest.len().saturating_mul(2));
591    for b in &digest {
592        let _ = write!(hex, "{b:02x}");
593    }
594    Ok(hex)
595}
596
597/// File name of the per-worker provenance sidecar inside
598/// `<install_root>/<id>/`.
599const SIDECAR_FILE_NAME: &str = "worker.json";
600
601/// Private provenance record written next to an installed worker binary.
602///
603/// Records what the worker was built against so [`WorkerBinary::resolve_ready_for`]
604/// can detect header drift (the toolchain's `lean.h` changing under a worker
605/// that keeps being selected). Fields stay private; callers go through the
606/// query methods.
607#[derive(serde::Serialize, serde::Deserialize)]
608pub(crate) struct WorkerSidecar {
609    toolchain: String,
610    /// Full SHA-256 of the `lean.h` the worker was built against.
611    header_digest: String,
612    /// `lean_toolchain::LEAN_VERSION` the host was built against.
613    built_against_lean_version: String,
614    /// The `lean-host-mcp` version (`CARGO_PKG_VERSION`) that built this worker.
615    /// Worker and host share the workspace version and are protocol/ABI-coupled
616    /// in lockstep, so a recorded value that differs from the running host is a
617    /// rebuild signal. `""` (serde default) for a sidecar written before this
618    /// field existed — unknown provenance, not a mismatch.
619    #[serde(default)]
620    built_by_host_version: String,
621    /// Whether `supported_by_digest(header_digest)` matched at build time.
622    digest_supported_at_build: bool,
623    /// Outcome of the post-build runtime smoke test. `None` for a sidecar
624    /// written by a host predating the smoke test — unknown, not failed, so the
625    /// gate treats it as a soft "reinstall to verify" note rather than a hard
626    /// `Unusable`.
627    #[serde(default)]
628    smoke: Option<SmokeOutcome>,
629}
630
631impl WorkerSidecar {
632    /// Write `<install_dir>/worker.json` recording `header_digest`, the host's
633    /// build-time context, and the post-build `smoke` outcome. Overwrites any
634    /// existing record.
635    pub(crate) fn record(
636        install_dir: &Path,
637        id: &ToolchainId,
638        header_digest: String,
639        smoke: SmokeOutcome,
640    ) -> std::io::Result<()> {
641        let sidecar = Self {
642            toolchain: id.as_str().to_owned(),
643            digest_supported_at_build: lean_toolchain::supported_by_digest(&header_digest).is_some(),
644            built_against_lean_version: lean_toolchain::LEAN_VERSION.to_owned(),
645            built_by_host_version: env!("CARGO_PKG_VERSION").to_owned(),
646            header_digest,
647            smoke: Some(smoke),
648        };
649        let json = serde_json::to_string_pretty(&sidecar).map_err(std::io::Error::other)?;
650        std::fs::write(install_dir.join(SIDECAR_FILE_NAME), json)
651    }
652
653    /// Load `<install_dir>/worker.json`. `None` when absent or unparseable —
654    /// an older host left no record, which is unknown provenance, not an error.
655    pub(crate) fn load(install_dir: &Path) -> Option<Self> {
656        let bytes = std::fs::read(install_dir.join(SIDECAR_FILE_NAME)).ok()?;
657        serde_json::from_slice(&bytes).ok()
658    }
659
660    /// Whether the recorded build-time digest still matches the toolchain.
661    pub(crate) fn header_matches(&self, current_digest: &str) -> bool {
662        self.header_digest == current_digest
663    }
664
665    /// The `lean-host-mcp` version that built this worker, or `""` if the
666    /// sidecar predates host-version provenance.
667    pub(crate) fn host_version(&self) -> &str {
668        &self.built_by_host_version
669    }
670
671    /// One-word label for the `install-worker --list` `host` column, given the
672    /// running host's version: `current` (built by this host), `stale` (built
673    /// by a different host — rebuild), or `unknown` (sidecar predates the
674    /// field).
675    pub(crate) fn host_status(&self, current_host: &str) -> &'static str {
676        match self.built_by_host_version.as_str() {
677            "" => "unknown",
678            v if v == current_host => "current",
679            _ => "stale",
680        }
681    }
682
683    /// One-word label for the `install-worker --list` `build` column, given the
684    /// toolchain's current `lean.h` digest (`None` when it could not be read, so
685    /// freshness cannot be judged): `fresh` (build still matches its toolchain),
686    /// `stale` (the header drifted under it — rebuild), or `unknown`.
687    pub(crate) fn header_status(&self, current_digest: Option<&str>) -> &'static str {
688        match current_digest {
689            Some(current) if self.header_matches(current) => "fresh",
690            Some(_) => "stale",
691            None => "unknown",
692        }
693    }
694
695    /// The recorded post-build runtime smoke outcome, if any.
696    pub(crate) fn smoke(&self) -> Option<&SmokeOutcome> {
697        self.smoke.as_ref()
698    }
699
700    /// One-word label for the `install-worker --list` `runtime` column:
701    /// `runs` / `crashed`, or `untested` for a sidecar written before the
702    /// post-build smoke test existed.
703    pub(crate) fn smoke_status(&self) -> &'static str {
704        self.smoke.as_ref().map_or("untested", SmokeOutcome::label)
705    }
706}
707
708/// Typed failures during toolchain resolution. Project-open code maps
709/// these into [`crate::error::ServerError::BadProject`].
710#[derive(Debug)]
711pub enum ToolchainError {
712    UnparseableToolchainString(String),
713    LeanToolchainFileMissing(PathBuf),
714    ElanToolchainNotInstalled {
715        toolchain: ToolchainId,
716        elan_dir: PathBuf,
717    },
718    WorkerNotInstalled {
719        toolchain: ToolchainId,
720        install_cmd: String,
721    },
722}
723
724impl fmt::Display for ToolchainError {
725    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
726        match self {
727            Self::UnparseableToolchainString(raw) => {
728                write!(f, "could not parse lean-toolchain string: {raw:?}")
729            }
730            Self::LeanToolchainFileMissing(path) => {
731                write!(f, "lean-toolchain file not found at {}", path.display())
732            }
733            Self::ElanToolchainNotInstalled { toolchain, elan_dir } => write!(
734                f,
735                "elan toolchain {} is not installed (expected {})",
736                toolchain,
737                elan_dir.display()
738            ),
739            Self::WorkerNotInstalled { toolchain, install_cmd } => {
740                write!(f, "no worker binary for toolchain {toolchain}; run: {install_cmd}")
741            }
742        }
743    }
744}
745
746impl std::error::Error for ToolchainError {}
747
748#[cfg(test)]
749#[allow(
750    clippy::unwrap_used,
751    clippy::expect_used,
752    clippy::panic,
753    reason = "test code uses unwrap/expect/panic to surface failure paths concisely"
754)]
755mod tests {
756    use std::fs;
757
758    use super::*;
759
760    #[test]
761    fn parse_accepts_elan_prefix_and_bare_short_form() {
762        assert_eq!(
763            ToolchainId::parse("leanprover/lean4:v4.30.0").unwrap().as_str(),
764            "v4.30.0",
765        );
766        assert_eq!(ToolchainId::parse("v4.30.0").unwrap().as_str(), "v4.30.0",);
767        assert_eq!(
768            ToolchainId::parse("nightly-2026-05-20").unwrap().as_str(),
769            "nightly-2026-05-20",
770        );
771        assert_eq!(
772            ToolchainId::parse("  leanprover/lean4:v4.30.0  \n").unwrap().as_str(),
773            "v4.30.0",
774        );
775    }
776
777    #[test]
778    fn parse_rejects_garbage() {
779        assert!(matches!(
780            ToolchainId::parse(""),
781            Err(ToolchainError::UnparseableToolchainString(_))
782        ));
783        assert!(matches!(
784            ToolchainId::parse("v4 .30"),
785            Err(ToolchainError::UnparseableToolchainString(_))
786        ));
787        // Unknown fork.
788        assert!(matches!(
789            ToolchainId::parse("acme/lean5:v6.0"),
790            Err(ToolchainError::UnparseableToolchainString(_))
791        ));
792    }
793
794    #[test]
795    fn from_lake_root_reads_lean_toolchain_file() {
796        let tmp = tempfile::tempdir().unwrap();
797        fs::write(tmp.path().join("lean-toolchain"), "leanprover/lean4:v4.30.0\n").unwrap();
798        let id = ToolchainId::from_lake_root(tmp.path()).unwrap();
799        assert_eq!(id.as_str(), "v4.30.0");
800    }
801
802    #[test]
803    fn from_lake_root_reports_missing_file() {
804        let tmp = tempfile::tempdir().unwrap();
805        assert!(matches!(
806            ToolchainId::from_lake_root(tmp.path()),
807            Err(ToolchainError::LeanToolchainFileMissing(_))
808        ));
809    }
810
811    #[test]
812    fn worker_binary_missing_under_override_returns_install_cmd() {
813        let tmp = tempfile::tempdir().unwrap();
814        let id = ToolchainId::parse("v4.30.0").unwrap();
815        let err = WorkerBinary::resolve_with_override(&id, Some(tmp.path())).unwrap_err();
816        match err {
817            ToolchainError::WorkerNotInstalled { install_cmd, .. } => {
818                assert!(install_cmd.contains("v4.30.0"), "got: {install_cmd}");
819            }
820            ToolchainError::UnparseableToolchainString(_)
821            | ToolchainError::LeanToolchainFileMissing(_)
822            | ToolchainError::ElanToolchainNotInstalled { .. } => {
823                panic!("unexpected ToolchainError variant");
824            }
825        }
826    }
827
828    #[test]
829    fn worker_binary_with_id_subdir_wins() {
830        let tmp = tempfile::tempdir().unwrap();
831        let id = ToolchainId::parse("v4.30.0").unwrap();
832        let nested = tmp.path().join("v4.30.0");
833        fs::create_dir_all(&nested).unwrap();
834        fs::write(nested.join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
835        let resolved = WorkerBinary::resolve_with_override(&id, Some(tmp.path())).unwrap();
836        assert_eq!(resolved.path, nested.join(WORKER_FILE_NAME));
837    }
838
839    #[test]
840    fn worker_binary_bare_developer_fallback_wins() {
841        let tmp = tempfile::tempdir().unwrap();
842        let id = ToolchainId::parse("v4.30.0").unwrap();
843        fs::write(tmp.path().join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
844        let resolved = WorkerBinary::resolve_with_override(&id, Some(tmp.path())).unwrap();
845        assert_eq!(resolved.path, tmp.path().join(WORKER_FILE_NAME));
846    }
847
848    /// The supported window's floor and head, read live from
849    /// `SUPPORTED_TOOLCHAINS` so these tests track lean-rs rather than pin a
850    /// literal that silently rots.
851    fn window_bounds() -> (&'static str, &'static str) {
852        let entries = lean_toolchain::SUPPORTED_TOOLCHAINS;
853        let floor = entries.first().unwrap().versions.first().unwrap();
854        let head = entries.last().unwrap().versions.first().unwrap();
855        (floor, head)
856    }
857
858    #[test]
859    fn window_verdict_accepts_in_window_pin_stripping_leading_v() {
860        let (_, head) = window_bounds();
861        let id = ToolchainId::parse(&format!("v{head}")).unwrap();
862        assert_eq!(id.window_verdict(), WindowVerdict::Supported);
863    }
864
865    #[test]
866    fn window_verdict_flags_above_head_pin_with_nearest_head() {
867        let (floor, head) = window_bounds();
868        let major: u32 = head.split('.').next().unwrap().parse().unwrap();
869        let id = ToolchainId::parse(&format!("v{}.0.0", major + 1)).unwrap();
870        match id.window_verdict() {
871            WindowVerdict::OutOfWindow { window, nearest } => {
872                assert_eq!(window, format!("{floor} ..= {head}"));
873                assert_eq!(nearest, head);
874            }
875            other @ (WindowVerdict::Supported | WindowVerdict::Unknown) => {
876                panic!("expected OutOfWindow, got {other:?}")
877            }
878        }
879    }
880
881    #[test]
882    fn window_verdict_flags_below_floor_pin_with_nearest_floor() {
883        let (floor, head) = window_bounds();
884        let id = ToolchainId::parse("v0.0.0").unwrap();
885        match id.window_verdict() {
886            WindowVerdict::OutOfWindow { window, nearest } => {
887                assert_eq!(window, format!("{floor} ..= {head}"));
888                assert_eq!(nearest, floor);
889            }
890            other @ (WindowVerdict::Supported | WindowVerdict::Unknown) => {
891                panic!("expected OutOfWindow, got {other:?}")
892            }
893        }
894    }
895
896    #[test]
897    fn window_verdict_flags_in_between_rc_with_nearest_release() {
898        // A release candidate of an already-supported release (e.g. `4.30.0-rc1`
899        // when `4.30.0` ships) is out of window, and its genuinely-nearest
900        // supported version is that release — not the window floor.
901        let (floor, _) = window_bounds();
902        let release = lean_toolchain::SUPPORTED_TOOLCHAINS
903            .iter()
904            .filter_map(|t| t.versions.first().copied())
905            // A `X.Y.Z` release (no `-rc`) other than the floor, so the rc we
906            // synthesize is genuinely between two supported versions.
907            .find(|v| !v.contains("-rc") && *v != floor)
908            .expect("the supported window should contain a non-floor numbered release");
909        let rc = format!("{release}-rc1");
910        // Guard: the synthesized rc must not itself be a supported entry.
911        assert!(
912            lean_toolchain::supported_for(&rc).is_none(),
913            "{rc} unexpectedly supported"
914        );
915        match ToolchainId::parse(&format!("v{rc}")).unwrap().window_verdict() {
916            WindowVerdict::OutOfWindow { nearest, .. } => assert_eq!(nearest, release),
917            other @ (WindowVerdict::Supported | WindowVerdict::Unknown) => {
918                panic!("expected OutOfWindow, got {other:?}")
919            }
920        }
921    }
922
923    #[test]
924    fn window_verdict_treats_nightly_as_unknown() {
925        let id = ToolchainId::parse("nightly-2026-05-20").unwrap();
926        assert_eq!(id.window_verdict(), WindowVerdict::Unknown);
927    }
928
929    #[test]
930    fn window_string_derives_from_supported_toolchains_not_a_literal() {
931        let (floor, head) = window_bounds();
932        let WindowVerdict::OutOfWindow { window, .. } = ToolchainId::parse("v0.0.0").unwrap().window_verdict() else {
933            panic!("expected OutOfWindow");
934        };
935        // If the bounds were hardcoded, this would drift when lean-rs bumps.
936        assert_eq!(window, format!("{floor} ..= {head}"));
937        assert!(window.contains(floor) && window.contains(head));
938    }
939
940    #[test]
941    fn version_key_orders_rc_before_release() {
942        assert!(version_key("4.31.0-rc1") < version_key("4.31.0"));
943        assert!(version_key("4.30.0") < version_key("4.31.0-rc1"));
944        assert_eq!(version_key("nightly-2026-05-20"), None);
945        assert_eq!(version_key("4.31"), None);
946    }
947
948    #[test]
949    fn sort_key_orders_rc_before_release() {
950        let rc = ToolchainId::parse("v4.31.0-rc1").unwrap();
951        let rel = ToolchainId::parse("v4.31.0").unwrap();
952        let rc2 = ToolchainId::parse("v4.31.0-rc2").unwrap();
953        let prev = ToolchainId::parse("v4.30.0").unwrap();
954        let ngt = ToolchainId::parse("nightly-2026-05-20").unwrap();
955        assert!(rc.sort_key() < rel.sort_key(), "rc before its release");
956        assert!(rc.sort_key() < rc2.sort_key(), "rc1 before rc2");
957        assert!(prev.sort_key() < rc.sort_key(), "older release before the next rc");
958        assert!(rel.sort_key() < ngt.sort_key(), "numbered release before a nightly");
959
960        // The lexical order this replaces would sort to [prev, rel, rc, rc2].
961        let mut ids = vec![rel.clone(), prev.clone(), rc2.clone(), rc.clone()];
962        ids.sort_by_key(ToolchainId::sort_key);
963        assert_eq!(ids, vec![prev, rc, rc2, rel]);
964    }
965
966    #[test]
967    fn sidecar_round_trips_record_then_load() {
968        let tmp = tempfile::tempdir().unwrap();
969        let id = ToolchainId::parse("v4.30.0").unwrap();
970        WorkerSidecar::record(tmp.path(), &id, "abc123".to_owned(), SmokeOutcome::Passed).unwrap();
971        let loaded = WorkerSidecar::load(tmp.path()).expect("sidecar should load");
972        assert!(loaded.header_matches("abc123"));
973        assert!(!loaded.header_matches("different"));
974        assert_eq!(loaded.header_status(Some("abc123")), "fresh");
975        assert_eq!(loaded.header_status(Some("different")), "stale");
976        assert_eq!(loaded.header_status(None), "unknown");
977        assert_eq!(loaded.smoke_status(), "runs");
978        assert_eq!(loaded.smoke(), Some(&SmokeOutcome::Passed));
979    }
980
981    #[test]
982    fn legacy_sidecar_without_smoke_field_loads_as_no_record() {
983        // A sidecar written by a host predating the smoke test has no `smoke`
984        // key; `#[serde(default)]` must read it back as unknown, not fail.
985        let tmp = tempfile::tempdir().unwrap();
986        let legacy = r#"{
987            "toolchain": "v4.30.0",
988            "header_digest": "abc123",
989            "built_against_lean_version": "4.30.0",
990            "digest_supported_at_build": true
991        }"#;
992        fs::write(tmp.path().join(SIDECAR_FILE_NAME), legacy).unwrap();
993        let loaded = WorkerSidecar::load(tmp.path()).expect("legacy sidecar should load");
994        assert_eq!(loaded.smoke(), None);
995        assert_eq!(loaded.smoke_status(), "untested");
996    }
997
998    #[test]
999    fn smoke_failed_is_unusable_even_with_matching_digest() {
1000        let tmp = tempfile::tempdir().unwrap();
1001        let id = ToolchainId::parse(&format!("v{}", window_bounds().1)).unwrap();
1002        fs::write(tmp.path().join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
1003        WorkerSidecar::record(
1004            tmp.path(),
1005            &id,
1006            "digest".to_owned(),
1007            SmokeOutcome::Failed {
1008                detail: "signal: 11 (SIGSEGV)".to_owned(),
1009            },
1010        )
1011        .unwrap();
1012        let sysroot = tmp.path().to_path_buf();
1013        let readiness = WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, Some("digest"));
1014        let Readiness::Unusable { detail, .. } = readiness else {
1015            panic!("expected Unusable, got {readiness:?}");
1016        };
1017        assert!(detail.contains("SIGSEGV"), "got: {detail}");
1018    }
1019
1020    #[test]
1021    fn smoke_record_missing_is_ready_with_reinstall_note() {
1022        // Sidecar present (digest guards drift) but no smoke record: a legacy
1023        // worker. Ready, but nudged to reinstall to verify it runs.
1024        let tmp = tempfile::tempdir().unwrap();
1025        let id = ToolchainId::parse(&format!("v{}", window_bounds().1)).unwrap();
1026        fs::write(tmp.path().join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
1027        let legacy = format!(
1028            r#"{{"toolchain":"{}","header_digest":"digest","built_against_lean_version":"x","digest_supported_at_build":true}}"#,
1029            id.as_str()
1030        );
1031        fs::write(tmp.path().join(SIDECAR_FILE_NAME), legacy).unwrap();
1032        let sysroot = tmp.path().to_path_buf();
1033        let readiness = WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, Some("digest"));
1034        let Readiness::Ready { note: Some(note), .. } = readiness else {
1035            panic!("expected Ready with a reinstall note, got {readiness:?}");
1036        };
1037        assert!(note.contains("smoke"), "got: {note}");
1038    }
1039
1040    #[test]
1041    fn ready_with_matching_digest_carries_no_note() {
1042        let tmp = tempfile::tempdir().unwrap();
1043        let id = ToolchainId::parse(&format!("v{}", window_bounds().1)).unwrap();
1044        fs::write(tmp.path().join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
1045        WorkerSidecar::record(tmp.path(), &id, "digest".to_owned(), SmokeOutcome::Passed).unwrap();
1046        let sysroot = tmp.path().to_path_buf();
1047        let readiness = WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, Some("digest"));
1048        assert!(
1049            matches!(readiness, Readiness::Ready { note: None, .. }),
1050            "expected Ready with no note, got {readiness:?}"
1051        );
1052    }
1053
1054    #[test]
1055    fn host_version_round_trips_and_legacy_sidecar_is_unknown() {
1056        // A freshly recorded sidecar carries this host's version → `current`.
1057        let tmp = tempfile::tempdir().unwrap();
1058        let id = ToolchainId::parse("v4.30.0").unwrap();
1059        WorkerSidecar::record(tmp.path(), &id, "abc".to_owned(), SmokeOutcome::Passed).unwrap();
1060        let loaded = WorkerSidecar::load(tmp.path()).unwrap();
1061        assert_eq!(loaded.host_version(), env!("CARGO_PKG_VERSION"));
1062        assert_eq!(loaded.host_status(env!("CARGO_PKG_VERSION")), "current");
1063        assert_eq!(loaded.host_status("9.9.9"), "stale");
1064
1065        // A sidecar written before the field existed reads back as unknown
1066        // provenance (serde default ""), never a spurious mismatch.
1067        let legacy = tempfile::tempdir().unwrap();
1068        let json = r#"{"toolchain":"v4.30.0","header_digest":"abc","built_against_lean_version":"x","digest_supported_at_build":true,"smoke":{"result":"passed"}}"#;
1069        fs::write(legacy.path().join(SIDECAR_FILE_NAME), json).unwrap();
1070        let old = WorkerSidecar::load(legacy.path()).unwrap();
1071        assert_eq!(old.host_version(), "");
1072        assert_eq!(old.host_status(env!("CARGO_PKG_VERSION")), "unknown");
1073    }
1074
1075    #[test]
1076    fn host_version_skew_is_ready_with_rebuild_note() {
1077        // A worker built by a different (version-locked) host: still spawns, but
1078        // every envelope carries a rebuild nudge. Hand-write the sidecar since
1079        // `record` always stamps the running host's version.
1080        let tmp = tempfile::tempdir().unwrap();
1081        let id = ToolchainId::parse(&format!("v{}", window_bounds().1)).unwrap();
1082        fs::write(tmp.path().join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
1083        let skewed = format!(
1084            r#"{{"toolchain":"{}","header_digest":"digest","built_against_lean_version":"x","built_by_host_version":"0.0.1-old","digest_supported_at_build":true,"smoke":{{"result":"passed"}}}}"#,
1085            id.as_str()
1086        );
1087        fs::write(tmp.path().join(SIDECAR_FILE_NAME), skewed).unwrap();
1088        let sysroot = tmp.path().to_path_buf();
1089        let readiness = WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, Some("digest"));
1090        let Readiness::Ready { note: Some(note), .. } = readiness else {
1091            panic!("expected Ready with a rebuild note, got {readiness:?}");
1092        };
1093        assert!(
1094            note.contains("0.0.1-old") && note.contains("version-locked"),
1095            "got: {note}"
1096        );
1097    }
1098
1099    #[test]
1100    fn forged_mismatching_digest_is_stale() {
1101        let tmp = tempfile::tempdir().unwrap();
1102        let id = ToolchainId::parse(&format!("v{}", window_bounds().1)).unwrap();
1103        fs::write(tmp.path().join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
1104        WorkerSidecar::record(tmp.path(), &id, "built-digest".to_owned(), SmokeOutcome::Passed).unwrap();
1105        let sysroot = tmp.path().to_path_buf();
1106        assert!(matches!(
1107            WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, Some("drifted-digest")),
1108            Readiness::Stale { .. }
1109        ));
1110    }
1111
1112    #[test]
1113    fn missing_sidecar_is_ready_with_soft_note() {
1114        let tmp = tempfile::tempdir().unwrap();
1115        let id = ToolchainId::parse(&format!("v{}", window_bounds().1)).unwrap();
1116        fs::write(tmp.path().join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
1117        let sysroot = tmp.path().to_path_buf();
1118        let readiness = WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, Some("whatever"));
1119        let Readiness::Ready { note: Some(note), .. } = readiness else {
1120            panic!("expected Ready with a soft note, got {readiness:?}");
1121        };
1122        assert!(note.contains("provenance"), "got: {note}");
1123    }
1124
1125    #[test]
1126    fn unknown_nightly_pin_installed_and_fresh_is_unknown_pin() {
1127        let tmp = tempfile::tempdir().unwrap();
1128        let id = ToolchainId::parse("nightly-2026-05-20").unwrap();
1129        fs::write(tmp.path().join(WORKER_FILE_NAME), b"#!/bin/sh\n").unwrap();
1130        WorkerSidecar::record(tmp.path(), &id, "d".to_owned(), SmokeOutcome::Passed).unwrap();
1131        let sysroot = tmp.path().to_path_buf();
1132        assert!(matches!(
1133            WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, Some("d")),
1134            Readiness::UnknownPin { .. }
1135        ));
1136    }
1137
1138    #[test]
1139    fn missing_worker_is_not_installed() {
1140        let tmp = tempfile::tempdir().unwrap();
1141        let id = ToolchainId::parse(&format!("v{}", window_bounds().1)).unwrap();
1142        let sysroot = tmp.path().to_path_buf();
1143        assert!(matches!(
1144            WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, None),
1145            Readiness::NotInstalled { .. }
1146        ));
1147    }
1148
1149    #[test]
1150    fn out_of_window_pin_is_unsupported_before_install_check() {
1151        let tmp = tempfile::tempdir().unwrap();
1152        // No worker installed, yet the window check fires first.
1153        let id = ToolchainId::parse("v0.0.0").unwrap();
1154        let sysroot = tmp.path().to_path_buf();
1155        assert!(matches!(
1156            WorkerBinary::resolve_ready_with_override(&id, Some(tmp.path()), sysroot, None),
1157            Readiness::Unsupported { .. }
1158        ));
1159    }
1160}