1use crate::install::{
19 claude_mcp_file, cursor_mcp_file, default_db, entry_db, expand_platform, git_hooks_dir,
20 has_our_server, installed_shape, is_disabled, is_our_hook_command, line_runs_for_store,
21 opt_ins, resolve_platform, resolve_scope, Externals, Platform, Scope, StoreRef, AUTO_ARG,
22 BRIEF_EVENT, ENRICH_EVENT, GIT_HOOKS, HOOK_BEGIN, HOOK_EVENT, IMPACT_EVENT, INTERCEPT_EVENT,
23 SERVER_NAME, TOUCH_EVENT,
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!(
116 "{:<4} {:<11} {}",
117 self.status.word(),
118 self.name,
119 self.message
120 );
121 if let Some(fix) = &self.fix {
122 line.push_str(&format!(" fix: {fix}"));
123 }
124 line.push('\n');
125 line
126 }
127}
128
129pub fn run_doctor(
131 project_root: &Path,
132 home: &Path,
133 opts: &DoctorOpts,
134) -> Result<DoctorReport, CliError> {
135 run_doctor_with(project_root, home, opts, &Externals::from_env())
136}
137
138pub fn run_doctor_with(
142 project_root: &Path,
143 home: &Path,
144 opts: &DoctorOpts,
145 ext: &Externals,
146) -> Result<DoctorReport, CliError> {
147 let (scope, _auto_scope) = resolve_scope(project_root, opts.scope);
148 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
149 let platforms = expand_platform(&resolved);
150
151 if is_disabled(project_root, home, scope, &platforms) {
155 return Ok(DoctorReport {
156 output: Check::warn("state", "disabled — enable with: mushroomdb enable", None)
157 .render(),
158 had_fail: false,
159 });
160 }
161
162 let (delivery, recorded_store) = installed_shape(project_root, home, scope, &platforms);
168 let mcp_delivery = delivery.wires_mcp();
169
170 let mut checks: Vec<Check> = Vec::new();
171 let mut primary: Option<(Platform, ConfigEntry)> = None;
172
173 for plat in &platforms {
176 if !mcp_delivery && matches!(plat, Platform::ClaudeCode) {
180 checks.push(Check::skip(
181 "config",
182 format!(
183 "delivery: {} — the skill teaches the binary, no MCP entry to check",
184 delivery.label()
185 ),
186 ));
187 continue;
188 }
189 match mcp_file_for(plat, project_root, home, scope) {
190 None => checks.push(Check::warn(
191 "config",
192 format!(
193 "{}'s configuration is owned by its own CLI — not checked here",
194 plat.label()
195 ),
196 None,
197 )),
198 Some(mcp_file) => match read_config_entry(&mcp_file, project_root, home) {
199 Ok(entry) => {
200 checks.push(Check::ok(
201 "config",
202 format!(
203 "{} — {} -> {}",
204 plat.label(),
205 mcp_file.display(),
206 entry.describe_store()
207 ),
208 ));
209 if primary.is_none() {
210 primary = Some((plat.clone(), entry));
211 }
212 }
213 Err(msg) => checks.push(Check::fail("config", msg, Some(install_fix(scope, plat)))),
214 },
215 }
216 }
217
218 let store: Option<StoreRef> = match &primary {
221 Some((_, entry)) => Some(entry.store.clone()),
222 None if !mcp_delivery => recorded_store,
223 None => None,
224 };
225
226 if let Some((_, entry)) = &primary {
229 if entry.command == "npx" {
230 checks.push(check_npx(entry, ext));
231 } else if let Some(check) = check_resolved_path(entry) {
232 checks.push(check);
233 }
234 }
235
236 match &store {
238 Some(store) => checks.extend(check_store_and_lock(store.path())),
239 None => checks.push(Check::fail(
240 "store",
241 no_store_message(mcp_delivery),
242 Some(install_fix_for_scope(scope)),
243 )),
244 }
245
246 if platforms.contains(&Platform::ClaudeCode) {
248 if let Some(store) = &store {
249 checks.push(check_hooks(project_root, home, scope, store));
250 let opted = opt_ins(project_root, home, scope, &platforms);
254 let settings_file = settings_file(project_root, home, scope);
255 let settings = read_json(&settings_file).unwrap_or(Js::Null);
256 for (asked, sub, event, matcher, flag) in [
257 (
258 opted.intercept_grep,
259 "intercept",
260 INTERCEPT_EVENT,
261 "Grep",
262 "--intercept-grep",
263 ),
264 (
265 opted.impact_before_edit,
266 "impact-hook",
267 IMPACT_EVENT,
268 "Edit|Write|MultiEdit",
269 "--impact-before-edit",
270 ),
271 (
272 opted.enrich_grep,
273 "enrich",
274 ENRICH_EVENT,
275 "Grep",
276 "--enrich-grep",
277 ),
278 ] {
279 if asked {
280 checks.push(check_opt_in_hook(
281 sub,
282 event,
283 matcher,
284 flag,
285 &settings_file,
286 &settings,
287 store,
288 ));
289 }
290 }
291 if opted.always_load {
292 checks.push(check_always_load(project_root, home, scope));
293 }
294 }
295 }
296
297 if scope == Scope::Project
300 && platforms
301 .iter()
302 .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor))
303 {
304 if let Some(store) = &store {
305 if let Some(check) = check_git_hooks(project_root, store) {
306 checks.push(check);
307 }
308 }
309 }
310
311 match &primary {
313 Some((_, entry)) => checks.push(check_handshake(entry)),
314 None if !mcp_delivery => checks.push(Check::skip(
316 "handshake",
317 format!("delivery: {} — no server to spawn", delivery.label()),
318 )),
319 None => checks.push(Check::fail(
320 "handshake",
321 "no usable config entry — nothing to spawn",
322 Some(install_fix_for_scope(scope)),
323 )),
324 }
325
326 if platforms.contains(&Platform::ClaudeCode) {
328 checks.push(check_scope_conflict(project_root, home, scope));
329 }
330
331 let had_fail = checks.iter().any(|c| c.status == Status::Fail);
332 let mut output = String::new();
333 for c in &checks {
334 output.push_str(&c.render());
335 }
336 Ok(DoctorReport { output, had_fail })
337}
338
339fn no_store_message(mcp_delivery: bool) -> &'static str {
342 if mcp_delivery {
343 "no usable config entry — cannot locate a database to check"
344 } else {
345 "no recorded SessionStart hook — cannot locate a database to check"
346 }
347}
348
349fn install_fix_for_scope(scope: Scope) -> String {
350 format!(
351 "mushroomdb install {}",
352 match scope {
353 Scope::Project => "--project",
354 Scope::User => "--user",
355 }
356 )
357}
358
359fn install_fix(scope: Scope, plat: &Platform) -> String {
360 format!(
361 "mushroomdb install --platform {} {}",
362 plat.label(),
363 match scope {
364 Scope::Project => "--project",
365 Scope::User => "--user",
366 }
367 )
368}
369
370fn mcp_file_for(
371 plat: &Platform,
372 project_root: &Path,
373 home: &Path,
374 scope: Scope,
375) -> Option<PathBuf> {
376 match plat {
377 Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
378 Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
379 Platform::Codex | Platform::All => None,
380 }
381}
382
383struct ConfigEntry {
389 store: StoreRef,
391 command: String,
392 args: Vec<String>,
393}
394
395impl ConfigEntry {
396 fn describe_store(&self) -> String {
400 if self.store.is_auto() {
401 format!("{AUTO_ARG} -> {}", self.store.path().display())
402 } else {
403 self.store.path().display().to_string()
404 }
405 }
406}
407
408fn entry_store(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
417 if arg == AUTO_ARG {
418 return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
419 }
420 let path = PathBuf::from(arg);
421 if path == default_db(Scope::Project, project_root, home) {
422 return StoreRef::pinned(path).also_auto();
423 }
424 StoreRef::pinned(path)
425}
426
427fn read_json(path: &Path) -> Result<Js, String> {
428 let raw = std::fs::read_to_string(path)
429 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
430 serde_json::from_str(&raw).map_err(|e| format!("invalid JSON in {}: {e}", path.display()))
431}
432
433fn read_config_entry(
434 mcp_file: &Path,
435 project_root: &Path,
436 home: &Path,
437) -> Result<ConfigEntry, String> {
438 if !mcp_file.exists() {
439 return Err(format!("{} does not exist", mcp_file.display()));
440 }
441 let root = read_json(mcp_file)?;
442 let entry = &root["mcpServers"]["mushroomdb"];
443 if entry.is_null() {
444 return Err(format!(
445 "no mcpServers.mushroomdb entry in {}",
446 mcp_file.display()
447 ));
448 }
449 let store = entry_store(
450 entry_db(entry).ok_or_else(|| {
451 format!(
452 "{}: mushroomdb entry has no `mcp <db>|--auto` argument",
453 mcp_file.display()
454 )
455 })?,
456 project_root,
457 home,
458 );
459 let command = entry["command"]
460 .as_str()
461 .ok_or_else(|| format!("{}: mushroomdb entry has no `command`", mcp_file.display()))?
462 .to_string();
463 let args = entry["args"]
464 .as_array()
465 .map(|a| {
466 a.iter()
467 .filter_map(|v| v.as_str().map(str::to_string))
468 .collect()
469 })
470 .unwrap_or_default();
471 Ok(ConfigEntry {
472 store,
473 command,
474 args,
475 })
476}
477
478const NPX_TIMEOUT: Duration = Duration::from_secs(60);
483
484fn check_npx(entry: &ConfigEntry, ext: &Externals) -> Check {
485 let pinned = entry
486 .args
487 .iter()
488 .find_map(|a| a.strip_prefix("mushroomdb@"))
489 .unwrap_or(crate::VERSION);
490 let Some(npx) = ext.which("npx") else {
491 return Check::fail(
492 "npx",
493 "npx is not on PATH",
494 Some(
495 "install Node.js (which provides npx), or re-install with --command <path>"
496 .to_string(),
497 ),
498 );
499 };
500 let args = vec![
501 "-y".to_string(),
502 format!("mushroomdb@{pinned}"),
503 "--version".to_string(),
504 ];
505 match run_capturing(&npx, &args, NPX_TIMEOUT) {
506 RunOutcome::Done(out) if out.contains(pinned) => Check::ok(
507 "npx",
508 format!("npx -y mushroomdb@{pinned} --version -> {}", out.trim()),
509 ),
510 RunOutcome::Done(out) => Check::fail(
511 "npx",
512 format!(
513 "npx -y mushroomdb@{pinned} --version printed {:?}, expected to contain {pinned}",
514 out.trim()
515 ),
516 Some("re-run `mushroomdb install` to repin the version".to_string()),
517 ),
518 RunOutcome::TimedOut => Check::warn(
519 "npx",
520 format!("npx -y mushroomdb@{pinned} --version timed out after {NPX_TIMEOUT:?}"),
521 Some("check network access to the npm registry".to_string()),
522 ),
523 RunOutcome::Failed(e) => Check::fail("npx", e, None),
524 }
525}
526
527fn check_resolved_path(entry: &ConfigEntry) -> Option<Check> {
538 let (name, path) = if entry.command == "node" {
539 ("launcher", Path::new(entry.args.first()?))
540 } else if Path::new(&entry.command).is_absolute() {
541 ("binary", Path::new(&entry.command))
542 } else {
543 return None;
544 };
545 Some(if path.is_file() {
546 Check::ok(name, path.display().to_string())
547 } else {
548 Check::fail(
549 name,
550 format!("{} no longer exists", path.display()),
551 Some("mushroomdb install (re-resolves it)".to_string()),
552 )
553 })
554}
555
556enum RunOutcome {
557 Done(String),
558 TimedOut,
559 Failed(String),
560}
561
562fn run_capturing(bin: &Path, args: &[String], timeout: Duration) -> RunOutcome {
564 let mut child = match Command::new(bin)
565 .args(args)
566 .stdin(Stdio::null())
567 .stdout(Stdio::piped())
568 .stderr(Stdio::null())
569 .spawn()
570 {
571 Ok(c) => c,
572 Err(e) => return RunOutcome::Failed(format!("cannot run {}: {e}", bin.display())),
573 };
574 let mut stdout = child.stdout.take().expect("piped stdout");
575 let (tx, rx) = std::sync::mpsc::channel::<String>();
576 std::thread::spawn(move || {
577 let mut out = String::new();
578 let _ = stdout.read_to_string(&mut out);
579 let _ = tx.send(out);
580 });
581 let deadline = Instant::now() + timeout;
582 loop {
583 match child.try_wait() {
584 Ok(Some(status)) => {
585 let out = rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default();
586 return if status.success() {
587 RunOutcome::Done(out)
588 } else {
589 RunOutcome::Failed(format!("{} exited with {status}", bin.display()))
590 };
591 }
592 Ok(None) if Instant::now() >= deadline => {
593 let _ = child.kill();
594 let _ = child.wait();
595 return RunOutcome::TimedOut;
596 }
597 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
598 Err(e) => return RunOutcome::Failed(format!("cannot wait for {}: {e}", bin.display())),
599 }
600 }
601}
602
603fn check_store_and_lock(db_dir: &Path) -> Vec<Check> {
608 let mut out = Vec::new();
609 let store = GraphDb::open_with_options(
610 db_dir,
611 OpenOptions {
612 read_only: true,
613 auto_migrate: true,
614 repair_wal: true,
615 },
616 );
617 match store {
618 Ok(db) => {
619 let stats = db.stats();
620 let stale = db.is_stale().unwrap_or(false);
621 let floor = db.wal_horizon_floor();
622 let total = db.wal_total_commits().unwrap_or(floor);
623 out.push(Check::ok(
624 "store",
625 format!(
626 "{} — {} nodes live ({} tombstoned), {} edges, history from commit {} of {}{}",
627 db_dir.display(),
628 stats.nodes_live,
629 stats.nodes_tombstoned,
630 stats.edges,
631 floor,
632 total,
633 if stale {
634 ", stale (newer commits pending refresh)"
635 } else {
636 ""
637 }
638 ),
639 ));
640 drop(db);
641
642 match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
646 Ok(handle) => {
647 drop(handle);
648 out.push(Check::ok(
649 "lock",
650 "free — no other process is writing".to_string(),
651 ));
652 }
653 Err(GraphError::Busy { .. }) => out.push(Check::warn(
654 "lock",
655 "another process is writing".to_string(),
656 Some("re-run once the other process finishes".to_string()),
657 )),
658 Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
659 }
660 }
661 Err(e) => out.push(Check::fail(
662 "store",
663 format!("cannot open {}: {e}", db_dir.display()),
664 Some(format!("mushroomdb verify {}", db_dir.display())),
665 )),
666 }
667 out
668}
669
670fn check_hooks(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
675 let settings_file = match scope {
676 Scope::Project => project_root.join(".claude").join("settings.json"),
677 Scope::User => home.join(".claude").join("settings.json"),
678 };
679 let root = read_json(&settings_file).unwrap_or(Js::Null);
680 let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", store);
681 let has_touch = has_hook_matching(&root, TOUCH_EVENT, "touch", store);
682 let has_brief = has_hook_matching(&root, BRIEF_EVENT, "brief", store);
683 if has_recall && has_touch && has_brief {
684 Check::ok(
685 "hooks",
686 format!(
687 "{HOOK_EVENT} + {TOUCH_EVENT} + {BRIEF_EVENT} present in {}",
688 settings_file.display()
689 ),
690 )
691 } else {
692 let mut missing = Vec::new();
693 if !has_recall {
694 missing.push(HOOK_EVENT);
695 }
696 if !has_touch {
697 missing.push(TOUCH_EVENT);
698 }
699 if !has_brief {
700 missing.push(BRIEF_EVENT);
701 }
702 Check::warn(
703 "hooks",
704 format!(
705 "missing {} in {}",
706 missing.join(", "),
707 settings_file.display()
708 ),
709 Some("mushroomdb install --platform claude-code".to_string()),
710 )
711 }
712}
713
714fn check_always_load(project_root: &Path, home: &Path, scope: Scope) -> Check {
725 let mcp_file = claude_mcp_file(project_root, home, scope);
726 let entry = read_json(&mcp_file).unwrap_or(Js::Null);
727 if entry["mcpServers"][SERVER_NAME]["alwaysLoad"] == Js::Bool(true) {
728 Check::ok(
729 "always-load",
730 format!(
731 "mcpServers.{SERVER_NAME} is marked alwaysLoad in {}",
732 mcp_file.display()
733 ),
734 )
735 } else {
736 Check::warn(
737 "always-load",
738 format!("manifest records it but {} does not", mcp_file.display()),
739 Some("mushroomdb install --platform claude-code --always-load".to_string()),
740 )
741 }
742}
743
744fn settings_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
746 match scope {
747 Scope::Project => project_root.join(".claude").join("settings.json"),
748 Scope::User => home.join(".claude").join("settings.json"),
749 }
750}
751
752fn check_opt_in_hook(
758 sub: &'static str,
759 event: &str,
760 matcher: &str,
761 flag: &str,
762 settings_file: &Path,
763 root: &Js,
764 store: &StoreRef,
765) -> Check {
766 if has_hook_matching(root, event, sub, store) {
767 Check::ok(
768 sub,
769 format!("{event} ({matcher}) present in {}", settings_file.display()),
770 )
771 } else {
772 Check::warn(
773 sub,
774 format!("missing {event} in {}", settings_file.display()),
775 Some(format!("mushroomdb install --platform claude-code {flag}")),
776 )
777 }
778}
779
780fn has_hook_matching(root: &Js, event: &str, sub: &str, store: &StoreRef) -> bool {
781 root["hooks"][event]
782 .as_array()
783 .map(|groups| {
784 groups.iter().any(|g| {
785 g["hooks"]
786 .as_array()
787 .map(|hs| {
788 hs.iter().any(|h| {
789 h["command"]
790 .as_str()
791 .is_some_and(|c| is_our_hook_command(c, sub, store))
792 })
793 })
794 .unwrap_or(false)
795 })
796 })
797 .unwrap_or(false)
798}
799
800fn check_git_hooks(project_root: &Path, store: &StoreRef) -> Option<Check> {
805 let dir = git_hooks_dir(project_root)?;
806 let missing: Vec<&str> = GIT_HOOKS
810 .iter()
811 .filter(|name| {
812 let content = std::fs::read_to_string(dir.join(name)).unwrap_or_default();
813 let names_store = content
814 .lines()
815 .any(|l| line_runs_for_store(l, "sync", store));
816 !(content.contains(HOOK_BEGIN) && names_store)
817 })
818 .copied()
819 .collect();
820 Some(if missing.is_empty() {
821 Check::ok(
822 "git-hooks",
823 format!("{} present in {}", GIT_HOOKS.join("/"), dir.display()),
824 )
825 } else {
826 Check::warn(
827 "git-hooks",
828 format!("missing in {}: {}", dir.display(), missing.join(", ")),
829 Some("mushroomdb install --project (omit --no-git-hooks)".to_string()),
830 )
831 })
832}
833
834fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
839 let (other_file, other_label, other_flag) = match scope {
840 Scope::Project => (
841 claude_mcp_file(project_root, home, Scope::User),
842 "user",
843 "--user",
844 ),
845 Scope::User => (
846 claude_mcp_file(project_root, home, Scope::Project),
847 "project",
848 "--project",
849 ),
850 };
851 if has_our_server(&other_file) {
852 Check::warn(
853 "scope",
854 format!(
855 "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
856 other_file.display()
857 ),
858 Some(format!("mushroomdb uninstall {other_flag}")),
859 )
860 } else {
861 Check::ok(
862 "scope",
863 "no duplicate server in the other scope".to_string(),
864 )
865 }
866}
867
868const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
873
874struct HandshakeOk {
875 version: String,
876 tool_count: usize,
877 task_tool: String,
879}
880
881fn check_handshake(entry: &ConfigEntry) -> Check {
882 match self_handshake(&entry.command, &entry.args) {
883 Ok(HandshakeOk {
884 version,
885 tool_count,
886 task_tool,
887 }) => Check::ok(
888 "handshake",
889 format!(
890 "initialize + tools/list ok — version {version}, {tool_count} tools \
891 ({task_tool} present)"
892 ),
893 ),
894 Err(msg) => Check::fail(
895 "handshake",
896 msg,
897 Some(format!(
898 "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
899 to rewrite the command",
900 entry.command,
901 entry.args.join(" ")
902 )),
903 ),
904 }
905}
906
907fn self_handshake(command: &str, args: &[String]) -> Result<HandshakeOk, String> {
914 let mut child = Command::new(command)
915 .args(args)
916 .stdin(Stdio::piped())
917 .stdout(Stdio::piped())
918 .stderr(Stdio::null())
919 .spawn()
920 .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
921
922 let mut stdin = child.stdin.take().expect("piped stdin");
923 let stdout = child.stdout.take().expect("piped stdout");
924
925 let (tx, rx) = std::sync::mpsc::channel::<String>();
926 std::thread::spawn(move || {
927 let mut reader = BufReader::new(stdout);
928 let mut line = String::new();
929 loop {
930 line.clear();
931 match reader.read_line(&mut line) {
932 Ok(0) | Err(_) => break,
933 Ok(_) => {
934 if tx.send(line.trim().to_string()).is_err() {
935 break;
936 }
937 }
938 }
939 }
940 });
941
942 let sent = writeln!(
943 stdin,
944 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
945 )
946 .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
947 .and_then(|()| stdin.flush());
948
949 let mut init_resp: Option<Js> = None;
950 let mut list_resp: Option<Js> = None;
951 if sent.is_ok() {
952 let deadline = Instant::now() + HANDSHAKE_TIMEOUT;
953 while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
954 let remaining = deadline.saturating_duration_since(Instant::now());
955 match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
956 Ok(line) if !line.is_empty() => {
957 if let Ok(v) = serde_json::from_str::<Js>(&line) {
958 match v.get("id").and_then(Js::as_i64) {
959 Some(1) => init_resp = Some(v),
960 Some(2) => list_resp = Some(v),
961 _ => {}
962 }
963 }
964 }
965 Ok(_) => {}
966 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
967 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
968 }
969 }
970 }
971
972 drop(stdin);
975 let reap_deadline = Instant::now() + Duration::from_secs(2);
976 loop {
977 match child.try_wait() {
978 Ok(Some(_)) | Err(_) => break,
979 Ok(None) if Instant::now() >= reap_deadline => {
980 let _ = child.kill();
981 let _ = child.wait();
982 break;
983 }
984 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
985 }
986 }
987
988 if sent.is_err() {
989 return Err(format!("cannot write to `{command}`'s stdin"));
990 }
991 let init = init_resp.ok_or_else(|| {
992 format!("`{command}` did not answer `initialize` within {HANDSHAKE_TIMEOUT:?}")
993 })?;
994 let list = list_resp.ok_or_else(|| {
995 format!("`{command}` did not answer `tools/list` within {HANDSHAKE_TIMEOUT:?}")
996 })?;
997
998 let version = init["result"]["serverInfo"]["version"]
999 .as_str()
1000 .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
1001 .to_string();
1002 if version != crate::VERSION {
1003 return Err(format!(
1004 "`{command}` reports version {version}, expected {}",
1005 crate::VERSION
1006 ));
1007 }
1008 let tools = list["result"]["tools"]
1009 .as_array()
1010 .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
1011 let task_tool = tools
1012 .iter()
1013 .filter_map(|t| t["name"].as_str())
1014 .find(|name| TASK_PATH_TOOLS.contains(name))
1015 .ok_or_else(|| {
1016 format!(
1017 "`{command}`: tools/list includes none of {}",
1018 TASK_PATH_TOOLS.join(", ")
1019 )
1020 })?
1021 .to_string();
1022
1023 Ok(HandshakeOk {
1024 version,
1025 tool_count: tools.len(),
1026 task_tool,
1027 })
1028}
1029
1030const TASK_PATH_TOOLS: [&str; 2] = ["explore", "explain_association"];