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, entry_db, expand_platform, git_hooks_dir, has_our_server,
20    is_our_hook_command, resolve_platform, resolve_scope, Externals, Platform, Scope, GIT_HOOKS,
21    HOOK_BEGIN, HOOK_EVENT, TOUCH_EVENT,
22};
23use crate::CliError;
24use core_api::{GraphDb, GraphError, OpenOptions};
25use serde_json::Value as Js;
26use std::io::{BufRead, BufReader, Read as _, Write as _};
27use std::path::{Path, PathBuf};
28use std::process::{Command, Stdio};
29use std::time::{Duration, Instant};
30
31/// Options parsed from `mushroomdb doctor [flags]`.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DoctorOpts {
34    /// Which platform's config to check. `None` = auto-detect, same as `install`.
35    pub platform: Option<Platform>,
36    /// Project or user scope. `None` = auto: project inside a git checkout.
37    pub scope: Option<Scope>,
38}
39
40/// Outcome of `mushroomdb doctor`: the rendered report and whether to exit 1.
41pub struct DoctorReport {
42    /// One line per check, already newline-terminated.
43    pub output: String,
44    /// True when any check is `fail`. The caller exits 1 on this and only this.
45    pub had_fail: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum Status {
50    Ok,
51    Warn,
52    Fail,
53}
54
55impl Status {
56    fn word(self) -> &'static str {
57        match self {
58            Status::Ok => "ok",
59            Status::Warn => "warn",
60            Status::Fail => "fail",
61        }
62    }
63}
64
65/// One printed line: a status, the check's name, a message, and an optional
66/// one-line fix.
67struct Check {
68    status: Status,
69    name: &'static str,
70    message: String,
71    fix: Option<String>,
72}
73
74impl Check {
75    fn ok(name: &'static str, message: impl Into<String>) -> Self {
76        Check {
77            status: Status::Ok,
78            name,
79            message: message.into(),
80            fix: None,
81        }
82    }
83    fn warn(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
84        Check {
85            status: Status::Warn,
86            name,
87            message: message.into(),
88            fix,
89        }
90    }
91    fn fail(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
92        Check {
93            status: Status::Fail,
94            name,
95            message: message.into(),
96            fix,
97        }
98    }
99    fn render(&self) -> String {
100        let mut line = format!(
101            "{:<4} {:<9} {}",
102            self.status.word(),
103            self.name,
104            self.message
105        );
106        if let Some(fix) = &self.fix {
107            line.push_str(&format!("  fix: {fix}"));
108        }
109        line.push('\n');
110        line
111    }
112}
113
114/// Run `doctor` against the real environment: `HOME` and the process PATH.
115pub fn run_doctor(
116    project_root: &Path,
117    home: &Path,
118    opts: &DoctorOpts,
119) -> Result<DoctorReport, CliError> {
120    run_doctor_with(project_root, home, opts, &Externals::from_env())
121}
122
123/// Like [`run_doctor`], with the external environment (PATH) supplied by the
124/// caller. Tests use this to stay deterministic and to point `npx` lookups at
125/// a directory of stand-ins.
126pub fn run_doctor_with(
127    project_root: &Path,
128    home: &Path,
129    opts: &DoctorOpts,
130    ext: &Externals,
131) -> Result<DoctorReport, CliError> {
132    let (scope, _auto_scope) = resolve_scope(project_root, opts.scope);
133    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
134    let platforms = expand_platform(&resolved);
135
136    let mut checks: Vec<Check> = Vec::new();
137    let mut primary: Option<(Platform, ConfigEntry)> = None;
138
139    // 1. config — one line per requested platform; the first entry that reads
140    //    cleanly becomes the target of every check below it.
141    for plat in &platforms {
142        match mcp_file_for(plat, project_root, home, scope) {
143            None => checks.push(Check::warn(
144                "config",
145                format!(
146                    "{}'s configuration is owned by its own CLI — not checked here",
147                    plat.label()
148                ),
149                None,
150            )),
151            Some(mcp_file) => match read_config_entry(&mcp_file) {
152                Ok(entry) => {
153                    checks.push(Check::ok(
154                        "config",
155                        format!("{} — {} -> {}", plat.label(), mcp_file.display(), entry.db),
156                    ));
157                    if primary.is_none() {
158                        primary = Some((plat.clone(), entry));
159                    }
160                }
161                Err(msg) => checks.push(Check::fail("config", msg, Some(install_fix(scope, plat)))),
162            },
163        }
164    }
165
166    // 2. npx — only when the resolved command actually is npx.
167    if let Some((_, entry)) = &primary {
168        if entry.command == "npx" {
169            checks.push(check_npx(entry, ext));
170        }
171    }
172
173    // 3. store, then the write-lock probe (same check family, adjacent lines).
174    match &primary {
175        Some((_, entry)) => checks.extend(check_store_and_lock(Path::new(&entry.db))),
176        None => checks.push(Check::fail(
177            "store",
178            "no usable config entry — cannot locate a database to check",
179            Some(install_fix_for_scope(scope)),
180        )),
181    }
182
183    // 4. hooks — Claude Code only; Cursor has no prompt/tool-use hooks to check.
184    if platforms.contains(&Platform::ClaudeCode) {
185        if let Some((_, entry)) = &primary {
186            checks.push(check_hooks(project_root, home, scope, &entry.db));
187        }
188    }
189
190    // 5. git hooks — project scope, and only for the platforms whose install
191    //    wires the repository (matches `install::write_everything`).
192    if scope == Scope::Project
193        && platforms
194            .iter()
195            .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor))
196    {
197        if let Some((_, entry)) = &primary {
198            if let Some(check) = check_git_hooks(project_root, &entry.db) {
199                checks.push(check);
200            }
201        }
202    }
203
204    // 6. self-handshake — spawn the configured command for real.
205    match &primary {
206        Some((_, entry)) => checks.push(check_handshake(entry)),
207        None => checks.push(Check::fail(
208            "handshake",
209            "no usable config entry — nothing to spawn",
210            Some(install_fix_for_scope(scope)),
211        )),
212    }
213
214    // 7. duplicate scope — a second Claude Code server in the other scope.
215    if platforms.contains(&Platform::ClaudeCode) {
216        checks.push(check_scope_conflict(project_root, home, scope));
217    }
218
219    let had_fail = checks.iter().any(|c| c.status == Status::Fail);
220    let mut output = String::new();
221    for c in &checks {
222        output.push_str(&c.render());
223    }
224    Ok(DoctorReport { output, had_fail })
225}
226
227fn install_fix_for_scope(scope: Scope) -> String {
228    format!(
229        "mushroomdb install {}",
230        match scope {
231            Scope::Project => "--project",
232            Scope::User => "--user",
233        }
234    )
235}
236
237fn install_fix(scope: Scope, plat: &Platform) -> String {
238    format!(
239        "mushroomdb install --platform {} {}",
240        plat.label(),
241        match scope {
242            Scope::Project => "--project",
243            Scope::User => "--user",
244        }
245    )
246}
247
248fn mcp_file_for(
249    plat: &Platform,
250    project_root: &Path,
251    home: &Path,
252    scope: Scope,
253) -> Option<PathBuf> {
254    match plat {
255        Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
256        Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
257        Platform::Codex | Platform::All => None,
258    }
259}
260
261// ---------------------------------------------------------------------------
262// config
263// ---------------------------------------------------------------------------
264
265/// The MCP server entry doctor found: what it points at and how it is spawned.
266struct ConfigEntry {
267    db: String,
268    command: String,
269    args: Vec<String>,
270}
271
272fn read_json(path: &Path) -> Result<Js, String> {
273    let raw = std::fs::read_to_string(path)
274        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
275    serde_json::from_str(&raw).map_err(|e| format!("invalid JSON in {}: {e}", path.display()))
276}
277
278fn read_config_entry(mcp_file: &Path) -> Result<ConfigEntry, String> {
279    if !mcp_file.exists() {
280        return Err(format!("{} does not exist", mcp_file.display()));
281    }
282    let root = read_json(mcp_file)?;
283    let entry = &root["mcpServers"]["mushroomdb"];
284    if entry.is_null() {
285        return Err(format!(
286            "no mcpServers.mushroomdb entry in {}",
287            mcp_file.display()
288        ));
289    }
290    let db = entry_db(entry)
291        .ok_or_else(|| {
292            format!(
293                "{}: mushroomdb entry has no `mcp <db>` argument",
294                mcp_file.display()
295            )
296        })?
297        .to_string();
298    let command = entry["command"]
299        .as_str()
300        .ok_or_else(|| format!("{}: mushroomdb entry has no `command`", mcp_file.display()))?
301        .to_string();
302    let args = entry["args"]
303        .as_array()
304        .map(|a| {
305            a.iter()
306                .filter_map(|v| v.as_str().map(str::to_string))
307                .collect()
308        })
309        .unwrap_or_default();
310    Ok(ConfigEntry { db, command, args })
311}
312
313// ---------------------------------------------------------------------------
314// npx
315// ---------------------------------------------------------------------------
316
317const NPX_TIMEOUT: Duration = Duration::from_secs(60);
318
319fn check_npx(entry: &ConfigEntry, ext: &Externals) -> Check {
320    let pinned = entry
321        .args
322        .iter()
323        .find_map(|a| a.strip_prefix("mushroomdb@"))
324        .unwrap_or(crate::VERSION);
325    let Some(npx) = ext.which("npx") else {
326        return Check::fail(
327            "npx",
328            "npx is not on PATH",
329            Some(
330                "install Node.js (which provides npx), or re-install with --command <path>"
331                    .to_string(),
332            ),
333        );
334    };
335    let args = vec![
336        "-y".to_string(),
337        format!("mushroomdb@{pinned}"),
338        "--version".to_string(),
339    ];
340    match run_capturing(&npx, &args, NPX_TIMEOUT) {
341        RunOutcome::Done(out) if out.contains(pinned) => Check::ok(
342            "npx",
343            format!("npx -y mushroomdb@{pinned} --version -> {}", out.trim()),
344        ),
345        RunOutcome::Done(out) => Check::fail(
346            "npx",
347            format!(
348                "npx -y mushroomdb@{pinned} --version printed {:?}, expected to contain {pinned}",
349                out.trim()
350            ),
351            Some("re-run `mushroomdb install` to repin the version".to_string()),
352        ),
353        RunOutcome::TimedOut => Check::warn(
354            "npx",
355            format!("npx -y mushroomdb@{pinned} --version timed out after {NPX_TIMEOUT:?}"),
356            Some("check network access to the npm registry".to_string()),
357        ),
358        RunOutcome::Failed(e) => Check::fail("npx", e, None),
359    }
360}
361
362enum RunOutcome {
363    Done(String),
364    TimedOut,
365    Failed(String),
366}
367
368/// Run `bin`, capturing stdout, giving up after `timeout`.
369fn run_capturing(bin: &Path, args: &[String], timeout: Duration) -> RunOutcome {
370    let mut child = match Command::new(bin)
371        .args(args)
372        .stdin(Stdio::null())
373        .stdout(Stdio::piped())
374        .stderr(Stdio::null())
375        .spawn()
376    {
377        Ok(c) => c,
378        Err(e) => return RunOutcome::Failed(format!("cannot run {}: {e}", bin.display())),
379    };
380    let mut stdout = child.stdout.take().expect("piped stdout");
381    let (tx, rx) = std::sync::mpsc::channel::<String>();
382    std::thread::spawn(move || {
383        let mut out = String::new();
384        let _ = stdout.read_to_string(&mut out);
385        let _ = tx.send(out);
386    });
387    let deadline = Instant::now() + timeout;
388    loop {
389        match child.try_wait() {
390            Ok(Some(status)) => {
391                let out = rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default();
392                return if status.success() {
393                    RunOutcome::Done(out)
394                } else {
395                    RunOutcome::Failed(format!("{} exited with {status}", bin.display()))
396                };
397            }
398            Ok(None) if Instant::now() >= deadline => {
399                let _ = child.kill();
400                let _ = child.wait();
401                return RunOutcome::TimedOut;
402            }
403            Ok(None) => std::thread::sleep(Duration::from_millis(25)),
404            Err(e) => return RunOutcome::Failed(format!("cannot wait for {}: {e}", bin.display())),
405        }
406    }
407}
408
409// ---------------------------------------------------------------------------
410// store + lock
411// ---------------------------------------------------------------------------
412
413fn check_store_and_lock(db_dir: &Path) -> Vec<Check> {
414    let mut out = Vec::new();
415    let store = GraphDb::open_with_options(
416        db_dir,
417        OpenOptions {
418            read_only: true,
419            auto_migrate: true,
420            repair_wal: true,
421        },
422    );
423    match store {
424        Ok(db) => {
425            let stats = db.stats();
426            let stale = db.is_stale().unwrap_or(false);
427            out.push(Check::ok(
428                "store",
429                format!(
430                    "{} — {} nodes live ({} tombstoned), {} edges{}",
431                    db_dir.display(),
432                    stats.nodes_live,
433                    stats.nodes_tombstoned,
434                    stats.edges,
435                    if stale {
436                        ", stale (newer commits pending refresh)"
437                    } else {
438                        ""
439                    }
440                ),
441            ));
442            drop(db);
443
444            // Briefly try to take the write lock. Success means nobody else
445            // holds it; the handle is dropped immediately, before this
446            // function returns, so the lock is never held past the check.
447            match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
448                Ok(handle) => {
449                    drop(handle);
450                    out.push(Check::ok(
451                        "lock",
452                        "free — no other process is writing".to_string(),
453                    ));
454                }
455                Err(GraphError::Busy { .. }) => out.push(Check::warn(
456                    "lock",
457                    "another process is writing".to_string(),
458                    Some("re-run once the other process finishes".to_string()),
459                )),
460                Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
461            }
462        }
463        Err(e) => out.push(Check::fail(
464            "store",
465            format!("cannot open {}: {e}", db_dir.display()),
466            Some(format!("mushroomdb verify {}", db_dir.display())),
467        )),
468    }
469    out
470}
471
472// ---------------------------------------------------------------------------
473// hooks
474// ---------------------------------------------------------------------------
475
476fn check_hooks(project_root: &Path, home: &Path, scope: Scope, db_str: &str) -> Check {
477    let settings_file = match scope {
478        Scope::Project => project_root.join(".claude").join("settings.json"),
479        Scope::User => home.join(".claude").join("settings.json"),
480    };
481    let root = read_json(&settings_file).unwrap_or(Js::Null);
482    let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", db_str);
483    let has_touch = has_hook_matching(&root, TOUCH_EVENT, "touch", db_str);
484    if has_recall && has_touch {
485        Check::ok(
486            "hooks",
487            format!(
488                "{HOOK_EVENT} + {TOUCH_EVENT} present in {}",
489                settings_file.display()
490            ),
491        )
492    } else {
493        let mut missing = Vec::new();
494        if !has_recall {
495            missing.push(HOOK_EVENT);
496        }
497        if !has_touch {
498            missing.push(TOUCH_EVENT);
499        }
500        Check::warn(
501            "hooks",
502            format!(
503                "missing {} in {}",
504                missing.join(", "),
505                settings_file.display()
506            ),
507            Some("mushroomdb install --platform claude-code".to_string()),
508        )
509    }
510}
511
512fn has_hook_matching(root: &Js, event: &str, sub: &str, db_str: &str) -> bool {
513    root["hooks"][event]
514        .as_array()
515        .map(|groups| {
516            groups.iter().any(|g| {
517                g["hooks"]
518                    .as_array()
519                    .map(|hs| {
520                        hs.iter().any(|h| {
521                            h["command"]
522                                .as_str()
523                                .is_some_and(|c| is_our_hook_command(c, sub, db_str))
524                        })
525                    })
526                    .unwrap_or(false)
527            })
528        })
529        .unwrap_or(false)
530}
531
532// ---------------------------------------------------------------------------
533// git hooks
534// ---------------------------------------------------------------------------
535
536fn check_git_hooks(project_root: &Path, db_str: &str) -> Option<Check> {
537    let dir = git_hooks_dir(project_root)?;
538    let missing: Vec<&str> = GIT_HOOKS
539        .iter()
540        .filter(|name| {
541            let content = std::fs::read_to_string(dir.join(name)).unwrap_or_default();
542            !(content.contains(HOOK_BEGIN) && content.contains(db_str))
543        })
544        .copied()
545        .collect();
546    Some(if missing.is_empty() {
547        Check::ok(
548            "git-hooks",
549            format!("{} present in {}", GIT_HOOKS.join("/"), dir.display()),
550        )
551    } else {
552        Check::warn(
553            "git-hooks",
554            format!("missing in {}: {}", dir.display(), missing.join(", ")),
555            Some("mushroomdb install --project (omit --no-git-hooks)".to_string()),
556        )
557    })
558}
559
560// ---------------------------------------------------------------------------
561// duplicate scope
562// ---------------------------------------------------------------------------
563
564fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
565    let (other_file, other_label, other_flag) = match scope {
566        Scope::Project => (
567            claude_mcp_file(project_root, home, Scope::User),
568            "user",
569            "--user",
570        ),
571        Scope::User => (
572            claude_mcp_file(project_root, home, Scope::Project),
573            "project",
574            "--project",
575        ),
576    };
577    if has_our_server(&other_file) {
578        Check::warn(
579            "scope",
580            format!(
581                "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
582                other_file.display()
583            ),
584            Some(format!("mushroomdb uninstall {other_flag}")),
585        )
586    } else {
587        Check::ok(
588            "scope",
589            "no duplicate server in the other scope".to_string(),
590        )
591    }
592}
593
594// ---------------------------------------------------------------------------
595// self-handshake
596// ---------------------------------------------------------------------------
597
598const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
599
600struct HandshakeOk {
601    version: String,
602    tool_count: usize,
603}
604
605fn check_handshake(entry: &ConfigEntry) -> Check {
606    match self_handshake(&entry.command, &entry.args) {
607        Ok(HandshakeOk {
608            version,
609            tool_count,
610        }) => Check::ok(
611            "handshake",
612            format!(
613                "initialize + tools/list ok — version {version}, {tool_count} tools (map present)"
614            ),
615        ),
616        Err(msg) => Check::fail(
617            "handshake",
618            msg,
619            Some(format!(
620                "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
621                 to rewrite the command",
622                entry.command,
623                entry.args.join(" ")
624            )),
625        ),
626    }
627}
628
629/// Spawn `command args…`, speak one `initialize` and one `tools/list` request
630/// over its stdio, and check the response.
631///
632/// Reads with a 10s deadline; closes stdin once both responses are in (or the
633/// deadline passes) so a well-behaved server exits on EOF, then reaps it with
634/// a short bounded wait — a broken server never hangs `doctor`.
635fn self_handshake(command: &str, args: &[String]) -> Result<HandshakeOk, String> {
636    let mut child = Command::new(command)
637        .args(args)
638        .stdin(Stdio::piped())
639        .stdout(Stdio::piped())
640        .stderr(Stdio::null())
641        .spawn()
642        .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
643
644    let mut stdin = child.stdin.take().expect("piped stdin");
645    let stdout = child.stdout.take().expect("piped stdout");
646
647    let (tx, rx) = std::sync::mpsc::channel::<String>();
648    std::thread::spawn(move || {
649        let mut reader = BufReader::new(stdout);
650        let mut line = String::new();
651        loop {
652            line.clear();
653            match reader.read_line(&mut line) {
654                Ok(0) | Err(_) => break,
655                Ok(_) => {
656                    if tx.send(line.trim().to_string()).is_err() {
657                        break;
658                    }
659                }
660            }
661        }
662    });
663
664    let sent = writeln!(
665        stdin,
666        r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
667    )
668    .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
669    .and_then(|()| stdin.flush());
670
671    let mut init_resp: Option<Js> = None;
672    let mut list_resp: Option<Js> = None;
673    if sent.is_ok() {
674        let deadline = Instant::now() + HANDSHAKE_TIMEOUT;
675        while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
676            let remaining = deadline.saturating_duration_since(Instant::now());
677            match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
678                Ok(line) if !line.is_empty() => {
679                    if let Ok(v) = serde_json::from_str::<Js>(&line) {
680                        match v.get("id").and_then(Js::as_i64) {
681                            Some(1) => init_resp = Some(v),
682                            Some(2) => list_resp = Some(v),
683                            _ => {}
684                        }
685                    }
686                }
687                Ok(_) => {}
688                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
689                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
690            }
691        }
692    }
693
694    // EOF on stdin is how a well-behaved server knows to exit; then reap it
695    // with a short bounded wait so a broken one cannot hang doctor.
696    drop(stdin);
697    let reap_deadline = Instant::now() + Duration::from_secs(2);
698    loop {
699        match child.try_wait() {
700            Ok(Some(_)) | Err(_) => break,
701            Ok(None) if Instant::now() >= reap_deadline => {
702                let _ = child.kill();
703                let _ = child.wait();
704                break;
705            }
706            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
707        }
708    }
709
710    if sent.is_err() {
711        return Err(format!("cannot write to `{command}`'s stdin"));
712    }
713    let init = init_resp.ok_or_else(|| {
714        format!("`{command}` did not answer `initialize` within {HANDSHAKE_TIMEOUT:?}")
715    })?;
716    let list = list_resp.ok_or_else(|| {
717        format!("`{command}` did not answer `tools/list` within {HANDSHAKE_TIMEOUT:?}")
718    })?;
719
720    let version = init["result"]["serverInfo"]["version"]
721        .as_str()
722        .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
723        .to_string();
724    if version != crate::VERSION {
725        return Err(format!(
726            "`{command}` reports version {version}, expected {}",
727            crate::VERSION
728        ));
729    }
730    let tools = list["result"]["tools"]
731        .as_array()
732        .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
733    if !tools.iter().any(|t| t["name"].as_str() == Some("map")) {
734        return Err(format!("`{command}`: tools/list does not include `map`"));
735    }
736
737    Ok(HandshakeOk {
738        version,
739        tool_count: tools.len(),
740    })
741}