1use std::fmt;
22use std::path::{Path, PathBuf};
23
24use crate::smoke::SmokeOutcome;
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct ToolchainId(String);
30
31pub const WORKER_FILE_NAME: &str = "lean-host-mcp-worker";
34
35pub const WORKERS_DIR_ENV: &str = "LEAN_HOST_MCP_WORKERS_DIR";
38
39impl ToolchainId {
40 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 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 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 #[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 #[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#[derive(Debug, Clone)]
164pub struct WorkerBinary {
165 pub path: PathBuf,
166 pub toolchain: ToolchainId,
167}
168
169impl WorkerBinary {
170 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 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 #[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 #[must_use]
269 pub fn resolve_ready_for(pin: &ToolchainId) -> Readiness {
270 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 #[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 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 let install_dir = worker.path.parent().unwrap_or(&worker.path);
324 let note = match WorkerSidecar::load(install_dir) {
325 Some(sidecar) => {
326 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 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 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 Some(SmokeOutcome::Passed) => host_skew,
370 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 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
405fn install_cmd(toolchain: &ToolchainId) -> String {
408 format!("lean-host-mcp install-worker --toolchain {}", toolchain.as_str())
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum WindowVerdict {
416 Supported,
418 OutOfWindow { window: String, nearest: String },
422 Unknown,
425}
426
427impl WindowVerdict {
428 #[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#[derive(Debug)]
448pub enum Readiness {
449 Ready {
454 worker: WorkerBinary,
455 lean_sysroot: PathBuf,
456 note: Option<String>,
457 },
458 Unsupported { window: String, nearest: String },
461 Stale {
463 toolchain: ToolchainId,
464 install_cmd: String,
465 },
466 Unusable {
473 toolchain: ToolchainId,
474 detail: String,
475 install_cmd: String,
476 },
477 NotInstalled {
479 toolchain: ToolchainId,
480 install_cmd: String,
481 },
482 ToolchainNotInstalled { toolchain: ToolchainId, elan_dir: PathBuf },
484 UnknownPin {
488 pin: String,
489 worker: WorkerBinary,
490 lean_sysroot: PathBuf,
491 },
492}
493
494fn 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
517fn version_scalar((major, minor, patch, rc_flag, rc_num): (u32, u32, u32, u8, u32)) -> u64 {
530 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
541fn 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 .then_with(|| b.cmp(a))
576 })
577 .map_or(floor, |(v, _)| v);
578 (window, nearest.to_owned())
579}
580
581pub(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
597const SIDECAR_FILE_NAME: &str = "worker.json";
600
601#[derive(serde::Serialize, serde::Deserialize)]
608pub(crate) struct WorkerSidecar {
609 toolchain: String,
610 header_digest: String,
612 built_against_lean_version: String,
614 #[serde(default)]
620 built_by_host_version: String,
621 digest_supported_at_build: bool,
623 #[serde(default)]
628 smoke: Option<SmokeOutcome>,
629}
630
631impl WorkerSidecar {
632 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 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 pub(crate) fn header_matches(&self, current_digest: &str) -> bool {
662 self.header_digest == current_digest
663 }
664
665 pub(crate) fn host_version(&self) -> &str {
668 &self.built_by_host_version
669 }
670
671 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 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 pub(crate) fn smoke(&self) -> Option<&SmokeOutcome> {
697 self.smoke.as_ref()
698 }
699
700 pub(crate) fn smoke_status(&self) -> &'static str {
704 self.smoke.as_ref().map_or("untested", SmokeOutcome::label)
705 }
706}
707
708#[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 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 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 let (floor, _) = window_bounds();
902 let release = lean_toolchain::SUPPORTED_TOOLCHAINS
903 .iter()
904 .filter_map(|t| t.versions.first().copied())
905 .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 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 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 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 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 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 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 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 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 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}