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 (plus identity-safe remove on Unix and
319/// delete-on-close cleanup on Windows).
320const TMPDIR_PROBE_BYTES: &[u8] = b"car tmpdir probe\n";
321
322/// What a daemon found when it probed its inherited `TMPDIR` at startup
323/// (Parslee-ai/car#1494).
324///
325/// Every agent invocation creates a fresh scratch directory under the daemon's
326/// `TMPDIR`. A daemon launched from inside the macOS installer can inherit a
327/// `PKInstallSandbox` path that disappears when the install ends, and it keeps
328/// that dead path for its whole lifetime, so every invocation fails while
329/// `car doctor`, which never contacts the daemon, could not see why. The daemon
330/// now leaves this record at `<CAR_HOME>/doctor/daemon-tmpdir.json`.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct DaemonTmpdirMarker {
333    /// Process id of the daemon that ran the probe.
334    pub pid: u32,
335    /// When that daemon ran the probe at startup, in Unix seconds.
336    pub booted_at_unix: u64,
337    /// CAR version of that daemon.
338    pub car_version: String,
339    /// Display form of the temp directory the daemon resolved
340    /// (`std::env::temp_dir()`, which follows `TMPDIR` on Unix). Retained for
341    /// human output and compatibility with legacy records.
342    pub checked_path: String,
343    /// Exact absolute path used for later rechecks, when it can be represented
344    /// losslessly as UTF-8. Older records and non-UTF-8 paths omit this field;
345    /// doctor then skips the recheck rather than guessing from `checked_path`.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub checked_path_absolute: Option<String>,
348    /// The create/write probe succeeded. Cleanup failures are also failures,
349    /// except when handle stat itself failed and cleanup could not be attempted;
350    /// that narrower case is retained in [`Self::note`].
351    pub ok: bool,
352    /// The failing step and its OS error, when `ok` is false.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub error: Option<String>,
355    /// A non-failing diagnostic, such as a handle-stat failure after create and
356    /// write had already proved that the TMPDIR works.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub note: Option<String>,
359}
360
361/// How doctor classifies a daemon TMPDIR record without contacting the daemon.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case", tag = "status")]
364pub enum DaemonTmpdirStatus {
365    /// The daemon that wrote the record is still running, so this is the
366    /// running daemon's result. A failed probe here makes the report unhealthy.
367    Current { marker: DaemonTmpdirMarker },
368    /// The running daemon's probe succeeded, but the checked directory has
369    /// disappeared since startup. This makes the report unhealthy without
370    /// misreporting the original probe as failed.
371    CurrentPathGone { marker: DaemonTmpdirMarker },
372    /// The running daemon's probe succeeded, but doctor could not stat the
373    /// exact path later. Failing closed prevents a permission error from being
374    /// rendered as OK while agent scratch creation fails.
375    CurrentPathRecheckFailed {
376        marker: DaemonTmpdirMarker,
377        error: String,
378    },
379    /// The daemon that wrote the record is no longer running: no daemon is up,
380    /// or a later one has not recorded. Shown, never a health failure.
381    Stale { marker: DaemonTmpdirMarker },
382    /// This platform has no liveness check, so the record cannot be tied to a
383    /// running daemon. Shown with its result, never a health failure.
384    Unverified { marker: DaemonTmpdirMarker },
385    /// A record exists but could not be read or parsed. Informational: the
386    /// next daemon start replaces it.
387    Unreadable { error: String },
388}
389
390/// The daemon TMPDIR record as `car doctor` reports it.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct DaemonTmpdirCheck {
393    /// Where the record lives (`<CAR_HOME>/doctor/daemon-tmpdir.json`).
394    pub marker_path: String,
395    #[serde(flatten)]
396    pub status: DaemonTmpdirStatus,
397}
398
399impl DaemonTmpdirCheck {
400    /// A running daemon's failed probe, vanished TMPDIR, or failed path recheck
401    /// is a problem. Stale, unverified and unreadable records are informational.
402    pub fn is_failing(&self) -> bool {
403        matches!(
404            &self.status,
405            DaemonTmpdirStatus::Current { marker } if !marker.ok
406        ) || matches!(
407            &self.status,
408            DaemonTmpdirStatus::CurrentPathGone { .. }
409                | DaemonTmpdirStatus::CurrentPathRecheckFailed { .. }
410        )
411    }
412}
413
414/// Where the daemon TMPDIR record lives under a state root.
415pub fn daemon_tmpdir_marker_path(car_home: &Path) -> PathBuf {
416    car_home.join(DOCTOR_DIR).join(DAEMON_TMPDIR_MARKER_FILE)
417}
418
419/// Probe `tmpdir` and record the result under `<car_home>/doctor/`. The daemon
420/// calls this once at startup with `std::env::temp_dir()`.
421///
422/// The probe exclusively creates one uniquely named file in `tmpdir` (an
423/// existing entry, symlink or not, is never opened) and writes a few bytes. On
424/// Unix it removes that file only after checking the path still names the file
425/// it created; on Windows the open handle requests delete-on-close, avoiding a
426/// second path lookup. Other platforms leave the tiny probe file behind because
427/// stable `std` exposes no portable file identity. The record is written to a
428/// private temporary file beside the previous one and atomically replaced with
429/// CAR's no-follow state helpers, so a reader sees the old record or the new
430/// one, never a torn one.
431///
432/// A failed probe is still a successful record: `Ok` with `ok: false`. `Err`
433/// means the record itself could not be written. Either way the caller carries
434/// on, because this is a diagnostic and never a startup gate.
435pub fn record_daemon_tmpdir_probe(
436    car_home: &Path,
437    tmpdir: &Path,
438) -> std::io::Result<DaemonTmpdirMarker> {
439    record_daemon_tmpdir_probe_result(car_home, tmpdir, probe_daemon_tmpdir(tmpdir))
440}
441
442/// Run only the filesystem round trip for the daemon TMPDIR probe.
443///
444/// Startup runs this on a dedicated thread, then records the returned outcome
445/// itself. Keeping marker publication on the startup side prevents an abandoned
446/// timed-out worker from later replacing the timeout record if its filesystem
447/// operation eventually returns.
448pub fn probe_daemon_tmpdir(tmpdir: &Path) -> Result<Option<String>, String> {
449    probe_tmpdir(tmpdir, std::process::id())
450}
451
452/// Record an already-completed (or timed-out) daemon TMPDIR probe.
453///
454/// `result` is `Ok(None)` for an unqualified success, `Ok(Some(note))` for a
455/// working TMPDIR with a non-failing diagnostic, and `Err(error)` for failure.
456/// This is separate from [`probe_daemon_tmpdir`] so startup can impose a
457/// deadline on TMPDIR I/O and still persist a timeout result from the
458/// non-blocked side.
459pub fn record_daemon_tmpdir_probe_result(
460    car_home: &Path,
461    tmpdir: &Path,
462    result: Result<Option<String>, String>,
463) -> std::io::Result<DaemonTmpdirMarker> {
464    record_daemon_tmpdir_probe_result_with_cwd(car_home, tmpdir, result, std::env::current_dir())
465}
466
467fn record_daemon_tmpdir_probe_result_with_cwd(
468    car_home: &Path,
469    tmpdir: &Path,
470    result: Result<Option<String>, String>,
471    current_dir: std::io::Result<PathBuf>,
472) -> std::io::Result<DaemonTmpdirMarker> {
473    let (error, mut note) = match result {
474        Ok(note) => (None, note),
475        Err(error) => (Some(error), None),
476    };
477    let (checked_path_absolute, path_note) = exact_absolute_tmpdir_path(tmpdir, current_dir);
478    append_note(&mut note, path_note);
479    let marker = DaemonTmpdirMarker {
480        pid: std::process::id(),
481        booted_at_unix: std::time::SystemTime::now()
482            .duration_since(std::time::UNIX_EPOCH)
483            .map(|d| d.as_secs())
484            .unwrap_or(0),
485        car_version: env!("CARGO_PKG_VERSION").to_string(),
486        checked_path: tmpdir.display().to_string(),
487        checked_path_absolute,
488        ok: error.is_none(),
489        error,
490        note,
491    };
492    write_daemon_tmpdir_marker(car_home, &marker)?;
493    Ok(marker)
494}
495
496fn exact_absolute_tmpdir_path(
497    tmpdir: &Path,
498    current_dir: std::io::Result<PathBuf>,
499) -> (Option<String>, Option<String>) {
500    let absolute = if tmpdir.is_absolute() {
501        tmpdir.to_path_buf()
502    } else {
503        let current_dir = match current_dir {
504            Ok(current_dir) => current_dir,
505            Err(error) => {
506                return (
507                    None,
508                    Some(format!(
509                        "TMPDIR recheck skipped: could not capture the daemon working directory: {error}"
510                    )),
511                );
512            }
513        };
514        current_dir.join(tmpdir)
515    };
516    match absolute.to_str() {
517        Some(path) => (Some(path.to_string()), None),
518        None => (
519            None,
520            Some(
521                "TMPDIR recheck skipped: the exact absolute probe path is not valid UTF-8"
522                    .to_string(),
523            ),
524        ),
525    }
526}
527
528fn append_note(note: &mut Option<String>, additional: Option<String>) {
529    let Some(additional) = additional else {
530        return;
531    };
532    match note {
533        Some(note) => {
534            note.push_str("; ");
535            note.push_str(&additional);
536        }
537        None => *note = Some(additional),
538    }
539}
540
541/// Read the daemon TMPDIR record: `None` when there is none, `Some(Err)` when
542/// one exists but cannot be read or parsed.
543pub fn read_daemon_tmpdir_marker(car_home: &Path) -> Option<Result<DaemonTmpdirMarker, String>> {
544    let text = match std::fs::read_to_string(daemon_tmpdir_marker_path(car_home)) {
545        Ok(text) => text,
546        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
547        Err(e) => return Some(Err(e.to_string())),
548    };
549    Some(serde_json::from_str(&text).map_err(|e| e.to_string()))
550}
551
552/// A name suffix no earlier probe or record write used: the pid plus the
553/// current time in nanoseconds.
554fn unique_probe_suffix(pid: u32) -> String {
555    let nanos = std::time::SystemTime::now()
556        .duration_since(std::time::UNIX_EPOCH)
557        .map(|d| d.as_nanos())
558        .unwrap_or(0);
559    format!("{pid}.{nanos}")
560}
561
562/// One create/write/remove round trip in `tmpdir`, the operations an agent
563/// scratch workspace needs. A successful result may carry a non-failing note.
564fn probe_tmpdir(tmpdir: &Path, pid: u32) -> Result<Option<String>, String> {
565    use std::io::Write;
566
567    let path = tmpdir.join(format!(".car-tmpdir-probe.{}", unique_probe_suffix(pid)));
568    // Follow directory symlinks by design: macOS normally reports TMPDIR below
569    // /var/folders, while /var itself is a symlink to /private/var. The probe
570    // must exercise the directory the daemon will actually use. Only the new
571    // probe entry itself is protected against symlink substitution.
572    let mut options = std::fs::OpenOptions::new();
573    options.write(true).create_new(true);
574    #[cfg(windows)]
575    {
576        use std::os::windows::fs::OpenOptionsExt;
577        use windows::Win32::Storage::FileSystem::FILE_FLAG_DELETE_ON_CLOSE;
578
579        // The kernel removes exactly this newly-created file when the handle
580        // closes, so Windows needs no race-prone second path lookup.
581        options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE.0);
582    }
583    let mut file = options
584        .open(&path)
585        .map_err(|e| format!("create failed: {e}"))?;
586    let created = file.metadata().map_err(|e| format!("stat failed: {e}"));
587    let wrote = file
588        .write_all(TMPDIR_PROBE_BYTES)
589        .map_err(|e| format!("write failed: {e}"));
590    drop(file);
591
592    // A failed handle stat must not short-circuit cleanup handling. Unix cannot
593    // safely unlink without the identity; Windows already armed delete-on-close.
594    let removed = cleanup_probe_file(&path, &created);
595    combine_probe_results(created.map(|_| ()), wrote, removed)
596}
597
598fn cleanup_probe_file(
599    path: &Path,
600    created: &Result<std::fs::Metadata, String>,
601) -> Result<(), String> {
602    match created {
603        Ok(created) => remove_probe_file(path, created),
604        #[cfg(windows)]
605        Err(_) => Ok(()),
606        #[cfg(not(windows))]
607        Err(_) => Err(format!(
608            "cleanup skipped: probe file identity could not be proven; left {}",
609            path.display()
610        )),
611    }
612}
613
614fn combine_probe_results(
615    stat: Result<(), String>,
616    write: Result<(), String>,
617    cleanup: Result<(), String>,
618) -> Result<Option<String>, String> {
619    match (stat, write, cleanup) {
620        (Ok(()), Ok(()), Ok(())) => Ok(None),
621        (Err(stat), Ok(()), Ok(())) => Ok(Some(stat)),
622        (Err(stat), Ok(()), Err(cleanup)) => Ok(Some(format!("{stat}; {cleanup}"))),
623        (Ok(()), Ok(()), Err(cleanup)) => Err(cleanup),
624        (stat, Err(write), cleanup) => {
625            let mut errors = Vec::new();
626            if let Err(stat) = stat {
627                errors.push(stat);
628            }
629            errors.push(write);
630            if let Err(cleanup) = cleanup {
631                errors.push(cleanup);
632            }
633            Err(errors.join("; "))
634        }
635    }
636}
637
638/// Remove the probe file, but only while `path` still names the regular file
639/// the probe created. Anything else at that path is left alone.
640#[cfg(unix)]
641fn remove_probe_file(path: &Path, created: &std::fs::Metadata) -> Result<(), String> {
642    use std::os::unix::fs::MetadataExt;
643
644    // This is intentionally the last check before unlink: compare the open
645    // handle's (dev, ino) with no-follow path metadata, and reject symlinks.
646    let on_disk = std::fs::symlink_metadata(path).map_err(|e| format!("remove failed: {e}"))?;
647    if !on_disk.file_type().is_file()
648        || created.dev() != on_disk.dev()
649        || created.ino() != on_disk.ino()
650    {
651        return Err(
652            "remove skipped: the probe path no longer names the file the probe created".to_string(),
653        );
654    }
655    std::fs::remove_file(path).map_err(|e| format!("remove failed: {e}"))
656}
657
658#[cfg(not(unix))]
659fn remove_probe_file(_path: &Path, _created: &std::fs::Metadata) -> Result<(), String> {
660    // Windows already requested delete-on-close on the open handle. Other
661    // non-Unix platforms have no portable identity comparison in stable std,
662    // so a successful create/write leaves the tiny uniquely named file rather
663    // than unlinking a path an attacker could have substituted.
664    Ok(())
665}
666
667/// Write `marker` to a new private temporary file beside the record and use
668/// CAR's no-follow atomic state-file replacement to publish it.
669fn write_daemon_tmpdir_marker(car_home: &Path, marker: &DaemonTmpdirMarker) -> std::io::Result<()> {
670    use std::io::Write;
671
672    let dir = car_home.join(DOCTOR_DIR);
673    // Unlike TMPDIR, CAR state directories must never be followed through a
674    // symlink. This helper securely walks and validates the path on Unix and
675    // Windows rather than relying on a check followed by a path-based open.
676    car_secrets::ensure_private_dir(&dir)?;
677    let mut json = serde_json::to_vec_pretty(marker)
678        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
679    json.push(b'\n');
680    let tmp = dir.join(format!(
681        "{DAEMON_TMPDIR_MARKER_FILE}.{}.tmp",
682        unique_probe_suffix(marker.pid)
683    ));
684    // The state helper uses create-new plus O_NOFOLLOW/reparse-point checks and
685    // validates the opened file. On a later error the private temp is left in
686    // place: path-based cleanup would reintroduce the substitution race these
687    // helpers close.
688    let mut file = car_secrets::create_private_file(&tmp)?;
689    file.write_all(&json)?;
690    car_secrets::atomic_replace_private_file(&tmp, &dir.join(DAEMON_TMPDIR_MARKER_FILE))
691}
692
693/// Classify a record. `writer_alive` is `Some` where this platform can check
694/// the writer's pid, `None` where it cannot.
695fn classify_daemon_tmpdir_marker(
696    marker: DaemonTmpdirMarker,
697    writer_alive: Option<bool>,
698) -> DaemonTmpdirStatus {
699    match writer_alive {
700        Some(true) => DaemonTmpdirStatus::Current { marker },
701        Some(false) => DaemonTmpdirStatus::Stale { marker },
702        None => DaemonTmpdirStatus::Unverified { marker },
703    }
704}
705
706/// Whether the daemon that wrote a record is still running, where that can be
707/// checked without contacting it.
708///
709/// Staleness rests on the pid alone, never on the recorded time. A daemon
710/// records only after it holds the primary-daemon lock for its `CAR_HOME`, so a
711/// later boot replaces the record, and a record whose writer is gone means no
712/// daemon is running or the running one has not recorded.
713fn daemon_writer_alive(pid: u32) -> Option<bool> {
714    #[cfg(unix)]
715    {
716        Some(unix_pid_alive(pid))
717    }
718    #[cfg(not(unix))]
719    {
720        let _ = pid;
721        None
722    }
723}
724
725/// `kill(pid, 0)`, the same rule as `wait_for_parent_exit` in
726/// `car-server/src/main.rs`: signal 0 sends nothing and only checks existence;
727/// 0 or `EPERM` (alive, but not ours to signal) means alive, anything else
728/// means gone.
729///
730/// Not proof against pid reuse, accepted exactly as that code accepts it: if
731/// the writer exited and the OS recycled its pid onto an unrelated process,
732/// this reads "alive" and a dead daemon's result is reported as current. Pids
733/// are allocated sequentially, so that needs the counter to wrap onto the old
734/// value, and re-running `car doctor` after relaunching CarHost clears it.
735#[cfg(unix)]
736fn unix_pid_alive(pid: u32) -> bool {
737    // kill(2) reads 0 and negative pids as process groups, not a process.
738    let Ok(pid) = libc::pid_t::try_from(pid) else {
739        return false;
740    };
741    if pid <= 0 {
742        return false;
743    }
744    // SAFETY: signal 0 delivers nothing; kill only checks that `pid` exists.
745    let rc = unsafe { libc::kill(pid, 0) };
746    rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
747}
748
749fn recheck_current_tmpdir_with<F>(status: DaemonTmpdirStatus, stat: F) -> DaemonTmpdirStatus
750where
751    F: FnOnce(&Path) -> std::io::Result<()>,
752{
753    let mut marker = match status {
754        DaemonTmpdirStatus::Current { marker } => marker,
755        status => return status,
756    };
757    if !marker.ok {
758        return DaemonTmpdirStatus::Current { marker };
759    }
760    let Some(exact_path) = marker.checked_path_absolute.clone() else {
761        append_note(
762            &mut marker.note,
763            Some(
764                "TMPDIR recheck skipped: the startup record has no exact absolute probe path"
765                    .to_string(),
766            ),
767        );
768        return DaemonTmpdirStatus::Current { marker };
769    };
770    match stat(Path::new(&exact_path)) {
771        Ok(()) => DaemonTmpdirStatus::Current { marker },
772        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
773            DaemonTmpdirStatus::CurrentPathGone { marker }
774        }
775        Err(error) => DaemonTmpdirStatus::CurrentPathRecheckFailed {
776            marker,
777            error: error.to_string(),
778        },
779    }
780}
781
782/// The daemon TMPDIR record as doctor reports it; `None` when there is none.
783fn check_daemon_tmpdir(home: &Path) -> Option<DaemonTmpdirCheck> {
784    let status = match read_daemon_tmpdir_marker(home)? {
785        Ok(marker) => {
786            let writer_alive = daemon_writer_alive(marker.pid);
787            let status = classify_daemon_tmpdir_marker(marker, writer_alive);
788            recheck_current_tmpdir_with(status, |path| std::fs::metadata(path).map(|_| ()))
789        }
790        Err(error) => DaemonTmpdirStatus::Unreadable { error },
791    };
792    Some(DaemonTmpdirCheck {
793        marker_path: daemon_tmpdir_marker_path(home).display().to_string(),
794        status,
795    })
796}
797
798/// Options controlling a diagnosis run.
799#[derive(Debug, Clone, Default)]
800pub struct DoctorOptions {
801    /// Deep-verify model weights (recompute sha256 vs etag) instead of the cheap
802    /// resolves-and-non-empty check. Slow (hashes every weight) but catches
803    /// truncated-but-non-empty corruption.
804    pub deep: bool,
805    /// Apply safe repairs: purge corrupt cache files, back up unparseable state,
806    /// refresh the version stamp.
807    pub repair: bool,
808}
809
810/// Verdict for a single `~/.car` JSON state file.
811#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(rename_all = "snake_case", tag = "status")]
813pub enum StateFileStatus {
814    /// Not present — fine; most state files are created on first use.
815    Absent,
816    /// Present and parses as JSON.
817    Ok { schema_version: Option<u32> },
818    /// Present but does not parse — corrupt or written by an incompatible
819    /// version. `backed_up_to` is set when `--repair` moved it aside.
820    Unparseable {
821        error: String,
822        backed_up_to: Option<String>,
823    },
824}
825
826/// One state-file check.
827#[derive(Debug, Clone, Serialize, Deserialize)]
828pub struct StateFileCheck {
829    pub name: String,
830    #[serde(flatten)]
831    pub status: StateFileStatus,
832}
833
834/// Verdict for one installed model directory.
835#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(rename_all = "snake_case", tag = "status")]
837pub enum ModelStatus {
838    /// Weight files present and (for `--deep`) hash-verified.
839    Healthy,
840    /// At least one weight file is corrupt/missing. `purged` is how many were
841    /// removed in `--repair` mode (so the next pull re-downloads them).
842    Corrupt {
843        bad_files: Vec<String>,
844        purged: usize,
845    },
846    /// The directory looks like a model install — it has `config.json` /
847    /// tokenizer stubs — but carries no resolvable weights at all. This is what
848    /// an interrupted `car models pull` leaves behind, and it used to be
849    /// invisible: `check_one_model` returned `None` for any dir with no weight
850    /// files, so the report dropped it and `car doctor` said "none installed /
851    /// Healthy" over a broken install (Parslee-ai/car#616).
852    ///
853    /// Note the loader already knew: `registry::ensure_local` gates reuse on
854    /// `mlx_dir_has_weights` and re-downloads a config-only stub
855    /// (car-releases#391). The diagnostic just disagreed with the runtime.
856    Incomplete { detail: String },
857}
858
859/// A file left behind by an interrupted download.
860///
861/// Reported, never deleted — see [`find_leftovers`] for why.
862#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
863pub struct Leftover {
864    pub path: String,
865    pub bytes: u64,
866}
867
868/// One model check.
869#[derive(Debug, Clone, Serialize, Deserialize)]
870pub struct ModelCheck {
871    pub name: String,
872    #[serde(flatten)]
873    pub status: ModelStatus,
874}
875
876/// Health of one CAR-managed Python runtime venv (`speech-runtime`,
877/// `visual-runtime`).
878///
879/// These venvs are how CAR runs every model architecture its in-process Rust
880/// MLX backend does not implement, so a broken one silently removes the entire
881/// external-runtime shelf — vision, speech, and every newer LLM family — while
882/// the directory still looks fully populated on disk. A venv's `bin/python` is
883/// an absolute symlink into the interpreter that built it, so an ordinary
884/// Homebrew upgrade (`python@3.13` rotated away) is enough to do it.
885#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct RuntimeCheck {
887    /// Directory name under `CAR_HOME` (e.g. `visual-runtime`).
888    pub name: String,
889    pub root: String,
890    /// The venv directory exists. A runtime that was never provisioned is not
891    /// unhealthy — it is simply absent, and provisions on first use.
892    pub present: bool,
893    /// The venv's interpreter actually runs. Only meaningful when `present`.
894    pub interpreter_ok: bool,
895}
896
897impl RuntimeCheck {
898    /// A runtime is broken when it exists but cannot run anything. Absent is
899    /// fine (lazy-provisioned); present-and-working is fine.
900    pub fn is_broken(&self) -> bool {
901        self.present && !self.interpreter_ok
902    }
903}
904
905/// The full diagnosis.
906#[derive(Debug, Clone, Serialize, Deserialize)]
907pub struct DoctorReport {
908    pub car_home: String,
909    /// The version of the binary that produced this report.
910    pub binary_version: String,
911    /// The version stamp found on disk, if any (None ⇒ never stamped).
912    pub on_disk_stamp: Option<VersionStamp>,
913    /// True when the on-disk stamp's version differs from the binary.
914    ///
915    /// Near-useless on a machine that runs the daemon: it stamps at boot, so
916    /// this reads false from then on. [`DoctorReport::carried_from_version`] is
917    /// the field that survives (car#881).
918    pub version_skew: bool,
919    /// The version that wrote the state this install carried across its most
920    /// recent upgrade, when the stamp recorded one.
921    ///
922    /// Unlike `version_skew` this outlives the daemon's boot stamp, so "state
923    /// here predates the running binary" stays answerable rather than being
924    /// erased seconds after it became true.
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub carried_from_version: Option<String>,
927    /// On-disk state uses a schema generation NEWER than this binary knows —
928    /// a downgrade. `STATE_SCHEMA_VERSION` documents that a reader older than
929    /// the state "should refuse rather than corrupt"; nothing enforced it.
930    #[serde(default)]
931    pub schema_from_the_future: bool,
932    pub state_files: Vec<StateFileCheck>,
933    pub models: Vec<ModelCheck>,
934    /// Models whose CAR-managed weights are physically ready, using the same
935    /// registry predicate as `models.list_unified`.
936    #[serde(default)]
937    pub installed_models: usize,
938    /// Partial downloads abandoned in the HuggingFace cache. Informational —
939    /// they waste disk but nothing is broken, so they don't affect
940    /// [`DoctorReport::is_healthy`].
941    #[serde(default)]
942    pub leftovers: Vec<Leftover>,
943    /// Health of the CAR-managed Python runtime venvs. Absent runtimes are
944    /// omitted; only ones that exist are reported.
945    #[serde(default)]
946    pub runtimes: Vec<RuntimeCheck>,
947    /// Top-level `~/.car` entries this version doesn't recognize.
948    pub unrecognized: Vec<String>,
949    /// Human-readable actions taken in `--repair` mode (empty otherwise).
950    pub repairs: Vec<String>,
951    /// The daemon's startup TMPDIR probe (Parslee-ai/car#1494), read from
952    /// `<CAR_HOME>/doctor/daemon-tmpdir.json`. `None` when no daemon has
953    /// recorded one, which is not an error; the key is then omitted, so a report
954    /// without a record serializes exactly as before.
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub daemon_tmpdir: Option<DaemonTmpdirCheck>,
957}
958
959impl DoctorReport {
960    /// True when nothing actionable was found (modulo unrecognized entries,
961    /// which are informational).
962    pub fn is_healthy(&self) -> bool {
963        // `carried_from_version` is deliberately NOT a health signal — carrying
964        // state across an upgrade is the normal case, and flagging it would make
965        // every upgraded install permanently "unhealthy". Running against state
966        // from a schema this binary does not know IS a problem.
967        !self.schema_from_the_future
968            && !self.version_skew
969            && self
970                .state_files
971                .iter()
972                .all(|f| !matches!(f.status, StateFileStatus::Unparseable { .. }))
973            && self
974                .models
975                .iter()
976                .all(|m| matches!(m.status, ModelStatus::Healthy))
977            && self.runtimes.iter().all(|r| !r.is_broken())
978            // A running daemon's failed probe, vanished TMPDIR, or failed
979            // recheck counts. Stale/unverified/unreadable is informational.
980            && !self
981                .daemon_tmpdir
982                .as_ref()
983                .is_some_and(DaemonTmpdirCheck::is_failing)
984    }
985}
986
987/// Run a diagnosis (and, if `opts.repair`, repairs) against the install that is
988/// actually running: state under [`car_home`](fn@car_home), weights under the shared cache.
989///
990/// The two are the same tree by default and separate under `CAR_HOME`, which is
991/// why they are passed separately. A relocated daemon still loads its weights
992/// from `~/.car/models` — that cache is machine-global on purpose — so a doctor
993/// that only looked under the state root would silently stop checking the
994/// weights, which is the failure mode `car doctor` mainly exists to catch.
995pub fn diagnose(opts: &DoctorOptions) -> DoctorReport {
996    diagnose_at(&car_home(), &crate::default_models_dir(), opts)
997}
998
999/// Diagnosis against an explicit base dir, weights assumed at `<home>/models` —
1000/// the testable core, and the shape every caller wanted before `CAR_HOME` could
1001/// separate the two.
1002pub fn diagnose_in(home: &Path, opts: &DoctorOptions) -> DoctorReport {
1003    diagnose_at(home, &home.join("models"), opts)
1004}
1005
1006/// Diagnosis with the state root and the weights cache named independently.
1007pub fn diagnose_at(home: &Path, models_dir: &Path, opts: &DoctorOptions) -> DoctorReport {
1008    let catalog_public_key = std::env::var("CAR_CATALOG_PUBKEY").ok();
1009    let registry = crate::registry::UnifiedRegistry::new_with_session(
1010        home.to_path_buf(),
1011        models_dir.to_path_buf(),
1012        catalog_public_key.as_deref(),
1013        crate::registry::SessionProbe::Inert,
1014    );
1015    diagnose_at_with_registry(home, models_dir, opts, &registry, None)
1016}
1017
1018/// Deterministic diagnosis seam for cross-crate tests.
1019///
1020/// This stays `#[doc(hidden)]` instead of feature-gated because dependency
1021/// crates' integration tests link `car-inference` without inheriting `cfg(test)`.
1022/// Unlike [`diagnose_at`], this loads only local models discovered below the
1023/// explicit weights directory, scans only the explicit Hugging Face hub root,
1024/// and never consults a credential/session probe. Production feedback and
1025/// doctor paths deliberately continue to use [`diagnose_at`].
1026#[doc(hidden)]
1027pub fn diagnose_at_isolated(
1028    home: &Path,
1029    models_dir: &Path,
1030    huggingface_hub_root: &Path,
1031    opts: &DoctorOptions,
1032) -> DoctorReport {
1033    let registry = crate::registry::UnifiedRegistry::new_isolated_for_diagnosis(
1034        home.to_path_buf(),
1035        models_dir.to_path_buf(),
1036    );
1037    diagnose_at_with_registry(
1038        home,
1039        models_dir,
1040        opts,
1041        &registry,
1042        Some(huggingface_hub_root),
1043    )
1044}
1045
1046fn diagnose_at_with_registry(
1047    home: &Path,
1048    models_dir: &Path,
1049    opts: &DoctorOptions,
1050    registry: &crate::registry::UnifiedRegistry,
1051    huggingface_hub_root: Option<&Path>,
1052) -> DoctorReport {
1053    let mut repairs = Vec::new();
1054
1055    // --- version stamp / skew ------------------------------------------------
1056    let on_disk_stamp = read_version_stamp(home);
1057    let binary = VersionStamp::current();
1058    let version_skew = on_disk_stamp
1059        .as_ref()
1060        .map(|s| {
1061            s.car_version != binary.car_version
1062                || s.state_schema_version != binary.state_schema_version
1063        })
1064        .unwrap_or(false);
1065    // Survives the daemon's boot stamp; `version_skew` does not.
1066    let carried_from_version = on_disk_stamp
1067        .as_ref()
1068        .and_then(|s| s.previous_car_version.clone());
1069    let schema_from_the_future = on_disk_stamp
1070        .as_ref()
1071        .is_some_and(|s| s.state_schema_version > binary.state_schema_version);
1072
1073    // --- state files ---------------------------------------------------------
1074    let mut state_files = Vec::new();
1075    for name in KNOWN_STATE_FILES {
1076        state_files.push(check_state_file(home, name, opts, &mut repairs));
1077    }
1078
1079    // --- models --------------------------------------------------------------
1080    let models = check_models(models_dir, opts, &mut repairs);
1081    // Kept separate from `models`: those entries are per-directory integrity
1082    // checks, while this is the registry's physical installed-model count.
1083    let installed_models = registry
1084        .list()
1085        .into_iter()
1086        .filter(|schema| {
1087            schema.downloads_weights()
1088                && crate::registry::physical_weights_ready_with_huggingface_hub(
1089                    schema,
1090                    models_dir,
1091                    huggingface_hub_root,
1092                )
1093        })
1094        .count();
1095
1096    // --- leftovers -----------------------------------------------------------
1097    let leftovers = match huggingface_hub_root {
1098        Some(hub) => find_leftovers_in(hub),
1099        None => find_leftovers(),
1100    };
1101    let unrecognized = find_unrecognized(home);
1102
1103    // --- refresh stamp on repair --------------------------------------------
1104    if opts.repair {
1105        // Only *report* a stamp refresh when the stamp actually moved. It was
1106        // rewritten and announced unconditionally, so `car doctor --repair` on
1107        // a perfectly healthy install always printed a line under "Repairs:",
1108        // implying something had been wrong (Parslee-ai/car#626). The write
1109        // still happens either way — it's cheap and makes a missing stamp
1110        // appear — but a no-op write is not a repair.
1111        let already_current = on_disk_stamp
1112            .as_ref()
1113            .map(|s| {
1114                s.car_version == binary.car_version
1115                    && s.state_schema_version == binary.state_schema_version
1116            })
1117            .unwrap_or(false);
1118        match write_version_stamp(home) {
1119            Ok(()) if !already_current => repairs.push(format!(
1120                "refreshed version stamp to {} (schema v{})",
1121                binary.car_version, binary.state_schema_version
1122            )),
1123            Ok(()) => {}
1124            Err(e) => repairs.push(format!("failed to refresh version stamp: {e}")),
1125        }
1126    }
1127
1128    // --- reap empty journals on repair ---------------------------------------
1129    let empty_journals = find_empty_journals(home);
1130    if opts.repair && !empty_journals.is_empty() {
1131        let mut removed = 0usize;
1132        for p in &empty_journals {
1133            if std::fs::remove_file(p).is_ok() {
1134                removed += 1;
1135            }
1136        }
1137        if removed > 0 {
1138            repairs.push(format!(
1139                "removed {removed} empty event journal(s) from journals/"
1140            ));
1141        }
1142    }
1143
1144    // --- managed Python runtimes --------------------------------------------
1145    let runtimes = check_runtimes(home);
1146
1147    // --- daemon TMPDIR probe record (car#1494) --------------------------------
1148    let daemon_tmpdir = check_daemon_tmpdir(home);
1149
1150    DoctorReport {
1151        car_home: home.display().to_string(),
1152        binary_version: binary.car_version,
1153        on_disk_stamp,
1154        version_skew,
1155        carried_from_version,
1156        schema_from_the_future,
1157        state_files,
1158        models,
1159        installed_models,
1160        leftovers,
1161        runtimes,
1162        unrecognized,
1163        repairs,
1164        daemon_tmpdir,
1165    }
1166}
1167
1168/// Directory names under `CAR_HOME` that hold a CAR-managed `uv` venv.
1169const MANAGED_RUNTIMES: &[&str] = &["speech-runtime", "visual-runtime"];
1170
1171/// Check each managed Python runtime that has actually been provisioned.
1172///
1173/// Absent runtimes are skipped rather than reported unhealthy: they provision
1174/// on first use, so "not there yet" is the normal state on a fresh install and
1175/// flagging it would make every new machine read as broken.
1176fn check_runtimes(home: &Path) -> Vec<RuntimeCheck> {
1177    MANAGED_RUNTIMES
1178        .iter()
1179        .filter_map(|name| {
1180            let root = home.join(name);
1181            if !root.exists() {
1182                return None;
1183            }
1184            Some(RuntimeCheck {
1185                name: (*name).to_string(),
1186                root: root.display().to_string(),
1187                present: true,
1188                interpreter_ok: crate::managed_venv::interpreter_healthy(&root),
1189            })
1190        })
1191        .collect()
1192}
1193
1194fn read_version_stamp(home: &Path) -> Option<VersionStamp> {
1195    let text = std::fs::read_to_string(home.join("version.json")).ok()?;
1196    serde_json::from_str(&text).ok()
1197}
1198
1199fn check_state_file(
1200    home: &Path,
1201    name: &str,
1202    opts: &DoctorOptions,
1203    repairs: &mut Vec<String>,
1204) -> StateFileCheck {
1205    let path = home.join(name);
1206    let is_jsonl = name.ends_with(".jsonl");
1207    let status = match std::fs::read_to_string(&path) {
1208        Err(_) => StateFileStatus::Absent,
1209        Ok(text) if text.trim().is_empty() => StateFileStatus::Ok {
1210            schema_version: None,
1211        },
1212        // JSONL (one JSON value per line) must be validated line-by-line — the
1213        // whole file is not a single JSON document.
1214        Ok(text) if is_jsonl => match jsonl_first_bad_line(&text) {
1215            None => StateFileStatus::Ok {
1216                schema_version: None,
1217            },
1218            Some(e) => unparseable(&path, name, e, opts, repairs),
1219        },
1220        Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
1221            Ok(value) => StateFileStatus::Ok {
1222                schema_version: value
1223                    .get("schema_version")
1224                    .and_then(serde_json::Value::as_u64)
1225                    .map(|v| v as u32),
1226            },
1227            Err(e) => unparseable(&path, name, e.to_string(), opts, repairs),
1228        },
1229    };
1230    StateFileCheck {
1231        name: name.to_string(),
1232        status,
1233    }
1234}
1235
1236/// Validate each non-empty line of a JSONL file; return the first parse error
1237/// (with its line number) or `None` if all lines are valid JSON.
1238fn jsonl_first_bad_line(text: &str) -> Option<String> {
1239    for (i, line) in text.lines().enumerate() {
1240        if line.trim().is_empty() {
1241            continue;
1242        }
1243        if let Err(e) = serde_json::from_str::<serde_json::Value>(line) {
1244            return Some(format!("line {}: {e}", i + 1));
1245        }
1246    }
1247    None
1248}
1249
1250/// Shared handling for an unparseable state file: in repair mode, move it aside
1251/// to `<name>.corrupt.bak` (never delete) so the owning crate writes a fresh
1252/// default on next run; otherwise just record the error.
1253fn unparseable(
1254    path: &Path,
1255    name: &str,
1256    error: String,
1257    opts: &DoctorOptions,
1258    repairs: &mut Vec<String>,
1259) -> StateFileStatus {
1260    let backed_up_to = if opts.repair {
1261        // Don't clobber a previous backup — keeps the "never delete" promise
1262        // honest if the same file goes corrupt twice. First backup gets the
1263        // plain name; a collision falls back to an epoch-suffixed one.
1264        let plain = path.with_file_name(format!("{name}.corrupt.bak"));
1265        let bak = if plain.exists() {
1266            let epoch = std::time::SystemTime::now()
1267                .duration_since(std::time::UNIX_EPOCH)
1268                .map(|d| d.as_secs())
1269                .unwrap_or(0);
1270            path.with_file_name(format!("{name}.corrupt.{epoch}.bak"))
1271        } else {
1272            plain
1273        };
1274        match std::fs::rename(path, &bak) {
1275            Ok(()) => {
1276                repairs.push(format!("backed up unparseable {name} → {}", bak.display()));
1277                Some(bak.display().to_string())
1278            }
1279            Err(err) => {
1280                repairs.push(format!("failed to back up {name}: {err}"));
1281                None
1282            }
1283        }
1284    } else {
1285        None
1286    };
1287    StateFileStatus::Unparseable {
1288        error,
1289        backed_up_to,
1290    }
1291}
1292
1293fn check_models(
1294    models_dir: &Path,
1295    opts: &DoctorOptions,
1296    repairs: &mut Vec<String>,
1297) -> Vec<ModelCheck> {
1298    let Ok(entries) = std::fs::read_dir(models_dir) else {
1299        return Vec::new();
1300    };
1301    let mut out = Vec::new();
1302    for entry in entries.filter_map(Result::ok) {
1303        let dir = entry.path();
1304        if !dir.is_dir() {
1305            continue;
1306        }
1307        let name = entry.file_name().to_string_lossy().to_string();
1308        // Skip dirs with no weight files of their own: a managed MLX dir is
1309        // often just a config stub whose weights live in the HF snapshot cache,
1310        // and flagging that as broken would be a false positive. We only assess
1311        // weights that are actually present here.
1312        if let Some(status) = check_one_model(&dir, opts, &name, repairs) {
1313            out.push(ModelCheck { name, status });
1314        }
1315    }
1316    out.sort_by(|a, b| a.name.cmp(&b.name));
1317    out
1318}
1319
1320fn check_one_model(
1321    dir: &Path,
1322    opts: &DoctorOptions,
1323    name: &str,
1324    repairs: &mut Vec<String>,
1325) -> Option<ModelStatus> {
1326    let weights = weight_files(dir);
1327    if weights.is_empty() {
1328        // No weight file of any kind — not even a dangling symlink (those DO
1329        // show up in `weight_files` and are caught as Corrupt below). Two very
1330        // different situations share this shape:
1331        //
1332        //   1. the dir isn't a model install at all — none of our business, and
1333        //      flagging it would be the false positive the old blanket `None`
1334        //      was protecting against;
1335        //   2. an install that got interrupted before its weights landed:
1336        //      `config.json` and the tokenizer stubs are there, the
1337        //      `*.safetensors` never arrived.
1338        //
1339        // Case 2 is exactly what a Ctrl-C'd `car models pull` leaves, and
1340        // returning `None` for it is what made a broken install read as
1341        // "Healthy" (Parslee-ai/car#616). Tell them apart on whether the dir
1342        // carries a model manifest.
1343        if is_interrupted_install(dir) {
1344            return Some(ModelStatus::Incomplete {
1345                detail: format!(
1346                    "manifest linked into the HuggingFace cache but no weights resolve — \
1347                     re-pull with `car models pull {name}`"
1348                ),
1349            });
1350        }
1351        return None;
1352    }
1353    let mut bad_files = Vec::new();
1354    for w in &weights {
1355        let corrupt = if opts.deep {
1356            verify_cache_file(w) == CacheIntegrity::Corrupt
1357        } else {
1358            !cache_file_usable(w)
1359        };
1360        if corrupt {
1361            bad_files.push(
1362                w.file_name()
1363                    .unwrap_or_default()
1364                    .to_string_lossy()
1365                    .to_string(),
1366            );
1367        }
1368    }
1369    if bad_files.is_empty() {
1370        return Some(ModelStatus::Healthy);
1371    }
1372    let purged = if opts.repair {
1373        let n = purge_corrupt_cache_files(dir);
1374        if n > 0 {
1375            repairs.push(format!(
1376                "purged {n} corrupt file(s) from model '{name}' — re-pull with `car models pull {name}`"
1377            ));
1378        }
1379        n
1380    } else {
1381        0
1382    };
1383    Some(ModelStatus::Corrupt { bad_files, purged })
1384}
1385
1386/// Weight files (`.safetensors` / `.gguf`) anywhere under a model dir. Recurses
1387/// to full depth so detection matches `purge_corrupt_cache_files`' recursion —
1388/// a corrupt weight nested ≥2 levels down (uncommon but possible) is still seen.
1389/// Cheap: no hashing here, just extension matching.
1390/// Is this a managed dir left half-built by an interrupted pull?
1391///
1392/// Called only for dirs with no weight file of any kind. Distinguishing an
1393/// interrupted install from a directory that was never a model needs care,
1394/// because over-eager flagging here is a known past false positive — see
1395/// `config_only_stub_is_skipped_not_flagged`, which pins a hand-made
1396/// config-only dir as *not* broken.
1397///
1398/// The discriminator is **how the manifest got there**. `car models pull`
1399/// populates a managed dir by symlinking into the HuggingFace snapshot cache
1400/// (`registry.rs`, "try symlink first"), writing the small config/tokenizer
1401/// files before the multi-gigabyte weights. So a dir that holds symlinked
1402/// manifest files but no resolvable weights is one CAR built and did not
1403/// finish — the exact residue of a Ctrl-C'd pull. A hand-made stub, or one
1404/// created by the copy fallback, has real files and no symlinks, and is left
1405/// alone.
1406///
1407/// Deliberately conservative: it under-reports (a stub whose links were later
1408/// cleaned up reads as "not a model") rather than resurrecting the false
1409/// positive. Weight *presence* uses the same `mlx_dir_has_weights` predicate
1410/// the loader gates on, so the diagnosis and the runtime agree — the whole
1411/// point of Parslee-ai/car#616, where `ensure_local` re-downloaded a stub the
1412/// doctor was calling healthy.
1413fn is_interrupted_install(dir: &Path) -> bool {
1414    const MANIFESTS: &[&str] = &[
1415        "config.json",
1416        "model_index.json",
1417        "tokenizer.json",
1418        "tokenizer_config.json",
1419        "model.safetensors.index.json",
1420    ];
1421    let has_symlinked_manifest = MANIFESTS.iter().any(|m| {
1422        let p = dir.join(m);
1423        std::fs::symlink_metadata(&p)
1424            .map(|meta| meta.file_type().is_symlink())
1425            .unwrap_or(false)
1426    });
1427    has_symlinked_manifest && !crate::registry::mlx_dir_has_weights(dir)
1428}
1429
1430/// Zero-byte `*.jsonl` files under `~/.car/journals/`.
1431///
1432/// The daemon opens a journal per session; one that never executes a proposal
1433/// leaves an empty file behind and nothing reaps it, so they accumulate
1434/// indefinitely — 35 of 43 on the install that prompted this
1435/// (Parslee-ai/car#626).
1436///
1437/// Unlike the HuggingFace partials, these ARE safe for `--repair` to delete:
1438/// they live in CAR's own directory, a zero-length journal provably holds no
1439/// events, and `EventLog::load` on a missing file behaves the same as on an
1440/// empty one. Only exactly-zero-length files qualify — anything with a byte in
1441/// it is left alone.
1442fn find_empty_journals(home: &Path) -> Vec<PathBuf> {
1443    let dir = home.join("journals");
1444    let Ok(entries) = std::fs::read_dir(&dir) else {
1445        return Vec::new();
1446    };
1447    let mut out: Vec<PathBuf> = entries
1448        .filter_map(Result::ok)
1449        .filter(|e| {
1450            e.path().extension().and_then(|x| x.to_str()) == Some("jsonl")
1451                && e.metadata()
1452                    .map(|m| m.is_file() && m.len() == 0)
1453                    .unwrap_or(false)
1454        })
1455        .map(|e| e.path())
1456        .collect();
1457    out.sort();
1458    out
1459}
1460
1461/// Partial downloads abandoned in the HuggingFace cache (`*.sync.part`).
1462///
1463/// Reported, **never deleted**, for two reasons. The shared HF cache belongs to
1464/// every tool on the machine, not just CAR (managed model dirs are only
1465/// symlinks into it), and a `.sync.part` may belong to a download that is
1466/// running *right now* — removing it would corrupt a live transfer. That also
1467/// keeps faith with `--repair`'s documented promise to never delete "anything
1468/// not provably corrupt": an in-flight partial is not corrupt, it is unfinished.
1469/// Surfacing the path and the size is the part that was missing
1470/// (Parslee-ai/car#616) — the operator decides.
1471///
1472/// Scans only `<cache>/hub/*/blobs/`, where hf-hub puts them, so this stays
1473/// cheap on a large cache rather than walking the whole tree.
1474fn find_leftovers() -> Vec<Leftover> {
1475    find_leftovers_in(&crate::registry::huggingface_cache_root())
1476}
1477
1478fn find_leftovers_in(hub: &Path) -> Vec<Leftover> {
1479    let mut out = Vec::new();
1480    let Ok(repos) = std::fs::read_dir(hub) else {
1481        return out;
1482    };
1483    for repo in repos.filter_map(Result::ok) {
1484        let blobs = repo.path().join("blobs");
1485        let Ok(entries) = std::fs::read_dir(&blobs) else {
1486            continue;
1487        };
1488        for e in entries.filter_map(Result::ok) {
1489            let p = e.path();
1490            let is_partial = p
1491                .file_name()
1492                .and_then(|n| n.to_str())
1493                .map(|n| n.ends_with(".sync.part") || n.ends_with(".incomplete"))
1494                .unwrap_or(false);
1495            if !is_partial {
1496                continue;
1497            }
1498            // Apparent size can far exceed blocks actually allocated (these are
1499            // written sparsely); `len()` is what the operator sees in `ls -lh`.
1500            let bytes = e.metadata().map(|m| m.len()).unwrap_or(0);
1501            out.push(Leftover {
1502                path: p.display().to_string(),
1503                bytes,
1504            });
1505        }
1506    }
1507    out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
1508    out
1509}
1510
1511fn weight_files(dir: &Path) -> Vec<PathBuf> {
1512    fn is_weight(p: &Path) -> bool {
1513        matches!(
1514            p.extension().and_then(|e| e.to_str()),
1515            Some("safetensors") | Some("gguf")
1516        )
1517    }
1518    let mut out = Vec::new();
1519    let Ok(entries) = std::fs::read_dir(dir) else {
1520        return out;
1521    };
1522    for entry in entries.filter_map(Result::ok) {
1523        let p = entry.path();
1524        // `file_type` doesn't follow symlinks, so weight *symlinks* (the normal
1525        // case) are seen as files, not recursed into — only real subdirs recurse.
1526        if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1527            out.extend(weight_files(&p));
1528        } else if is_weight(&p) {
1529            out.push(p);
1530        }
1531    }
1532    out
1533}
1534
1535fn find_unrecognized(home: &Path) -> Vec<String> {
1536    let Ok(entries) = std::fs::read_dir(home) else {
1537        return Vec::new();
1538    };
1539    let mut out: Vec<String> = entries
1540        .filter_map(Result::ok)
1541        .filter_map(|e| {
1542            let name = e.file_name().to_string_lossy().to_string();
1543            let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1544            let known = if is_dir {
1545                KNOWN_DIRS.contains(&name.as_str())
1546            } else {
1547                KNOWN_STATE_FILES.contains(&name.as_str())
1548                    || KNOWN_NON_JSON_FILES.contains(&name.as_str())
1549                    // Volatile runtime artifacts (locks, temps, backups, binary
1550                    // caches, append-only logs) are not "leftovers".
1551                    || TOLERATED_SUFFIXES.iter().any(|s| name.ends_with(s))
1552                    // Hidden dotfiles are config/runtime, not leftovers.
1553                    || name.starts_with('.')
1554            };
1555            if known {
1556                None
1557            } else {
1558                Some(name)
1559            }
1560        })
1561        .collect();
1562    out.sort();
1563    out
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568    use super::*;
1569    use tempfile::TempDir;
1570
1571    fn opts(deep: bool, repair: bool) -> DoctorOptions {
1572        DoctorOptions { deep, repair }
1573    }
1574
1575    fn diagnose_repair_with_isolated_model_roots(home: &Path) -> DoctorReport {
1576        let models_dir = TempDir::new().unwrap();
1577        let huggingface_hub = TempDir::new().unwrap();
1578        let blobs = huggingface_hub.path().join("models--fixture/blobs");
1579        std::fs::create_dir_all(&blobs).unwrap();
1580        std::fs::write(blobs.join("isolated.sync.part"), b"partial").unwrap();
1581
1582        let report = diagnose_at_isolated(
1583            home,
1584            models_dir.path(),
1585            huggingface_hub.path(),
1586            &opts(false, true),
1587        );
1588        assert_eq!(
1589            report.leftovers.len(),
1590            1,
1591            "the isolated seam must scan its injected Hugging Face root, not skip leftovers"
1592        );
1593        assert!(
1594            Path::new(&report.leftovers[0].path).starts_with(huggingface_hub.path()),
1595            "the leftover must come from the test-owned Hugging Face root"
1596        );
1597        report
1598    }
1599
1600    #[test]
1601    fn clean_home_is_healthy() {
1602        let tmp = TempDir::new().unwrap();
1603        std::fs::write(tmp.path().join("models.json"), "{}").unwrap();
1604        std::fs::create_dir_all(tmp.path().join("models")).unwrap();
1605        let r = diagnose_in(tmp.path(), &opts(false, false));
1606        assert!(r.is_healthy(), "clean home should be healthy: {r:?}");
1607        assert!(r.unrecognized.is_empty());
1608    }
1609
1610    #[test]
1611    fn secret_index_is_recognized_not_a_leftover() {
1612        // The names-only secret index is a live current-version file (written by
1613        // the OS-keychain store on every platform). doctor once flagged it as a
1614        // possible older-install leftover and told the user to remove it — only
1615        // visible once a secret had been stored, e.g. after `car auth login` on
1616        // Windows. It must be recognized state, and it must parse as JSON.
1617        let tmp = TempDir::new().unwrap();
1618        std::fs::write(tmp.path().join("models.json"), "{}").unwrap();
1619        std::fs::create_dir_all(tmp.path().join("models")).unwrap();
1620        std::fs::write(tmp.path().join("secret_index.json"), r#"{"entries":[]}"#).unwrap();
1621        let r = diagnose_in(tmp.path(), &opts(false, false));
1622        assert!(
1623            !r.unrecognized.contains(&"secret_index.json".to_string()),
1624            "secret_index.json must not be flagged as unrecognized: {:?}",
1625            r.unrecognized
1626        );
1627        assert!(
1628            r.is_healthy(),
1629            "home with a secret index should be healthy: {r:?}"
1630        );
1631    }
1632
1633    #[test]
1634    fn unparseable_state_file_is_flagged_and_backed_up_on_repair() {
1635        let tmp = TempDir::new().unwrap();
1636        std::fs::write(tmp.path().join("connectors.json"), "{not json").unwrap();
1637
1638        // Read-only: flagged, not moved.
1639        let r = diagnose_in(tmp.path(), &opts(false, false));
1640        let c = r
1641            .state_files
1642            .iter()
1643            .find(|f| f.name == "connectors.json")
1644            .unwrap();
1645        assert!(matches!(
1646            c.status,
1647            StateFileStatus::Unparseable {
1648                backed_up_to: None,
1649                ..
1650            }
1651        ));
1652        assert!(!r.is_healthy());
1653        assert!(
1654            tmp.path().join("connectors.json").exists(),
1655            "untouched without --repair"
1656        );
1657
1658        // Repair: moved to .corrupt.bak, original gone.
1659        let r = diagnose_in(tmp.path(), &opts(false, true));
1660        let c = r
1661            .state_files
1662            .iter()
1663            .find(|f| f.name == "connectors.json")
1664            .unwrap();
1665        assert!(matches!(
1666            c.status,
1667            StateFileStatus::Unparseable {
1668                backed_up_to: Some(_),
1669                ..
1670            }
1671        ));
1672        assert!(!tmp.path().join("connectors.json").exists());
1673        assert!(tmp.path().join("connectors.json.corrupt.bak").exists());
1674    }
1675
1676    #[test]
1677    fn dotenv_env_file_is_never_parsed_or_moved() {
1678        // ~/.car/env is dotenv (KEY=VALUE), not JSON. It must be recognized
1679        // (not a leftover), never flagged Unparseable, and never moved aside by
1680        // --repair (it holds secrets).
1681        let tmp = TempDir::new().unwrap();
1682        std::fs::write(
1683            tmp.path().join("env"),
1684            "ANTHROPIC_API_KEY=sk-secret\nFOO=bar\n",
1685        )
1686        .unwrap();
1687
1688        let r = diagnose_in(tmp.path(), &opts(false, true));
1689        assert!(
1690            r.is_healthy(),
1691            "dotenv env must not make the install unhealthy"
1692        );
1693        assert!(
1694            !r.unrecognized.contains(&"env".to_string()),
1695            "env is recognized"
1696        );
1697        assert!(
1698            r.state_files.iter().all(|f| f.name != "env"),
1699            "env is never JSON-checked"
1700        );
1701        assert!(
1702            tmp.path().join("env").exists(),
1703            "repair must not move the secrets file"
1704        );
1705        assert!(!tmp.path().join("env.corrupt.bak").exists());
1706    }
1707
1708    #[test]
1709    fn empty_state_file_is_ok() {
1710        let tmp = TempDir::new().unwrap();
1711        std::fs::write(tmp.path().join("messaging.json"), "").unwrap();
1712        let r = diagnose_in(tmp.path(), &opts(false, false));
1713        let c = r
1714            .state_files
1715            .iter()
1716            .find(|f| f.name == "messaging.json")
1717            .unwrap();
1718        assert!(matches!(c.status, StateFileStatus::Ok { .. }));
1719    }
1720
1721    /// Parslee-ai/car#616 — the residue of an interrupted `car models pull`:
1722    /// the small manifest files got symlinked into the HF snapshot, the weights
1723    /// never arrived. Observed live as `~/.car/models/Qwen3-4B-MLX/` holding
1724    /// three symlinks and nothing else, while `car doctor --deep --repair`
1725    /// reported "none installed / ✓ Healthy".
1726    #[test]
1727    #[cfg(unix)]
1728    fn interrupted_pull_is_reported_not_skipped() {
1729        let tmp = TempDir::new().unwrap();
1730        // Stand in for the HF snapshot the manifests link into.
1731        let snap = tmp.path().join("hfsnap");
1732        std::fs::create_dir_all(&snap).unwrap();
1733        std::fs::write(snap.join("config.json"), "{}").unwrap();
1734        std::fs::write(snap.join("tokenizer.json"), "{}").unwrap();
1735
1736        let m = tmp.path().join("models").join("Qwen3-4B-MLX");
1737        std::fs::create_dir_all(&m).unwrap();
1738        std::os::unix::fs::symlink(snap.join("config.json"), m.join("config.json")).unwrap();
1739        std::os::unix::fs::symlink(snap.join("tokenizer.json"), m.join("tokenizer.json")).unwrap();
1740        // No *.safetensors anywhere — the pull died before the weights.
1741
1742        let r = diagnose_in(tmp.path(), &opts(false, false));
1743        let check = r
1744            .models
1745            .iter()
1746            .find(|c| c.name == "Qwen3-4B-MLX")
1747            .expect("an interrupted install must appear in the report, not be dropped");
1748        assert!(
1749            matches!(check.status, ModelStatus::Incomplete { .. }),
1750            "expected Incomplete, got {:?}",
1751            check.status
1752        );
1753        assert!(
1754            !r.is_healthy(),
1755            "a half-installed model must not read as healthy"
1756        );
1757    }
1758
1759    /// The other half of #616: abandoned partial downloads were invisible.
1760    /// Reported with sizes, and deliberately never deleted — the HF cache is
1761    /// shared, and a `.sync.part` may belong to a live transfer.
1762    #[test]
1763    fn abandoned_partial_downloads_are_reported_never_deleted() {
1764        let hf = TempDir::new().unwrap();
1765        let blobs = hf.path().join("hub").join("models--x--y").join("blobs");
1766        std::fs::create_dir_all(&blobs).unwrap();
1767        let part = blobs.join("deadbeef.sync.part");
1768        std::fs::write(&part, vec![0u8; 4096]).unwrap();
1769        std::fs::write(blobs.join("finished"), b"whole").unwrap();
1770
1771        let home = TempDir::new().unwrap();
1772        // `find_leftovers` reads HF_HOME through the registry's cache resolver.
1773        let prev = std::env::var_os("HF_HOME");
1774        std::env::set_var("HF_HOME", hf.path());
1775        let r = diagnose_in(home.path(), &opts(false, true));
1776        match prev {
1777            Some(v) => std::env::set_var("HF_HOME", v),
1778            None => std::env::remove_var("HF_HOME"),
1779        }
1780
1781        assert_eq!(
1782            r.leftovers.len(),
1783            1,
1784            "expected one partial: {:?}",
1785            r.leftovers
1786        );
1787        assert_eq!(r.leftovers[0].bytes, 4096);
1788        assert!(r.leftovers[0].path.ends_with("deadbeef.sync.part"));
1789        assert!(
1790            part.exists(),
1791            "--repair must NOT delete a partial: the HF cache is shared and the \
1792             transfer may still be running"
1793        );
1794        // Wasted disk is informational, not breakage.
1795        assert!(r.is_healthy());
1796    }
1797
1798    /// Parslee-ai/car#626 — `--repair` on a healthy install listed "refreshed
1799    /// version stamp" every time, implying it had fixed something.
1800    #[test]
1801    fn repair_does_not_report_an_unchanged_version_stamp() {
1802        let tmp = TempDir::new().unwrap();
1803        // First repair on an unstamped home: the stamp genuinely appears.
1804        let first = diagnose_in(tmp.path(), &opts(false, true));
1805        assert!(
1806            first.repairs.iter().any(|r| r.contains("version stamp")),
1807            "writing a missing stamp IS a repair: {:?}",
1808            first.repairs
1809        );
1810        // Second repair, nothing changed: silence.
1811        let second = diagnose_in(tmp.path(), &opts(false, true));
1812        assert!(
1813            !second.repairs.iter().any(|r| r.contains("version stamp")),
1814            "an unchanged stamp is not a repair: {:?}",
1815            second.repairs
1816        );
1817    }
1818
1819    /// Parslee-ai/car#626 — a journal per session that never executed anything
1820    /// left an empty file nobody reaped (35 of 43 on the reporting install).
1821    /// Safe for `--repair`: zero length provably means zero events.
1822    #[test]
1823    fn empty_journals_are_reaped_on_repair_only() {
1824        let tmp = TempDir::new().unwrap();
1825        let journals = tmp.path().join("journals");
1826        std::fs::create_dir_all(&journals).unwrap();
1827        let empty = journals.join("aaaaaaaaaaaa.jsonl");
1828        let full = journals.join("bbbbbbbbbbbb.jsonl");
1829        let other = journals.join("notes.txt");
1830        std::fs::write(&empty, b"").unwrap();
1831        std::fs::write(&full, b"{\"kind\":\"proposal_received\"}\n").unwrap();
1832        std::fs::write(&other, b"").unwrap();
1833
1834        // Read-only: nothing removed.
1835        let _ = diagnose_in(tmp.path(), &opts(false, false));
1836        assert!(empty.exists(), "no --repair, no deletion");
1837
1838        let r = diagnose_in(tmp.path(), &opts(false, true));
1839        assert!(!empty.exists(), "empty journal should be reaped");
1840        assert!(full.exists(), "a journal with events must be kept");
1841        assert!(other.exists(), "non-.jsonl files are not ours to remove");
1842        assert!(
1843            r.repairs.iter().any(|x| x.contains("empty event journal")),
1844            "the reap should be reported: {:?}",
1845            r.repairs
1846        );
1847    }
1848
1849    #[test]
1850    fn config_only_stub_is_skipped_not_flagged() {
1851        // A managed dir with only a config (weights live in the HF cache) must
1852        // NOT be reported as broken — that was a false positive.
1853        let tmp = TempDir::new().unwrap();
1854        let m = tmp.path().join("models").join("Stub");
1855        std::fs::create_dir_all(&m).unwrap();
1856        std::fs::write(m.join("config.json"), "{}").unwrap();
1857        let r = diagnose_in(tmp.path(), &opts(false, false));
1858        assert!(
1859            r.models.iter().all(|m| m.name != "Stub"),
1860            "stub should be skipped"
1861        );
1862        assert!(r.is_healthy());
1863    }
1864
1865    #[cfg(unix)]
1866    #[test]
1867    fn corrupt_model_weight_is_purged_on_repair() {
1868        let tmp = TempDir::new().unwrap();
1869        let m = tmp.path().join("models").join("Qwen3-Test");
1870        std::fs::create_dir_all(&m).unwrap();
1871        // A dangling weight symlink reads as corrupt under the cheap check.
1872        std::os::unix::fs::symlink(m.join("gone"), m.join("model.safetensors")).unwrap();
1873
1874        let r = diagnose_in(tmp.path(), &opts(false, false));
1875        let mc = r.models.iter().find(|m| m.name == "Qwen3-Test").unwrap();
1876        assert!(matches!(mc.status, ModelStatus::Corrupt { purged: 0, .. }));
1877
1878        let r = diagnose_in(tmp.path(), &opts(false, true));
1879        let mc = r.models.iter().find(|m| m.name == "Qwen3-Test").unwrap();
1880        match &mc.status {
1881            ModelStatus::Corrupt { purged, .. } => assert_eq!(*purged, 1),
1882            other => panic!("expected Corrupt, got {other:?}"),
1883        }
1884        assert!(std::fs::symlink_metadata(m.join("model.safetensors")).is_err());
1885    }
1886
1887    #[test]
1888    fn shared_huggingface_weights_count_as_installed() {
1889        let tmp = TempDir::new().unwrap();
1890        let models_dir = tmp.path().join("models");
1891        std::fs::create_dir(&models_dir).unwrap();
1892        let huggingface_hub = tmp.path().join("huggingface-hub");
1893        let snapshot = huggingface_hub
1894            .join("models--example--doctor-only")
1895            .join("snapshots")
1896            .join("revision");
1897        std::fs::create_dir_all(&snapshot).unwrap();
1898        std::fs::write(snapshot.join("config.json"), b"{}").unwrap();
1899        std::fs::write(snapshot.join("model.safetensors"), b"weights").unwrap();
1900
1901        let schema: crate::schema::ModelSchema = serde_json::from_value(serde_json::json!({
1902            "id": "example/doctor-only:4bit",
1903            "name": "doctor-only",
1904            "provider": "example",
1905            "family": "doctor-test",
1906            "capabilities": ["generate"],
1907            "context_length": 4096,
1908            "source": {
1909                "type": "mlx",
1910                "hf_repo": "example/doctor-only",
1911                "hf_weight_file": null
1912            }
1913        }))
1914        .unwrap();
1915        let mut registry = crate::registry::UnifiedRegistry::new_empty(models_dir.clone());
1916        registry.register(schema);
1917
1918        let report = diagnose_at_with_registry(
1919            tmp.path(),
1920            &models_dir,
1921            &opts(false, false),
1922            &registry,
1923            Some(&huggingface_hub),
1924        );
1925        assert!(
1926            report.models.is_empty(),
1927            "shared-cache weights do not create a managed model directory check"
1928        );
1929        assert_eq!(
1930            report.installed_models, 1,
1931            "registry physical readiness must count shared-cache-only weights"
1932        );
1933    }
1934
1935    #[test]
1936    fn current_version_state_entries_are_recognized() {
1937        let tmp = TempDir::new().unwrap();
1938        for name in ["model-resource-policy.json", "parslee-auth-authority.json"] {
1939            std::fs::write(tmp.path().join(name), b"{}").unwrap();
1940        }
1941        for name in ["proposal-completed-index", "selfheal"] {
1942            std::fs::create_dir(tmp.path().join(name)).unwrap();
1943        }
1944
1945        let unrecognized = find_unrecognized(tmp.path());
1946        assert!(
1947            unrecognized.is_empty(),
1948            "current-version state must not be reported as older-install debris: {unrecognized:?}"
1949        );
1950    }
1951
1952    #[test]
1953    fn repair_never_json_validates_or_moves_the_binary_peer_identity_key() {
1954        let tmp = TempDir::new().unwrap();
1955        let key_path = tmp.path().join("peer-identity.key");
1956        let key_bytes = b"0123456789abcdef0123456789abcdef";
1957        std::fs::write(&key_path, key_bytes).unwrap();
1958
1959        let report = diagnose_repair_with_isolated_model_roots(tmp.path());
1960
1961        assert_eq!(
1962            std::fs::read(&key_path).unwrap(),
1963            key_bytes,
1964            "repair must preserve the binary peer identity byte-for-byte"
1965        );
1966        assert!(
1967            !tmp.path().join("peer-identity.key.corrupt.bak").exists(),
1968            "the non-JSON identity key must never be classified as corrupt JSON"
1969        );
1970        assert!(
1971            report
1972                .state_files
1973                .iter()
1974                .all(|file| file.name != "peer-identity.key"),
1975            "the peer identity belongs to the non-JSON allowlist"
1976        );
1977        assert!(
1978            !report
1979                .unrecognized
1980                .contains(&"peer-identity.key".to_string()),
1981            "the live identity key must be recognized"
1982        );
1983    }
1984
1985    /// Exercises the known-non-JSON branch: invalid UTF-8 must bypass
1986    /// `check_state_file` (whose read-to-string error branch reports Absent),
1987    /// remain recognized by `find_unrecognized`, and survive repair untouched.
1988    #[test]
1989    fn repair_preserves_a_non_utf8_peer_identity_key() {
1990        let tmp = TempDir::new().unwrap();
1991        let key_path = tmp.path().join("peer-identity.key");
1992        let key_bytes = [0xff, 0xfe, 0xfd, 0x00, 0x80, 0x81, 0x82, 0x83];
1993        std::fs::write(&key_path, key_bytes).unwrap();
1994
1995        let report = diagnose_repair_with_isolated_model_roots(tmp.path());
1996
1997        assert_eq!(
1998            std::fs::read(&key_path).unwrap(),
1999            key_bytes,
2000            "repair must preserve genuinely non-UTF-8 identity bytes"
2001        );
2002        assert!(
2003            !tmp.path().join("peer-identity.key.corrupt.bak").exists(),
2004            "repair must not create a corrupt-JSON backup for a binary key"
2005        );
2006        assert!(
2007            report
2008                .state_files
2009                .iter()
2010                .all(|file| file.name != "peer-identity.key"),
2011            "the binary key must bypass the JSON state-file checker"
2012        );
2013        assert!(
2014            !report
2015                .unrecognized
2016                .contains(&"peer-identity.key".to_string()),
2017            "the binary key must take the recognized non-JSON branch"
2018        );
2019    }
2020
2021    #[test]
2022    fn unrecognized_entries_are_reported_not_removed() {
2023        let tmp = TempDir::new().unwrap();
2024        std::fs::write(tmp.path().join("mystery-leftover.json"), "{}").unwrap();
2025        std::fs::create_dir_all(tmp.path().join("old_install_dir")).unwrap();
2026        let r = diagnose_in(tmp.path(), &opts(false, true));
2027        assert!(r
2028            .unrecognized
2029            .contains(&"mystery-leftover.json".to_string()));
2030        assert!(r.unrecognized.contains(&"old_install_dir".to_string()));
2031        // Repair must NOT delete unrecognized entries.
2032        assert!(tmp.path().join("mystery-leftover.json").exists());
2033        assert!(tmp.path().join("old_install_dir").exists());
2034    }
2035
2036    #[test]
2037    fn version_skew_detected() {
2038        let tmp = TempDir::new().unwrap();
2039        let stale = VersionStamp {
2040            car_version: "0.0.1-ancient".to_string(),
2041            state_schema_version: 1,
2042            previous_car_version: None,
2043            previous_state_schema_version: None,
2044        };
2045        std::fs::write(
2046            tmp.path().join("version.json"),
2047            serde_json::to_string(&stale).unwrap(),
2048        )
2049        .unwrap();
2050        let r = diagnose_in(tmp.path(), &opts(false, false));
2051        assert!(r.version_skew);
2052        assert!(!r.is_healthy());
2053
2054        // Repair refreshes the stamp to the current binary, clearing skew next run.
2055        let _ = diagnose_in(tmp.path(), &opts(false, true));
2056        let r = diagnose_in(tmp.path(), &opts(false, false));
2057        assert!(!r.version_skew, "stamp refreshed, skew cleared");
2058    }
2059}
2060
2061#[cfg(test)]
2062mod version_stamp_continuity_tests {
2063    use super::*;
2064
2065    fn write(home: &Path, stamp: &VersionStamp) {
2066        std::fs::create_dir_all(home).unwrap();
2067        std::fs::write(
2068            home.join("version.json"),
2069            serde_json::to_string_pretty(stamp).unwrap(),
2070        )
2071        .unwrap();
2072    }
2073
2074    fn stamp(version: &str, schema: u32) -> VersionStamp {
2075        VersionStamp {
2076            car_version: version.to_string(),
2077            state_schema_version: schema,
2078            previous_car_version: None,
2079            previous_state_schema_version: None,
2080        }
2081    }
2082
2083    /// The car#881 bug, as a test. A daemon boot used to overwrite the stamp
2084    /// unconditionally, so the fact that an older CAR wrote this state was gone
2085    /// before `car doctor` — the only reader — could ever be run.
2086    #[test]
2087    fn stamping_after_an_upgrade_preserves_what_it_replaced() {
2088        let dir = tempfile::tempdir().unwrap();
2089        write(dir.path(), &stamp("0.39.0", 1));
2090
2091        let t = stamp_version(dir.path()).unwrap();
2092        assert!(t.upgraded(), "0.39.0 -> current is an upgrade");
2093        assert_eq!(
2094            t.current.previous_car_version.as_deref(),
2095            Some("0.39.0"),
2096            "the predecessor must survive the stamp that replaced it"
2097        );
2098
2099        let report = diagnose_in(dir.path(), &DoctorOptions::default());
2100        assert_eq!(
2101            report.carried_from_version.as_deref(),
2102            Some("0.39.0"),
2103            "doctor must still see it AFTER the daemon stamped"
2104        );
2105    }
2106
2107    /// The subtler half. Rewriting `previous` on every boot would collapse it to
2108    /// the current version after one restart — the same erasure, one level down.
2109    #[test]
2110    fn rebooting_on_the_same_version_keeps_the_original_predecessor() {
2111        let dir = tempfile::tempdir().unwrap();
2112        write(dir.path(), &stamp("0.39.0", 1));
2113
2114        stamp_version(dir.path()).unwrap(); // the upgrade
2115        for _ in 0..5 {
2116            stamp_version(dir.path()).unwrap(); // ordinary reboots
2117        }
2118
2119        let report = diagnose_in(dir.path(), &DoctorOptions::default());
2120        assert_eq!(
2121            report.carried_from_version.as_deref(),
2122            Some("0.39.0"),
2123            "five reboots must not rewrite the predecessor to the current version"
2124        );
2125    }
2126
2127    #[test]
2128    fn a_fresh_install_records_no_predecessor() {
2129        let dir = tempfile::tempdir().unwrap();
2130        let t = stamp_version(dir.path()).unwrap();
2131        assert!(t.previous.is_none());
2132        assert!(!t.upgraded());
2133        assert!(t.current.previous_car_version.is_none());
2134        assert!(!diagnose_in(dir.path(), &DoctorOptions::default()).schema_from_the_future);
2135    }
2136
2137    /// A downgrade. STATE_SCHEMA_VERSION's contract says a reader older than the
2138    /// state should refuse rather than corrupt; nothing acted on it before.
2139    #[test]
2140    fn state_from_a_newer_schema_is_flagged() {
2141        let dir = tempfile::tempdir().unwrap();
2142        write(dir.path(), &stamp("99.0.0", STATE_SCHEMA_VERSION + 7));
2143
2144        let t = stamp_version(dir.path()).unwrap();
2145        assert!(
2146            t.schema_from_the_future(),
2147            "newer on-disk schema must be flagged"
2148        );
2149
2150        // And it must be visible to doctor as unhealthy, not merely noted.
2151        write(dir.path(), &stamp("99.0.0", STATE_SCHEMA_VERSION + 7));
2152        let report = diagnose_in(dir.path(), &DoctorOptions::default());
2153        assert!(report.schema_from_the_future);
2154        assert!(!report.is_healthy());
2155    }
2156
2157    /// Carrying state across an upgrade is the NORMAL case. Treating it as a
2158    /// health problem would make every upgraded install permanently unhealthy,
2159    /// which is how a warning gets ignored.
2160    #[test]
2161    fn carrying_state_across_an_upgrade_is_not_unhealthy() {
2162        let dir = tempfile::tempdir().unwrap();
2163        write(dir.path(), &stamp("0.39.0", STATE_SCHEMA_VERSION));
2164        stamp_version(dir.path()).unwrap();
2165
2166        let report = diagnose_in(dir.path(), &DoctorOptions::default());
2167        assert!(report.carried_from_version.is_some());
2168        assert!(report.is_healthy(), "an ordinary upgrade is not a defect");
2169    }
2170
2171    /// An older stamp has no `previous_*` keys at all. It must deserialize, not
2172    /// blow up the boot path.
2173    #[test]
2174    fn a_pre_existing_stamp_without_the_new_fields_still_loads() {
2175        let dir = tempfile::tempdir().unwrap();
2176        std::fs::create_dir_all(dir.path()).unwrap();
2177        std::fs::write(
2178            dir.path().join("version.json"),
2179            r#"{"car_version":"0.39.0","state_schema_version":1}"#,
2180        )
2181        .unwrap();
2182
2183        let t = stamp_version(dir.path()).unwrap();
2184        assert_eq!(
2185            t.previous.as_ref().map(|p| p.car_version.as_str()),
2186            Some("0.39.0")
2187        );
2188        assert_eq!(t.current.previous_car_version.as_deref(), Some("0.39.0"));
2189    }
2190
2191    /// A corrupt stamp must not stop the daemon stamping — it is best-effort by
2192    /// contract, and refusing to boot over an unreadable version file would be a
2193    /// far worse failure than the one it guards.
2194    #[test]
2195    fn a_corrupt_stamp_is_treated_as_absent_and_replaced() {
2196        let dir = tempfile::tempdir().unwrap();
2197        std::fs::create_dir_all(dir.path()).unwrap();
2198        std::fs::write(dir.path().join("version.json"), "{ not json").unwrap();
2199
2200        let t = stamp_version(dir.path()).unwrap();
2201        assert!(t.previous.is_none(), "unreadable == no usable predecessor");
2202        assert_eq!(t.current.car_version, VersionStamp::current().car_version);
2203    }
2204}
2205
2206#[cfg(test)]
2207mod runtime_check_tests {
2208    use super::*;
2209
2210    /// A never-provisioned runtime is absent, not broken — a fresh install must
2211    /// not read as unhealthy.
2212    #[test]
2213    fn absent_runtimes_are_not_reported() {
2214        let dir = tempfile::tempdir().unwrap();
2215        assert!(check_runtimes(dir.path()).is_empty());
2216    }
2217
2218    /// The failure this check exists for: the venv directory is fully populated
2219    /// but its interpreter symlink points at a Homebrew formula that is gone.
2220    // Unix-only because the *setup* needs a POSIX symlink, not because the
2221    // behaviour is. Same shape as `interrupted_pull_is_reported_not_skipped`.
2222    #[cfg(unix)]
2223    #[test]
2224    fn rotated_away_interpreter_is_reported_broken() {
2225        let dir = tempfile::tempdir().unwrap();
2226        let bin = dir.path().join("visual-runtime").join("bin");
2227        std::fs::create_dir_all(&bin).unwrap();
2228        std::os::unix::fs::symlink(
2229            "/opt/homebrew/opt/python@0.0/bin/python0.0",
2230            bin.join("python"),
2231        )
2232        .unwrap();
2233
2234        let checks = check_runtimes(dir.path());
2235        assert_eq!(checks.len(), 1);
2236        assert_eq!(checks[0].name, "visual-runtime");
2237        assert!(checks[0].present);
2238        assert!(!checks[0].interpreter_ok);
2239        assert!(checks[0].is_broken());
2240    }
2241
2242    /// A broken runtime must sink `is_healthy` — the whole point is that this
2243    /// stops being silent.
2244    #[test]
2245    fn broken_runtime_makes_report_unhealthy() {
2246        let mut report = DoctorReport {
2247            car_home: "/tmp/x".into(),
2248            binary_version: VersionStamp::current().car_version,
2249            on_disk_stamp: None,
2250            version_skew: false,
2251            carried_from_version: None,
2252            schema_from_the_future: false,
2253            state_files: Vec::new(),
2254            models: Vec::new(),
2255            installed_models: 0,
2256            leftovers: Vec::new(),
2257            runtimes: Vec::new(),
2258            unrecognized: Vec::new(),
2259            repairs: Vec::new(),
2260            daemon_tmpdir: None,
2261        };
2262        assert!(report.is_healthy());
2263
2264        report.runtimes.push(RuntimeCheck {
2265            name: "speech-runtime".into(),
2266            root: "/tmp/x/speech-runtime".into(),
2267            present: true,
2268            interpreter_ok: false,
2269        });
2270        assert!(!report.is_healthy());
2271    }
2272}
2273
2274/// Parslee-ai/car#1494: the daemon's startup TMPDIR probe record, written by
2275/// [`record_daemon_tmpdir_probe`] and read by the offline doctor. Temp dirs and
2276/// injected paths only.
2277#[cfg(test)]
2278mod daemon_tmpdir_tests {
2279    use super::*;
2280
2281    const NOT_A_DIRECTORY: &[u8] = b"a regular file where TMPDIR should be";
2282
2283    fn marker(pid: u32, ok: bool) -> DaemonTmpdirMarker {
2284        let checked_path = "/private/tmp/PKInstallSandbox.test/tmp".to_string();
2285        DaemonTmpdirMarker {
2286            pid,
2287            booted_at_unix: 1_789_000_000,
2288            car_version: VersionStamp::current().car_version,
2289            checked_path: checked_path.clone(),
2290            checked_path_absolute: Some(checked_path),
2291            ok,
2292            error: (!ok)
2293                .then(|| "create failed: No such file or directory (os error 2)".to_string()),
2294            note: None,
2295        }
2296    }
2297
2298    fn write_marker(home: &Path, marker: &DaemonTmpdirMarker) {
2299        let path = daemon_tmpdir_marker_path(home);
2300        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2301        std::fs::write(path, serde_json::to_string_pretty(marker).unwrap()).unwrap();
2302    }
2303
2304    /// Doctor against `home`, with the weights and Hugging Face roots isolated
2305    /// in temp dirs so nothing outside the test is read.
2306    fn diagnose_home(home: &Path) -> DoctorReport {
2307        let models = tempfile::tempdir().unwrap();
2308        let hub = tempfile::tempdir().unwrap();
2309        diagnose_at_isolated(home, models.path(), hub.path(), &DoctorOptions::default())
2310    }
2311
2312    fn status_of(report: &DoctorReport) -> DaemonTmpdirStatus {
2313        report
2314            .daemon_tmpdir
2315            .clone()
2316            .expect("the record must be reported")
2317            .status
2318    }
2319
2320    /// A pid that named a real process a moment ago and no longer does: a
2321    /// short-lived child, spawned and reaped.
2322    #[cfg(unix)]
2323    fn dead_pid() -> u32 {
2324        let mut child = std::process::Command::new("true")
2325            .spawn()
2326            .expect("spawn `true`");
2327        let pid = child.id();
2328        child.wait().expect("reap `true`");
2329        assert!(
2330            !unix_pid_alive(pid),
2331            "a reaped child's pid must read as gone"
2332        );
2333        pid
2334    }
2335
2336    fn healthy_tmpdir(parent: &Path) -> PathBuf {
2337        let dir = parent.join("healthy-tmpdir");
2338        std::fs::create_dir(&dir).unwrap();
2339        dir
2340    }
2341
2342    #[test]
2343    fn probing_a_file_tmpdir_records_a_failure() {
2344        let tmp = tempfile::tempdir().unwrap();
2345        let home = tmp.path().join("car-home");
2346        let tmpdir = tmp.path().join("tmpdir-is-a-file");
2347        std::fs::write(&tmpdir, NOT_A_DIRECTORY).unwrap();
2348
2349        let recorded = record_daemon_tmpdir_probe(&home, &tmpdir)
2350            .expect("a failed probe is still a successful record");
2351
2352        assert!(!recorded.ok, "{recorded:?}");
2353        assert_eq!(recorded.pid, std::process::id());
2354        assert_eq!(recorded.checked_path, tmpdir.display().to_string());
2355        let error = recorded.error.clone().expect("a failure names its error");
2356        assert!(error.starts_with("create failed"), "{error}");
2357        #[cfg(unix)]
2358        assert!(
2359            error.contains(&format!("os error {}", libc::ENOTDIR)),
2360            "a file where the directory should be is ENOTDIR: {error}"
2361        );
2362        assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2363        assert_eq!(std::fs::read(&tmpdir).unwrap(), NOT_A_DIRECTORY);
2364    }
2365
2366    #[test]
2367    fn a_healthy_probe_replaces_a_prior_failure_and_leaves_nothing_behind() {
2368        let tmp = tempfile::tempdir().unwrap();
2369        let home = tmp.path().join("car-home");
2370        let broken = tmp.path().join("tmpdir-is-a-file");
2371        std::fs::write(&broken, NOT_A_DIRECTORY).unwrap();
2372        let healthy = healthy_tmpdir(tmp.path());
2373
2374        assert!(!record_daemon_tmpdir_probe(&home, &broken).unwrap().ok);
2375        let recorded = record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2376
2377        assert!(recorded.ok, "{recorded:?}");
2378        assert_eq!(recorded.error, None);
2379        assert_eq!(recorded.checked_path_absolute.as_deref(), healthy.to_str());
2380        assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2381        #[cfg(any(unix, windows))]
2382        assert!(
2383            std::fs::read_dir(&healthy).unwrap().next().is_none(),
2384            "Unix unlinks the identity-checked probe and Windows deletes it on close"
2385        );
2386        #[cfg(all(not(unix), not(windows)))]
2387        assert_eq!(
2388            std::fs::read_dir(&healthy).unwrap().count(),
2389            1,
2390            "without portable file identity the tiny probe file is left safely behind"
2391        );
2392        let names: Vec<_> = std::fs::read_dir(home.join(DOCTOR_DIR))
2393            .unwrap()
2394            .map(|entry| entry.unwrap().file_name())
2395            .collect();
2396        assert_eq!(
2397            names,
2398            vec![std::ffi::OsString::from(DAEMON_TMPDIR_MARKER_FILE)],
2399            "no temporary record may be left behind"
2400        );
2401    }
2402
2403    #[cfg(unix)]
2404    #[test]
2405    fn a_symlinked_tmpdir_is_followed_and_its_probe_file_is_removed() {
2406        let tmp = tempfile::tempdir().unwrap();
2407        let home = tmp.path().join("car-home");
2408        let target = healthy_tmpdir(tmp.path());
2409        let link = tmp.path().join("tmpdir-link");
2410        std::os::unix::fs::symlink(&target, &link).unwrap();
2411
2412        let recorded = record_daemon_tmpdir_probe(&home, &link).unwrap();
2413
2414        assert!(
2415            recorded.ok,
2416            "a directory symlink is valid TMPDIR: {recorded:?}"
2417        );
2418        assert_eq!(recorded.checked_path, link.display().to_string());
2419        assert!(
2420            std::fs::read_dir(&target).unwrap().next().is_none(),
2421            "the identity-checked probe file is removed from the resolved directory"
2422        );
2423    }
2424
2425    #[cfg(unix)]
2426    #[test]
2427    fn cleanup_does_not_remove_a_different_file_at_the_probe_path() {
2428        let tmp = tempfile::tempdir().unwrap();
2429        let created_path = tmp.path().join("created");
2430        let probe_path = tmp.path().join("substitute");
2431        std::fs::write(&created_path, b"created").unwrap();
2432        std::fs::write(&probe_path, b"substitute").unwrap();
2433        let created = std::fs::metadata(&created_path).unwrap();
2434
2435        let error = remove_probe_file(&probe_path, &created).unwrap_err();
2436
2437        assert!(error.starts_with("remove skipped"), "{error}");
2438        assert_eq!(std::fs::read(&probe_path).unwrap(), b"substitute");
2439    }
2440
2441    #[test]
2442    fn a_stat_failure_after_create_and_write_records_healthy_with_a_note() {
2443        let tmp = tempfile::tempdir().unwrap();
2444        let home = tmp.path().join("car-home");
2445        let tmpdir = healthy_tmpdir(tmp.path());
2446        let result = combine_probe_results(
2447            Err("stat failed: injected failure".to_string()),
2448            Ok(()),
2449            Err("cleanup skipped: identity could not be proven; file left".to_string()),
2450        );
2451        let recorded = record_daemon_tmpdir_probe_result(&home, &tmpdir, result).unwrap();
2452
2453        assert!(
2454            recorded.ok,
2455            "create and write proved TMPDIR works: {recorded:?}"
2456        );
2457        assert_eq!(recorded.error, None);
2458        let note = recorded.note.as_deref().expect("stat failure is retained");
2459        assert!(note.contains("stat failed: injected failure"), "{note}");
2460        assert!(note.contains("identity could not be proven"), "{note}");
2461        assert!(note.contains("left"), "{note}");
2462
2463        let report = diagnose_home(&home);
2464        assert!(!report.daemon_tmpdir.as_ref().unwrap().is_failing());
2465        assert!(
2466            report.is_healthy(),
2467            "a note must keep doctor at exit 0: {report:?}"
2468        );
2469    }
2470
2471    #[cfg(windows)]
2472    #[test]
2473    fn windows_deletes_the_probe_file_when_its_handle_closes() {
2474        let tmp = tempfile::tempdir().unwrap();
2475        let home = tmp.path().join("car-home");
2476        let healthy = healthy_tmpdir(tmp.path());
2477
2478        let recorded = record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2479
2480        assert!(recorded.ok, "{recorded:?}");
2481        assert!(
2482            std::fs::read_dir(&healthy).unwrap().next().is_none(),
2483            "FILE_FLAG_DELETE_ON_CLOSE must remove the probe file"
2484        );
2485    }
2486
2487    #[test]
2488    fn the_record_does_not_trip_find_unrecognized() {
2489        let tmp = tempfile::tempdir().unwrap();
2490        let home = tmp.path().join("car-home");
2491        let healthy = healthy_tmpdir(tmp.path());
2492
2493        record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2494
2495        assert!(daemon_tmpdir_marker_path(&home).is_file());
2496        assert!(KNOWN_DIRS.contains(&DOCTOR_DIR));
2497        assert!(
2498            find_unrecognized(&home).is_empty(),
2499            "{:?}",
2500            find_unrecognized(&home)
2501        );
2502        assert!(diagnose_home(&home).unrecognized.is_empty());
2503    }
2504
2505    #[test]
2506    fn an_absent_record_is_not_an_error_and_adds_no_json_key() {
2507        let home = tempfile::tempdir().unwrap();
2508        let report = diagnose_home(home.path());
2509        assert!(report.daemon_tmpdir.is_none());
2510        assert!(report.is_healthy(), "{report:?}");
2511        let json = serde_json::to_value(&report).unwrap();
2512        assert!(
2513            json.get("daemon_tmpdir").is_none(),
2514            "additive: a report without a record serializes as before"
2515        );
2516    }
2517
2518    #[cfg(unix)]
2519    #[test]
2520    fn doctor_reads_failure_healthy_stale_and_absent_records() {
2521        let home = tempfile::tempdir().unwrap();
2522        let live = std::process::id();
2523
2524        let absent = diagnose_home(home.path());
2525        assert!(absent.daemon_tmpdir.is_none() && absent.is_healthy());
2526
2527        let failed = marker(live, false);
2528        write_marker(home.path(), &failed);
2529        let report = diagnose_home(home.path());
2530        assert_eq!(
2531            status_of(&report),
2532            DaemonTmpdirStatus::Current { marker: failed }
2533        );
2534        assert!(!report.is_healthy(), "failure from the running daemon");
2535
2536        let mut healthy = marker(live, true);
2537        healthy.checked_path = healthy_tmpdir(home.path()).display().to_string();
2538        healthy.checked_path_absolute = Some(healthy.checked_path.clone());
2539        write_marker(home.path(), &healthy);
2540        let report = diagnose_home(home.path());
2541        assert_eq!(
2542            status_of(&report),
2543            DaemonTmpdirStatus::Current { marker: healthy }
2544        );
2545        assert!(
2546            report.is_healthy(),
2547            "healthy record from the running daemon"
2548        );
2549
2550        let stale = marker(dead_pid(), false);
2551        write_marker(home.path(), &stale);
2552        let report = diagnose_home(home.path());
2553        assert_eq!(
2554            status_of(&report),
2555            DaemonTmpdirStatus::Stale { marker: stale }
2556        );
2557        assert!(report.is_healthy(), "a stale failure is not failing");
2558    }
2559
2560    #[cfg(unix)]
2561    #[test]
2562    fn a_running_daemons_failed_probe_is_unhealthy() {
2563        let home = tempfile::tempdir().unwrap();
2564        let recorded = marker(std::process::id(), false);
2565        write_marker(home.path(), &recorded);
2566
2567        let report = diagnose_home(home.path());
2568
2569        let check = report
2570            .daemon_tmpdir
2571            .clone()
2572            .expect("the record is reported");
2573        assert_eq!(
2574            check.marker_path,
2575            daemon_tmpdir_marker_path(home.path()).display().to_string()
2576        );
2577        assert_eq!(
2578            check.status,
2579            DaemonTmpdirStatus::Current { marker: recorded }
2580        );
2581        assert!(check.is_failing());
2582        assert!(!report.is_healthy(), "{report:?}");
2583    }
2584
2585    #[cfg(unix)]
2586    #[test]
2587    fn a_record_from_a_daemon_that_is_gone_is_stale_not_failing() {
2588        let home = tempfile::tempdir().unwrap();
2589        let recorded = marker(dead_pid(), false);
2590        write_marker(home.path(), &recorded);
2591
2592        let report = diagnose_home(home.path());
2593
2594        let check = report
2595            .daemon_tmpdir
2596            .clone()
2597            .expect("the record is reported");
2598        assert_eq!(check.status, DaemonTmpdirStatus::Stale { marker: recorded });
2599        assert!(!check.is_failing());
2600        assert!(report.is_healthy(), "{report:?}");
2601    }
2602
2603    #[cfg(unix)]
2604    #[test]
2605    fn a_current_ok_record_whose_checked_path_disappeared_is_failing() {
2606        let home = tempfile::tempdir().unwrap();
2607        let checked_path = healthy_tmpdir(home.path());
2608        let mut recorded = marker(std::process::id(), true);
2609        recorded.checked_path = checked_path.display().to_string();
2610        recorded.checked_path_absolute = Some(recorded.checked_path.clone());
2611        write_marker(home.path(), &recorded);
2612        std::fs::remove_dir(&checked_path).unwrap();
2613
2614        let report = diagnose_home(home.path());
2615
2616        assert_eq!(
2617            status_of(&report),
2618            DaemonTmpdirStatus::CurrentPathGone {
2619                marker: recorded.clone()
2620            }
2621        );
2622        assert!(report.daemon_tmpdir.as_ref().unwrap().is_failing());
2623        assert!(
2624            !report.is_healthy(),
2625            "the running daemon's vanished TMPDIR must produce exit 1"
2626        );
2627    }
2628
2629    #[test]
2630    fn a_non_not_found_recheck_error_is_distinct_and_failing() {
2631        let recorded = marker(std::process::id(), true);
2632        let status = recheck_current_tmpdir_with(
2633            DaemonTmpdirStatus::Current {
2634                marker: recorded.clone(),
2635            },
2636            |_| {
2637                Err(std::io::Error::new(
2638                    std::io::ErrorKind::PermissionDenied,
2639                    "injected permission denied",
2640                ))
2641            },
2642        );
2643
2644        assert_eq!(
2645            status,
2646            DaemonTmpdirStatus::CurrentPathRecheckFailed {
2647                marker: recorded,
2648                error: "injected permission denied".to_string(),
2649            }
2650        );
2651        let check = DaemonTmpdirCheck {
2652            marker_path: "doctor/daemon-tmpdir.json".to_string(),
2653            status,
2654        };
2655        assert!(check.is_failing());
2656    }
2657
2658    #[cfg(unix)]
2659    #[test]
2660    fn a_relative_probe_path_survives_doctor_running_from_a_different_cwd() {
2661        let runner_temp_root = std::env::temp_dir();
2662        let fixture_root = if runner_temp_root.to_str().is_some() {
2663            runner_temp_root.as_path()
2664        } else {
2665            Path::new(env!("CARGO_MANIFEST_DIR"))
2666        };
2667        assert!(
2668            fixture_root.to_str().is_some(),
2669            "the fixture root must be valid UTF-8"
2670        );
2671        let daemon_cwd = tempfile::Builder::new()
2672            .prefix("car-daemon-cwd-")
2673            .tempdir_in(fixture_root)
2674            .unwrap();
2675        let doctor_cwd = tempfile::Builder::new()
2676            .prefix("car-doctor-cwd-")
2677            .tempdir_in(fixture_root)
2678            .unwrap();
2679        assert!(daemon_cwd.path().to_str().is_some());
2680        assert!(doctor_cwd.path().to_str().is_some());
2681
2682        let relative = Path::new("relative-tmpdir");
2683        let actual = daemon_cwd.path().join(relative);
2684        std::fs::create_dir(&actual).unwrap();
2685        assert!(
2686            matches!(
2687                std::fs::metadata(doctor_cwd.path().join(relative)),
2688                Err(error) if error.kind() == std::io::ErrorKind::NotFound
2689            ),
2690            "the old display-path recheck would report this path as gone"
2691        );
2692
2693        let (exact, note) =
2694            exact_absolute_tmpdir_path(relative, Ok(daemon_cwd.path().to_path_buf()));
2695        assert_eq!(note, None);
2696        assert_eq!(exact.as_deref(), actual.to_str());
2697        let mut recorded = marker(std::process::id(), true);
2698        recorded.checked_path = relative.display().to_string();
2699        recorded.checked_path_absolute = exact;
2700
2701        let mut received_path = None;
2702        let status = recheck_current_tmpdir_with(
2703            DaemonTmpdirStatus::Current {
2704                marker: recorded.clone(),
2705            },
2706            |path| {
2707                received_path = Some(path.to_path_buf());
2708                std::fs::metadata(path).map(|_| ())
2709            },
2710        );
2711
2712        assert_eq!(received_path.as_deref(), Some(actual.as_path()));
2713        assert!(received_path
2714            .as_ref()
2715            .is_some_and(|path| path.is_absolute()));
2716        assert_eq!(status, DaemonTmpdirStatus::Current { marker: recorded });
2717    }
2718
2719    #[cfg(unix)]
2720    #[test]
2721    fn an_old_record_without_an_exact_path_skips_recheck_with_a_note() {
2722        let home = tempfile::tempdir().unwrap();
2723        let mut recorded = marker(std::process::id(), true);
2724        recorded.checked_path_absolute = None;
2725        write_marker(home.path(), &recorded);
2726
2727        let report = diagnose_home(home.path());
2728        let DaemonTmpdirStatus::Current { marker } = status_of(&report) else {
2729            panic!("old record must remain current: {:?}", report.daemon_tmpdir);
2730        };
2731
2732        assert!(marker
2733            .note
2734            .as_deref()
2735            .is_some_and(|note| note.contains("startup record has no exact absolute probe path")));
2736        assert!(
2737            report.is_healthy(),
2738            "skipping an unsafe guess is informational"
2739        );
2740    }
2741
2742    #[cfg(unix)]
2743    #[test]
2744    fn a_non_utf8_probe_path_skips_future_rechecks_with_a_note() {
2745        use std::os::unix::ffi::OsStringExt;
2746
2747        let path = PathBuf::from(std::ffi::OsString::from_vec(vec![
2748            b'/', b't', b'm', b'p', b'/', 0xff,
2749        ]));
2750        let (exact, note) = exact_absolute_tmpdir_path(&path, Ok(PathBuf::from("/ignored")));
2751
2752        assert_eq!(exact, None);
2753        assert!(note
2754            .as_deref()
2755            .is_some_and(|note| note.contains("not valid UTF-8")));
2756    }
2757
2758    #[test]
2759    fn an_ok_record_is_healthy() {
2760        let home = tempfile::tempdir().unwrap();
2761        let checked_path = healthy_tmpdir(home.path());
2762        let mut recorded = marker(std::process::id(), true);
2763        recorded.checked_path = checked_path.display().to_string();
2764        recorded.checked_path_absolute = Some(recorded.checked_path.clone());
2765        write_marker(home.path(), &recorded);
2766
2767        let report = diagnose_home(home.path());
2768
2769        #[cfg(unix)]
2770        assert_eq!(
2771            status_of(&report),
2772            DaemonTmpdirStatus::Current { marker: recorded }
2773        );
2774        #[cfg(not(unix))]
2775        assert_eq!(
2776            status_of(&report),
2777            DaemonTmpdirStatus::Unverified { marker: recorded }
2778        );
2779        assert!(report.is_healthy(), "{report:?}");
2780    }
2781
2782    /// Off Unix there is no liveness check: the record is shown with its
2783    /// result and never folded into health as a failure.
2784    #[test]
2785    fn without_a_liveness_check_a_failed_record_is_unverified_not_failing() {
2786        let recorded = marker(std::process::id(), false);
2787        let status = classify_daemon_tmpdir_marker(recorded.clone(), None);
2788        assert_eq!(status, DaemonTmpdirStatus::Unverified { marker: recorded });
2789        let check = DaemonTmpdirCheck {
2790            marker_path: "doctor/daemon-tmpdir.json".to_string(),
2791            status,
2792        };
2793        assert!(!check.is_failing());
2794    }
2795
2796    #[test]
2797    fn an_unreadable_record_is_reported_not_failing() {
2798        let home = tempfile::tempdir().unwrap();
2799        let path = daemon_tmpdir_marker_path(home.path());
2800        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2801        std::fs::write(&path, "{ not json").unwrap();
2802
2803        let report = diagnose_home(home.path());
2804
2805        assert!(
2806            matches!(status_of(&report), DaemonTmpdirStatus::Unreadable { .. }),
2807            "{:?}",
2808            report.daemon_tmpdir
2809        );
2810        assert!(report.is_healthy(), "{report:?}");
2811    }
2812
2813    #[test]
2814    fn a_record_serializes_additively_and_round_trips() {
2815        let home = tempfile::tempdir().unwrap();
2816        write_marker(home.path(), &marker(std::process::id(), true));
2817
2818        let json = serde_json::to_value(diagnose_home(home.path())).unwrap();
2819
2820        let record = json
2821            .get("daemon_tmpdir")
2822            .expect("the record serializes under `daemon_tmpdir`");
2823        assert!(
2824            record.get("status").is_some() && record.get("marker_path").is_some(),
2825            "{record}"
2826        );
2827        assert_eq!(record["marker"]["ok"], serde_json::Value::Bool(true));
2828        let back: DoctorReport = serde_json::from_value(json).unwrap();
2829        assert!(back.daemon_tmpdir.is_some());
2830    }
2831
2832    #[cfg(unix)]
2833    #[test]
2834    fn the_record_is_never_written_through_a_symlinked_doctor_dir() {
2835        let tmp = tempfile::tempdir().unwrap();
2836        let home = tmp.path().join("car-home");
2837        std::fs::create_dir(&home).unwrap();
2838        let elsewhere = tmp.path().join("elsewhere");
2839        std::fs::create_dir(&elsewhere).unwrap();
2840        std::os::unix::fs::symlink(&elsewhere, home.join(DOCTOR_DIR)).unwrap();
2841        let healthy = healthy_tmpdir(tmp.path());
2842
2843        assert!(record_daemon_tmpdir_probe(&home, &healthy).is_err());
2844        assert!(
2845            std::fs::read_dir(&elsewhere).unwrap().next().is_none(),
2846            "nothing may land where the symlink points"
2847        );
2848    }
2849
2850    #[cfg(unix)]
2851    #[test]
2852    fn a_symlink_at_the_record_path_is_replaced_not_followed() {
2853        let tmp = tempfile::tempdir().unwrap();
2854        let home = tmp.path().join("car-home");
2855        std::fs::create_dir_all(home.join(DOCTOR_DIR)).unwrap();
2856        let victim = tmp.path().join("victim");
2857        std::fs::write(&victim, b"victim").unwrap();
2858        std::os::unix::fs::symlink(&victim, daemon_tmpdir_marker_path(&home)).unwrap();
2859        let healthy = healthy_tmpdir(tmp.path());
2860
2861        let recorded = record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2862
2863        assert_eq!(std::fs::read(&victim).unwrap(), b"victim");
2864        assert!(std::fs::symlink_metadata(daemon_tmpdir_marker_path(&home))
2865            .unwrap()
2866            .file_type()
2867            .is_file());
2868        assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2869    }
2870}