1use std::path::{Path, PathBuf};
42
43use serde::{Deserialize, Serialize};
44
45use crate::download::{
46 cache_file_usable, purge_corrupt_cache_files, verify_cache_file, CacheIntegrity,
47};
48
49const KNOWN_STATE_FILES: &[&str] = &[
54 "messaging.json",
55 "models.json",
56 "connectors.json",
57 "car-connectors.json",
58 "agents.json",
59 "routing.json",
60 "declagents.json",
61 "lane-defaults.json",
62 "update-prefs.json",
63 "upgrade-cache.json",
64 "catalog-cache.json",
65 "discovered_models.json",
66 "a2a-peers.json",
67 "external-agents.jsonl",
68 "nudge-state.json",
69 "benchmark_priors.json",
70 "key_pool_stats.json",
71 "model_profiles.json",
72 "agent-permissions.json",
73 "version.json",
74 "secret_index.json",
82 "gateway-state.json",
89 "parslee-credential-state.json",
96 "model-resource-policy.json",
98 "parslee-auth-authority.json",
100];
101
102const KNOWN_NON_JSON_FILES: &[&str] = &[
107 "env",
108 "peer-identity.key",
110];
111
112const KNOWN_DIRS: &[&str] = &[
115 "models",
116 "journals",
117 "logs",
118 "agents",
119 "runs",
120 "run",
121 "workflow-runs",
122 "workflows",
123 "tasks",
124 "trajectories",
125 "registry",
126 "meetings",
127 "speech-runtime",
128 "visual-runtime",
129 "coder",
130 "projects",
131 "memory",
132 "reason",
137 "doctor",
138 "bin",
139 "voiceprints",
140 "sync",
141 "proposal-completed-index",
143 "selfheal",
145];
146
147const TOLERATED_SUFFIXES: &[&str] = &[".lock", ".tmp", ".bak", ".bin", ".jsonl"];
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct VersionStamp {
156 pub car_version: String,
158 pub state_schema_version: u32,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub previous_car_version: Option<String>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub previous_state_schema_version: Option<u32>,
179}
180
181pub const STATE_SCHEMA_VERSION: u32 = 1;
184
185impl VersionStamp {
186 pub fn current() -> Self {
188 VersionStamp {
189 car_version: env!("CARGO_PKG_VERSION").to_string(),
190 state_schema_version: STATE_SCHEMA_VERSION,
191 previous_car_version: None,
192 previous_state_schema_version: None,
193 }
194 }
195
196 pub fn succeeding(existing: Option<&VersionStamp>) -> Self {
202 let mut next = VersionStamp::current();
203 if let Some(prior) = existing {
204 if prior.car_version != next.car_version
205 || prior.state_schema_version != next.state_schema_version
206 {
207 next.previous_car_version = Some(prior.car_version.clone());
208 next.previous_state_schema_version = Some(prior.state_schema_version);
209 } else {
210 next.previous_car_version = prior.previous_car_version.clone();
211 next.previous_state_schema_version = prior.previous_state_schema_version;
212 }
213 }
214 next
215 }
216}
217
218#[derive(Debug, Clone)]
221pub struct StampTransition {
222 pub previous: Option<VersionStamp>,
227 pub current: VersionStamp,
229}
230
231impl StampTransition {
232 pub fn upgraded(&self) -> bool {
234 self.previous
235 .as_ref()
236 .is_some_and(|p| p.car_version != self.current.car_version)
237 }
238
239 pub fn schema_from_the_future(&self) -> bool {
247 self.previous
248 .as_ref()
249 .is_some_and(|p| p.state_schema_version > self.current.state_schema_version)
250 }
251}
252
253pub fn car_home() -> PathBuf {
274 ::car_home::root_or_relative()
276}
277
278pub fn write_version_stamp(car_home: &Path) -> std::io::Result<()> {
282 stamp_version(car_home).map(|_| ())
283}
284
285pub fn stamp_version(car_home: &Path) -> std::io::Result<StampTransition> {
293 std::fs::create_dir_all(car_home)?;
294 let previous = read_version_stamp(car_home);
295 let stamp = VersionStamp::succeeding(previous.as_ref());
296 let json = serde_json::to_string_pretty(&stamp)
297 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
298 let tmp = car_home.join(format!("version.json.{}.tmp", std::process::id()));
302 std::fs::write(&tmp, json)?;
303 std::fs::rename(&tmp, car_home.join("version.json"))?;
304 Ok(StampTransition {
305 previous,
306 current: stamp,
307 })
308}
309
310const DOCTOR_DIR: &str = "doctor";
313
314const DAEMON_TMPDIR_MARKER_FILE: &str = "daemon-tmpdir.json";
316
317const TMPDIR_PROBE_BYTES: &[u8] = b"car tmpdir probe\n";
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct DaemonTmpdirMarker {
332 pub pid: u32,
334 pub booted_at_unix: u64,
336 pub car_version: String,
338 pub checked_path: String,
341 pub ok: bool,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub error: Option<String>,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case", tag = "status")]
351pub enum DaemonTmpdirStatus {
352 Current { marker: DaemonTmpdirMarker },
355 Stale { marker: DaemonTmpdirMarker },
358 Unverified { marker: DaemonTmpdirMarker },
361 Unreadable { error: String },
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct DaemonTmpdirCheck {
369 pub marker_path: String,
371 #[serde(flatten)]
372 pub status: DaemonTmpdirStatus,
373}
374
375impl DaemonTmpdirCheck {
376 pub fn is_failing(&self) -> bool {
379 matches!(&self.status, DaemonTmpdirStatus::Current { marker } if !marker.ok)
380 }
381}
382
383pub fn daemon_tmpdir_marker_path(car_home: &Path) -> PathBuf {
385 car_home.join(DOCTOR_DIR).join(DAEMON_TMPDIR_MARKER_FILE)
386}
387
388pub fn record_daemon_tmpdir_probe(
404 car_home: &Path,
405 tmpdir: &Path,
406) -> std::io::Result<DaemonTmpdirMarker> {
407 let error = probe_daemon_tmpdir(tmpdir).err();
408 record_daemon_tmpdir_probe_result(car_home, tmpdir, error)
409}
410
411pub fn probe_daemon_tmpdir(tmpdir: &Path) -> Result<(), String> {
418 probe_tmpdir(tmpdir, std::process::id())
419}
420
421pub fn record_daemon_tmpdir_probe_result(
427 car_home: &Path,
428 tmpdir: &Path,
429 error: Option<String>,
430) -> std::io::Result<DaemonTmpdirMarker> {
431 let marker = DaemonTmpdirMarker {
432 pid: std::process::id(),
433 booted_at_unix: std::time::SystemTime::now()
434 .duration_since(std::time::UNIX_EPOCH)
435 .map(|d| d.as_secs())
436 .unwrap_or(0),
437 car_version: env!("CARGO_PKG_VERSION").to_string(),
438 checked_path: tmpdir.display().to_string(),
439 ok: error.is_none(),
440 error,
441 };
442 write_daemon_tmpdir_marker(car_home, &marker)?;
443 Ok(marker)
444}
445
446pub fn read_daemon_tmpdir_marker(car_home: &Path) -> Option<Result<DaemonTmpdirMarker, String>> {
449 let text = match std::fs::read_to_string(daemon_tmpdir_marker_path(car_home)) {
450 Ok(text) => text,
451 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
452 Err(e) => return Some(Err(e.to_string())),
453 };
454 Some(serde_json::from_str(&text).map_err(|e| e.to_string()))
455}
456
457fn unique_probe_suffix(pid: u32) -> String {
460 let nanos = std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .map(|d| d.as_nanos())
463 .unwrap_or(0);
464 format!("{pid}.{nanos}")
465}
466
467fn probe_tmpdir(tmpdir: &Path, pid: u32) -> Result<(), String> {
470 use std::io::Write;
471
472 let path = tmpdir.join(format!(".car-tmpdir-probe.{}", unique_probe_suffix(pid)));
473 let mut file = std::fs::OpenOptions::new()
478 .write(true)
479 .create_new(true)
480 .open(&path)
481 .map_err(|e| format!("create failed: {e}"))?;
482 let created = file.metadata().map_err(|e| format!("stat failed: {e}"));
483 let wrote = file
484 .write_all(TMPDIR_PROBE_BYTES)
485 .map_err(|e| format!("write failed: {e}"));
486 drop(file);
487
488 let removed = cleanup_probe_file(&path, &created);
492 combine_probe_results(created.map(|_| ()).and(wrote), removed)
493}
494
495fn cleanup_probe_file(
496 path: &Path,
497 created: &Result<std::fs::Metadata, String>,
498) -> Result<(), String> {
499 match created {
500 Ok(created) => remove_probe_file(path, created),
501 Err(_) => Err(format!(
502 "cleanup skipped: probe file identity could not be proven; left {}",
503 path.display()
504 )),
505 }
506}
507
508fn combine_probe_results(
509 operation: Result<(), String>,
510 cleanup: Result<(), String>,
511) -> Result<(), String> {
512 match (operation, cleanup) {
513 (Ok(()), Ok(())) => Ok(()),
514 (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
515 (Err(operation), Err(cleanup)) => Err(format!("{operation}; {cleanup}")),
516 }
517}
518
519#[cfg(unix)]
522fn remove_probe_file(path: &Path, created: &std::fs::Metadata) -> Result<(), String> {
523 use std::os::unix::fs::MetadataExt;
524
525 let on_disk = std::fs::symlink_metadata(path).map_err(|e| format!("remove failed: {e}"))?;
528 if !on_disk.file_type().is_file()
529 || created.dev() != on_disk.dev()
530 || created.ino() != on_disk.ino()
531 {
532 return Err(
533 "remove skipped: the probe path no longer names the file the probe created".to_string(),
534 );
535 }
536 std::fs::remove_file(path).map_err(|e| format!("remove failed: {e}"))
537}
538
539#[cfg(not(unix))]
540fn remove_probe_file(_path: &Path, _created: &std::fs::Metadata) -> Result<(), String> {
541 Ok(())
545}
546
547fn write_daemon_tmpdir_marker(car_home: &Path, marker: &DaemonTmpdirMarker) -> std::io::Result<()> {
550 use std::io::Write;
551
552 let dir = car_home.join(DOCTOR_DIR);
553 car_secrets::ensure_private_dir(&dir)?;
557 let mut json = serde_json::to_vec_pretty(marker)
558 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
559 json.push(b'\n');
560 let tmp = dir.join(format!(
561 "{DAEMON_TMPDIR_MARKER_FILE}.{}.tmp",
562 unique_probe_suffix(marker.pid)
563 ));
564 let mut file = car_secrets::create_private_file(&tmp)?;
569 file.write_all(&json)?;
570 car_secrets::atomic_replace_private_file(&tmp, &dir.join(DAEMON_TMPDIR_MARKER_FILE))
571}
572
573fn classify_daemon_tmpdir_marker(
576 marker: DaemonTmpdirMarker,
577 writer_alive: Option<bool>,
578) -> DaemonTmpdirStatus {
579 match writer_alive {
580 Some(true) => DaemonTmpdirStatus::Current { marker },
581 Some(false) => DaemonTmpdirStatus::Stale { marker },
582 None => DaemonTmpdirStatus::Unverified { marker },
583 }
584}
585
586fn daemon_writer_alive(pid: u32) -> Option<bool> {
594 #[cfg(unix)]
595 {
596 Some(unix_pid_alive(pid))
597 }
598 #[cfg(not(unix))]
599 {
600 let _ = pid;
601 None
602 }
603}
604
605#[cfg(unix)]
616fn unix_pid_alive(pid: u32) -> bool {
617 let Ok(pid) = libc::pid_t::try_from(pid) else {
619 return false;
620 };
621 if pid <= 0 {
622 return false;
623 }
624 let rc = unsafe { libc::kill(pid, 0) };
626 rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
627}
628
629fn check_daemon_tmpdir(home: &Path) -> Option<DaemonTmpdirCheck> {
631 let status = match read_daemon_tmpdir_marker(home)? {
632 Ok(marker) => {
633 let writer_alive = daemon_writer_alive(marker.pid);
634 classify_daemon_tmpdir_marker(marker, writer_alive)
635 }
636 Err(error) => DaemonTmpdirStatus::Unreadable { error },
637 };
638 Some(DaemonTmpdirCheck {
639 marker_path: daemon_tmpdir_marker_path(home).display().to_string(),
640 status,
641 })
642}
643
644#[derive(Debug, Clone, Default)]
646pub struct DoctorOptions {
647 pub deep: bool,
651 pub repair: bool,
654}
655
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658#[serde(rename_all = "snake_case", tag = "status")]
659pub enum StateFileStatus {
660 Absent,
662 Ok { schema_version: Option<u32> },
664 Unparseable {
667 error: String,
668 backed_up_to: Option<String>,
669 },
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct StateFileCheck {
675 pub name: String,
676 #[serde(flatten)]
677 pub status: StateFileStatus,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case", tag = "status")]
683pub enum ModelStatus {
684 Healthy,
686 Corrupt {
689 bad_files: Vec<String>,
690 purged: usize,
691 },
692 Incomplete { detail: String },
703}
704
705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
709pub struct Leftover {
710 pub path: String,
711 pub bytes: u64,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct ModelCheck {
717 pub name: String,
718 #[serde(flatten)]
719 pub status: ModelStatus,
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct RuntimeCheck {
733 pub name: String,
735 pub root: String,
736 pub present: bool,
739 pub interpreter_ok: bool,
741}
742
743impl RuntimeCheck {
744 pub fn is_broken(&self) -> bool {
747 self.present && !self.interpreter_ok
748 }
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize)]
753pub struct DoctorReport {
754 pub car_home: String,
755 pub binary_version: String,
757 pub on_disk_stamp: Option<VersionStamp>,
759 pub version_skew: bool,
765 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub carried_from_version: Option<String>,
773 #[serde(default)]
777 pub schema_from_the_future: bool,
778 pub state_files: Vec<StateFileCheck>,
779 pub models: Vec<ModelCheck>,
780 #[serde(default)]
783 pub installed_models: usize,
784 #[serde(default)]
788 pub leftovers: Vec<Leftover>,
789 #[serde(default)]
792 pub runtimes: Vec<RuntimeCheck>,
793 pub unrecognized: Vec<String>,
795 pub repairs: Vec<String>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub daemon_tmpdir: Option<DaemonTmpdirCheck>,
803}
804
805impl DoctorReport {
806 pub fn is_healthy(&self) -> bool {
809 !self.schema_from_the_future
814 && !self.version_skew
815 && self
816 .state_files
817 .iter()
818 .all(|f| !matches!(f.status, StateFileStatus::Unparseable { .. }))
819 && self
820 .models
821 .iter()
822 .all(|m| matches!(m.status, ModelStatus::Healthy))
823 && self.runtimes.iter().all(|r| !r.is_broken())
824 && !self
827 .daemon_tmpdir
828 .as_ref()
829 .is_some_and(DaemonTmpdirCheck::is_failing)
830 }
831}
832
833pub fn diagnose(opts: &DoctorOptions) -> DoctorReport {
842 diagnose_at(&car_home(), &crate::default_models_dir(), opts)
843}
844
845pub fn diagnose_in(home: &Path, opts: &DoctorOptions) -> DoctorReport {
849 diagnose_at(home, &home.join("models"), opts)
850}
851
852pub fn diagnose_at(home: &Path, models_dir: &Path, opts: &DoctorOptions) -> DoctorReport {
854 let catalog_public_key = std::env::var("CAR_CATALOG_PUBKEY").ok();
855 let registry = crate::registry::UnifiedRegistry::new_with_session(
856 home.to_path_buf(),
857 models_dir.to_path_buf(),
858 catalog_public_key.as_deref(),
859 crate::registry::SessionProbe::Inert,
860 );
861 diagnose_at_with_registry(home, models_dir, opts, ®istry, None)
862}
863
864#[doc(hidden)]
873pub fn diagnose_at_isolated(
874 home: &Path,
875 models_dir: &Path,
876 huggingface_hub_root: &Path,
877 opts: &DoctorOptions,
878) -> DoctorReport {
879 let registry = crate::registry::UnifiedRegistry::new_isolated_for_diagnosis(
880 home.to_path_buf(),
881 models_dir.to_path_buf(),
882 );
883 diagnose_at_with_registry(
884 home,
885 models_dir,
886 opts,
887 ®istry,
888 Some(huggingface_hub_root),
889 )
890}
891
892fn diagnose_at_with_registry(
893 home: &Path,
894 models_dir: &Path,
895 opts: &DoctorOptions,
896 registry: &crate::registry::UnifiedRegistry,
897 huggingface_hub_root: Option<&Path>,
898) -> DoctorReport {
899 let mut repairs = Vec::new();
900
901 let on_disk_stamp = read_version_stamp(home);
903 let binary = VersionStamp::current();
904 let version_skew = on_disk_stamp
905 .as_ref()
906 .map(|s| {
907 s.car_version != binary.car_version
908 || s.state_schema_version != binary.state_schema_version
909 })
910 .unwrap_or(false);
911 let carried_from_version = on_disk_stamp
913 .as_ref()
914 .and_then(|s| s.previous_car_version.clone());
915 let schema_from_the_future = on_disk_stamp
916 .as_ref()
917 .is_some_and(|s| s.state_schema_version > binary.state_schema_version);
918
919 let mut state_files = Vec::new();
921 for name in KNOWN_STATE_FILES {
922 state_files.push(check_state_file(home, name, opts, &mut repairs));
923 }
924
925 let models = check_models(models_dir, opts, &mut repairs);
927 let installed_models = registry
930 .list()
931 .into_iter()
932 .filter(|schema| {
933 schema.downloads_weights()
934 && crate::registry::physical_weights_ready_with_huggingface_hub(
935 schema,
936 models_dir,
937 huggingface_hub_root,
938 )
939 })
940 .count();
941
942 let leftovers = match huggingface_hub_root {
944 Some(hub) => find_leftovers_in(hub),
945 None => find_leftovers(),
946 };
947 let unrecognized = find_unrecognized(home);
948
949 if opts.repair {
951 let already_current = on_disk_stamp
958 .as_ref()
959 .map(|s| {
960 s.car_version == binary.car_version
961 && s.state_schema_version == binary.state_schema_version
962 })
963 .unwrap_or(false);
964 match write_version_stamp(home) {
965 Ok(()) if !already_current => repairs.push(format!(
966 "refreshed version stamp to {} (schema v{})",
967 binary.car_version, binary.state_schema_version
968 )),
969 Ok(()) => {}
970 Err(e) => repairs.push(format!("failed to refresh version stamp: {e}")),
971 }
972 }
973
974 let empty_journals = find_empty_journals(home);
976 if opts.repair && !empty_journals.is_empty() {
977 let mut removed = 0usize;
978 for p in &empty_journals {
979 if std::fs::remove_file(p).is_ok() {
980 removed += 1;
981 }
982 }
983 if removed > 0 {
984 repairs.push(format!(
985 "removed {removed} empty event journal(s) from journals/"
986 ));
987 }
988 }
989
990 let runtimes = check_runtimes(home);
992
993 let daemon_tmpdir = check_daemon_tmpdir(home);
995
996 DoctorReport {
997 car_home: home.display().to_string(),
998 binary_version: binary.car_version,
999 on_disk_stamp,
1000 version_skew,
1001 carried_from_version,
1002 schema_from_the_future,
1003 state_files,
1004 models,
1005 installed_models,
1006 leftovers,
1007 runtimes,
1008 unrecognized,
1009 repairs,
1010 daemon_tmpdir,
1011 }
1012}
1013
1014const MANAGED_RUNTIMES: &[&str] = &["speech-runtime", "visual-runtime"];
1016
1017fn check_runtimes(home: &Path) -> Vec<RuntimeCheck> {
1023 MANAGED_RUNTIMES
1024 .iter()
1025 .filter_map(|name| {
1026 let root = home.join(name);
1027 if !root.exists() {
1028 return None;
1029 }
1030 Some(RuntimeCheck {
1031 name: (*name).to_string(),
1032 root: root.display().to_string(),
1033 present: true,
1034 interpreter_ok: crate::managed_venv::interpreter_healthy(&root),
1035 })
1036 })
1037 .collect()
1038}
1039
1040fn read_version_stamp(home: &Path) -> Option<VersionStamp> {
1041 let text = std::fs::read_to_string(home.join("version.json")).ok()?;
1042 serde_json::from_str(&text).ok()
1043}
1044
1045fn check_state_file(
1046 home: &Path,
1047 name: &str,
1048 opts: &DoctorOptions,
1049 repairs: &mut Vec<String>,
1050) -> StateFileCheck {
1051 let path = home.join(name);
1052 let is_jsonl = name.ends_with(".jsonl");
1053 let status = match std::fs::read_to_string(&path) {
1054 Err(_) => StateFileStatus::Absent,
1055 Ok(text) if text.trim().is_empty() => StateFileStatus::Ok {
1056 schema_version: None,
1057 },
1058 Ok(text) if is_jsonl => match jsonl_first_bad_line(&text) {
1061 None => StateFileStatus::Ok {
1062 schema_version: None,
1063 },
1064 Some(e) => unparseable(&path, name, e, opts, repairs),
1065 },
1066 Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
1067 Ok(value) => StateFileStatus::Ok {
1068 schema_version: value
1069 .get("schema_version")
1070 .and_then(serde_json::Value::as_u64)
1071 .map(|v| v as u32),
1072 },
1073 Err(e) => unparseable(&path, name, e.to_string(), opts, repairs),
1074 },
1075 };
1076 StateFileCheck {
1077 name: name.to_string(),
1078 status,
1079 }
1080}
1081
1082fn jsonl_first_bad_line(text: &str) -> Option<String> {
1085 for (i, line) in text.lines().enumerate() {
1086 if line.trim().is_empty() {
1087 continue;
1088 }
1089 if let Err(e) = serde_json::from_str::<serde_json::Value>(line) {
1090 return Some(format!("line {}: {e}", i + 1));
1091 }
1092 }
1093 None
1094}
1095
1096fn unparseable(
1100 path: &Path,
1101 name: &str,
1102 error: String,
1103 opts: &DoctorOptions,
1104 repairs: &mut Vec<String>,
1105) -> StateFileStatus {
1106 let backed_up_to = if opts.repair {
1107 let plain = path.with_file_name(format!("{name}.corrupt.bak"));
1111 let bak = if plain.exists() {
1112 let epoch = std::time::SystemTime::now()
1113 .duration_since(std::time::UNIX_EPOCH)
1114 .map(|d| d.as_secs())
1115 .unwrap_or(0);
1116 path.with_file_name(format!("{name}.corrupt.{epoch}.bak"))
1117 } else {
1118 plain
1119 };
1120 match std::fs::rename(path, &bak) {
1121 Ok(()) => {
1122 repairs.push(format!("backed up unparseable {name} → {}", bak.display()));
1123 Some(bak.display().to_string())
1124 }
1125 Err(err) => {
1126 repairs.push(format!("failed to back up {name}: {err}"));
1127 None
1128 }
1129 }
1130 } else {
1131 None
1132 };
1133 StateFileStatus::Unparseable {
1134 error,
1135 backed_up_to,
1136 }
1137}
1138
1139fn check_models(
1140 models_dir: &Path,
1141 opts: &DoctorOptions,
1142 repairs: &mut Vec<String>,
1143) -> Vec<ModelCheck> {
1144 let Ok(entries) = std::fs::read_dir(models_dir) else {
1145 return Vec::new();
1146 };
1147 let mut out = Vec::new();
1148 for entry in entries.filter_map(Result::ok) {
1149 let dir = entry.path();
1150 if !dir.is_dir() {
1151 continue;
1152 }
1153 let name = entry.file_name().to_string_lossy().to_string();
1154 if let Some(status) = check_one_model(&dir, opts, &name, repairs) {
1159 out.push(ModelCheck { name, status });
1160 }
1161 }
1162 out.sort_by(|a, b| a.name.cmp(&b.name));
1163 out
1164}
1165
1166fn check_one_model(
1167 dir: &Path,
1168 opts: &DoctorOptions,
1169 name: &str,
1170 repairs: &mut Vec<String>,
1171) -> Option<ModelStatus> {
1172 let weights = weight_files(dir);
1173 if weights.is_empty() {
1174 if is_interrupted_install(dir) {
1190 return Some(ModelStatus::Incomplete {
1191 detail: format!(
1192 "manifest linked into the HuggingFace cache but no weights resolve — \
1193 re-pull with `car models pull {name}`"
1194 ),
1195 });
1196 }
1197 return None;
1198 }
1199 let mut bad_files = Vec::new();
1200 for w in &weights {
1201 let corrupt = if opts.deep {
1202 verify_cache_file(w) == CacheIntegrity::Corrupt
1203 } else {
1204 !cache_file_usable(w)
1205 };
1206 if corrupt {
1207 bad_files.push(
1208 w.file_name()
1209 .unwrap_or_default()
1210 .to_string_lossy()
1211 .to_string(),
1212 );
1213 }
1214 }
1215 if bad_files.is_empty() {
1216 return Some(ModelStatus::Healthy);
1217 }
1218 let purged = if opts.repair {
1219 let n = purge_corrupt_cache_files(dir);
1220 if n > 0 {
1221 repairs.push(format!(
1222 "purged {n} corrupt file(s) from model '{name}' — re-pull with `car models pull {name}`"
1223 ));
1224 }
1225 n
1226 } else {
1227 0
1228 };
1229 Some(ModelStatus::Corrupt { bad_files, purged })
1230}
1231
1232fn is_interrupted_install(dir: &Path) -> bool {
1260 const MANIFESTS: &[&str] = &[
1261 "config.json",
1262 "model_index.json",
1263 "tokenizer.json",
1264 "tokenizer_config.json",
1265 "model.safetensors.index.json",
1266 ];
1267 let has_symlinked_manifest = MANIFESTS.iter().any(|m| {
1268 let p = dir.join(m);
1269 std::fs::symlink_metadata(&p)
1270 .map(|meta| meta.file_type().is_symlink())
1271 .unwrap_or(false)
1272 });
1273 has_symlinked_manifest && !crate::registry::mlx_dir_has_weights(dir)
1274}
1275
1276fn find_empty_journals(home: &Path) -> Vec<PathBuf> {
1289 let dir = home.join("journals");
1290 let Ok(entries) = std::fs::read_dir(&dir) else {
1291 return Vec::new();
1292 };
1293 let mut out: Vec<PathBuf> = entries
1294 .filter_map(Result::ok)
1295 .filter(|e| {
1296 e.path().extension().and_then(|x| x.to_str()) == Some("jsonl")
1297 && e.metadata()
1298 .map(|m| m.is_file() && m.len() == 0)
1299 .unwrap_or(false)
1300 })
1301 .map(|e| e.path())
1302 .collect();
1303 out.sort();
1304 out
1305}
1306
1307fn find_leftovers() -> Vec<Leftover> {
1321 find_leftovers_in(&crate::registry::huggingface_cache_root())
1322}
1323
1324fn find_leftovers_in(hub: &Path) -> Vec<Leftover> {
1325 let mut out = Vec::new();
1326 let Ok(repos) = std::fs::read_dir(hub) else {
1327 return out;
1328 };
1329 for repo in repos.filter_map(Result::ok) {
1330 let blobs = repo.path().join("blobs");
1331 let Ok(entries) = std::fs::read_dir(&blobs) else {
1332 continue;
1333 };
1334 for e in entries.filter_map(Result::ok) {
1335 let p = e.path();
1336 let is_partial = p
1337 .file_name()
1338 .and_then(|n| n.to_str())
1339 .map(|n| n.ends_with(".sync.part") || n.ends_with(".incomplete"))
1340 .unwrap_or(false);
1341 if !is_partial {
1342 continue;
1343 }
1344 let bytes = e.metadata().map(|m| m.len()).unwrap_or(0);
1347 out.push(Leftover {
1348 path: p.display().to_string(),
1349 bytes,
1350 });
1351 }
1352 }
1353 out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
1354 out
1355}
1356
1357fn weight_files(dir: &Path) -> Vec<PathBuf> {
1358 fn is_weight(p: &Path) -> bool {
1359 matches!(
1360 p.extension().and_then(|e| e.to_str()),
1361 Some("safetensors") | Some("gguf")
1362 )
1363 }
1364 let mut out = Vec::new();
1365 let Ok(entries) = std::fs::read_dir(dir) else {
1366 return out;
1367 };
1368 for entry in entries.filter_map(Result::ok) {
1369 let p = entry.path();
1370 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1373 out.extend(weight_files(&p));
1374 } else if is_weight(&p) {
1375 out.push(p);
1376 }
1377 }
1378 out
1379}
1380
1381fn find_unrecognized(home: &Path) -> Vec<String> {
1382 let Ok(entries) = std::fs::read_dir(home) else {
1383 return Vec::new();
1384 };
1385 let mut out: Vec<String> = entries
1386 .filter_map(Result::ok)
1387 .filter_map(|e| {
1388 let name = e.file_name().to_string_lossy().to_string();
1389 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1390 let known = if is_dir {
1391 KNOWN_DIRS.contains(&name.as_str())
1392 } else {
1393 KNOWN_STATE_FILES.contains(&name.as_str())
1394 || KNOWN_NON_JSON_FILES.contains(&name.as_str())
1395 || TOLERATED_SUFFIXES.iter().any(|s| name.ends_with(s))
1398 || name.starts_with('.')
1400 };
1401 if known {
1402 None
1403 } else {
1404 Some(name)
1405 }
1406 })
1407 .collect();
1408 out.sort();
1409 out
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414 use super::*;
1415 use tempfile::TempDir;
1416
1417 fn opts(deep: bool, repair: bool) -> DoctorOptions {
1418 DoctorOptions { deep, repair }
1419 }
1420
1421 fn diagnose_repair_with_isolated_model_roots(home: &Path) -> DoctorReport {
1422 let models_dir = TempDir::new().unwrap();
1423 let huggingface_hub = TempDir::new().unwrap();
1424 let blobs = huggingface_hub.path().join("models--fixture/blobs");
1425 std::fs::create_dir_all(&blobs).unwrap();
1426 std::fs::write(blobs.join("isolated.sync.part"), b"partial").unwrap();
1427
1428 let report = diagnose_at_isolated(
1429 home,
1430 models_dir.path(),
1431 huggingface_hub.path(),
1432 &opts(false, true),
1433 );
1434 assert_eq!(
1435 report.leftovers.len(),
1436 1,
1437 "the isolated seam must scan its injected Hugging Face root, not skip leftovers"
1438 );
1439 assert!(
1440 Path::new(&report.leftovers[0].path).starts_with(huggingface_hub.path()),
1441 "the leftover must come from the test-owned Hugging Face root"
1442 );
1443 report
1444 }
1445
1446 #[test]
1447 fn clean_home_is_healthy() {
1448 let tmp = TempDir::new().unwrap();
1449 std::fs::write(tmp.path().join("models.json"), "{}").unwrap();
1450 std::fs::create_dir_all(tmp.path().join("models")).unwrap();
1451 let r = diagnose_in(tmp.path(), &opts(false, false));
1452 assert!(r.is_healthy(), "clean home should be healthy: {r:?}");
1453 assert!(r.unrecognized.is_empty());
1454 }
1455
1456 #[test]
1457 fn secret_index_is_recognized_not_a_leftover() {
1458 let tmp = TempDir::new().unwrap();
1464 std::fs::write(tmp.path().join("models.json"), "{}").unwrap();
1465 std::fs::create_dir_all(tmp.path().join("models")).unwrap();
1466 std::fs::write(tmp.path().join("secret_index.json"), r#"{"entries":[]}"#).unwrap();
1467 let r = diagnose_in(tmp.path(), &opts(false, false));
1468 assert!(
1469 !r.unrecognized.contains(&"secret_index.json".to_string()),
1470 "secret_index.json must not be flagged as unrecognized: {:?}",
1471 r.unrecognized
1472 );
1473 assert!(
1474 r.is_healthy(),
1475 "home with a secret index should be healthy: {r:?}"
1476 );
1477 }
1478
1479 #[test]
1480 fn unparseable_state_file_is_flagged_and_backed_up_on_repair() {
1481 let tmp = TempDir::new().unwrap();
1482 std::fs::write(tmp.path().join("connectors.json"), "{not json").unwrap();
1483
1484 let r = diagnose_in(tmp.path(), &opts(false, false));
1486 let c = r
1487 .state_files
1488 .iter()
1489 .find(|f| f.name == "connectors.json")
1490 .unwrap();
1491 assert!(matches!(
1492 c.status,
1493 StateFileStatus::Unparseable {
1494 backed_up_to: None,
1495 ..
1496 }
1497 ));
1498 assert!(!r.is_healthy());
1499 assert!(
1500 tmp.path().join("connectors.json").exists(),
1501 "untouched without --repair"
1502 );
1503
1504 let r = diagnose_in(tmp.path(), &opts(false, true));
1506 let c = r
1507 .state_files
1508 .iter()
1509 .find(|f| f.name == "connectors.json")
1510 .unwrap();
1511 assert!(matches!(
1512 c.status,
1513 StateFileStatus::Unparseable {
1514 backed_up_to: Some(_),
1515 ..
1516 }
1517 ));
1518 assert!(!tmp.path().join("connectors.json").exists());
1519 assert!(tmp.path().join("connectors.json.corrupt.bak").exists());
1520 }
1521
1522 #[test]
1523 fn dotenv_env_file_is_never_parsed_or_moved() {
1524 let tmp = TempDir::new().unwrap();
1528 std::fs::write(
1529 tmp.path().join("env"),
1530 "ANTHROPIC_API_KEY=sk-secret\nFOO=bar\n",
1531 )
1532 .unwrap();
1533
1534 let r = diagnose_in(tmp.path(), &opts(false, true));
1535 assert!(
1536 r.is_healthy(),
1537 "dotenv env must not make the install unhealthy"
1538 );
1539 assert!(
1540 !r.unrecognized.contains(&"env".to_string()),
1541 "env is recognized"
1542 );
1543 assert!(
1544 r.state_files.iter().all(|f| f.name != "env"),
1545 "env is never JSON-checked"
1546 );
1547 assert!(
1548 tmp.path().join("env").exists(),
1549 "repair must not move the secrets file"
1550 );
1551 assert!(!tmp.path().join("env.corrupt.bak").exists());
1552 }
1553
1554 #[test]
1555 fn empty_state_file_is_ok() {
1556 let tmp = TempDir::new().unwrap();
1557 std::fs::write(tmp.path().join("messaging.json"), "").unwrap();
1558 let r = diagnose_in(tmp.path(), &opts(false, false));
1559 let c = r
1560 .state_files
1561 .iter()
1562 .find(|f| f.name == "messaging.json")
1563 .unwrap();
1564 assert!(matches!(c.status, StateFileStatus::Ok { .. }));
1565 }
1566
1567 #[test]
1573 #[cfg(unix)]
1574 fn interrupted_pull_is_reported_not_skipped() {
1575 let tmp = TempDir::new().unwrap();
1576 let snap = tmp.path().join("hfsnap");
1578 std::fs::create_dir_all(&snap).unwrap();
1579 std::fs::write(snap.join("config.json"), "{}").unwrap();
1580 std::fs::write(snap.join("tokenizer.json"), "{}").unwrap();
1581
1582 let m = tmp.path().join("models").join("Qwen3-4B-MLX");
1583 std::fs::create_dir_all(&m).unwrap();
1584 std::os::unix::fs::symlink(snap.join("config.json"), m.join("config.json")).unwrap();
1585 std::os::unix::fs::symlink(snap.join("tokenizer.json"), m.join("tokenizer.json")).unwrap();
1586 let r = diagnose_in(tmp.path(), &opts(false, false));
1589 let check = r
1590 .models
1591 .iter()
1592 .find(|c| c.name == "Qwen3-4B-MLX")
1593 .expect("an interrupted install must appear in the report, not be dropped");
1594 assert!(
1595 matches!(check.status, ModelStatus::Incomplete { .. }),
1596 "expected Incomplete, got {:?}",
1597 check.status
1598 );
1599 assert!(
1600 !r.is_healthy(),
1601 "a half-installed model must not read as healthy"
1602 );
1603 }
1604
1605 #[test]
1609 fn abandoned_partial_downloads_are_reported_never_deleted() {
1610 let hf = TempDir::new().unwrap();
1611 let blobs = hf.path().join("hub").join("models--x--y").join("blobs");
1612 std::fs::create_dir_all(&blobs).unwrap();
1613 let part = blobs.join("deadbeef.sync.part");
1614 std::fs::write(&part, vec![0u8; 4096]).unwrap();
1615 std::fs::write(blobs.join("finished"), b"whole").unwrap();
1616
1617 let home = TempDir::new().unwrap();
1618 let prev = std::env::var_os("HF_HOME");
1620 std::env::set_var("HF_HOME", hf.path());
1621 let r = diagnose_in(home.path(), &opts(false, true));
1622 match prev {
1623 Some(v) => std::env::set_var("HF_HOME", v),
1624 None => std::env::remove_var("HF_HOME"),
1625 }
1626
1627 assert_eq!(
1628 r.leftovers.len(),
1629 1,
1630 "expected one partial: {:?}",
1631 r.leftovers
1632 );
1633 assert_eq!(r.leftovers[0].bytes, 4096);
1634 assert!(r.leftovers[0].path.ends_with("deadbeef.sync.part"));
1635 assert!(
1636 part.exists(),
1637 "--repair must NOT delete a partial: the HF cache is shared and the \
1638 transfer may still be running"
1639 );
1640 assert!(r.is_healthy());
1642 }
1643
1644 #[test]
1647 fn repair_does_not_report_an_unchanged_version_stamp() {
1648 let tmp = TempDir::new().unwrap();
1649 let first = diagnose_in(tmp.path(), &opts(false, true));
1651 assert!(
1652 first.repairs.iter().any(|r| r.contains("version stamp")),
1653 "writing a missing stamp IS a repair: {:?}",
1654 first.repairs
1655 );
1656 let second = diagnose_in(tmp.path(), &opts(false, true));
1658 assert!(
1659 !second.repairs.iter().any(|r| r.contains("version stamp")),
1660 "an unchanged stamp is not a repair: {:?}",
1661 second.repairs
1662 );
1663 }
1664
1665 #[test]
1669 fn empty_journals_are_reaped_on_repair_only() {
1670 let tmp = TempDir::new().unwrap();
1671 let journals = tmp.path().join("journals");
1672 std::fs::create_dir_all(&journals).unwrap();
1673 let empty = journals.join("aaaaaaaaaaaa.jsonl");
1674 let full = journals.join("bbbbbbbbbbbb.jsonl");
1675 let other = journals.join("notes.txt");
1676 std::fs::write(&empty, b"").unwrap();
1677 std::fs::write(&full, b"{\"kind\":\"proposal_received\"}\n").unwrap();
1678 std::fs::write(&other, b"").unwrap();
1679
1680 let _ = diagnose_in(tmp.path(), &opts(false, false));
1682 assert!(empty.exists(), "no --repair, no deletion");
1683
1684 let r = diagnose_in(tmp.path(), &opts(false, true));
1685 assert!(!empty.exists(), "empty journal should be reaped");
1686 assert!(full.exists(), "a journal with events must be kept");
1687 assert!(other.exists(), "non-.jsonl files are not ours to remove");
1688 assert!(
1689 r.repairs.iter().any(|x| x.contains("empty event journal")),
1690 "the reap should be reported: {:?}",
1691 r.repairs
1692 );
1693 }
1694
1695 #[test]
1696 fn config_only_stub_is_skipped_not_flagged() {
1697 let tmp = TempDir::new().unwrap();
1700 let m = tmp.path().join("models").join("Stub");
1701 std::fs::create_dir_all(&m).unwrap();
1702 std::fs::write(m.join("config.json"), "{}").unwrap();
1703 let r = diagnose_in(tmp.path(), &opts(false, false));
1704 assert!(
1705 r.models.iter().all(|m| m.name != "Stub"),
1706 "stub should be skipped"
1707 );
1708 assert!(r.is_healthy());
1709 }
1710
1711 #[cfg(unix)]
1712 #[test]
1713 fn corrupt_model_weight_is_purged_on_repair() {
1714 let tmp = TempDir::new().unwrap();
1715 let m = tmp.path().join("models").join("Qwen3-Test");
1716 std::fs::create_dir_all(&m).unwrap();
1717 std::os::unix::fs::symlink(m.join("gone"), m.join("model.safetensors")).unwrap();
1719
1720 let r = diagnose_in(tmp.path(), &opts(false, false));
1721 let mc = r.models.iter().find(|m| m.name == "Qwen3-Test").unwrap();
1722 assert!(matches!(mc.status, ModelStatus::Corrupt { purged: 0, .. }));
1723
1724 let r = diagnose_in(tmp.path(), &opts(false, true));
1725 let mc = r.models.iter().find(|m| m.name == "Qwen3-Test").unwrap();
1726 match &mc.status {
1727 ModelStatus::Corrupt { purged, .. } => assert_eq!(*purged, 1),
1728 other => panic!("expected Corrupt, got {other:?}"),
1729 }
1730 assert!(std::fs::symlink_metadata(m.join("model.safetensors")).is_err());
1731 }
1732
1733 #[test]
1734 fn shared_huggingface_weights_count_as_installed() {
1735 let tmp = TempDir::new().unwrap();
1736 let models_dir = tmp.path().join("models");
1737 std::fs::create_dir(&models_dir).unwrap();
1738 let huggingface_hub = tmp.path().join("huggingface-hub");
1739 let snapshot = huggingface_hub
1740 .join("models--example--doctor-only")
1741 .join("snapshots")
1742 .join("revision");
1743 std::fs::create_dir_all(&snapshot).unwrap();
1744 std::fs::write(snapshot.join("config.json"), b"{}").unwrap();
1745 std::fs::write(snapshot.join("model.safetensors"), b"weights").unwrap();
1746
1747 let schema: crate::schema::ModelSchema = serde_json::from_value(serde_json::json!({
1748 "id": "example/doctor-only:4bit",
1749 "name": "doctor-only",
1750 "provider": "example",
1751 "family": "doctor-test",
1752 "capabilities": ["generate"],
1753 "context_length": 4096,
1754 "source": {
1755 "type": "mlx",
1756 "hf_repo": "example/doctor-only",
1757 "hf_weight_file": null
1758 }
1759 }))
1760 .unwrap();
1761 let mut registry = crate::registry::UnifiedRegistry::new_empty(models_dir.clone());
1762 registry.register(schema);
1763
1764 let report = diagnose_at_with_registry(
1765 tmp.path(),
1766 &models_dir,
1767 &opts(false, false),
1768 ®istry,
1769 Some(&huggingface_hub),
1770 );
1771 assert!(
1772 report.models.is_empty(),
1773 "shared-cache weights do not create a managed model directory check"
1774 );
1775 assert_eq!(
1776 report.installed_models, 1,
1777 "registry physical readiness must count shared-cache-only weights"
1778 );
1779 }
1780
1781 #[test]
1782 fn current_version_state_entries_are_recognized() {
1783 let tmp = TempDir::new().unwrap();
1784 for name in ["model-resource-policy.json", "parslee-auth-authority.json"] {
1785 std::fs::write(tmp.path().join(name), b"{}").unwrap();
1786 }
1787 for name in ["proposal-completed-index", "selfheal"] {
1788 std::fs::create_dir(tmp.path().join(name)).unwrap();
1789 }
1790
1791 let unrecognized = find_unrecognized(tmp.path());
1792 assert!(
1793 unrecognized.is_empty(),
1794 "current-version state must not be reported as older-install debris: {unrecognized:?}"
1795 );
1796 }
1797
1798 #[test]
1799 fn repair_never_json_validates_or_moves_the_binary_peer_identity_key() {
1800 let tmp = TempDir::new().unwrap();
1801 let key_path = tmp.path().join("peer-identity.key");
1802 let key_bytes = b"0123456789abcdef0123456789abcdef";
1803 std::fs::write(&key_path, key_bytes).unwrap();
1804
1805 let report = diagnose_repair_with_isolated_model_roots(tmp.path());
1806
1807 assert_eq!(
1808 std::fs::read(&key_path).unwrap(),
1809 key_bytes,
1810 "repair must preserve the binary peer identity byte-for-byte"
1811 );
1812 assert!(
1813 !tmp.path().join("peer-identity.key.corrupt.bak").exists(),
1814 "the non-JSON identity key must never be classified as corrupt JSON"
1815 );
1816 assert!(
1817 report
1818 .state_files
1819 .iter()
1820 .all(|file| file.name != "peer-identity.key"),
1821 "the peer identity belongs to the non-JSON allowlist"
1822 );
1823 assert!(
1824 !report
1825 .unrecognized
1826 .contains(&"peer-identity.key".to_string()),
1827 "the live identity key must be recognized"
1828 );
1829 }
1830
1831 #[test]
1835 fn repair_preserves_a_non_utf8_peer_identity_key() {
1836 let tmp = TempDir::new().unwrap();
1837 let key_path = tmp.path().join("peer-identity.key");
1838 let key_bytes = [0xff, 0xfe, 0xfd, 0x00, 0x80, 0x81, 0x82, 0x83];
1839 std::fs::write(&key_path, key_bytes).unwrap();
1840
1841 let report = diagnose_repair_with_isolated_model_roots(tmp.path());
1842
1843 assert_eq!(
1844 std::fs::read(&key_path).unwrap(),
1845 key_bytes,
1846 "repair must preserve genuinely non-UTF-8 identity bytes"
1847 );
1848 assert!(
1849 !tmp.path().join("peer-identity.key.corrupt.bak").exists(),
1850 "repair must not create a corrupt-JSON backup for a binary key"
1851 );
1852 assert!(
1853 report
1854 .state_files
1855 .iter()
1856 .all(|file| file.name != "peer-identity.key"),
1857 "the binary key must bypass the JSON state-file checker"
1858 );
1859 assert!(
1860 !report
1861 .unrecognized
1862 .contains(&"peer-identity.key".to_string()),
1863 "the binary key must take the recognized non-JSON branch"
1864 );
1865 }
1866
1867 #[test]
1868 fn unrecognized_entries_are_reported_not_removed() {
1869 let tmp = TempDir::new().unwrap();
1870 std::fs::write(tmp.path().join("mystery-leftover.json"), "{}").unwrap();
1871 std::fs::create_dir_all(tmp.path().join("old_install_dir")).unwrap();
1872 let r = diagnose_in(tmp.path(), &opts(false, true));
1873 assert!(r
1874 .unrecognized
1875 .contains(&"mystery-leftover.json".to_string()));
1876 assert!(r.unrecognized.contains(&"old_install_dir".to_string()));
1877 assert!(tmp.path().join("mystery-leftover.json").exists());
1879 assert!(tmp.path().join("old_install_dir").exists());
1880 }
1881
1882 #[test]
1883 fn version_skew_detected() {
1884 let tmp = TempDir::new().unwrap();
1885 let stale = VersionStamp {
1886 car_version: "0.0.1-ancient".to_string(),
1887 state_schema_version: 1,
1888 previous_car_version: None,
1889 previous_state_schema_version: None,
1890 };
1891 std::fs::write(
1892 tmp.path().join("version.json"),
1893 serde_json::to_string(&stale).unwrap(),
1894 )
1895 .unwrap();
1896 let r = diagnose_in(tmp.path(), &opts(false, false));
1897 assert!(r.version_skew);
1898 assert!(!r.is_healthy());
1899
1900 let _ = diagnose_in(tmp.path(), &opts(false, true));
1902 let r = diagnose_in(tmp.path(), &opts(false, false));
1903 assert!(!r.version_skew, "stamp refreshed, skew cleared");
1904 }
1905}
1906
1907#[cfg(test)]
1908mod version_stamp_continuity_tests {
1909 use super::*;
1910
1911 fn write(home: &Path, stamp: &VersionStamp) {
1912 std::fs::create_dir_all(home).unwrap();
1913 std::fs::write(
1914 home.join("version.json"),
1915 serde_json::to_string_pretty(stamp).unwrap(),
1916 )
1917 .unwrap();
1918 }
1919
1920 fn stamp(version: &str, schema: u32) -> VersionStamp {
1921 VersionStamp {
1922 car_version: version.to_string(),
1923 state_schema_version: schema,
1924 previous_car_version: None,
1925 previous_state_schema_version: None,
1926 }
1927 }
1928
1929 #[test]
1933 fn stamping_after_an_upgrade_preserves_what_it_replaced() {
1934 let dir = tempfile::tempdir().unwrap();
1935 write(dir.path(), &stamp("0.39.0", 1));
1936
1937 let t = stamp_version(dir.path()).unwrap();
1938 assert!(t.upgraded(), "0.39.0 -> current is an upgrade");
1939 assert_eq!(
1940 t.current.previous_car_version.as_deref(),
1941 Some("0.39.0"),
1942 "the predecessor must survive the stamp that replaced it"
1943 );
1944
1945 let report = diagnose_in(dir.path(), &DoctorOptions::default());
1946 assert_eq!(
1947 report.carried_from_version.as_deref(),
1948 Some("0.39.0"),
1949 "doctor must still see it AFTER the daemon stamped"
1950 );
1951 }
1952
1953 #[test]
1956 fn rebooting_on_the_same_version_keeps_the_original_predecessor() {
1957 let dir = tempfile::tempdir().unwrap();
1958 write(dir.path(), &stamp("0.39.0", 1));
1959
1960 stamp_version(dir.path()).unwrap(); for _ in 0..5 {
1962 stamp_version(dir.path()).unwrap(); }
1964
1965 let report = diagnose_in(dir.path(), &DoctorOptions::default());
1966 assert_eq!(
1967 report.carried_from_version.as_deref(),
1968 Some("0.39.0"),
1969 "five reboots must not rewrite the predecessor to the current version"
1970 );
1971 }
1972
1973 #[test]
1974 fn a_fresh_install_records_no_predecessor() {
1975 let dir = tempfile::tempdir().unwrap();
1976 let t = stamp_version(dir.path()).unwrap();
1977 assert!(t.previous.is_none());
1978 assert!(!t.upgraded());
1979 assert!(t.current.previous_car_version.is_none());
1980 assert!(!diagnose_in(dir.path(), &DoctorOptions::default()).schema_from_the_future);
1981 }
1982
1983 #[test]
1986 fn state_from_a_newer_schema_is_flagged() {
1987 let dir = tempfile::tempdir().unwrap();
1988 write(dir.path(), &stamp("99.0.0", STATE_SCHEMA_VERSION + 7));
1989
1990 let t = stamp_version(dir.path()).unwrap();
1991 assert!(
1992 t.schema_from_the_future(),
1993 "newer on-disk schema must be flagged"
1994 );
1995
1996 write(dir.path(), &stamp("99.0.0", STATE_SCHEMA_VERSION + 7));
1998 let report = diagnose_in(dir.path(), &DoctorOptions::default());
1999 assert!(report.schema_from_the_future);
2000 assert!(!report.is_healthy());
2001 }
2002
2003 #[test]
2007 fn carrying_state_across_an_upgrade_is_not_unhealthy() {
2008 let dir = tempfile::tempdir().unwrap();
2009 write(dir.path(), &stamp("0.39.0", STATE_SCHEMA_VERSION));
2010 stamp_version(dir.path()).unwrap();
2011
2012 let report = diagnose_in(dir.path(), &DoctorOptions::default());
2013 assert!(report.carried_from_version.is_some());
2014 assert!(report.is_healthy(), "an ordinary upgrade is not a defect");
2015 }
2016
2017 #[test]
2020 fn a_pre_existing_stamp_without_the_new_fields_still_loads() {
2021 let dir = tempfile::tempdir().unwrap();
2022 std::fs::create_dir_all(dir.path()).unwrap();
2023 std::fs::write(
2024 dir.path().join("version.json"),
2025 r#"{"car_version":"0.39.0","state_schema_version":1}"#,
2026 )
2027 .unwrap();
2028
2029 let t = stamp_version(dir.path()).unwrap();
2030 assert_eq!(
2031 t.previous.as_ref().map(|p| p.car_version.as_str()),
2032 Some("0.39.0")
2033 );
2034 assert_eq!(t.current.previous_car_version.as_deref(), Some("0.39.0"));
2035 }
2036
2037 #[test]
2041 fn a_corrupt_stamp_is_treated_as_absent_and_replaced() {
2042 let dir = tempfile::tempdir().unwrap();
2043 std::fs::create_dir_all(dir.path()).unwrap();
2044 std::fs::write(dir.path().join("version.json"), "{ not json").unwrap();
2045
2046 let t = stamp_version(dir.path()).unwrap();
2047 assert!(t.previous.is_none(), "unreadable == no usable predecessor");
2048 assert_eq!(t.current.car_version, VersionStamp::current().car_version);
2049 }
2050}
2051
2052#[cfg(test)]
2053mod runtime_check_tests {
2054 use super::*;
2055
2056 #[test]
2059 fn absent_runtimes_are_not_reported() {
2060 let dir = tempfile::tempdir().unwrap();
2061 assert!(check_runtimes(dir.path()).is_empty());
2062 }
2063
2064 #[cfg(unix)]
2069 #[test]
2070 fn rotated_away_interpreter_is_reported_broken() {
2071 let dir = tempfile::tempdir().unwrap();
2072 let bin = dir.path().join("visual-runtime").join("bin");
2073 std::fs::create_dir_all(&bin).unwrap();
2074 std::os::unix::fs::symlink(
2075 "/opt/homebrew/opt/python@0.0/bin/python0.0",
2076 bin.join("python"),
2077 )
2078 .unwrap();
2079
2080 let checks = check_runtimes(dir.path());
2081 assert_eq!(checks.len(), 1);
2082 assert_eq!(checks[0].name, "visual-runtime");
2083 assert!(checks[0].present);
2084 assert!(!checks[0].interpreter_ok);
2085 assert!(checks[0].is_broken());
2086 }
2087
2088 #[test]
2091 fn broken_runtime_makes_report_unhealthy() {
2092 let mut report = DoctorReport {
2093 car_home: "/tmp/x".into(),
2094 binary_version: VersionStamp::current().car_version,
2095 on_disk_stamp: None,
2096 version_skew: false,
2097 carried_from_version: None,
2098 schema_from_the_future: false,
2099 state_files: Vec::new(),
2100 models: Vec::new(),
2101 installed_models: 0,
2102 leftovers: Vec::new(),
2103 runtimes: Vec::new(),
2104 unrecognized: Vec::new(),
2105 repairs: Vec::new(),
2106 daemon_tmpdir: None,
2107 };
2108 assert!(report.is_healthy());
2109
2110 report.runtimes.push(RuntimeCheck {
2111 name: "speech-runtime".into(),
2112 root: "/tmp/x/speech-runtime".into(),
2113 present: true,
2114 interpreter_ok: false,
2115 });
2116 assert!(!report.is_healthy());
2117 }
2118}
2119
2120#[cfg(test)]
2124mod daemon_tmpdir_tests {
2125 use super::*;
2126
2127 const NOT_A_DIRECTORY: &[u8] = b"a regular file where TMPDIR should be";
2128
2129 fn marker(pid: u32, ok: bool) -> DaemonTmpdirMarker {
2130 DaemonTmpdirMarker {
2131 pid,
2132 booted_at_unix: 1_789_000_000,
2133 car_version: VersionStamp::current().car_version,
2134 checked_path: "/private/tmp/PKInstallSandbox.test/tmp".to_string(),
2135 ok,
2136 error: (!ok)
2137 .then(|| "create failed: No such file or directory (os error 2)".to_string()),
2138 }
2139 }
2140
2141 fn write_marker(home: &Path, marker: &DaemonTmpdirMarker) {
2142 let path = daemon_tmpdir_marker_path(home);
2143 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2144 std::fs::write(path, serde_json::to_string_pretty(marker).unwrap()).unwrap();
2145 }
2146
2147 fn diagnose_home(home: &Path) -> DoctorReport {
2150 let models = tempfile::tempdir().unwrap();
2151 let hub = tempfile::tempdir().unwrap();
2152 diagnose_at_isolated(home, models.path(), hub.path(), &DoctorOptions::default())
2153 }
2154
2155 fn status_of(report: &DoctorReport) -> DaemonTmpdirStatus {
2156 report
2157 .daemon_tmpdir
2158 .clone()
2159 .expect("the record must be reported")
2160 .status
2161 }
2162
2163 #[cfg(unix)]
2166 fn dead_pid() -> u32 {
2167 let mut child = std::process::Command::new("true")
2168 .spawn()
2169 .expect("spawn `true`");
2170 let pid = child.id();
2171 child.wait().expect("reap `true`");
2172 assert!(
2173 !unix_pid_alive(pid),
2174 "a reaped child's pid must read as gone"
2175 );
2176 pid
2177 }
2178
2179 fn healthy_tmpdir(parent: &Path) -> PathBuf {
2180 let dir = parent.join("healthy-tmpdir");
2181 std::fs::create_dir(&dir).unwrap();
2182 dir
2183 }
2184
2185 #[test]
2186 fn probing_a_file_tmpdir_records_a_failure() {
2187 let tmp = tempfile::tempdir().unwrap();
2188 let home = tmp.path().join("car-home");
2189 let tmpdir = tmp.path().join("tmpdir-is-a-file");
2190 std::fs::write(&tmpdir, NOT_A_DIRECTORY).unwrap();
2191
2192 let recorded = record_daemon_tmpdir_probe(&home, &tmpdir)
2193 .expect("a failed probe is still a successful record");
2194
2195 assert!(!recorded.ok, "{recorded:?}");
2196 assert_eq!(recorded.pid, std::process::id());
2197 assert_eq!(recorded.checked_path, tmpdir.display().to_string());
2198 let error = recorded.error.clone().expect("a failure names its error");
2199 assert!(error.starts_with("create failed"), "{error}");
2200 #[cfg(unix)]
2201 assert!(
2202 error.contains(&format!("os error {}", libc::ENOTDIR)),
2203 "a file where the directory should be is ENOTDIR: {error}"
2204 );
2205 assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2206 assert_eq!(std::fs::read(&tmpdir).unwrap(), NOT_A_DIRECTORY);
2207 }
2208
2209 #[test]
2210 fn a_healthy_probe_replaces_a_prior_failure_and_leaves_nothing_behind() {
2211 let tmp = tempfile::tempdir().unwrap();
2212 let home = tmp.path().join("car-home");
2213 let broken = tmp.path().join("tmpdir-is-a-file");
2214 std::fs::write(&broken, NOT_A_DIRECTORY).unwrap();
2215 let healthy = healthy_tmpdir(tmp.path());
2216
2217 assert!(!record_daemon_tmpdir_probe(&home, &broken).unwrap().ok);
2218 let recorded = record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2219
2220 assert!(recorded.ok, "{recorded:?}");
2221 assert_eq!(recorded.error, None);
2222 assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2223 #[cfg(unix)]
2224 assert!(
2225 std::fs::read_dir(&healthy).unwrap().next().is_none(),
2226 "the probe removes the one file it created after proving its identity"
2227 );
2228 #[cfg(not(unix))]
2229 assert_eq!(
2230 std::fs::read_dir(&healthy).unwrap().count(),
2231 1,
2232 "without portable file identity the tiny probe file is left safely behind"
2233 );
2234 let names: Vec<_> = std::fs::read_dir(home.join(DOCTOR_DIR))
2235 .unwrap()
2236 .map(|entry| entry.unwrap().file_name())
2237 .collect();
2238 assert_eq!(
2239 names,
2240 vec![std::ffi::OsString::from(DAEMON_TMPDIR_MARKER_FILE)],
2241 "no temporary record may be left behind"
2242 );
2243 }
2244
2245 #[cfg(unix)]
2246 #[test]
2247 fn a_symlinked_tmpdir_is_followed_and_its_probe_file_is_removed() {
2248 let tmp = tempfile::tempdir().unwrap();
2249 let home = tmp.path().join("car-home");
2250 let target = healthy_tmpdir(tmp.path());
2251 let link = tmp.path().join("tmpdir-link");
2252 std::os::unix::fs::symlink(&target, &link).unwrap();
2253
2254 let recorded = record_daemon_tmpdir_probe(&home, &link).unwrap();
2255
2256 assert!(
2257 recorded.ok,
2258 "a directory symlink is valid TMPDIR: {recorded:?}"
2259 );
2260 assert_eq!(recorded.checked_path, link.display().to_string());
2261 assert!(
2262 std::fs::read_dir(&target).unwrap().next().is_none(),
2263 "the identity-checked probe file is removed from the resolved directory"
2264 );
2265 }
2266
2267 #[cfg(unix)]
2268 #[test]
2269 fn cleanup_does_not_remove_a_different_file_at_the_probe_path() {
2270 let tmp = tempfile::tempdir().unwrap();
2271 let created_path = tmp.path().join("created");
2272 let probe_path = tmp.path().join("substitute");
2273 std::fs::write(&created_path, b"created").unwrap();
2274 std::fs::write(&probe_path, b"substitute").unwrap();
2275 let created = std::fs::metadata(&created_path).unwrap();
2276
2277 let error = remove_probe_file(&probe_path, &created).unwrap_err();
2278
2279 assert!(error.starts_with("remove skipped"), "{error}");
2280 assert_eq!(std::fs::read(&probe_path).unwrap(), b"substitute");
2281 }
2282
2283 #[test]
2284 fn a_failed_handle_stat_records_that_the_probe_file_was_left() {
2285 let tmp = tempfile::tempdir().unwrap();
2286 let probe_path = tmp.path().join("probe");
2287 std::fs::write(&probe_path, TMPDIR_PROBE_BYTES).unwrap();
2288 let created = Err("stat failed: injected failure".to_string());
2289
2290 let cleanup = cleanup_probe_file(&probe_path, &created);
2291 let error = combine_probe_results(created.map(|_| ()), cleanup).unwrap_err();
2292
2293 assert!(error.contains("stat failed: injected failure"), "{error}");
2294 assert!(error.contains("identity could not be proven"), "{error}");
2295 assert!(error.contains("left"), "{error}");
2296 assert_eq!(std::fs::read(&probe_path).unwrap(), TMPDIR_PROBE_BYTES);
2297 }
2298
2299 #[test]
2300 fn the_record_does_not_trip_find_unrecognized() {
2301 let tmp = tempfile::tempdir().unwrap();
2302 let home = tmp.path().join("car-home");
2303 let healthy = healthy_tmpdir(tmp.path());
2304
2305 record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2306
2307 assert!(daemon_tmpdir_marker_path(&home).is_file());
2308 assert!(KNOWN_DIRS.contains(&DOCTOR_DIR));
2309 assert!(
2310 find_unrecognized(&home).is_empty(),
2311 "{:?}",
2312 find_unrecognized(&home)
2313 );
2314 assert!(diagnose_home(&home).unrecognized.is_empty());
2315 }
2316
2317 #[test]
2318 fn an_absent_record_is_not_an_error_and_adds_no_json_key() {
2319 let home = tempfile::tempdir().unwrap();
2320 let report = diagnose_home(home.path());
2321 assert!(report.daemon_tmpdir.is_none());
2322 assert!(report.is_healthy(), "{report:?}");
2323 let json = serde_json::to_value(&report).unwrap();
2324 assert!(
2325 json.get("daemon_tmpdir").is_none(),
2326 "additive: a report without a record serializes as before"
2327 );
2328 }
2329
2330 #[cfg(unix)]
2331 #[test]
2332 fn doctor_reads_failure_healthy_stale_and_absent_records() {
2333 let home = tempfile::tempdir().unwrap();
2334 let live = std::process::id();
2335
2336 let absent = diagnose_home(home.path());
2337 assert!(absent.daemon_tmpdir.is_none() && absent.is_healthy());
2338
2339 let failed = marker(live, false);
2340 write_marker(home.path(), &failed);
2341 let report = diagnose_home(home.path());
2342 assert_eq!(
2343 status_of(&report),
2344 DaemonTmpdirStatus::Current { marker: failed }
2345 );
2346 assert!(!report.is_healthy(), "failure from the running daemon");
2347
2348 let healthy = marker(live, true);
2349 write_marker(home.path(), &healthy);
2350 let report = diagnose_home(home.path());
2351 assert_eq!(
2352 status_of(&report),
2353 DaemonTmpdirStatus::Current { marker: healthy }
2354 );
2355 assert!(
2356 report.is_healthy(),
2357 "healthy record from the running daemon"
2358 );
2359
2360 let stale = marker(dead_pid(), false);
2361 write_marker(home.path(), &stale);
2362 let report = diagnose_home(home.path());
2363 assert_eq!(
2364 status_of(&report),
2365 DaemonTmpdirStatus::Stale { marker: stale }
2366 );
2367 assert!(report.is_healthy(), "a stale failure is not failing");
2368 }
2369
2370 #[cfg(unix)]
2371 #[test]
2372 fn a_running_daemons_failed_probe_is_unhealthy() {
2373 let home = tempfile::tempdir().unwrap();
2374 let recorded = marker(std::process::id(), false);
2375 write_marker(home.path(), &recorded);
2376
2377 let report = diagnose_home(home.path());
2378
2379 let check = report
2380 .daemon_tmpdir
2381 .clone()
2382 .expect("the record is reported");
2383 assert_eq!(
2384 check.marker_path,
2385 daemon_tmpdir_marker_path(home.path()).display().to_string()
2386 );
2387 assert_eq!(
2388 check.status,
2389 DaemonTmpdirStatus::Current { marker: recorded }
2390 );
2391 assert!(check.is_failing());
2392 assert!(!report.is_healthy(), "{report:?}");
2393 }
2394
2395 #[cfg(unix)]
2396 #[test]
2397 fn a_record_from_a_daemon_that_is_gone_is_stale_not_failing() {
2398 let home = tempfile::tempdir().unwrap();
2399 let recorded = marker(dead_pid(), false);
2400 write_marker(home.path(), &recorded);
2401
2402 let report = diagnose_home(home.path());
2403
2404 let check = report
2405 .daemon_tmpdir
2406 .clone()
2407 .expect("the record is reported");
2408 assert_eq!(check.status, DaemonTmpdirStatus::Stale { marker: recorded });
2409 assert!(!check.is_failing());
2410 assert!(report.is_healthy(), "{report:?}");
2411 }
2412
2413 #[test]
2414 fn an_ok_record_is_healthy() {
2415 let home = tempfile::tempdir().unwrap();
2416 let recorded = marker(std::process::id(), true);
2417 write_marker(home.path(), &recorded);
2418
2419 let report = diagnose_home(home.path());
2420
2421 #[cfg(unix)]
2422 assert_eq!(
2423 status_of(&report),
2424 DaemonTmpdirStatus::Current { marker: recorded }
2425 );
2426 #[cfg(not(unix))]
2427 assert_eq!(
2428 status_of(&report),
2429 DaemonTmpdirStatus::Unverified { marker: recorded }
2430 );
2431 assert!(report.is_healthy(), "{report:?}");
2432 }
2433
2434 #[test]
2437 fn without_a_liveness_check_a_failed_record_is_unverified_not_failing() {
2438 let recorded = marker(std::process::id(), false);
2439 let status = classify_daemon_tmpdir_marker(recorded.clone(), None);
2440 assert_eq!(status, DaemonTmpdirStatus::Unverified { marker: recorded });
2441 let check = DaemonTmpdirCheck {
2442 marker_path: "doctor/daemon-tmpdir.json".to_string(),
2443 status,
2444 };
2445 assert!(!check.is_failing());
2446 }
2447
2448 #[test]
2449 fn an_unreadable_record_is_reported_not_failing() {
2450 let home = tempfile::tempdir().unwrap();
2451 let path = daemon_tmpdir_marker_path(home.path());
2452 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2453 std::fs::write(&path, "{ not json").unwrap();
2454
2455 let report = diagnose_home(home.path());
2456
2457 assert!(
2458 matches!(status_of(&report), DaemonTmpdirStatus::Unreadable { .. }),
2459 "{:?}",
2460 report.daemon_tmpdir
2461 );
2462 assert!(report.is_healthy(), "{report:?}");
2463 }
2464
2465 #[test]
2466 fn a_record_serializes_additively_and_round_trips() {
2467 let home = tempfile::tempdir().unwrap();
2468 write_marker(home.path(), &marker(std::process::id(), true));
2469
2470 let json = serde_json::to_value(diagnose_home(home.path())).unwrap();
2471
2472 let record = json
2473 .get("daemon_tmpdir")
2474 .expect("the record serializes under `daemon_tmpdir`");
2475 assert!(
2476 record.get("status").is_some() && record.get("marker_path").is_some(),
2477 "{record}"
2478 );
2479 assert_eq!(record["marker"]["ok"], serde_json::Value::Bool(true));
2480 let back: DoctorReport = serde_json::from_value(json).unwrap();
2481 assert!(back.daemon_tmpdir.is_some());
2482 }
2483
2484 #[cfg(unix)]
2485 #[test]
2486 fn the_record_is_never_written_through_a_symlinked_doctor_dir() {
2487 let tmp = tempfile::tempdir().unwrap();
2488 let home = tmp.path().join("car-home");
2489 std::fs::create_dir(&home).unwrap();
2490 let elsewhere = tmp.path().join("elsewhere");
2491 std::fs::create_dir(&elsewhere).unwrap();
2492 std::os::unix::fs::symlink(&elsewhere, home.join(DOCTOR_DIR)).unwrap();
2493 let healthy = healthy_tmpdir(tmp.path());
2494
2495 assert!(record_daemon_tmpdir_probe(&home, &healthy).is_err());
2496 assert!(
2497 std::fs::read_dir(&elsewhere).unwrap().next().is_none(),
2498 "nothing may land where the symlink points"
2499 );
2500 }
2501
2502 #[cfg(unix)]
2503 #[test]
2504 fn a_symlink_at_the_record_path_is_replaced_not_followed() {
2505 let tmp = tempfile::tempdir().unwrap();
2506 let home = tmp.path().join("car-home");
2507 std::fs::create_dir_all(home.join(DOCTOR_DIR)).unwrap();
2508 let victim = tmp.path().join("victim");
2509 std::fs::write(&victim, b"victim").unwrap();
2510 std::os::unix::fs::symlink(&victim, daemon_tmpdir_marker_path(&home)).unwrap();
2511 let healthy = healthy_tmpdir(tmp.path());
2512
2513 let recorded = record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2514
2515 assert_eq!(std::fs::read(&victim).unwrap(), b"victim");
2516 assert!(std::fs::symlink_metadata(daemon_tmpdir_marker_path(&home))
2517 .unwrap()
2518 .file_type()
2519 .is_file());
2520 assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2521 }
2522}