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