1use 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#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DoctorOpts {
34 pub platform: Option<Platform>,
36 pub scope: Option<Scope>,
38}
39
40pub struct DoctorReport {
42 pub output: String,
44 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
65struct 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
114pub 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
123pub 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 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 if let Some((_, entry)) = &primary {
168 if entry.command == "npx" {
169 checks.push(check_npx(entry, ext));
170 }
171 }
172
173 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 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 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 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 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
261struct 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
313const 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
368fn 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
409fn 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 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
472fn 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
532fn 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
560fn 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
594const 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
629fn 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 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}