1use 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#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct DoctorOpts {
35 pub platform: Option<Platform>,
37 pub scope: Option<Scope>,
39}
40
41pub struct DoctorReport {
43 pub output: String,
45 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
66struct 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
115pub 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
124pub 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 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 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 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 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 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 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 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 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
281struct ConfigEntry {
287 store: StoreRef,
289 command: String,
290 args: Vec<String>,
291}
292
293impl ConfigEntry {
294 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
306fn 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
376const 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
425fn 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
460fn 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
501fn 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 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
564fn 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
624fn check_git_hooks(project_root: &Path, store: &StoreRef) -> Option<Check> {
629 let dir = git_hooks_dir(project_root)?;
630 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
658fn 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
692const 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
727fn 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 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}