1use crate::install::{
19 claude_mcp_file, cursor_mcp_file, default_db, entry_db, expand_platform, git_hooks_dir,
20 has_our_server, hook_block_lines, installed_shape, is_disabled, is_our_hook_command,
21 is_retired_hook_of_ours, line_runs_for_store, opt_ins, resolve_platform, resolve_scope,
22 Externals, Platform, Scope, StoreRef, AUTO_ARG, BRIEF_EVENT, GIT_HOOKS, HOOK_EVENT,
23 RETIRED_GIT_HOOK_SUBCOMMAND, RETIRED_HOOK_SUBCOMMANDS, SERVER_NAME,
24};
25use crate::CliError;
26use core_api::{GraphDb, GraphError, OpenOptions};
27use serde_json::Value as Js;
28use std::io::{BufRead, BufReader, Read as _, Write as _};
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{Duration, Instant};
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DoctorOpts {
36 pub platform: Option<Platform>,
38 pub scope: Option<Scope>,
40}
41
42pub struct DoctorReport {
44 pub output: String,
46 pub had_fail: bool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum Status {
52 Ok,
53 Skip,
55 Warn,
56 Fail,
57}
58
59impl Status {
60 fn word(self) -> &'static str {
61 match self {
62 Status::Ok => "ok",
63 Status::Skip => "skip",
64 Status::Warn => "warn",
65 Status::Fail => "fail",
66 }
67 }
68}
69
70struct Check {
73 status: Status,
74 name: &'static str,
75 message: String,
76 fix: Option<String>,
77}
78
79impl Check {
80 fn ok(name: &'static str, message: impl Into<String>) -> Self {
81 Check {
82 status: Status::Ok,
83 name,
84 message: message.into(),
85 fix: None,
86 }
87 }
88 fn skip(name: &'static str, message: impl Into<String>) -> Self {
89 Check {
90 status: Status::Skip,
91 name,
92 message: message.into(),
93 fix: None,
94 }
95 }
96 fn warn(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
97 Check {
98 status: Status::Warn,
99 name,
100 message: message.into(),
101 fix,
102 }
103 }
104 fn fail(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
105 Check {
106 status: Status::Fail,
107 name,
108 message: message.into(),
109 fix,
110 }
111 }
112 fn render(&self) -> String {
113 let mut line = format!(
117 "{:<4} {:<11} {}",
118 self.status.word(),
119 self.name,
120 self.message
121 );
122 if let Some(fix) = &self.fix {
123 line.push_str(&format!(" fix: {fix}"));
124 }
125 line.push('\n');
126 line
127 }
128}
129
130pub fn run_doctor(
132 project_root: &Path,
133 home: &Path,
134 opts: &DoctorOpts,
135) -> Result<DoctorReport, CliError> {
136 run_doctor_with(project_root, home, opts, &Externals::from_env())
137}
138
139pub fn run_doctor_with(
143 project_root: &Path,
144 home: &Path,
145 opts: &DoctorOpts,
146 ext: &Externals,
147) -> Result<DoctorReport, CliError> {
148 let (scope, _auto_scope) = resolve_scope(project_root, opts.scope);
149 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
150 let platforms = expand_platform(&resolved);
151
152 if is_disabled(project_root, home, scope, &platforms) {
156 return Ok(DoctorReport {
157 output: Check::warn("state", "disabled — enable with: mushroomdb enable", None)
158 .render(),
159 had_fail: false,
160 });
161 }
162
163 let (delivery, recorded_store) = installed_shape(project_root, home, scope, &platforms);
169 let mcp_delivery = delivery.wires_mcp();
170
171 let mut checks: Vec<Check> = Vec::new();
172 let mut primary: Option<(Platform, ConfigEntry)> = None;
173
174 for plat in &platforms {
177 if !mcp_delivery && matches!(plat, Platform::ClaudeCode) {
181 checks.push(Check::skip(
182 "config",
183 format!(
184 "delivery: {} — the skill teaches the binary, no MCP entry to check",
185 delivery.label()
186 ),
187 ));
188 continue;
189 }
190 match mcp_file_for(plat, project_root, home, scope) {
191 None => checks.push(Check::warn(
192 "config",
193 format!(
194 "{}'s configuration is owned by its own CLI — not checked here",
195 plat.label()
196 ),
197 None,
198 )),
199 Some(mcp_file) => match read_config_entry(&mcp_file, project_root, home) {
200 Ok(entry) => {
201 checks.push(Check::ok(
202 "config",
203 format!(
204 "{} — {} -> {}",
205 plat.label(),
206 mcp_file.display(),
207 entry.describe_store()
208 ),
209 ));
210 if primary.is_none() {
211 primary = Some((plat.clone(), entry));
212 }
213 }
214 Err(msg) => checks.push(Check::fail("config", msg, Some(install_fix(scope, plat)))),
215 },
216 }
217 }
218
219 let store: Option<StoreRef> = match &primary {
222 Some((_, entry)) => Some(entry.store.clone()),
223 None if !mcp_delivery => recorded_store,
224 None => None,
225 };
226
227 if let Some((_, entry)) = &primary {
230 if entry.command == "npx" {
231 checks.push(check_npx(entry, ext));
232 } else if let Some(check) = check_resolved_path(entry) {
233 checks.push(check);
234 }
235 }
236
237 let mut store_opened_in: Option<Duration> = None;
242 match &store {
243 Some(store) => {
244 let StoreChecks {
245 checks: cs,
246 opened_in,
247 } = check_store_and_lock(store.path());
248 checks.extend(cs);
249 store_opened_in = opened_in;
250 }
251 None => checks.push(Check::fail(
252 "store",
253 no_store_message(mcp_delivery),
254 Some(install_fix_for_scope(scope)),
255 )),
256 }
257
258 if platforms.contains(&Platform::ClaudeCode) {
260 if let Some(store) = &store {
261 checks.push(check_hooks(project_root, home, scope, store));
262 checks.extend(check_retired_hooks(project_root, home, scope, store));
265 let opted = opt_ins(project_root, home, scope, &platforms);
266 if opted.always_load {
267 checks.push(check_always_load(project_root, home, scope));
268 }
269 }
270 }
271
272 if scope == Scope::Project
276 && platforms
277 .iter()
278 .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor))
279 {
280 if let Some(store) = &store {
281 checks.extend(check_retired_git_hooks(project_root, store));
282 }
283 }
284
285 match &primary {
287 Some((_, entry)) => checks.push(check_handshake(entry, store_opened_in)),
288 None if !mcp_delivery => checks.push(Check::skip(
290 "handshake",
291 format!("delivery: {} — no server to spawn", delivery.label()),
292 )),
293 None => checks.push(Check::fail(
294 "handshake",
295 "no usable config entry — nothing to spawn",
296 Some(install_fix_for_scope(scope)),
297 )),
298 }
299
300 if platforms.contains(&Platform::ClaudeCode) {
302 checks.push(check_scope_conflict(project_root, home, scope));
303 }
304
305 let had_fail = checks.iter().any(|c| c.status == Status::Fail);
306 let mut output = String::new();
307 for c in &checks {
308 output.push_str(&c.render());
309 }
310 Ok(DoctorReport { output, had_fail })
311}
312
313fn no_store_message(mcp_delivery: bool) -> &'static str {
316 if mcp_delivery {
317 "no usable config entry — cannot locate a database to check"
318 } else {
319 "no recorded SessionStart hook — cannot locate a database to check"
320 }
321}
322
323fn install_fix_for_scope(scope: Scope) -> String {
324 format!(
325 "mushroomdb install {}",
326 match scope {
327 Scope::Project => "--project",
328 Scope::User => "--user",
329 }
330 )
331}
332
333fn install_fix(scope: Scope, plat: &Platform) -> String {
334 format!(
335 "mushroomdb install --platform {} {}",
336 plat.label(),
337 match scope {
338 Scope::Project => "--project",
339 Scope::User => "--user",
340 }
341 )
342}
343
344fn mcp_file_for(
345 plat: &Platform,
346 project_root: &Path,
347 home: &Path,
348 scope: Scope,
349) -> Option<PathBuf> {
350 match plat {
351 Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
352 Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
353 Platform::Codex | Platform::All => None,
354 }
355}
356
357struct ConfigEntry {
363 store: StoreRef,
365 command: String,
366 args: Vec<String>,
367}
368
369impl ConfigEntry {
370 fn describe_store(&self) -> String {
374 if self.store.is_auto() {
375 format!("{AUTO_ARG} -> {}", self.store.path().display())
376 } else {
377 self.store.path().display().to_string()
378 }
379 }
380}
381
382fn entry_store(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
391 if arg == AUTO_ARG {
392 return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
393 }
394 let path = PathBuf::from(arg);
395 if path == default_db(Scope::Project, project_root, home) {
396 return StoreRef::pinned(path).also_auto();
397 }
398 StoreRef::pinned(path)
399}
400
401fn read_json(path: &Path) -> Result<Js, String> {
402 let raw = std::fs::read_to_string(path)
403 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
404 serde_json::from_str(&raw).map_err(|e| format!("invalid JSON in {}: {e}", path.display()))
405}
406
407fn read_config_entry(
408 mcp_file: &Path,
409 project_root: &Path,
410 home: &Path,
411) -> Result<ConfigEntry, String> {
412 if !mcp_file.exists() {
413 return Err(format!("{} does not exist", mcp_file.display()));
414 }
415 let root = read_json(mcp_file)?;
416 let entry = &root["mcpServers"]["mushroomdb"];
417 if entry.is_null() {
418 return Err(format!(
419 "no mcpServers.mushroomdb entry in {}",
420 mcp_file.display()
421 ));
422 }
423 let store = entry_store(
424 entry_db(entry).ok_or_else(|| {
425 format!(
426 "{}: mushroomdb entry has no `mcp <db>|--auto` argument",
427 mcp_file.display()
428 )
429 })?,
430 project_root,
431 home,
432 );
433 let command = entry["command"]
434 .as_str()
435 .ok_or_else(|| format!("{}: mushroomdb entry has no `command`", mcp_file.display()))?
436 .to_string();
437 let args = entry["args"]
438 .as_array()
439 .map(|a| {
440 a.iter()
441 .filter_map(|v| v.as_str().map(str::to_string))
442 .collect()
443 })
444 .unwrap_or_default();
445 Ok(ConfigEntry {
446 store,
447 command,
448 args,
449 })
450}
451
452const NPX_TIMEOUT: Duration = Duration::from_secs(60);
457
458fn check_npx(entry: &ConfigEntry, ext: &Externals) -> Check {
459 let pinned = entry
460 .args
461 .iter()
462 .find_map(|a| a.strip_prefix("mushroomdb@"))
463 .unwrap_or(crate::VERSION);
464 let Some(npx) = ext.which("npx") else {
465 return Check::fail(
466 "npx",
467 "npx is not on PATH",
468 Some(
469 "install Node.js (which provides npx), or re-install with --command <path>"
470 .to_string(),
471 ),
472 );
473 };
474 let args = vec![
475 "-y".to_string(),
476 format!("mushroomdb@{pinned}"),
477 "--version".to_string(),
478 ];
479 match run_capturing(&npx, &args, NPX_TIMEOUT) {
480 RunOutcome::Done(out) if out.contains(pinned) => Check::ok(
481 "npx",
482 format!("npx -y mushroomdb@{pinned} --version -> {}", out.trim()),
483 ),
484 RunOutcome::Done(out) => Check::fail(
485 "npx",
486 format!(
487 "npx -y mushroomdb@{pinned} --version printed {:?}, expected to contain {pinned}",
488 out.trim()
489 ),
490 Some("re-run `mushroomdb install` to repin the version".to_string()),
491 ),
492 RunOutcome::TimedOut => Check::warn(
493 "npx",
494 format!("npx -y mushroomdb@{pinned} --version timed out after {NPX_TIMEOUT:?}"),
495 Some("check network access to the npm registry".to_string()),
496 ),
497 RunOutcome::Failed(e) => Check::fail("npx", e, None),
498 }
499}
500
501fn check_resolved_path(entry: &ConfigEntry) -> Option<Check> {
512 let (name, path) = if entry.command == "node" {
513 ("launcher", Path::new(entry.args.first()?))
514 } else if Path::new(&entry.command).is_absolute() {
515 ("binary", Path::new(&entry.command))
516 } else {
517 return None;
518 };
519 Some(if path.is_file() {
520 Check::ok(name, path.display().to_string())
521 } else {
522 Check::fail(
523 name,
524 format!("{} no longer exists", path.display()),
525 Some("mushroomdb install (re-resolves it)".to_string()),
526 )
527 })
528}
529
530enum RunOutcome {
531 Done(String),
532 TimedOut,
533 Failed(String),
534}
535
536fn run_capturing(bin: &Path, args: &[String], timeout: Duration) -> RunOutcome {
538 let mut child = match Command::new(bin)
539 .args(args)
540 .stdin(Stdio::null())
541 .stdout(Stdio::piped())
542 .stderr(Stdio::null())
543 .spawn()
544 {
545 Ok(c) => c,
546 Err(e) => return RunOutcome::Failed(format!("cannot run {}: {e}", bin.display())),
547 };
548 let mut stdout = child.stdout.take().expect("piped stdout");
549 let (tx, rx) = std::sync::mpsc::channel::<String>();
550 std::thread::spawn(move || {
551 let mut out = String::new();
552 let _ = stdout.read_to_string(&mut out);
553 let _ = tx.send(out);
554 });
555 let deadline = Instant::now() + timeout;
556 loop {
557 match child.try_wait() {
558 Ok(Some(status)) => {
559 let out = rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default();
560 return if status.success() {
561 RunOutcome::Done(out)
562 } else {
563 RunOutcome::Failed(format!("{} exited with {status}", bin.display()))
564 };
565 }
566 Ok(None) if Instant::now() >= deadline => {
567 let _ = child.kill();
568 let _ = child.wait();
569 return RunOutcome::TimedOut;
570 }
571 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
572 Err(e) => return RunOutcome::Failed(format!("cannot wait for {}: {e}", bin.display())),
573 }
574 }
575}
576
577struct StoreChecks {
587 checks: Vec<Check>,
588 opened_in: Option<Duration>,
589}
590
591fn check_store_and_lock(db_dir: &Path) -> StoreChecks {
592 let mut out = Vec::new();
593 let started = Instant::now();
594 let store = GraphDb::open_with_options(
595 db_dir,
596 OpenOptions {
597 read_only: true,
598 auto_migrate: true,
599 repair_wal: true,
600 },
601 );
602 let mut opened_in = started.elapsed();
605 match store {
606 Ok(db) => {
607 let stats = db.stats();
608 let stale = db.is_stale().unwrap_or(false);
609 let floor = db.wal_horizon_floor();
610 let total = db.wal_total_commits().unwrap_or(floor);
611 let namespaces = if stats.namespaces.len() > 1 {
616 format!(", {} namespaces", stats.namespaces.len())
617 } else {
618 String::new()
619 };
620 out.push(Check::ok(
621 "store",
622 format!(
623 "{} — {} nodes live ({} tombstoned), {} edges{}, history from commit {} of {}{}",
624 db_dir.display(),
625 stats.nodes_live,
626 stats.nodes_tombstoned,
627 stats.edges,
628 namespaces,
629 floor,
630 total,
631 if stale {
632 ", stale (newer commits pending refresh)"
633 } else {
634 ""
635 }
636 ),
637 ));
638 drop(db);
639
640 let write_started = Instant::now();
649 match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
650 Ok(handle) => {
651 opened_in = opened_in.max(write_started.elapsed());
652 drop(handle);
653 out.push(Check::ok(
654 "lock",
655 "free — no other process is writing".to_string(),
656 ));
657 }
658 Err(GraphError::Busy { .. }) => out.push(Check::warn(
659 "lock",
660 "another process is writing".to_string(),
661 Some("re-run once the other process finishes".to_string()),
662 )),
663 Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
664 }
665 }
666 Err(e) => out.push(Check::fail(
667 "store",
668 format!("cannot open {}: {e}", db_dir.display()),
669 Some(format!("mushroomdb verify {}", db_dir.display())),
670 )),
671 }
672 StoreChecks {
673 checks: out,
674 opened_in: Some(opened_in),
675 }
676}
677
678fn check_hooks(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
683 let settings_file = match scope {
684 Scope::Project => project_root.join(".claude").join("settings.json"),
685 Scope::User => home.join(".claude").join("settings.json"),
686 };
687 let root = read_json(&settings_file).unwrap_or(Js::Null);
688 let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", store);
689 let has_brief = has_hook_matching(&root, BRIEF_EVENT, "brief", store);
690 if has_recall && has_brief {
691 Check::ok(
692 "hooks",
693 format!(
694 "{HOOK_EVENT} + {BRIEF_EVENT} present in {}",
695 settings_file.display()
696 ),
697 )
698 } else {
699 let mut missing = Vec::new();
700 if !has_recall {
701 missing.push(HOOK_EVENT);
702 }
703 if !has_brief {
704 missing.push(BRIEF_EVENT);
705 }
706 Check::warn(
707 "hooks",
708 format!(
709 "missing {} in {}",
710 missing.join(", "),
711 settings_file.display()
712 ),
713 Some("mushroomdb install --platform claude-code".to_string()),
714 )
715 }
716}
717
718fn check_always_load(project_root: &Path, home: &Path, scope: Scope) -> Check {
729 let mcp_file = claude_mcp_file(project_root, home, scope);
730 let entry = read_json(&mcp_file).unwrap_or(Js::Null);
731 if entry["mcpServers"][SERVER_NAME]["alwaysLoad"] == Js::Bool(true) {
732 Check::ok(
733 "always-load",
734 format!(
735 "mcpServers.{SERVER_NAME} is marked alwaysLoad in {}",
736 mcp_file.display()
737 ),
738 )
739 } else {
740 Check::warn(
741 "always-load",
742 format!("manifest records it but {} does not", mcp_file.display()),
743 Some("mushroomdb install --platform claude-code --always-load".to_string()),
744 )
745 }
746}
747
748fn settings_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
750 match scope {
751 Scope::Project => project_root.join(".claude").join("settings.json"),
752 Scope::User => home.join(".claude").join("settings.json"),
753 }
754}
755
756fn check_retired_hooks(
763 project_root: &Path,
764 home: &Path,
765 scope: Scope,
766 store: &StoreRef,
767) -> Vec<Check> {
768 let settings_file = settings_file(project_root, home, scope);
769 let root = read_json(&settings_file).unwrap_or(Js::Null);
770 RETIRED_HOOK_SUBCOMMANDS
771 .iter()
772 .filter(|(event, sub)| {
773 has_hook_where(&root, event, |c| is_retired_hook_of_ours(c, sub, store))
774 })
775 .map(|&(event, sub)| {
776 Check::warn(
777 sub,
778 format!(
779 "retired {event} hook still in {} — 0.7 has no `{sub}`",
780 settings_file.display()
781 ),
782 Some(install_fix_for_scope(scope)),
783 )
784 })
785 .collect()
786}
787
788fn has_hook_matching(root: &Js, event: &str, sub: &str, store: &StoreRef) -> bool {
789 has_hook_where(root, event, |c| is_our_hook_command(c, sub, store))
790}
791
792fn has_hook_where(root: &Js, event: &str, pred: impl Fn(&str) -> bool) -> bool {
794 root["hooks"][event]
795 .as_array()
796 .map(|groups| {
797 groups.iter().any(|g| {
798 g["hooks"]
799 .as_array()
800 .map(|hs| hs.iter().any(|h| h["command"].as_str().is_some_and(&pred)))
801 .unwrap_or(false)
802 })
803 })
804 .unwrap_or(false)
805}
806
807fn check_retired_git_hooks(project_root: &Path, store: &StoreRef) -> Vec<Check> {
817 let Some(dir) = git_hooks_dir(project_root) else {
818 return Vec::new();
819 };
820 GIT_HOOKS
821 .iter()
822 .filter(|name| {
823 std::fs::read_to_string(dir.join(name)).is_ok_and(|content| {
824 hook_block_lines(&content)
825 .any(|l| line_runs_for_store(l, RETIRED_GIT_HOOK_SUBCOMMAND, store))
826 })
827 })
828 .map(|name| {
829 Check::warn(
830 "git-hooks",
831 format!(
832 "retired {RETIRED_GIT_HOOK_SUBCOMMAND} block still in {} — 0.7 has no `{RETIRED_GIT_HOOK_SUBCOMMAND}`",
833 dir.join(name).display()
834 ),
835 Some("mushroomdb install --project".to_string()),
836 )
837 })
838 .collect()
839}
840
841fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
846 let (other_file, other_label, other_flag) = match scope {
847 Scope::Project => (
848 claude_mcp_file(project_root, home, Scope::User),
849 "user",
850 "--user",
851 ),
852 Scope::User => (
853 claude_mcp_file(project_root, home, Scope::Project),
854 "project",
855 "--project",
856 ),
857 };
858 if has_our_server(&other_file) {
859 Check::warn(
860 "scope",
861 format!(
862 "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
863 other_file.display()
864 ),
865 Some(format!("mushroomdb uninstall {other_flag}")),
866 )
867 } else {
868 Check::ok(
869 "scope",
870 "no duplicate server in the other scope".to_string(),
871 )
872 }
873}
874
875const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
883
884const HANDSHAKE_SPAWN_SLACK: Duration = Duration::from_secs(5);
887
888fn slow_store_hint(store_opened_in: Option<Duration>, deadline: Duration) -> String {
898 match store_opened_in {
899 Some(open) if deadline > HANDSHAKE_TIMEOUT => format!(
900 "; the store took {:.1}s to open here, so the server was already given \
901 {:.1}s and still did not answer — if the command is right, run \
902 `mushroomdb build-index` and `mushroomdb snapshot` to make the open cheap",
903 open.as_secs_f64(),
904 deadline.as_secs_f64()
905 ),
906 _ => String::new(),
907 }
908}
909
910fn handshake_deadline(store_opened_in: Option<Duration>) -> Duration {
925 match store_opened_in {
926 Some(open) => HANDSHAKE_TIMEOUT.max(open * 2 + HANDSHAKE_SPAWN_SLACK),
927 None => HANDSHAKE_TIMEOUT,
928 }
929}
930
931struct HandshakeOk {
932 version: String,
933 tool_count: usize,
934 task_tool: String,
936}
937
938fn check_handshake(entry: &ConfigEntry, store_opened_in: Option<Duration>) -> Check {
939 let deadline = handshake_deadline(store_opened_in);
940 match self_handshake(&entry.command, &entry.args, deadline) {
941 Ok(HandshakeOk {
942 version,
943 tool_count,
944 task_tool,
945 }) => Check::ok(
946 "handshake",
947 format!(
948 "initialize + tools/list ok — version {version}, {tool_count} tools \
949 ({task_tool} present)"
950 ),
951 ),
952 Err(msg) => Check::fail(
953 "handshake",
954 msg,
955 Some(format!(
956 "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
957 to rewrite the command{}",
958 entry.command,
959 entry.args.join(" "),
960 slow_store_hint(store_opened_in, deadline)
961 )),
962 ),
963 }
964}
965
966fn self_handshake(
974 command: &str,
975 args: &[String],
976 timeout: Duration,
977) -> Result<HandshakeOk, String> {
978 let mut child = Command::new(command)
979 .args(args)
980 .stdin(Stdio::piped())
981 .stdout(Stdio::piped())
982 .stderr(Stdio::null())
983 .spawn()
984 .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
985
986 let mut stdin = child.stdin.take().expect("piped stdin");
987 let stdout = child.stdout.take().expect("piped stdout");
988
989 let (tx, rx) = std::sync::mpsc::channel::<String>();
990 std::thread::spawn(move || {
991 let mut reader = BufReader::new(stdout);
992 let mut line = String::new();
993 loop {
994 line.clear();
995 match reader.read_line(&mut line) {
996 Ok(0) | Err(_) => break,
997 Ok(_) => {
998 if tx.send(line.trim().to_string()).is_err() {
999 break;
1000 }
1001 }
1002 }
1003 }
1004 });
1005
1006 let sent = writeln!(
1007 stdin,
1008 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
1009 )
1010 .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
1011 .and_then(|()| stdin.flush());
1012
1013 let mut init_resp: Option<Js> = None;
1014 let mut list_resp: Option<Js> = None;
1015 if sent.is_ok() {
1016 let deadline = Instant::now() + timeout;
1017 while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
1018 let remaining = deadline.saturating_duration_since(Instant::now());
1019 match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
1020 Ok(line) if !line.is_empty() => {
1021 if let Ok(v) = serde_json::from_str::<Js>(&line) {
1022 match v.get("id").and_then(Js::as_i64) {
1023 Some(1) => init_resp = Some(v),
1024 Some(2) => list_resp = Some(v),
1025 _ => {}
1026 }
1027 }
1028 }
1029 Ok(_) => {}
1030 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
1031 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1032 }
1033 }
1034 }
1035
1036 drop(stdin);
1039 let reap_deadline = Instant::now() + Duration::from_secs(2);
1040 loop {
1041 match child.try_wait() {
1042 Ok(Some(_)) | Err(_) => break,
1043 Ok(None) if Instant::now() >= reap_deadline => {
1044 let _ = child.kill();
1045 let _ = child.wait();
1046 break;
1047 }
1048 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
1049 }
1050 }
1051
1052 if sent.is_err() {
1053 return Err(format!("cannot write to `{command}`'s stdin"));
1054 }
1055 let init = init_resp
1056 .ok_or_else(|| format!("`{command}` did not answer `initialize` within {timeout:?}"))?;
1057 let list = list_resp
1058 .ok_or_else(|| format!("`{command}` did not answer `tools/list` within {timeout:?}"))?;
1059
1060 let version = init["result"]["serverInfo"]["version"]
1061 .as_str()
1062 .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
1063 .to_string();
1064 if version != crate::VERSION {
1065 return Err(format!(
1066 "`{command}` reports version {version}, expected {}",
1067 crate::VERSION
1068 ));
1069 }
1070 let tools = list["result"]["tools"]
1071 .as_array()
1072 .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
1073 let task_tool = tools
1074 .iter()
1075 .filter_map(|t| t["name"].as_str())
1076 .find(|name| TASK_PATH_TOOLS.contains(name))
1077 .ok_or_else(|| {
1078 format!(
1079 "`{command}`: tools/list includes none of {}",
1080 TASK_PATH_TOOLS.join(", ")
1081 )
1082 })?
1083 .to_string();
1084
1085 Ok(HandshakeOk {
1086 version,
1087 tool_count: tools.len(),
1088 task_tool,
1089 })
1090}
1091
1092const TASK_PATH_TOOLS: [&str; 1] = ["explain_association"];
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102
1103 #[test]
1111 fn the_handshake_deadline_holds_its_floor_and_scales() {
1112 assert_eq!(handshake_deadline(None), HANDSHAKE_TIMEOUT);
1115
1116 for ms in [0, 1, 50, 500, 2_400] {
1118 assert_eq!(
1119 handshake_deadline(Some(Duration::from_millis(ms))),
1120 HANDSHAKE_TIMEOUT,
1121 "a {ms}ms open must not shorten the deadline below its floor"
1122 );
1123 }
1124
1125 assert_eq!(
1127 handshake_deadline(Some(Duration::from_millis(2_500))),
1128 HANDSHAKE_TIMEOUT
1129 );
1130
1131 assert_eq!(
1133 handshake_deadline(Some(Duration::from_secs(3))),
1134 Duration::from_secs(11)
1135 );
1136 let ci = Duration::from_millis(13_300);
1138 let got = handshake_deadline(Some(ci));
1139 assert_eq!(got, Duration::from_millis(31_600));
1140 assert!(
1141 got > ci,
1142 "the deadline must exceed the open it was measured from"
1143 );
1144
1145 let mut prev = handshake_deadline(Some(Duration::ZERO));
1147 for s in 1..60 {
1148 let next = handshake_deadline(Some(Duration::from_secs(s)));
1149 assert!(next >= prev, "deadline shrank at a {s}s open");
1150 prev = next;
1151 }
1152 }
1153
1154 #[test]
1159 fn the_fix_hint_names_a_slow_store_only_when_it_is_the_reason() {
1160 assert_eq!(slow_store_hint(None, HANDSHAKE_TIMEOUT), "");
1162 assert_eq!(
1163 slow_store_hint(Some(Duration::from_millis(80)), HANDSHAKE_TIMEOUT),
1164 ""
1165 );
1166 assert_eq!(slow_store_hint(None, Duration::from_secs(40)), "");
1168
1169 let open = Duration::from_millis(13_300);
1172 let hint = slow_store_hint(Some(open), handshake_deadline(Some(open)));
1173 assert!(hint.contains("13.3s"), "the open time: {hint}");
1174 assert!(hint.contains("31.6s"), "the deadline it bought: {hint}");
1175 assert!(hint.contains("build-index"), "{hint}");
1176 assert!(hint.contains("snapshot"), "{hint}");
1177
1178 let modest = Duration::from_millis(4_800);
1181 assert!(handshake_deadline(Some(modest)) > HANDSHAKE_TIMEOUT);
1182 assert!(
1183 slow_store_hint(Some(modest), handshake_deadline(Some(modest))).contains("4.8s"),
1184 "an open below ten seconds can still be the reason"
1185 );
1186 }
1187}