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 out.push(Check::ok(
622 "store",
623 format!(
624 "{} — {} nodes live ({} tombstoned), {} edges{}",
625 db_dir.display(),
626 stats.nodes_live,
627 stats.nodes_tombstoned,
628 stats.edges,
629 if stale {
630 ", stale (newer commits pending refresh)"
631 } else {
632 ""
633 }
634 ),
635 ));
636 drop(db);
637
638 match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
642 Ok(handle) => {
643 drop(handle);
644 out.push(Check::ok(
645 "lock",
646 "free — no other process is writing".to_string(),
647 ));
648 }
649 Err(GraphError::Busy { .. }) => out.push(Check::warn(
650 "lock",
651 "another process is writing".to_string(),
652 Some("re-run once the other process finishes".to_string()),
653 )),
654 Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
655 }
656 }
657 Err(e) => out.push(Check::fail(
658 "store",
659 format!("cannot open {}: {e}", db_dir.display()),
660 Some(format!("mushroomdb verify {}", db_dir.display())),
661 )),
662 }
663 out
664}
665
666fn check_hooks(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
671 let settings_file = match scope {
672 Scope::Project => project_root.join(".claude").join("settings.json"),
673 Scope::User => home.join(".claude").join("settings.json"),
674 };
675 let root = read_json(&settings_file).unwrap_or(Js::Null);
676 let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", store);
677 let has_touch = has_hook_matching(&root, TOUCH_EVENT, "touch", store);
678 let has_brief = has_hook_matching(&root, BRIEF_EVENT, "brief", store);
679 if has_recall && has_touch && has_brief {
680 Check::ok(
681 "hooks",
682 format!(
683 "{HOOK_EVENT} + {TOUCH_EVENT} + {BRIEF_EVENT} present in {}",
684 settings_file.display()
685 ),
686 )
687 } else {
688 let mut missing = Vec::new();
689 if !has_recall {
690 missing.push(HOOK_EVENT);
691 }
692 if !has_touch {
693 missing.push(TOUCH_EVENT);
694 }
695 if !has_brief {
696 missing.push(BRIEF_EVENT);
697 }
698 Check::warn(
699 "hooks",
700 format!(
701 "missing {} in {}",
702 missing.join(", "),
703 settings_file.display()
704 ),
705 Some("mushroomdb install --platform claude-code".to_string()),
706 )
707 }
708}
709
710fn check_always_load(project_root: &Path, home: &Path, scope: Scope) -> Check {
721 let mcp_file = claude_mcp_file(project_root, home, scope);
722 let entry = read_json(&mcp_file).unwrap_or(Js::Null);
723 if entry["mcpServers"][SERVER_NAME]["alwaysLoad"] == Js::Bool(true) {
724 Check::ok(
725 "always-load",
726 format!(
727 "mcpServers.{SERVER_NAME} is marked alwaysLoad in {}",
728 mcp_file.display()
729 ),
730 )
731 } else {
732 Check::warn(
733 "always-load",
734 format!("manifest records it but {} does not", mcp_file.display()),
735 Some("mushroomdb install --platform claude-code --always-load".to_string()),
736 )
737 }
738}
739
740fn settings_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
742 match scope {
743 Scope::Project => project_root.join(".claude").join("settings.json"),
744 Scope::User => home.join(".claude").join("settings.json"),
745 }
746}
747
748fn check_opt_in_hook(
754 sub: &'static str,
755 event: &str,
756 matcher: &str,
757 flag: &str,
758 settings_file: &Path,
759 root: &Js,
760 store: &StoreRef,
761) -> Check {
762 if has_hook_matching(root, event, sub, store) {
763 Check::ok(
764 sub,
765 format!("{event} ({matcher}) present in {}", settings_file.display()),
766 )
767 } else {
768 Check::warn(
769 sub,
770 format!("missing {event} in {}", settings_file.display()),
771 Some(format!("mushroomdb install --platform claude-code {flag}")),
772 )
773 }
774}
775
776fn has_hook_matching(root: &Js, event: &str, sub: &str, store: &StoreRef) -> bool {
777 root["hooks"][event]
778 .as_array()
779 .map(|groups| {
780 groups.iter().any(|g| {
781 g["hooks"]
782 .as_array()
783 .map(|hs| {
784 hs.iter().any(|h| {
785 h["command"]
786 .as_str()
787 .is_some_and(|c| is_our_hook_command(c, sub, store))
788 })
789 })
790 .unwrap_or(false)
791 })
792 })
793 .unwrap_or(false)
794}
795
796fn check_git_hooks(project_root: &Path, store: &StoreRef) -> Option<Check> {
801 let dir = git_hooks_dir(project_root)?;
802 let missing: Vec<&str> = GIT_HOOKS
806 .iter()
807 .filter(|name| {
808 let content = std::fs::read_to_string(dir.join(name)).unwrap_or_default();
809 let names_store = content
810 .lines()
811 .any(|l| line_runs_for_store(l, "sync", store));
812 !(content.contains(HOOK_BEGIN) && names_store)
813 })
814 .copied()
815 .collect();
816 Some(if missing.is_empty() {
817 Check::ok(
818 "git-hooks",
819 format!("{} present in {}", GIT_HOOKS.join("/"), dir.display()),
820 )
821 } else {
822 Check::warn(
823 "git-hooks",
824 format!("missing in {}: {}", dir.display(), missing.join(", ")),
825 Some("mushroomdb install --project (omit --no-git-hooks)".to_string()),
826 )
827 })
828}
829
830fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
835 let (other_file, other_label, other_flag) = match scope {
836 Scope::Project => (
837 claude_mcp_file(project_root, home, Scope::User),
838 "user",
839 "--user",
840 ),
841 Scope::User => (
842 claude_mcp_file(project_root, home, Scope::Project),
843 "project",
844 "--project",
845 ),
846 };
847 if has_our_server(&other_file) {
848 Check::warn(
849 "scope",
850 format!(
851 "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
852 other_file.display()
853 ),
854 Some(format!("mushroomdb uninstall {other_flag}")),
855 )
856 } else {
857 Check::ok(
858 "scope",
859 "no duplicate server in the other scope".to_string(),
860 )
861 }
862}
863
864const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
869
870struct HandshakeOk {
871 version: String,
872 tool_count: usize,
873 task_tool: String,
875}
876
877fn check_handshake(entry: &ConfigEntry) -> Check {
878 match self_handshake(&entry.command, &entry.args) {
879 Ok(HandshakeOk {
880 version,
881 tool_count,
882 task_tool,
883 }) => Check::ok(
884 "handshake",
885 format!(
886 "initialize + tools/list ok — version {version}, {tool_count} tools \
887 ({task_tool} present)"
888 ),
889 ),
890 Err(msg) => Check::fail(
891 "handshake",
892 msg,
893 Some(format!(
894 "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
895 to rewrite the command",
896 entry.command,
897 entry.args.join(" ")
898 )),
899 ),
900 }
901}
902
903fn self_handshake(command: &str, args: &[String]) -> Result<HandshakeOk, String> {
910 let mut child = Command::new(command)
911 .args(args)
912 .stdin(Stdio::piped())
913 .stdout(Stdio::piped())
914 .stderr(Stdio::null())
915 .spawn()
916 .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
917
918 let mut stdin = child.stdin.take().expect("piped stdin");
919 let stdout = child.stdout.take().expect("piped stdout");
920
921 let (tx, rx) = std::sync::mpsc::channel::<String>();
922 std::thread::spawn(move || {
923 let mut reader = BufReader::new(stdout);
924 let mut line = String::new();
925 loop {
926 line.clear();
927 match reader.read_line(&mut line) {
928 Ok(0) | Err(_) => break,
929 Ok(_) => {
930 if tx.send(line.trim().to_string()).is_err() {
931 break;
932 }
933 }
934 }
935 }
936 });
937
938 let sent = writeln!(
939 stdin,
940 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
941 )
942 .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
943 .and_then(|()| stdin.flush());
944
945 let mut init_resp: Option<Js> = None;
946 let mut list_resp: Option<Js> = None;
947 if sent.is_ok() {
948 let deadline = Instant::now() + HANDSHAKE_TIMEOUT;
949 while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
950 let remaining = deadline.saturating_duration_since(Instant::now());
951 match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
952 Ok(line) if !line.is_empty() => {
953 if let Ok(v) = serde_json::from_str::<Js>(&line) {
954 match v.get("id").and_then(Js::as_i64) {
955 Some(1) => init_resp = Some(v),
956 Some(2) => list_resp = Some(v),
957 _ => {}
958 }
959 }
960 }
961 Ok(_) => {}
962 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
963 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
964 }
965 }
966 }
967
968 drop(stdin);
971 let reap_deadline = Instant::now() + Duration::from_secs(2);
972 loop {
973 match child.try_wait() {
974 Ok(Some(_)) | Err(_) => break,
975 Ok(None) if Instant::now() >= reap_deadline => {
976 let _ = child.kill();
977 let _ = child.wait();
978 break;
979 }
980 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
981 }
982 }
983
984 if sent.is_err() {
985 return Err(format!("cannot write to `{command}`'s stdin"));
986 }
987 let init = init_resp.ok_or_else(|| {
988 format!("`{command}` did not answer `initialize` within {HANDSHAKE_TIMEOUT:?}")
989 })?;
990 let list = list_resp.ok_or_else(|| {
991 format!("`{command}` did not answer `tools/list` within {HANDSHAKE_TIMEOUT:?}")
992 })?;
993
994 let version = init["result"]["serverInfo"]["version"]
995 .as_str()
996 .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
997 .to_string();
998 if version != crate::VERSION {
999 return Err(format!(
1000 "`{command}` reports version {version}, expected {}",
1001 crate::VERSION
1002 ));
1003 }
1004 let tools = list["result"]["tools"]
1005 .as_array()
1006 .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
1007 let task_tool = tools
1008 .iter()
1009 .filter_map(|t| t["name"].as_str())
1010 .find(|name| TASK_PATH_TOOLS.contains(name))
1011 .ok_or_else(|| {
1012 format!(
1013 "`{command}`: tools/list includes none of {}",
1014 TASK_PATH_TOOLS.join(", ")
1015 )
1016 })?
1017 .to_string();
1018
1019 Ok(HandshakeOk {
1020 version,
1021 tool_count: tools.len(),
1022 task_tool,
1023 })
1024}
1025
1026const TASK_PATH_TOOLS: [&str; 2] = ["explore", "explain_association"];