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