Skip to main content

cli/
doctor.rs

1//! `mushroomdb doctor` — verify an install end to end: the config entry, the
2//! store, the hooks, and a real stdio handshake with the configured MCP
3//! command.
4//!
5//! # Design
6//!
7//! Every check reads what is actually on disk — the same files `install`
8//! wrote — rather than recomputing what an install *should* look like, so
9//! doctor catches drift (a hand-edited config, a stale command) that a
10//! `run_install_with` re-derivation would paper over. Checks run in a fixed
11//! order and each prints exactly one line: `ok|warn|fail  <name>  <message>`,
12//! with a trailing `fix: …` when there is something to run. The same store
13//! state always produces the same output.
14//!
15//! `fail` on any check is the only thing that sets the process exit code;
16//! `warn` is informational.
17
18use crate::install::{
19    claude_mcp_file, cursor_mcp_file, default_db, entry_db, expand_platform, git_hooks_dir,
20    has_our_server, installed_shape, is_disabled, is_our_hook_command, line_runs_for_store,
21    opt_ins, resolve_platform, resolve_scope, Externals, Platform, Scope, StoreRef, AUTO_ARG,
22    BRIEF_EVENT, ENRICH_EVENT, GIT_HOOKS, HOOK_BEGIN, HOOK_EVENT, IMPACT_EVENT, INTERCEPT_EVENT,
23    SERVER_NAME, TOUCH_EVENT,
24};
25use crate::CliError;
26use core_api::{GraphDb, GraphError, OpenOptions};
27use serde_json::Value as Js;
28use std::io::{BufRead, BufReader, Read as _, Write as _};
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{Duration, Instant};
32
33/// Options parsed from `mushroomdb doctor [flags]`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DoctorOpts {
36    /// Which platform's config to check. `None` = auto-detect, same as `install`.
37    pub platform: Option<Platform>,
38    /// Project or user scope. `None` = auto: project inside a git checkout.
39    pub scope: Option<Scope>,
40}
41
42/// Outcome of `mushroomdb doctor`: the rendered report and whether to exit 1.
43pub struct DoctorReport {
44    /// One line per check, already newline-terminated.
45    pub output: String,
46    /// True when any check is `fail`. The caller exits 1 on this and only this.
47    pub had_fail: bool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum Status {
52    Ok,
53    /// The check does not apply to this install — not a finding either way.
54    Skip,
55    Warn,
56    Fail,
57}
58
59impl Status {
60    fn word(self) -> &'static str {
61        match self {
62            Status::Ok => "ok",
63            Status::Skip => "skip",
64            Status::Warn => "warn",
65            Status::Fail => "fail",
66        }
67    }
68}
69
70/// One printed line: a status, the check's name, a message, and an optional
71/// one-line fix.
72struct Check {
73    status: Status,
74    name: &'static str,
75    message: String,
76    fix: Option<String>,
77}
78
79impl Check {
80    fn ok(name: &'static str, message: impl Into<String>) -> Self {
81        Check {
82            status: Status::Ok,
83            name,
84            message: message.into(),
85            fix: None,
86        }
87    }
88    fn skip(name: &'static str, message: impl Into<String>) -> Self {
89        Check {
90            status: Status::Skip,
91            name,
92            message: message.into(),
93            fix: None,
94        }
95    }
96    fn warn(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
97        Check {
98            status: Status::Warn,
99            name,
100            message: message.into(),
101            fix,
102        }
103    }
104    fn fail(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
105        Check {
106            status: Status::Fail,
107            name,
108            message: message.into(),
109            fix,
110        }
111    }
112    fn render(&self) -> String {
113        // 11 is the longest check name (`impact-hook`, `always-load`), so the
114        // message column lines up for every row rather than for most of them.
115        let mut line = format!(
116            "{:<4} {:<11} {}",
117            self.status.word(),
118            self.name,
119            self.message
120        );
121        if let Some(fix) = &self.fix {
122            line.push_str(&format!("  fix: {fix}"));
123        }
124        line.push('\n');
125        line
126    }
127}
128
129/// Run `doctor` against the real environment: `HOME` and the process PATH.
130pub fn run_doctor(
131    project_root: &Path,
132    home: &Path,
133    opts: &DoctorOpts,
134) -> Result<DoctorReport, CliError> {
135    run_doctor_with(project_root, home, opts, &Externals::from_env())
136}
137
138/// Like [`run_doctor`], with the external environment (PATH) supplied by the
139/// caller. Tests use this to stay deterministic and to point `npx` lookups at
140/// a directory of stand-ins.
141pub fn run_doctor_with(
142    project_root: &Path,
143    home: &Path,
144    opts: &DoctorOpts,
145    ext: &Externals,
146) -> Result<DoctorReport, CliError> {
147    let (scope, _auto_scope) = resolve_scope(project_root, opts.scope);
148    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
149    let platforms = expand_platform(&resolved);
150
151    // A disabled install has no config to check — every check below it would
152    // report exactly what `disable` intentionally removed, which is not a
153    // failure. Report the state and stop; `warn` never sets the exit code.
154    if is_disabled(project_root, home, scope, &platforms) {
155        return Ok(DoctorReport {
156            output: Check::warn("state", "disabled — enable with: mushroomdb enable", None)
157                .render(),
158            had_fail: false,
159        });
160    }
161
162    // A `cli` install registers no MCP server on purpose, so the two checks
163    // that read one have nothing to read and no fault to report; the store it
164    // is wired to comes from the hooks it did write. Everything else — the
165    // store, the hooks, the git hooks — is as breakable here as anywhere and
166    // still runs.
167    let (delivery, recorded_store) = installed_shape(project_root, home, scope, &platforms);
168    let mcp_delivery = delivery.wires_mcp();
169
170    let mut checks: Vec<Check> = Vec::new();
171    let mut primary: Option<(Platform, ConfigEntry)> = None;
172
173    // 1. config — one line per requested platform; the first entry that reads
174    //    cleanly becomes the target of every check below it.
175    for plat in &platforms {
176        // A `cli` delivery closes Claude Code's door and no other's: Cursor
177        // and Codex are registered as servers whatever it asked for, so their
178        // entries are still there to check (see [`install::Delivery`]).
179        if !mcp_delivery && matches!(plat, Platform::ClaudeCode) {
180            checks.push(Check::skip(
181                "config",
182                format!(
183                    "delivery: {} — the skill teaches the binary, no MCP entry to check",
184                    delivery.label()
185                ),
186            ));
187            continue;
188        }
189        match mcp_file_for(plat, project_root, home, scope) {
190            None => checks.push(Check::warn(
191                "config",
192                format!(
193                    "{}'s configuration is owned by its own CLI — not checked here",
194                    plat.label()
195                ),
196                None,
197            )),
198            Some(mcp_file) => match read_config_entry(&mcp_file, project_root, home) {
199                Ok(entry) => {
200                    checks.push(Check::ok(
201                        "config",
202                        format!(
203                            "{} — {} -> {}",
204                            plat.label(),
205                            mcp_file.display(),
206                            entry.describe_store()
207                        ),
208                    ));
209                    if primary.is_none() {
210                        primary = Some((plat.clone(), entry));
211                    }
212                }
213                Err(msg) => checks.push(Check::fail("config", msg, Some(install_fix(scope, plat)))),
214            },
215        }
216    }
217
218    // The store every check below reads: the one the config entry names, or —
219    // with no entry to name it — the one this install's hooks were written for.
220    let store: Option<StoreRef> = match &primary {
221        Some((_, entry)) => Some(entry.store.clone()),
222        None if !mcp_delivery => recorded_store,
223        None => None,
224    };
225
226    // 2. how the server is spawned — `npx` fetches the package, a resolved
227    //    binary or launcher is a file that has to still be there.
228    if let Some((_, entry)) = &primary {
229        if entry.command == "npx" {
230            checks.push(check_npx(entry, ext));
231        } else if let Some(check) = check_resolved_path(entry) {
232            checks.push(check);
233        }
234    }
235
236    // 3. store, then the write-lock probe (same check family, adjacent lines).
237    //
238    // How long that open took is carried down to the handshake, which spawns a
239    // server that must repeat it before it can answer anything.
240    let mut store_opened_in: Option<Duration> = None;
241    match &store {
242        Some(store) => {
243            let StoreChecks {
244                checks: cs,
245                opened_in,
246            } = check_store_and_lock(store.path());
247            checks.extend(cs);
248            store_opened_in = opened_in;
249        }
250        None => checks.push(Check::fail(
251            "store",
252            no_store_message(mcp_delivery),
253            Some(install_fix_for_scope(scope)),
254        )),
255    }
256
257    // 4. hooks — Claude Code only; Cursor has no prompt/tool-use hooks to check.
258    if platforms.contains(&Platform::ClaudeCode) {
259        if let Some(store) = &store {
260            checks.push(check_hooks(project_root, home, scope, store));
261            // The opt-in hooks each earn a line only where the manifest says
262            // this install asked for one. Reporting them otherwise would say
263            // something about every install that is true of none.
264            let opted = opt_ins(project_root, home, scope, &platforms);
265            let settings_file = settings_file(project_root, home, scope);
266            let settings = read_json(&settings_file).unwrap_or(Js::Null);
267            for (asked, sub, event, matcher, flag) in [
268                (
269                    opted.intercept_grep,
270                    "intercept",
271                    INTERCEPT_EVENT,
272                    "Grep",
273                    "--intercept-grep",
274                ),
275                (
276                    opted.impact_before_edit,
277                    "impact-hook",
278                    IMPACT_EVENT,
279                    "Edit|Write|MultiEdit",
280                    "--impact-before-edit",
281                ),
282                (
283                    opted.enrich_grep,
284                    "enrich",
285                    ENRICH_EVENT,
286                    "Grep",
287                    "--enrich-grep",
288                ),
289            ] {
290                if asked {
291                    checks.push(check_opt_in_hook(
292                        sub,
293                        event,
294                        matcher,
295                        flag,
296                        &settings_file,
297                        &settings,
298                        store,
299                    ));
300                }
301            }
302            if opted.always_load {
303                checks.push(check_always_load(project_root, home, scope));
304            }
305        }
306    }
307
308    // 5. git hooks — project scope, and only for the platforms whose install
309    //    wires the repository (matches `install::write_everything`).
310    if scope == Scope::Project
311        && platforms
312            .iter()
313            .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor))
314    {
315        if let Some(store) = &store {
316            if let Some(check) = check_git_hooks(project_root, store) {
317                checks.push(check);
318            }
319        }
320    }
321
322    // 6. self-handshake — spawn the configured command for real.
323    match &primary {
324        Some((_, entry)) => checks.push(check_handshake(entry, store_opened_in)),
325        // No entry and none expected: nothing to spawn, and nothing wrong.
326        None if !mcp_delivery => checks.push(Check::skip(
327            "handshake",
328            format!("delivery: {} — no server to spawn", delivery.label()),
329        )),
330        None => checks.push(Check::fail(
331            "handshake",
332            "no usable config entry — nothing to spawn",
333            Some(install_fix_for_scope(scope)),
334        )),
335    }
336
337    // 7. duplicate scope — a second Claude Code server in the other scope.
338    if platforms.contains(&Platform::ClaudeCode) {
339        checks.push(check_scope_conflict(project_root, home, scope));
340    }
341
342    let had_fail = checks.iter().any(|c| c.status == Status::Fail);
343    let mut output = String::new();
344    for c in &checks {
345        output.push_str(&c.render());
346    }
347    Ok(DoctorReport { output, had_fail })
348}
349
350/// Why doctor could not find a store to check, in the terms of whichever
351/// artifact was supposed to name one.
352fn no_store_message(mcp_delivery: bool) -> &'static str {
353    if mcp_delivery {
354        "no usable config entry — cannot locate a database to check"
355    } else {
356        "no recorded SessionStart hook — cannot locate a database to check"
357    }
358}
359
360fn install_fix_for_scope(scope: Scope) -> String {
361    format!(
362        "mushroomdb install {}",
363        match scope {
364            Scope::Project => "--project",
365            Scope::User => "--user",
366        }
367    )
368}
369
370fn install_fix(scope: Scope, plat: &Platform) -> String {
371    format!(
372        "mushroomdb install --platform {} {}",
373        plat.label(),
374        match scope {
375            Scope::Project => "--project",
376            Scope::User => "--user",
377        }
378    )
379}
380
381fn mcp_file_for(
382    plat: &Platform,
383    project_root: &Path,
384    home: &Path,
385    scope: Scope,
386) -> Option<PathBuf> {
387    match plat {
388        Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
389        Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
390        Platform::Codex | Platform::All => None,
391    }
392}
393
394// ---------------------------------------------------------------------------
395// config
396// ---------------------------------------------------------------------------
397
398/// The MCP server entry doctor found: what it points at and how it is spawned.
399struct ConfigEntry {
400    /// The store the entry names, as written: a path, or `--auto`.
401    store: StoreRef,
402    command: String,
403    args: Vec<String>,
404}
405
406impl ConfigEntry {
407    /// How the config line reports the store. An `--auto` entry says what it
408    /// resolves to as well, since that is the directory every check below it
409    /// reads and the one the reader wants to see.
410    fn describe_store(&self) -> String {
411        if self.store.is_auto() {
412            format!("{AUTO_ARG} -> {}", self.store.path().display())
413        } else {
414            self.store.path().display().to_string()
415        }
416    }
417}
418
419/// The store an entry's argument names, resolved the way the command it was
420/// written for would resolve it.
421///
422/// `--auto` is resolved from `project_root` rather than doctor's own working
423/// directory: doctor was already told which project it is checking, and a
424/// report that changes depending on which subdirectory it was typed in would
425/// be a worse report. That is the same directory a hook resolves to, since a
426/// hook walks up to the working tree root from wherever it fired.
427fn entry_store(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
428    if arg == AUTO_ARG {
429        return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
430    }
431    let path = PathBuf::from(arg);
432    if path == default_db(Scope::Project, project_root, home) {
433        return StoreRef::pinned(path).also_auto();
434    }
435    StoreRef::pinned(path)
436}
437
438fn read_json(path: &Path) -> Result<Js, String> {
439    let raw = std::fs::read_to_string(path)
440        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
441    serde_json::from_str(&raw).map_err(|e| format!("invalid JSON in {}: {e}", path.display()))
442}
443
444fn read_config_entry(
445    mcp_file: &Path,
446    project_root: &Path,
447    home: &Path,
448) -> Result<ConfigEntry, String> {
449    if !mcp_file.exists() {
450        return Err(format!("{} does not exist", mcp_file.display()));
451    }
452    let root = read_json(mcp_file)?;
453    let entry = &root["mcpServers"]["mushroomdb"];
454    if entry.is_null() {
455        return Err(format!(
456            "no mcpServers.mushroomdb entry in {}",
457            mcp_file.display()
458        ));
459    }
460    let store = entry_store(
461        entry_db(entry).ok_or_else(|| {
462            format!(
463                "{}: mushroomdb entry has no `mcp <db>|--auto` argument",
464                mcp_file.display()
465            )
466        })?,
467        project_root,
468        home,
469    );
470    let command = entry["command"]
471        .as_str()
472        .ok_or_else(|| format!("{}: mushroomdb entry has no `command`", mcp_file.display()))?
473        .to_string();
474    let args = entry["args"]
475        .as_array()
476        .map(|a| {
477            a.iter()
478                .filter_map(|v| v.as_str().map(str::to_string))
479                .collect()
480        })
481        .unwrap_or_default();
482    Ok(ConfigEntry {
483        store,
484        command,
485        args,
486    })
487}
488
489// ---------------------------------------------------------------------------
490// npx
491// ---------------------------------------------------------------------------
492
493const NPX_TIMEOUT: Duration = Duration::from_secs(60);
494
495fn check_npx(entry: &ConfigEntry, ext: &Externals) -> Check {
496    let pinned = entry
497        .args
498        .iter()
499        .find_map(|a| a.strip_prefix("mushroomdb@"))
500        .unwrap_or(crate::VERSION);
501    let Some(npx) = ext.which("npx") else {
502        return Check::fail(
503            "npx",
504            "npx is not on PATH",
505            Some(
506                "install Node.js (which provides npx), or re-install with --command <path>"
507                    .to_string(),
508            ),
509        );
510    };
511    let args = vec![
512        "-y".to_string(),
513        format!("mushroomdb@{pinned}"),
514        "--version".to_string(),
515    ];
516    match run_capturing(&npx, &args, NPX_TIMEOUT) {
517        RunOutcome::Done(out) if out.contains(pinned) => Check::ok(
518            "npx",
519            format!("npx -y mushroomdb@{pinned} --version -> {}", out.trim()),
520        ),
521        RunOutcome::Done(out) => Check::fail(
522            "npx",
523            format!(
524                "npx -y mushroomdb@{pinned} --version printed {:?}, expected to contain {pinned}",
525                out.trim()
526            ),
527            Some("re-run `mushroomdb install` to repin the version".to_string()),
528        ),
529        RunOutcome::TimedOut => Check::warn(
530            "npx",
531            format!("npx -y mushroomdb@{pinned} --version timed out after {NPX_TIMEOUT:?}"),
532            Some("check network access to the npm registry".to_string()),
533        ),
534        RunOutcome::Failed(e) => Check::fail("npx", e, None),
535    }
536}
537
538/// The resolved-path counterpart to [`check_npx`].
539///
540/// `install` writes a path rather than `npx` so the hooks do not spawn `npx`
541/// on every prompt: the package's native binary, or `node <launcher.js>` when
542/// the binary was not fetched. Both live in npm's cache, and pruning it — or
543/// npx evicting an old version — leaves a command that cannot run. The file
544/// existing is the whole check; what it does once it runs is the handshake's
545/// business.
546///
547/// A bare `command` is a PATH lookup, not a file, and is left to the handshake.
548fn check_resolved_path(entry: &ConfigEntry) -> Option<Check> {
549    let (name, path) = if entry.command == "node" {
550        ("launcher", Path::new(entry.args.first()?))
551    } else if Path::new(&entry.command).is_absolute() {
552        ("binary", Path::new(&entry.command))
553    } else {
554        return None;
555    };
556    Some(if path.is_file() {
557        Check::ok(name, path.display().to_string())
558    } else {
559        Check::fail(
560            name,
561            format!("{} no longer exists", path.display()),
562            Some("mushroomdb install (re-resolves it)".to_string()),
563        )
564    })
565}
566
567enum RunOutcome {
568    Done(String),
569    TimedOut,
570    Failed(String),
571}
572
573/// Run `bin`, capturing stdout, giving up after `timeout`.
574fn run_capturing(bin: &Path, args: &[String], timeout: Duration) -> RunOutcome {
575    let mut child = match Command::new(bin)
576        .args(args)
577        .stdin(Stdio::null())
578        .stdout(Stdio::piped())
579        .stderr(Stdio::null())
580        .spawn()
581    {
582        Ok(c) => c,
583        Err(e) => return RunOutcome::Failed(format!("cannot run {}: {e}", bin.display())),
584    };
585    let mut stdout = child.stdout.take().expect("piped stdout");
586    let (tx, rx) = std::sync::mpsc::channel::<String>();
587    std::thread::spawn(move || {
588        let mut out = String::new();
589        let _ = stdout.read_to_string(&mut out);
590        let _ = tx.send(out);
591    });
592    let deadline = Instant::now() + timeout;
593    loop {
594        match child.try_wait() {
595            Ok(Some(status)) => {
596                let out = rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default();
597                return if status.success() {
598                    RunOutcome::Done(out)
599                } else {
600                    RunOutcome::Failed(format!("{} exited with {status}", bin.display()))
601                };
602            }
603            Ok(None) if Instant::now() >= deadline => {
604                let _ = child.kill();
605                let _ = child.wait();
606                return RunOutcome::TimedOut;
607            }
608            Ok(None) => std::thread::sleep(Duration::from_millis(25)),
609            Err(e) => return RunOutcome::Failed(format!("cannot wait for {}: {e}", bin.display())),
610        }
611    }
612}
613
614// ---------------------------------------------------------------------------
615// store + lock
616// ---------------------------------------------------------------------------
617
618/// The store and lock checks, plus how long the store took to open.
619///
620/// The open time is not reported to the user — it is handed to the handshake
621/// check, which spawns a server that has to do the same open again. See
622/// [`handshake_deadline`].
623struct StoreChecks {
624    checks: Vec<Check>,
625    opened_in: Option<Duration>,
626}
627
628fn check_store_and_lock(db_dir: &Path) -> StoreChecks {
629    let mut out = Vec::new();
630    let started = Instant::now();
631    let store = GraphDb::open_with_options(
632        db_dir,
633        OpenOptions {
634            read_only: true,
635            auto_migrate: true,
636            repair_wal: true,
637        },
638    );
639    // Measured whether or not the open succeeded: a slow *failing* open is
640    // still evidence about what the spawned server is in for.
641    let mut opened_in = started.elapsed();
642    match store {
643        Ok(db) => {
644            let stats = db.stats();
645            let stale = db.is_stale().unwrap_or(false);
646            let floor = db.wal_horizon_floor();
647            let total = db.wal_total_commits().unwrap_or(floor);
648            // A store that names no namespace is one implicit `default`
649            // namespace; saying "1 namespaces" on every single-tenant store
650            // would be noise, so the clause appears only once there is more
651            // than one — the same rule `format_stats` follows.
652            let namespaces = if stats.namespaces.len() > 1 {
653                format!(", {} namespaces", stats.namespaces.len())
654            } else {
655                String::new()
656            };
657            out.push(Check::ok(
658                "store",
659                format!(
660                    "{} — {} nodes live ({} tombstoned), {} edges{}, history from commit {} of {}{}",
661                    db_dir.display(),
662                    stats.nodes_live,
663                    stats.nodes_tombstoned,
664                    stats.edges,
665                    namespaces,
666                    floor,
667                    total,
668                    if stale {
669                        ", stale (newer commits pending refresh)"
670                    } else {
671                        ""
672                    }
673                ),
674            ));
675            drop(db);
676
677            // Briefly try to take the write lock. Success means nobody else
678            // holds it; the handle is dropped immediately, before this
679            // function returns, so the lock is never held past the check.
680            //
681            // Timed as well as the read-only open above, and the larger of the
682            // two is what the handshake budgets from: this one is the
683            // read-write shape the MCP server itself uses, and the read-only
684            // open before it may do less work.
685            let write_started = Instant::now();
686            match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
687                Ok(handle) => {
688                    opened_in = opened_in.max(write_started.elapsed());
689                    drop(handle);
690                    out.push(Check::ok(
691                        "lock",
692                        "free — no other process is writing".to_string(),
693                    ));
694                }
695                Err(GraphError::Busy { .. }) => out.push(Check::warn(
696                    "lock",
697                    "another process is writing".to_string(),
698                    Some("re-run once the other process finishes".to_string()),
699                )),
700                Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
701            }
702        }
703        Err(e) => out.push(Check::fail(
704            "store",
705            format!("cannot open {}: {e}", db_dir.display()),
706            Some(format!("mushroomdb verify {}", db_dir.display())),
707        )),
708    }
709    StoreChecks {
710        checks: out,
711        opened_in: Some(opened_in),
712    }
713}
714
715// ---------------------------------------------------------------------------
716// hooks
717// ---------------------------------------------------------------------------
718
719fn check_hooks(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
720    let settings_file = match scope {
721        Scope::Project => project_root.join(".claude").join("settings.json"),
722        Scope::User => home.join(".claude").join("settings.json"),
723    };
724    let root = read_json(&settings_file).unwrap_or(Js::Null);
725    let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", store);
726    let has_touch = has_hook_matching(&root, TOUCH_EVENT, "touch", store);
727    let has_brief = has_hook_matching(&root, BRIEF_EVENT, "brief", store);
728    if has_recall && has_touch && has_brief {
729        Check::ok(
730            "hooks",
731            format!(
732                "{HOOK_EVENT} + {TOUCH_EVENT} + {BRIEF_EVENT} present in {}",
733                settings_file.display()
734            ),
735        )
736    } else {
737        let mut missing = Vec::new();
738        if !has_recall {
739            missing.push(HOOK_EVENT);
740        }
741        if !has_touch {
742            missing.push(TOUCH_EVENT);
743        }
744        if !has_brief {
745            missing.push(BRIEF_EVENT);
746        }
747        Check::warn(
748            "hooks",
749            format!(
750                "missing {} in {}",
751                missing.join(", "),
752                settings_file.display()
753            ),
754            Some("mushroomdb install --platform claude-code".to_string()),
755        )
756    }
757}
758
759/// `alwaysLoad` on the registered server, for an install whose manifest asked
760/// for it.
761///
762/// Reads the file rather than trusting the flag. The two can disagree for
763/// ordinary reasons — the entry hand-edited, a `--delivery cli` install that
764/// records the flag but registers no server for it to sit on, another tool
765/// rewriting `.mcp.json` — and in every one of them the manifest says the
766/// experiment is on while the host is deferring the server exactly as before.
767/// That is the failure a benchmark arm would silently record as "no effect",
768/// so it is a `warn` naming the file, not an `ok`.
769fn check_always_load(project_root: &Path, home: &Path, scope: Scope) -> Check {
770    let mcp_file = claude_mcp_file(project_root, home, scope);
771    let entry = read_json(&mcp_file).unwrap_or(Js::Null);
772    if entry["mcpServers"][SERVER_NAME]["alwaysLoad"] == Js::Bool(true) {
773        Check::ok(
774            "always-load",
775            format!(
776                "mcpServers.{SERVER_NAME} is marked alwaysLoad in {}",
777                mcp_file.display()
778            ),
779        )
780    } else {
781        Check::warn(
782            "always-load",
783            format!("manifest records it but {} does not", mcp_file.display()),
784            Some("mushroomdb install --platform claude-code --always-load".to_string()),
785        )
786    }
787}
788
789/// The `settings.json` a Claude Code install at this scope writes its hooks to.
790fn settings_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
791    match scope {
792        Scope::Project => project_root.join(".claude").join("settings.json"),
793        Scope::User => home.join(".claude").join("settings.json"),
794    }
795}
796
797/// One experimental hook, for an install whose manifest asked for it.
798///
799/// `sub` is the subcommand word, which is what tells two hooks sharing an
800/// event apart; `matcher` and `flag` only ever reach the reported text. The
801/// parsed settings arrive from the caller so three checks cost one read.
802fn check_opt_in_hook(
803    sub: &'static str,
804    event: &str,
805    matcher: &str,
806    flag: &str,
807    settings_file: &Path,
808    root: &Js,
809    store: &StoreRef,
810) -> Check {
811    if has_hook_matching(root, event, sub, store) {
812        Check::ok(
813            sub,
814            format!("{event} ({matcher}) present in {}", settings_file.display()),
815        )
816    } else {
817        Check::warn(
818            sub,
819            format!("missing {event} in {}", settings_file.display()),
820            Some(format!("mushroomdb install --platform claude-code {flag}")),
821        )
822    }
823}
824
825fn has_hook_matching(root: &Js, event: &str, sub: &str, store: &StoreRef) -> bool {
826    root["hooks"][event]
827        .as_array()
828        .map(|groups| {
829            groups.iter().any(|g| {
830                g["hooks"]
831                    .as_array()
832                    .map(|hs| {
833                        hs.iter().any(|h| {
834                            h["command"]
835                                .as_str()
836                                .is_some_and(|c| is_our_hook_command(c, sub, store))
837                        })
838                    })
839                    .unwrap_or(false)
840            })
841        })
842        .unwrap_or(false)
843}
844
845// ---------------------------------------------------------------------------
846// git hooks
847// ---------------------------------------------------------------------------
848
849fn check_git_hooks(project_root: &Path, store: &StoreRef) -> Option<Check> {
850    let dir = git_hooks_dir(project_root)?;
851    // The block belongs to this store if it names it either way. An `--auto`
852    // block in a checkout whose store is the default is the same store, and
853    // that is the shape every project install now writes.
854    let missing: Vec<&str> = GIT_HOOKS
855        .iter()
856        .filter(|name| {
857            let content = std::fs::read_to_string(dir.join(name)).unwrap_or_default();
858            let names_store = content
859                .lines()
860                .any(|l| line_runs_for_store(l, "sync", store));
861            !(content.contains(HOOK_BEGIN) && names_store)
862        })
863        .copied()
864        .collect();
865    Some(if missing.is_empty() {
866        Check::ok(
867            "git-hooks",
868            format!("{} present in {}", GIT_HOOKS.join("/"), dir.display()),
869        )
870    } else {
871        Check::warn(
872            "git-hooks",
873            format!("missing in {}: {}", dir.display(), missing.join(", ")),
874            Some("mushroomdb install --project (omit --no-git-hooks)".to_string()),
875        )
876    })
877}
878
879// ---------------------------------------------------------------------------
880// duplicate scope
881// ---------------------------------------------------------------------------
882
883fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
884    let (other_file, other_label, other_flag) = match scope {
885        Scope::Project => (
886            claude_mcp_file(project_root, home, Scope::User),
887            "user",
888            "--user",
889        ),
890        Scope::User => (
891            claude_mcp_file(project_root, home, Scope::Project),
892            "project",
893            "--project",
894        ),
895    };
896    if has_our_server(&other_file) {
897        Check::warn(
898            "scope",
899            format!(
900                "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
901                other_file.display()
902            ),
903            Some(format!("mushroomdb uninstall {other_flag}")),
904        )
905    } else {
906        Check::ok(
907            "scope",
908            "no duplicate server in the other scope".to_string(),
909        )
910    }
911}
912
913// ---------------------------------------------------------------------------
914// self-handshake
915// ---------------------------------------------------------------------------
916
917/// Floor for the handshake deadline. Enough for any store that opens quickly,
918/// and short enough that a genuinely broken server is reported rather than
919/// waited on.
920const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
921
922/// Slack added on top of twice the observed open, to cover process spawn,
923/// dynamic linking and the two round trips the handshake itself costs.
924const HANDSHAKE_SPAWN_SLACK: Duration = Duration::from_secs(5);
925
926/// The clause appended to a failed handshake's fix hint when the store's own
927/// open is why the deadline was generous.
928///
929/// A server that must open a slow store before it can answer anything is not a
930/// broken server, and saying so saves the reader from debugging a command that
931/// is fine. The trigger is that [`handshake_deadline`] was *raised above its
932/// floor* — exactly the case where the store's cost is the thing worth naming —
933/// rather than the open alone crossing ten seconds, which would stay silent on
934/// the five-second opens that already scale the deadline past the floor.
935fn slow_store_hint(store_opened_in: Option<Duration>, deadline: Duration) -> String {
936    match store_opened_in {
937        Some(open) if deadline > HANDSHAKE_TIMEOUT => format!(
938            "; the store took {:.1}s to open here, so the server was already given \
939             {:.1}s and still did not answer — if the command is right, run \
940             `mushroomdb build-index` and `mushroomdb snapshot` to make the open cheap",
941            open.as_secs_f64(),
942            deadline.as_secs_f64()
943        ),
944        _ => String::new(),
945    }
946}
947
948/// How long to wait for the spawned server's `initialize` and `tools/list`.
949///
950/// The server has to open the same store `doctor` just opened, and that open is
951/// not always cheap: a WAL-only store replays its vector rules' index builds
952/// every time, which on a debug build of a 200-vector 1,536-dimension corpus is
953/// over ten seconds. A fixed 10 s deadline therefore failed the handshake on a
954/// store whose *own* open, moments earlier, had taken longer than that without
955/// anyone calling it a fault.
956///
957/// So the deadline follows the evidence: at least [`HANDSHAKE_TIMEOUT`], and
958/// otherwise twice the open `doctor` measured plus [`HANDSHAKE_SPAWN_SLACK`].
959/// Twice, because the spawned server opens the store on a cold page cache while
960/// `doctor`'s own open has just warmed it, and because a debug build's timing
961/// varies more than a release one's.
962fn handshake_deadline(store_opened_in: Option<Duration>) -> Duration {
963    match store_opened_in {
964        Some(open) => HANDSHAKE_TIMEOUT.max(open * 2 + HANDSHAKE_SPAWN_SLACK),
965        None => HANDSHAKE_TIMEOUT,
966    }
967}
968
969struct HandshakeOk {
970    version: String,
971    tool_count: usize,
972    /// The repository task tool the listing carried — see [`TASK_PATH_TOOLS`].
973    task_tool: String,
974}
975
976fn check_handshake(entry: &ConfigEntry, store_opened_in: Option<Duration>) -> Check {
977    let deadline = handshake_deadline(store_opened_in);
978    match self_handshake(&entry.command, &entry.args, deadline) {
979        Ok(HandshakeOk {
980            version,
981            tool_count,
982            task_tool,
983        }) => Check::ok(
984            "handshake",
985            format!(
986                "initialize + tools/list ok — version {version}, {tool_count} tools \
987                 ({task_tool} present)"
988            ),
989        ),
990        Err(msg) => Check::fail(
991            "handshake",
992            msg,
993            Some(format!(
994                "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
995                 to rewrite the command{}",
996                entry.command,
997                entry.args.join(" "),
998                slow_store_hint(store_opened_in, deadline)
999            )),
1000        ),
1001    }
1002}
1003
1004/// Spawn `command args…`, speak one `initialize` and one `tools/list` request
1005/// over its stdio, and check the response.
1006///
1007/// Reads with `deadline` (see [`handshake_deadline`]); closes stdin once both
1008/// responses are in (or the deadline passes) so a well-behaved server exits on
1009/// EOF, then reaps it with a short bounded wait — a broken server never hangs
1010/// `doctor`.
1011fn self_handshake(
1012    command: &str,
1013    args: &[String],
1014    timeout: Duration,
1015) -> Result<HandshakeOk, String> {
1016    let mut child = Command::new(command)
1017        .args(args)
1018        .stdin(Stdio::piped())
1019        .stdout(Stdio::piped())
1020        .stderr(Stdio::null())
1021        .spawn()
1022        .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
1023
1024    let mut stdin = child.stdin.take().expect("piped stdin");
1025    let stdout = child.stdout.take().expect("piped stdout");
1026
1027    let (tx, rx) = std::sync::mpsc::channel::<String>();
1028    std::thread::spawn(move || {
1029        let mut reader = BufReader::new(stdout);
1030        let mut line = String::new();
1031        loop {
1032            line.clear();
1033            match reader.read_line(&mut line) {
1034                Ok(0) | Err(_) => break,
1035                Ok(_) => {
1036                    if tx.send(line.trim().to_string()).is_err() {
1037                        break;
1038                    }
1039                }
1040            }
1041        }
1042    });
1043
1044    let sent = writeln!(
1045        stdin,
1046        r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
1047    )
1048    .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
1049    .and_then(|()| stdin.flush());
1050
1051    let mut init_resp: Option<Js> = None;
1052    let mut list_resp: Option<Js> = None;
1053    if sent.is_ok() {
1054        let deadline = Instant::now() + timeout;
1055        while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
1056            let remaining = deadline.saturating_duration_since(Instant::now());
1057            match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
1058                Ok(line) if !line.is_empty() => {
1059                    if let Ok(v) = serde_json::from_str::<Js>(&line) {
1060                        match v.get("id").and_then(Js::as_i64) {
1061                            Some(1) => init_resp = Some(v),
1062                            Some(2) => list_resp = Some(v),
1063                            _ => {}
1064                        }
1065                    }
1066                }
1067                Ok(_) => {}
1068                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
1069                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1070            }
1071        }
1072    }
1073
1074    // EOF on stdin is how a well-behaved server knows to exit; then reap it
1075    // with a short bounded wait so a broken one cannot hang doctor.
1076    drop(stdin);
1077    let reap_deadline = Instant::now() + Duration::from_secs(2);
1078    loop {
1079        match child.try_wait() {
1080            Ok(Some(_)) | Err(_) => break,
1081            Ok(None) if Instant::now() >= reap_deadline => {
1082                let _ = child.kill();
1083                let _ = child.wait();
1084                break;
1085            }
1086            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
1087        }
1088    }
1089
1090    if sent.is_err() {
1091        return Err(format!("cannot write to `{command}`'s stdin"));
1092    }
1093    let init = init_resp
1094        .ok_or_else(|| format!("`{command}` did not answer `initialize` within {timeout:?}"))?;
1095    let list = list_resp
1096        .ok_or_else(|| format!("`{command}` did not answer `tools/list` within {timeout:?}"))?;
1097
1098    let version = init["result"]["serverInfo"]["version"]
1099        .as_str()
1100        .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
1101        .to_string();
1102    if version != crate::VERSION {
1103        return Err(format!(
1104            "`{command}` reports version {version}, expected {}",
1105            crate::VERSION
1106        ));
1107    }
1108    let tools = list["result"]["tools"]
1109        .as_array()
1110        .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
1111    let task_tool = tools
1112        .iter()
1113        .filter_map(|t| t["name"].as_str())
1114        .find(|name| TASK_PATH_TOOLS.contains(name))
1115        .ok_or_else(|| {
1116            format!(
1117                "`{command}`: tools/list includes none of {}",
1118                TASK_PATH_TOOLS.join(", ")
1119            )
1120        })?
1121        .to_string();
1122
1123    Ok(HandshakeOk {
1124        version,
1125        tool_count: tools.len(),
1126        task_tool,
1127    })
1128}
1129
1130/// The tool names that prove the repository task path is served rather than the
1131/// graph API alone.
1132///
1133/// Either is enough, because which one is listed follows the store: a store
1134/// `ingest-git` built advertises `explore` and hides the rest, and any other
1135/// store advertises `explain_association` among its nineteen. Requiring one
1136/// particular name would fail `doctor` on exactly the stores the other surface
1137/// exists for.
1138const TASK_PATH_TOOLS: [&str; 2] = ["explore", "explain_association"];
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143
1144    /// The deadline never drops below its floor, and it scales with a slow open.
1145    ///
1146    /// The floor is what keeps a broken server from being waited on: a store
1147    /// that opens in milliseconds gives `2 × open + 5 s` well under 10 s, and
1148    /// the handshake must still get the full 10 s. Above the floor the evidence
1149    /// wins, because a server that has to repeat a 13 s open cannot answer in
1150    /// 10 s however healthy it is.
1151    #[test]
1152    fn the_handshake_deadline_holds_its_floor_and_scales() {
1153        // No measurement at all — the store check did not run, or there is no
1154        // store. Nothing to scale from, so the floor stands.
1155        assert_eq!(handshake_deadline(None), HANDSHAKE_TIMEOUT);
1156
1157        // Fast opens: 2 × open + 5 s is below the floor, so the floor holds.
1158        for ms in [0, 1, 50, 500, 2_400] {
1159            assert_eq!(
1160                handshake_deadline(Some(Duration::from_millis(ms))),
1161                HANDSHAKE_TIMEOUT,
1162                "a {ms}ms open must not shorten the deadline below its floor"
1163            );
1164        }
1165
1166        // 2.5 s is the crossover: 2 × 2.5 + 5 == 10 s exactly.
1167        assert_eq!(
1168            handshake_deadline(Some(Duration::from_millis(2_500))),
1169            HANDSHAKE_TIMEOUT
1170        );
1171
1172        // Above it the measurement decides.
1173        assert_eq!(
1174            handshake_deadline(Some(Duration::from_secs(3))),
1175            Duration::from_secs(11)
1176        );
1177        // The CI failure this exists for: a 13.3 s open got a 10 s deadline.
1178        let ci = Duration::from_millis(13_300);
1179        let got = handshake_deadline(Some(ci));
1180        assert_eq!(got, Duration::from_millis(31_600));
1181        assert!(
1182            got > ci,
1183            "the deadline must exceed the open it was measured from"
1184        );
1185
1186        // Monotonic: a slower open never buys a shorter deadline.
1187        let mut prev = handshake_deadline(Some(Duration::ZERO));
1188        for s in 1..60 {
1189            let next = handshake_deadline(Some(Duration::from_secs(s)));
1190            assert!(next >= prev, "deadline shrank at a {s}s open");
1191            prev = next;
1192        }
1193    }
1194
1195    /// The fix hint names the store's cost exactly when that cost is why the
1196    /// deadline was raised — and says nothing at the floor, where the store is
1197    /// not the explanation and mentioning it would send the reader the wrong
1198    /// way.
1199    #[test]
1200    fn the_fix_hint_names_a_slow_store_only_when_it_is_the_reason() {
1201        // At the floor: silent, whether or not an open was measured.
1202        assert_eq!(slow_store_hint(None, HANDSHAKE_TIMEOUT), "");
1203        assert_eq!(
1204            slow_store_hint(Some(Duration::from_millis(80)), HANDSHAKE_TIMEOUT),
1205            ""
1206        );
1207        // Never speaks without a measurement, even on a raised deadline.
1208        assert_eq!(slow_store_hint(None, Duration::from_secs(40)), "");
1209
1210        // Raised: the hint carries both numbers and the two commands that fix
1211        // a slow open.
1212        let open = Duration::from_millis(13_300);
1213        let hint = slow_store_hint(Some(open), handshake_deadline(Some(open)));
1214        assert!(hint.contains("13.3s"), "the open time: {hint}");
1215        assert!(hint.contains("31.6s"), "the deadline it bought: {hint}");
1216        assert!(hint.contains("build-index"), "{hint}");
1217        assert!(hint.contains("snapshot"), "{hint}");
1218
1219        // A 4.8s open already raises the deadline past the floor, and that is
1220        // the case the first spelling of this hint missed.
1221        let modest = Duration::from_millis(4_800);
1222        assert!(handshake_deadline(Some(modest)) > HANDSHAKE_TIMEOUT);
1223        assert!(
1224            slow_store_hint(Some(modest), handshake_deadline(Some(modest))).contains("4.8s"),
1225            "an open below ten seconds can still be the reason"
1226        );
1227    }
1228}