Skip to main content

car_inference/
doctor.rs

1//! `car doctor` — an offline health check + repair for a CAR install.
2//!
3//! Built to run *when things are broken*. It touches only the local filesystem
4//! (`~/.car` and the shared HuggingFace model cache), never the daemon, so it
5//! still works after a half-finished reinstall or when `car-server` won't even
6//! start. It surfaces — and, in `--repair` mode, fixes what's safe to fix — the
7//! failure modes that successive installs and a shared, externally-mutated cache
8//! produce:
9//!
10//!   * **Corrupt model weights** — truncated/byte-rotted/externally-deleted files
11//!     in the model cache (reuses the Task-1/2 integrity primitives in
12//!     [`crate::download`]).
13//!   * **Incomplete installs** — a managed model dir whose manifest was symlinked
14//!     into the HuggingFace snapshot but whose weights never arrived, i.e. what
15//!     an interrupted `car models pull` leaves behind (Parslee-ai/car#616).
16//!   * **Unparseable state files** — a `~/.car/*.json` an older version wrote in a
17//!     shape this binary can't read, or a partially-written file.
18//!   * **Version skew** — state last written by a different CAR version than the
19//!     binary now running (read from the `version.json` stamp).
20//!   * **Abandoned partial downloads** — `*.sync.part` blobs stranded in the
21//!     HuggingFace cache. Reported with sizes, never deleted (see
22//!     [`find_leftovers`]).
23//!   * **Empty event journals** — a zero-byte journal per session that never
24//!     executed anything; reaped under `--repair` (see [`find_empty_journals`]).
25//!   * **Leftovers** — top-level `~/.car` entries this version doesn't recognize,
26//!     which may be debris from a previous install.
27//!   * **An unusable daemon `TMPDIR`** — the daemon probes the temp directory it
28//!     inherited when it starts and records the result under `<CAR_HOME>/doctor/`
29//!     ([`record_daemon_tmpdir_probe`]). Doctor reads that record from disk, so a
30//!     daemon handed a torn-down installer-sandbox `TMPDIR` is visible without
31//!     contacting it (Parslee-ai/car#1494).
32//!
33//! Repair is deliberately conservative: it purges *provably* corrupt cache files
34//! (so the next daemon run re-downloads them), backs up unparseable state files
35//! to `<name>.corrupt.bak` rather than deleting them, removes provably-empty
36//! journals, and refreshes the version stamp (reporting that only when the stamp
37//! actually moved). It never deletes unrecognized entries, partial downloads in
38//! the shared HuggingFace cache, or anything else it can't prove is bad —
39//! diagnosis names them and leaves the decision to the operator.
40
41use 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
49/// JSON state files CAR writes directly under `~/.car/`. Used both to validate
50/// each (does it parse? what schema version?) and to tell recognized files from
51/// possible leftovers. Owned across several crates, but the check is generic
52/// (parse as JSON), so no cross-crate type dependency is needed.
53const 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    // Names-only index for the OS-keychain secret store (car-ffi-common's
75    // `INDEX_FILE`), written on every platform whenever a secret is stored —
76    // and load-bearing on Windows/Linux, whose keychains have no portable
77    // enumeration. Absent on a fresh install with no stored secrets, which is
78    // why its omission here only surfaced once `car auth login` / `car keys`
79    // had run (e.g. validating on Windows): doctor wrongly flagged the live
80    // file as a possible older-install leftover and invited its removal.
81    "secret_index.json",
82    // Durable "this Parslee environment has no OpenRouter upstream" observation
83    // (car#786). Written by whichever process first gets that gateway answer, so
84    // it is absent until one does — same shape as secret_index.json above, and
85    // the same reason it belongs here: an unrecognized live state file gets
86    // reported as possible debris from an older install and the user is invited
87    // to delete it.
88    "gateway-state.json",
89    // Durable "the Parslee server rejected this credential" observation
90    // (car#887), the sibling of gateway-state.json above and here for exactly
91    // the same reason: written only once some process has actually been
92    // rejected, so it is absent on a healthy install and would otherwise be
93    // reported as older-install debris on precisely the machines where it is
94    // doing its job.
95    "parslee-credential-state.json",
96    // Written by car-inference::resource_policy for the local-model resource policy.
97    "model-resource-policy.json",
98    // Written by car-auth::authority_hint for prompt-free Parslee auth discovery.
99    "parslee-auth-authority.json",
100];
101
102/// Recognized files that are deliberately NOT JSON and must never be parsed or
103/// moved aside. `env` is a dotenv `KEY=VALUE` file (loaded by `env_loader`,
104/// holds API keys) — JSON-validating it would flag a healthy install and, under
105/// `--repair`, rename the user's secrets file to `env.corrupt.bak`.
106const KNOWN_NON_JSON_FILES: &[&str] = &[
107    "env",
108    // Written by car-server for the daemon's persistent peer signing identity.
109    "peer-identity.key",
110];
111
112/// Subdirectories CAR creates under `~/.car/`. Anything else at the top level is
113/// reported as unrecognized (a possible leftover), never auto-removed.
114const 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    // `car reason` state — created by car-cli, holds model_profiles.json. CAR
133    // makes it on the in-process path, so it exists on any machine that has run
134    // `car reason` once, and doctor was reporting a live directory as possible
135    // leftover debris from an older install.
136    "reason",
137    "doctor",
138    "bin",
139    "voiceprints",
140    "sync",
141    // Written by car-server-core::run_store for completed-proposal owner indexes.
142    "proposal-completed-index",
143    // Written by car-server's self-healing detection ledger.
144    "selfheal",
145];
146
147/// File-name suffixes for volatile/runtime artifacts that are never "leftovers":
148/// lockfiles, temp/backup files, binary caches, and append-only logs.
149const TOLERATED_SUFFIXES: &[&str] = &[".lock", ".tmp", ".bak", ".bin", ".jsonl"];
150
151/// The on-disk version stamp (`~/.car/version.json`). Written by the daemon on
152/// boot and refreshed by `doctor --repair`; read by `doctor` to detect skew
153/// between the binary and the state it's operating on.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct VersionStamp {
156    /// The CAR package version (`CARGO_PKG_VERSION`) that last wrote state.
157    pub car_version: String,
158    /// The state-schema generation. Bumped only on a breaking change to the
159    /// on-disk layout, independent of the package version. A reader newer than
160    /// this can migrate; older than this should refuse rather than corrupt.
161    pub state_schema_version: u32,
162    /// The version that wrote the state THIS stamp replaced — the predecessor
163    /// carried across the most recent upgrade.
164    ///
165    /// Without it, skew detection was dead on arrival (car#881): the daemon
166    /// stamps on every boot and CarHost is a login item, so by the time anyone
167    /// runs `car doctor` the stamp already reads as the running binary and the
168    /// evidence that older state exists is gone. Observed directly — a stamp
169    /// saying `0.47.0` beside an `agents.json` last written in the v0.39 era.
170    ///
171    /// Only updated when the version actually CHANGES. Rewriting it on every
172    /// boot would collapse it to the current version and lose the real
173    /// predecessor after one restart, which is the same bug one level down.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub previous_car_version: Option<String>,
176    /// The schema generation of the state this stamp replaced. Same update rule.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub previous_state_schema_version: Option<u32>,
179}
180
181/// The current state-schema generation this binary writes/expects. Start at 1;
182/// bump when a `~/.car` layout change requires migration.
183pub const STATE_SCHEMA_VERSION: u32 = 1;
184
185impl VersionStamp {
186    /// The stamp this binary would write on a machine with no prior state.
187    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    /// The stamp to write now, given what is already on disk.
197    ///
198    /// Carries the predecessor forward rather than overwriting it: an upgrade
199    /// records what it replaced, and every later boot on the same version keeps
200    /// that record instead of collapsing it to itself.
201    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/// What stamping observed. Returned so the daemon can act on an upgrade instead
219/// of silently erasing the evidence of one.
220#[derive(Debug, Clone)]
221pub struct StampTransition {
222    /// The stamp found on disk, if any. `None` on a fresh install — or when the
223    /// file was unreadable, which is deliberately not distinguished here: both
224    /// mean "no usable predecessor", and doctor reports an unparseable stamp
225    /// separately.
226    pub previous: Option<VersionStamp>,
227    /// What was written.
228    pub current: VersionStamp,
229}
230
231impl StampTransition {
232    /// The package version changed since state was last written.
233    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    /// On-disk state uses a schema generation NEWER than this binary
240    /// understands — i.e. a downgrade.
241    ///
242    /// This is the case `STATE_SCHEMA_VERSION`'s own contract calls out ("older
243    /// than this should refuse rather than corrupt") and that nothing acted on.
244    /// A user reverting to an older CAR would silently operate on state written
245    /// to a layout it does not know.
246    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
253/// Canonical CAR **state** directory. Mirrors the resolution every other part
254/// of CAR uses so `car doctor` always inspects the *same* directory the daemon
255/// and registry write to.
256///
257/// That invariant is the whole point, and it is why this now honors `CAR_HOME`.
258/// It used to refuse one, on the reasoning that "nothing else in CAR honors an
259/// override, and a doctor that diagnoses a different dir than the real install
260/// could report a corrupt install as healthy". The premise stopped being true
261/// when `CAR_HOME` shipped: for a relocated daemon, `$CAR_HOME` *is* the real
262/// install, and it is a doctor still reading `~/.car` that would grade the
263/// wrong directory. The conclusion is unchanged — diagnose the install that is
264/// actually running — so the resolution follows [`car_home::root_or_relative`]
265/// wherever it goes.
266///
267/// State only, though. The same "diagnose what is running" argument cuts the
268/// other way for model **weights**, which stay in the machine-shared
269/// `~/.car/models` no matter where the state root points: following the
270/// override there would make `car doctor` stop looking at the very cache the
271/// relocated daemon loads from. [`diagnose`] therefore pairs this with
272/// [`crate::default_models_dir`] instead of joining `models` onto it.
273pub fn car_home() -> PathBuf {
274    // `::` — this function shares its name with the crate it calls.
275    ::car_home::root_or_relative()
276}
277
278/// Write/refresh the `version.json` stamp under `car_home`. Called on daemon
279/// boot and by `doctor --repair`. Best-effort: a failure to stamp must never
280/// block startup, so the caller logs and continues.
281pub fn write_version_stamp(car_home: &Path) -> std::io::Result<()> {
282    stamp_version(car_home).map(|_| ())
283}
284
285/// Read the existing stamp, write the succeeding one, and return both.
286///
287/// Read-before-write is the whole point (car#881). The old `write_version_stamp`
288/// overwrote unconditionally, so the daemon destroyed the record of what wrote
289/// the state before anything could look at it — and `car doctor`, the only
290/// reader, runs solely when a human asks. Callers that care about an upgrade
291/// call this and inspect [`StampTransition`].
292pub 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    // Atomic-ish: write a per-process temp file in the same dir, then rename
299    // over. The pid-scoped name keeps two concurrent boots (or boot + repair)
300    // from interleaving writes to one shared temp and tearing version.json.
301    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
310/// Subdirectory of the state root for doctor-facing status records. It is in
311/// [`KNOWN_DIRS`], so nothing written here is reported as older-install debris.
312const DOCTOR_DIR: &str = "doctor";
313
314/// The daemon's startup TMPDIR probe record, under [`DOCTOR_DIR`].
315const DAEMON_TMPDIR_MARKER_FILE: &str = "daemon-tmpdir.json";
316
317/// Bytes the TMPDIR probe writes. Tiny on purpose: the probe proves the
318/// directory accepts create and write (and, on Unix, identity-safe remove).
319const TMPDIR_PROBE_BYTES: &[u8] = b"car tmpdir probe\n";
320
321/// What a daemon found when it probed its inherited `TMPDIR` at startup
322/// (Parslee-ai/car#1494).
323///
324/// Every agent invocation creates a fresh scratch directory under the daemon's
325/// `TMPDIR`. A daemon launched from inside the macOS installer can inherit a
326/// `PKInstallSandbox` path that disappears when the install ends, and it keeps
327/// that dead path for its whole lifetime, so every invocation fails while
328/// `car doctor`, which never contacts the daemon, could not see why. The daemon
329/// now leaves this record at `<CAR_HOME>/doctor/daemon-tmpdir.json`.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct DaemonTmpdirMarker {
332    /// Process id of the daemon that ran the probe.
333    pub pid: u32,
334    /// When that daemon ran the probe at startup, in Unix seconds.
335    pub booted_at_unix: u64,
336    /// CAR version of that daemon.
337    pub car_version: String,
338    /// The temp directory the daemon resolved (`std::env::temp_dir()`, which
339    /// follows `TMPDIR` on Unix).
340    pub checked_path: String,
341    /// The create/write probe succeeded, including identity-safe remove on Unix.
342    pub ok: bool,
343    /// The failing step and its OS error, when `ok` is false.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub error: Option<String>,
346}
347
348/// How doctor classifies a daemon TMPDIR record without contacting the daemon.
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case", tag = "status")]
351pub enum DaemonTmpdirStatus {
352    /// The daemon that wrote the record is still running, so this is the
353    /// running daemon's result. A failed probe here makes the report unhealthy.
354    Current { marker: DaemonTmpdirMarker },
355    /// The daemon that wrote the record is no longer running: no daemon is up,
356    /// or a later one has not recorded. Shown, never a health failure.
357    Stale { marker: DaemonTmpdirMarker },
358    /// This platform has no liveness check, so the record cannot be tied to a
359    /// running daemon. Shown with its result, never a health failure.
360    Unverified { marker: DaemonTmpdirMarker },
361    /// A record exists but could not be read or parsed. Informational: the
362    /// next daemon start replaces it.
363    Unreadable { error: String },
364}
365
366/// The daemon TMPDIR record as `car doctor` reports it.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct DaemonTmpdirCheck {
369    /// Where the record lives (`<CAR_HOME>/doctor/daemon-tmpdir.json`).
370    pub marker_path: String,
371    #[serde(flatten)]
372    pub status: DaemonTmpdirStatus,
373}
374
375impl DaemonTmpdirCheck {
376    /// Only the running daemon's own failed probe is a problem. Stale,
377    /// unverified and unreadable records are informational.
378    pub fn is_failing(&self) -> bool {
379        matches!(&self.status, DaemonTmpdirStatus::Current { marker } if !marker.ok)
380    }
381}
382
383/// Where the daemon TMPDIR record lives under a state root.
384pub fn daemon_tmpdir_marker_path(car_home: &Path) -> PathBuf {
385    car_home.join(DOCTOR_DIR).join(DAEMON_TMPDIR_MARKER_FILE)
386}
387
388/// Probe `tmpdir` and record the result under `<car_home>/doctor/`. The daemon
389/// calls this once at startup with `std::env::temp_dir()`.
390///
391/// The probe exclusively creates one uniquely named file in `tmpdir` (an
392/// existing entry, symlink or not, is never opened), writes a few bytes, and,
393/// on Unix, removes that file only after checking the path still names the file
394/// it created. Off Unix, stable `std` exposes no portable file identity, so the
395/// tiny probe file is deliberately left behind rather than risking deletion of
396/// a substituted entry. The record is written to a private temporary file
397/// beside the previous one and atomically replaced with CAR's no-follow state
398/// helpers, so a reader sees the old record or the new one, never a torn one.
399///
400/// A failed probe is still a successful record: `Ok` with `ok: false`. `Err`
401/// means the record itself could not be written. Either way the caller carries
402/// on, because this is a diagnostic and never a startup gate.
403pub 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
411/// Run only the filesystem round trip for the daemon TMPDIR probe.
412///
413/// Startup runs this on a dedicated thread, then records the returned outcome
414/// itself. Keeping marker publication on the startup side prevents an abandoned
415/// timed-out worker from later replacing the timeout record if its filesystem
416/// operation eventually returns.
417pub fn probe_daemon_tmpdir(tmpdir: &Path) -> Result<(), String> {
418    probe_tmpdir(tmpdir, std::process::id())
419}
420
421/// Record an already-completed (or timed-out) daemon TMPDIR probe.
422///
423/// `error` is `None` for success and names the failed step otherwise. This is
424/// separate from [`probe_daemon_tmpdir`] so startup can impose a deadline on
425/// TMPDIR I/O and still persist a timeout result from the non-blocked side.
426pub 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
446/// Read the daemon TMPDIR record: `None` when there is none, `Some(Err)` when
447/// one exists but cannot be read or parsed.
448pub 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
457/// A name suffix no earlier probe or record write used: the pid plus the
458/// current time in nanoseconds.
459fn 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
467/// One create/write/remove round trip in `tmpdir`, the operations an agent
468/// scratch workspace needs. Returns the failing step and its error.
469fn 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    // Follow directory symlinks by design: macOS normally reports TMPDIR below
474    // /var/folders, while /var itself is a symlink to /private/var. The probe
475    // must exercise the directory the daemon will actually use. Only the new
476    // probe entry itself is protected against symlink substitution.
477    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    // A failed handle stat must not short-circuit cleanup handling. Without the
489    // created identity no deletion is safe, so report explicitly that the file
490    // was left rather than silently leaking it.
491    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/// Remove the probe file, but only while `path` still names the regular file
520/// the probe created. Anything else at that path is left alone.
521#[cfg(unix)]
522fn remove_probe_file(path: &Path, created: &std::fs::Metadata) -> Result<(), String> {
523    use std::os::unix::fs::MetadataExt;
524
525    // This is intentionally the last check before unlink: compare the open
526    // handle's (dev, ino) with no-follow path metadata, and reject symlinks.
527    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    // Stable std has no portable identity comparison off Unix. A successful
542    // create/write is enough to validate TMPDIR; leave the tiny uniquely named
543    // file rather than unlinking a path an attacker could have substituted.
544    Ok(())
545}
546
547/// Write `marker` to a new private temporary file beside the record and use
548/// CAR's no-follow atomic state-file replacement to publish it.
549fn 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    // Unlike TMPDIR, CAR state directories must never be followed through a
554    // symlink. This helper securely walks and validates the path on Unix and
555    // Windows rather than relying on a check followed by a path-based open.
556    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    // The state helper uses create-new plus O_NOFOLLOW/reparse-point checks and
565    // validates the opened file. On a later error the private temp is left in
566    // place: path-based cleanup would reintroduce the substitution race these
567    // helpers close.
568    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
573/// Classify a record. `writer_alive` is `Some` where this platform can check
574/// the writer's pid, `None` where it cannot.
575fn 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
586/// Whether the daemon that wrote a record is still running, where that can be
587/// checked without contacting it.
588///
589/// Staleness rests on the pid alone, never on the recorded time. A daemon
590/// records only after it holds the primary-daemon lock for its `CAR_HOME`, so a
591/// later boot replaces the record, and a record whose writer is gone means no
592/// daemon is running or the running one has not recorded.
593fn 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/// `kill(pid, 0)`, the same rule as `wait_for_parent_exit` in
606/// `car-server/src/main.rs`: signal 0 sends nothing and only checks existence;
607/// 0 or `EPERM` (alive, but not ours to signal) means alive, anything else
608/// means gone.
609///
610/// Not proof against pid reuse, accepted exactly as that code accepts it: if
611/// the writer exited and the OS recycled its pid onto an unrelated process,
612/// this reads "alive" and a dead daemon's result is reported as current. Pids
613/// are allocated sequentially, so that needs the counter to wrap onto the old
614/// value, and re-running `car doctor` after relaunching CarHost clears it.
615#[cfg(unix)]
616fn unix_pid_alive(pid: u32) -> bool {
617    // kill(2) reads 0 and negative pids as process groups, not a process.
618    let Ok(pid) = libc::pid_t::try_from(pid) else {
619        return false;
620    };
621    if pid <= 0 {
622        return false;
623    }
624    // SAFETY: signal 0 delivers nothing; kill only checks that `pid` exists.
625    let rc = unsafe { libc::kill(pid, 0) };
626    rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
627}
628
629/// The daemon TMPDIR record as doctor reports it; `None` when there is none.
630fn 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/// Options controlling a diagnosis run.
645#[derive(Debug, Clone, Default)]
646pub struct DoctorOptions {
647    /// Deep-verify model weights (recompute sha256 vs etag) instead of the cheap
648    /// resolves-and-non-empty check. Slow (hashes every weight) but catches
649    /// truncated-but-non-empty corruption.
650    pub deep: bool,
651    /// Apply safe repairs: purge corrupt cache files, back up unparseable state,
652    /// refresh the version stamp.
653    pub repair: bool,
654}
655
656/// Verdict for a single `~/.car` JSON state file.
657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658#[serde(rename_all = "snake_case", tag = "status")]
659pub enum StateFileStatus {
660    /// Not present — fine; most state files are created on first use.
661    Absent,
662    /// Present and parses as JSON.
663    Ok { schema_version: Option<u32> },
664    /// Present but does not parse — corrupt or written by an incompatible
665    /// version. `backed_up_to` is set when `--repair` moved it aside.
666    Unparseable {
667        error: String,
668        backed_up_to: Option<String>,
669    },
670}
671
672/// One state-file check.
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct StateFileCheck {
675    pub name: String,
676    #[serde(flatten)]
677    pub status: StateFileStatus,
678}
679
680/// Verdict for one installed model directory.
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case", tag = "status")]
683pub enum ModelStatus {
684    /// Weight files present and (for `--deep`) hash-verified.
685    Healthy,
686    /// At least one weight file is corrupt/missing. `purged` is how many were
687    /// removed in `--repair` mode (so the next pull re-downloads them).
688    Corrupt {
689        bad_files: Vec<String>,
690        purged: usize,
691    },
692    /// The directory looks like a model install — it has `config.json` /
693    /// tokenizer stubs — but carries no resolvable weights at all. This is what
694    /// an interrupted `car models pull` leaves behind, and it used to be
695    /// invisible: `check_one_model` returned `None` for any dir with no weight
696    /// files, so the report dropped it and `car doctor` said "none installed /
697    /// Healthy" over a broken install (Parslee-ai/car#616).
698    ///
699    /// Note the loader already knew: `registry::ensure_local` gates reuse on
700    /// `mlx_dir_has_weights` and re-downloads a config-only stub
701    /// (car-releases#391). The diagnostic just disagreed with the runtime.
702    Incomplete { detail: String },
703}
704
705/// A file left behind by an interrupted download.
706///
707/// Reported, never deleted — see [`find_leftovers`] for why.
708#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
709pub struct Leftover {
710    pub path: String,
711    pub bytes: u64,
712}
713
714/// One model check.
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct ModelCheck {
717    pub name: String,
718    #[serde(flatten)]
719    pub status: ModelStatus,
720}
721
722/// Health of one CAR-managed Python runtime venv (`speech-runtime`,
723/// `visual-runtime`).
724///
725/// These venvs are how CAR runs every model architecture its in-process Rust
726/// MLX backend does not implement, so a broken one silently removes the entire
727/// external-runtime shelf — vision, speech, and every newer LLM family — while
728/// the directory still looks fully populated on disk. A venv's `bin/python` is
729/// an absolute symlink into the interpreter that built it, so an ordinary
730/// Homebrew upgrade (`python@3.13` rotated away) is enough to do it.
731#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct RuntimeCheck {
733    /// Directory name under `CAR_HOME` (e.g. `visual-runtime`).
734    pub name: String,
735    pub root: String,
736    /// The venv directory exists. A runtime that was never provisioned is not
737    /// unhealthy — it is simply absent, and provisions on first use.
738    pub present: bool,
739    /// The venv's interpreter actually runs. Only meaningful when `present`.
740    pub interpreter_ok: bool,
741}
742
743impl RuntimeCheck {
744    /// A runtime is broken when it exists but cannot run anything. Absent is
745    /// fine (lazy-provisioned); present-and-working is fine.
746    pub fn is_broken(&self) -> bool {
747        self.present && !self.interpreter_ok
748    }
749}
750
751/// The full diagnosis.
752#[derive(Debug, Clone, Serialize, Deserialize)]
753pub struct DoctorReport {
754    pub car_home: String,
755    /// The version of the binary that produced this report.
756    pub binary_version: String,
757    /// The version stamp found on disk, if any (None ⇒ never stamped).
758    pub on_disk_stamp: Option<VersionStamp>,
759    /// True when the on-disk stamp's version differs from the binary.
760    ///
761    /// Near-useless on a machine that runs the daemon: it stamps at boot, so
762    /// this reads false from then on. [`DoctorReport::carried_from_version`] is
763    /// the field that survives (car#881).
764    pub version_skew: bool,
765    /// The version that wrote the state this install carried across its most
766    /// recent upgrade, when the stamp recorded one.
767    ///
768    /// Unlike `version_skew` this outlives the daemon's boot stamp, so "state
769    /// here predates the running binary" stays answerable rather than being
770    /// erased seconds after it became true.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub carried_from_version: Option<String>,
773    /// On-disk state uses a schema generation NEWER than this binary knows —
774    /// a downgrade. `STATE_SCHEMA_VERSION` documents that a reader older than
775    /// the state "should refuse rather than corrupt"; nothing enforced it.
776    #[serde(default)]
777    pub schema_from_the_future: bool,
778    pub state_files: Vec<StateFileCheck>,
779    pub models: Vec<ModelCheck>,
780    /// Models whose CAR-managed weights are physically ready, using the same
781    /// registry predicate as `models.list_unified`.
782    #[serde(default)]
783    pub installed_models: usize,
784    /// Partial downloads abandoned in the HuggingFace cache. Informational —
785    /// they waste disk but nothing is broken, so they don't affect
786    /// [`DoctorReport::is_healthy`].
787    #[serde(default)]
788    pub leftovers: Vec<Leftover>,
789    /// Health of the CAR-managed Python runtime venvs. Absent runtimes are
790    /// omitted; only ones that exist are reported.
791    #[serde(default)]
792    pub runtimes: Vec<RuntimeCheck>,
793    /// Top-level `~/.car` entries this version doesn't recognize.
794    pub unrecognized: Vec<String>,
795    /// Human-readable actions taken in `--repair` mode (empty otherwise).
796    pub repairs: Vec<String>,
797    /// The daemon's startup TMPDIR probe (Parslee-ai/car#1494), read from
798    /// `<CAR_HOME>/doctor/daemon-tmpdir.json`. `None` when no daemon has
799    /// recorded one, which is not an error; the key is then omitted, so a report
800    /// without a record serializes exactly as before.
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub daemon_tmpdir: Option<DaemonTmpdirCheck>,
803}
804
805impl DoctorReport {
806    /// True when nothing actionable was found (modulo unrecognized entries,
807    /// which are informational).
808    pub fn is_healthy(&self) -> bool {
809        // `carried_from_version` is deliberately NOT a health signal — carrying
810        // state across an upgrade is the normal case, and flagging it would make
811        // every upgraded install permanently "unhealthy". Running against state
812        // from a schema this binary does not know IS a problem.
813        !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            // Only the running daemon's own failed TMPDIR probe counts. A stale,
825            // unverified or unreadable record is informational.
826            && !self
827                .daemon_tmpdir
828                .as_ref()
829                .is_some_and(DaemonTmpdirCheck::is_failing)
830    }
831}
832
833/// Run a diagnosis (and, if `opts.repair`, repairs) against the install that is
834/// actually running: state under [`car_home`](fn@car_home), weights under the shared cache.
835///
836/// The two are the same tree by default and separate under `CAR_HOME`, which is
837/// why they are passed separately. A relocated daemon still loads its weights
838/// from `~/.car/models` — that cache is machine-global on purpose — so a doctor
839/// that only looked under the state root would silently stop checking the
840/// weights, which is the failure mode `car doctor` mainly exists to catch.
841pub fn diagnose(opts: &DoctorOptions) -> DoctorReport {
842    diagnose_at(&car_home(), &crate::default_models_dir(), opts)
843}
844
845/// Diagnosis against an explicit base dir, weights assumed at `<home>/models` —
846/// the testable core, and the shape every caller wanted before `CAR_HOME` could
847/// separate the two.
848pub fn diagnose_in(home: &Path, opts: &DoctorOptions) -> DoctorReport {
849    diagnose_at(home, &home.join("models"), opts)
850}
851
852/// Diagnosis with the state root and the weights cache named independently.
853pub 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, &registry, None)
862}
863
864/// Deterministic diagnosis seam for cross-crate tests.
865///
866/// This stays `#[doc(hidden)]` instead of feature-gated because dependency
867/// crates' integration tests link `car-inference` without inheriting `cfg(test)`.
868/// Unlike [`diagnose_at`], this loads only local models discovered below the
869/// explicit weights directory, scans only the explicit Hugging Face hub root,
870/// and never consults a credential/session probe. Production feedback and
871/// doctor paths deliberately continue to use [`diagnose_at`].
872#[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        &registry,
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    // --- version stamp / skew ------------------------------------------------
902    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    // Survives the daemon's boot stamp; `version_skew` does not.
912    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    // --- state files ---------------------------------------------------------
920    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    // --- models --------------------------------------------------------------
926    let models = check_models(models_dir, opts, &mut repairs);
927    // Kept separate from `models`: those entries are per-directory integrity
928    // checks, while this is the registry's physical installed-model count.
929    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    // --- leftovers -----------------------------------------------------------
943    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    // --- refresh stamp on repair --------------------------------------------
950    if opts.repair {
951        // Only *report* a stamp refresh when the stamp actually moved. It was
952        // rewritten and announced unconditionally, so `car doctor --repair` on
953        // a perfectly healthy install always printed a line under "Repairs:",
954        // implying something had been wrong (Parslee-ai/car#626). The write
955        // still happens either way — it's cheap and makes a missing stamp
956        // appear — but a no-op write is not a repair.
957        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    // --- reap empty journals on repair ---------------------------------------
975    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    // --- managed Python runtimes --------------------------------------------
991    let runtimes = check_runtimes(home);
992
993    // --- daemon TMPDIR probe record (car#1494) --------------------------------
994    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
1014/// Directory names under `CAR_HOME` that hold a CAR-managed `uv` venv.
1015const MANAGED_RUNTIMES: &[&str] = &["speech-runtime", "visual-runtime"];
1016
1017/// Check each managed Python runtime that has actually been provisioned.
1018///
1019/// Absent runtimes are skipped rather than reported unhealthy: they provision
1020/// on first use, so "not there yet" is the normal state on a fresh install and
1021/// flagging it would make every new machine read as broken.
1022fn 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        // JSONL (one JSON value per line) must be validated line-by-line — the
1059        // whole file is not a single JSON document.
1060        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
1082/// Validate each non-empty line of a JSONL file; return the first parse error
1083/// (with its line number) or `None` if all lines are valid JSON.
1084fn 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
1096/// Shared handling for an unparseable state file: in repair mode, move it aside
1097/// to `<name>.corrupt.bak` (never delete) so the owning crate writes a fresh
1098/// default on next run; otherwise just record the error.
1099fn 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        // Don't clobber a previous backup — keeps the "never delete" promise
1108        // honest if the same file goes corrupt twice. First backup gets the
1109        // plain name; a collision falls back to an epoch-suffixed one.
1110        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        // Skip dirs with no weight files of their own: a managed MLX dir is
1155        // often just a config stub whose weights live in the HF snapshot cache,
1156        // and flagging that as broken would be a false positive. We only assess
1157        // weights that are actually present here.
1158        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        // No weight file of any kind — not even a dangling symlink (those DO
1175        // show up in `weight_files` and are caught as Corrupt below). Two very
1176        // different situations share this shape:
1177        //
1178        //   1. the dir isn't a model install at all — none of our business, and
1179        //      flagging it would be the false positive the old blanket `None`
1180        //      was protecting against;
1181        //   2. an install that got interrupted before its weights landed:
1182        //      `config.json` and the tokenizer stubs are there, the
1183        //      `*.safetensors` never arrived.
1184        //
1185        // Case 2 is exactly what a Ctrl-C'd `car models pull` leaves, and
1186        // returning `None` for it is what made a broken install read as
1187        // "Healthy" (Parslee-ai/car#616). Tell them apart on whether the dir
1188        // carries a model manifest.
1189        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
1232/// Weight files (`.safetensors` / `.gguf`) anywhere under a model dir. Recurses
1233/// to full depth so detection matches `purge_corrupt_cache_files`' recursion —
1234/// a corrupt weight nested ≥2 levels down (uncommon but possible) is still seen.
1235/// Cheap: no hashing here, just extension matching.
1236/// Is this a managed dir left half-built by an interrupted pull?
1237///
1238/// Called only for dirs with no weight file of any kind. Distinguishing an
1239/// interrupted install from a directory that was never a model needs care,
1240/// because over-eager flagging here is a known past false positive — see
1241/// `config_only_stub_is_skipped_not_flagged`, which pins a hand-made
1242/// config-only dir as *not* broken.
1243///
1244/// The discriminator is **how the manifest got there**. `car models pull`
1245/// populates a managed dir by symlinking into the HuggingFace snapshot cache
1246/// (`registry.rs`, "try symlink first"), writing the small config/tokenizer
1247/// files before the multi-gigabyte weights. So a dir that holds symlinked
1248/// manifest files but no resolvable weights is one CAR built and did not
1249/// finish — the exact residue of a Ctrl-C'd pull. A hand-made stub, or one
1250/// created by the copy fallback, has real files and no symlinks, and is left
1251/// alone.
1252///
1253/// Deliberately conservative: it under-reports (a stub whose links were later
1254/// cleaned up reads as "not a model") rather than resurrecting the false
1255/// positive. Weight *presence* uses the same `mlx_dir_has_weights` predicate
1256/// the loader gates on, so the diagnosis and the runtime agree — the whole
1257/// point of Parslee-ai/car#616, where `ensure_local` re-downloaded a stub the
1258/// doctor was calling healthy.
1259fn 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
1276/// Zero-byte `*.jsonl` files under `~/.car/journals/`.
1277///
1278/// The daemon opens a journal per session; one that never executes a proposal
1279/// leaves an empty file behind and nothing reaps it, so they accumulate
1280/// indefinitely — 35 of 43 on the install that prompted this
1281/// (Parslee-ai/car#626).
1282///
1283/// Unlike the HuggingFace partials, these ARE safe for `--repair` to delete:
1284/// they live in CAR's own directory, a zero-length journal provably holds no
1285/// events, and `EventLog::load` on a missing file behaves the same as on an
1286/// empty one. Only exactly-zero-length files qualify — anything with a byte in
1287/// it is left alone.
1288fn 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
1307/// Partial downloads abandoned in the HuggingFace cache (`*.sync.part`).
1308///
1309/// Reported, **never deleted**, for two reasons. The shared HF cache belongs to
1310/// every tool on the machine, not just CAR (managed model dirs are only
1311/// symlinks into it), and a `.sync.part` may belong to a download that is
1312/// running *right now* — removing it would corrupt a live transfer. That also
1313/// keeps faith with `--repair`'s documented promise to never delete "anything
1314/// not provably corrupt": an in-flight partial is not corrupt, it is unfinished.
1315/// Surfacing the path and the size is the part that was missing
1316/// (Parslee-ai/car#616) — the operator decides.
1317///
1318/// Scans only `<cache>/hub/*/blobs/`, where hf-hub puts them, so this stays
1319/// cheap on a large cache rather than walking the whole tree.
1320fn 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            // Apparent size can far exceed blocks actually allocated (these are
1345            // written sparsely); `len()` is what the operator sees in `ls -lh`.
1346            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        // `file_type` doesn't follow symlinks, so weight *symlinks* (the normal
1371        // case) are seen as files, not recursed into — only real subdirs recurse.
1372        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                    // Volatile runtime artifacts (locks, temps, backups, binary
1396                    // caches, append-only logs) are not "leftovers".
1397                    || TOLERATED_SUFFIXES.iter().any(|s| name.ends_with(s))
1398                    // Hidden dotfiles are config/runtime, not leftovers.
1399                    || 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        // The names-only secret index is a live current-version file (written by
1459        // the OS-keychain store on every platform). doctor once flagged it as a
1460        // possible older-install leftover and told the user to remove it — only
1461        // visible once a secret had been stored, e.g. after `car auth login` on
1462        // Windows. It must be recognized state, and it must parse as JSON.
1463        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        // Read-only: flagged, not moved.
1485        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        // Repair: moved to .corrupt.bak, original gone.
1505        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        // ~/.car/env is dotenv (KEY=VALUE), not JSON. It must be recognized
1525        // (not a leftover), never flagged Unparseable, and never moved aside by
1526        // --repair (it holds secrets).
1527        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    /// Parslee-ai/car#616 — the residue of an interrupted `car models pull`:
1568    /// the small manifest files got symlinked into the HF snapshot, the weights
1569    /// never arrived. Observed live as `~/.car/models/Qwen3-4B-MLX/` holding
1570    /// three symlinks and nothing else, while `car doctor --deep --repair`
1571    /// reported "none installed / ✓ Healthy".
1572    #[test]
1573    #[cfg(unix)]
1574    fn interrupted_pull_is_reported_not_skipped() {
1575        let tmp = TempDir::new().unwrap();
1576        // Stand in for the HF snapshot the manifests link into.
1577        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        // No *.safetensors anywhere — the pull died before the weights.
1587
1588        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    /// The other half of #616: abandoned partial downloads were invisible.
1606    /// Reported with sizes, and deliberately never deleted — the HF cache is
1607    /// shared, and a `.sync.part` may belong to a live transfer.
1608    #[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        // `find_leftovers` reads HF_HOME through the registry's cache resolver.
1619        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        // Wasted disk is informational, not breakage.
1641        assert!(r.is_healthy());
1642    }
1643
1644    /// Parslee-ai/car#626 — `--repair` on a healthy install listed "refreshed
1645    /// version stamp" every time, implying it had fixed something.
1646    #[test]
1647    fn repair_does_not_report_an_unchanged_version_stamp() {
1648        let tmp = TempDir::new().unwrap();
1649        // First repair on an unstamped home: the stamp genuinely appears.
1650        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        // Second repair, nothing changed: silence.
1657        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    /// Parslee-ai/car#626 — a journal per session that never executed anything
1666    /// left an empty file nobody reaped (35 of 43 on the reporting install).
1667    /// Safe for `--repair`: zero length provably means zero events.
1668    #[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        // Read-only: nothing removed.
1681        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        // A managed dir with only a config (weights live in the HF cache) must
1698        // NOT be reported as broken — that was a false positive.
1699        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        // A dangling weight symlink reads as corrupt under the cheap check.
1718        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            &registry,
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    /// Exercises the known-non-JSON branch: invalid UTF-8 must bypass
1832    /// `check_state_file` (whose read-to-string error branch reports Absent),
1833    /// remain recognized by `find_unrecognized`, and survive repair untouched.
1834    #[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        // Repair must NOT delete unrecognized entries.
1878        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        // Repair refreshes the stamp to the current binary, clearing skew next run.
1901        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    /// The car#881 bug, as a test. A daemon boot used to overwrite the stamp
1930    /// unconditionally, so the fact that an older CAR wrote this state was gone
1931    /// before `car doctor` — the only reader — could ever be run.
1932    #[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    /// The subtler half. Rewriting `previous` on every boot would collapse it to
1954    /// the current version after one restart — the same erasure, one level down.
1955    #[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(); // the upgrade
1961        for _ in 0..5 {
1962            stamp_version(dir.path()).unwrap(); // ordinary reboots
1963        }
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    /// A downgrade. STATE_SCHEMA_VERSION's contract says a reader older than the
1984    /// state should refuse rather than corrupt; nothing acted on it before.
1985    #[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        // And it must be visible to doctor as unhealthy, not merely noted.
1997        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    /// Carrying state across an upgrade is the NORMAL case. Treating it as a
2004    /// health problem would make every upgraded install permanently unhealthy,
2005    /// which is how a warning gets ignored.
2006    #[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    /// An older stamp has no `previous_*` keys at all. It must deserialize, not
2018    /// blow up the boot path.
2019    #[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    /// A corrupt stamp must not stop the daemon stamping — it is best-effort by
2038    /// contract, and refusing to boot over an unreadable version file would be a
2039    /// far worse failure than the one it guards.
2040    #[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    /// A never-provisioned runtime is absent, not broken — a fresh install must
2057    /// not read as unhealthy.
2058    #[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    /// The failure this check exists for: the venv directory is fully populated
2065    /// but its interpreter symlink points at a Homebrew formula that is gone.
2066    // Unix-only because the *setup* needs a POSIX symlink, not because the
2067    // behaviour is. Same shape as `interrupted_pull_is_reported_not_skipped`.
2068    #[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    /// A broken runtime must sink `is_healthy` — the whole point is that this
2089    /// stops being silent.
2090    #[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/// Parslee-ai/car#1494: the daemon's startup TMPDIR probe record, written by
2121/// [`record_daemon_tmpdir_probe`] and read by the offline doctor. Temp dirs and
2122/// injected paths only.
2123#[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    /// Doctor against `home`, with the weights and Hugging Face roots isolated
2148    /// in temp dirs so nothing outside the test is read.
2149    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    /// A pid that named a real process a moment ago and no longer does: a
2164    /// short-lived child, spawned and reaped.
2165    #[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    /// Off Unix there is no liveness check: the record is shown with its
2435    /// result and never folded into health as a failure.
2436    #[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}