1use std::{
14 env,
15 io::Write,
16 path::Path,
17 process::{Command, Stdio},
18};
19
20use anyhow::Result;
21
22use crate::adapter::which_all;
23
24use crate::{
25 adapter,
26 model::{CrewMode, Plan, Profile, QuipuFlavor, State, ToolName},
27};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Level {
31 Ok,
32 Warn,
33 Fail,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Finding {
38 pub level: Level,
39 pub subject: String,
40 pub detail: String,
41}
42
43impl Finding {
44 fn new(level: Level, subject: impl Into<String>, detail: impl Into<String>) -> Self {
45 Self {
46 level,
47 subject: subject.into(),
48 detail: detail.into(),
49 }
50 }
51}
52
53pub struct Scope {
56 pub tools: Vec<ToolName>,
57 pub quipu_flavor: QuipuFlavor,
58 pub external_quipu: bool,
59 pub remote_quipu: Option<String>,
60 pub crew: Option<CrewMode>,
61 pub work_dir: bool,
65}
66
67impl Scope {
68 pub fn from_plan(plan: &Plan) -> Self {
69 Self {
70 tools: plan.tools.clone(),
71 quipu_flavor: plan.quipu_flavor,
72 external_quipu: plan.external_quipu,
73 remote_quipu: env::var("QUIPU_SERVER")
74 .ok()
75 .filter(|url| !url.trim().is_empty())
76 .or_else(|| plan.quipu_mcp.as_ref().map(|mcp| mcp.url.clone())),
77 crew: plan.crew.as_ref().map(|crew| crew.mode),
78 work_dir: false,
79 }
80 }
81
82 pub fn everything() -> Self {
83 Self {
84 tools: Profile::Everything.tools(),
85 quipu_flavor: QuipuFlavor::Release,
86 external_quipu: false,
87 remote_quipu: env::var("QUIPU_SERVER")
88 .ok()
89 .filter(|url| !url.trim().is_empty()),
90 crew: None,
91 work_dir: false,
92 }
93 }
94}
95
96pub fn installed(plan: &Plan, state: &State) -> bool {
98 !plan.tools.is_empty()
99 && plan.tools.iter().all(|tool| {
100 state
101 .tools
102 .get(tool.as_str())
103 .is_some_and(|recorded| recorded.verified)
104 })
105}
106
107pub fn is_work_dir(plan: &Path) -> bool {
110 plan.parent().map_or(true, |dir| {
111 dir.as_os_str().is_empty() || dir == Path::new(".")
112 })
113}
114
115fn one_line(text: &str) -> String {
117 text.split_whitespace().collect::<Vec<_>>().join(" ")
118}
119
120fn crew_prerequisites(mode: CrewMode) -> Vec<&'static str> {
121 let mut commands = vec!["curl"];
122 if matches!(mode, CrewMode::Shantytown | CrewMode::Both) {
123 commands.push("python3");
124 }
125 if matches!(mode, CrewMode::Creel | CrewMode::Both) {
126 commands.push("tar");
127 }
128 commands
129}
130
131fn prerequisite_hint(command: &str) -> &'static str {
132 match command {
133 "go" => "Desire Path is built from source: install Go 1.22+ from https://go.dev/dl/",
134 "cargo" => "install Rust from https://rustup.rs",
135 "python3" => "install Python 3.9+ from your package manager",
136 "git" | "curl" | "tar" | "bash" => "install it from your package manager",
137 _ => "install it and rerun `tambo doctor`",
138 }
139}
140
141pub const SELF_VERSION: &str = concat!(
143 "caboodle ",
144 env!("CARGO_PKG_VERSION"),
145 " (",
146 env!("CABOODLE_GIT_SHA"),
147 ")"
148);
149
150fn check_self_on_path(path: Option<&std::ffi::OsStr>) -> Option<Finding> {
154 let current = env::current_exe().ok()?;
155 let command = if current.file_name().is_some_and(|name| name == "tambo") {
156 "tambo"
157 } else {
158 "caboodle"
159 };
160 let self_version = format!(
161 "{command} {} ({})",
162 env!("CARGO_PKG_VERSION"),
163 env!("CABOODLE_GIT_SHA")
164 );
165 let runs = which_all(command, path).into_iter().next()?;
166 let canonical = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
167 if canonical(&runs) == canonical(¤t) {
168 return None;
169 }
170 let runs_version = Command::new(&runs)
171 .arg("--version")
172 .stdin(Stdio::null())
173 .output()
174 .ok()
175 .map(|out| {
176 String::from_utf8_lossy(&out.stdout)
177 .lines()
178 .next()
179 .unwrap_or("")
180 .trim()
181 .to_owned()
182 })
183 .filter(|v| !v.is_empty())
184 .unwrap_or_else(|| "version unreadable".to_owned());
185 Some(if runs_version == self_version {
186 Finding::new(
187 Level::Ok,
188 format!("{command} on PATH"),
189 format!(
190 "{} is a copy of this build ({self_version})",
191 runs.display()
192 ),
193 )
194 } else {
195 Finding::new(
196 Level::Warn,
197 format!("{command} on PATH"),
198 format!(
199 "PATH runs {} ({runs_version}), NOT this {command} {} ({self_version}). Remove the stale copy or reorder PATH",
200 runs.display(),
201 current.display()
202 ),
203 )
204 })
205}
206
207pub fn diagnose(scope: &Scope) -> Vec<Finding> {
209 let path = env::var_os("PATH");
210 let mut findings = vec![Finding::new(
211 Level::Ok,
212 "platform",
213 format!("{}-{}", env::consts::ARCH, env::consts::OS),
214 )];
215 findings.extend(check_bin_dir_on_path(
216 adapter::managed_bin_dir().as_deref(),
217 path.as_deref(),
218 ));
219 findings.extend(check_self_on_path(path.as_deref()));
220 findings.extend(check_prerequisites(scope, path.as_deref()));
221 for &tool in &scope.tools {
222 if tool == ToolName::Quipu && scope.external_quipu {
223 let mut plan = Plan::for_profile(Profile::Kg);
224 plan.external_quipu = true;
225 let identity = adapter::adapter_for_plan(tool, &plan).and_then(|a| a.version());
226 findings.push(match identity {
227 Ok(version) => Finding::new(
228 Level::Ok,
229 "quipu",
230 format!("externally owned ({})", one_line(&version)),
231 ),
232 Err(error) => Finding::new(
233 Level::Fail,
234 "quipu",
235 format!("external identity unavailable: {error:#}"),
236 ),
237 });
238 } else {
239 findings.extend(check_tool(tool, scope.quipu_flavor, path.as_deref()));
240 }
241 }
242 if let Some(server) = &scope.remote_quipu {
243 if scope.tools.contains(&ToolName::Camayoc) || scope.tools.contains(&ToolName::Quipu) {
244 findings.extend(check_graph(server));
245 }
246 }
247 findings.extend(check_mcp(scope));
248 findings
249}
250
251fn expected_mcp_servers(scope: &Scope) -> Vec<&'static str> {
254 let mut names = Vec::new();
255 if scope.tools.contains(&ToolName::Bobbin) {
256 names.push("bobbin");
257 }
258 if scope.tools.contains(&ToolName::Yupana) {
259 names.push("yupana");
260 }
261 names
262}
263
264pub(crate) fn parse_mcp_list(text: &str) -> Vec<(String, bool)> {
266 text.lines()
267 .filter_map(|line| {
268 let (name, rest) = line.split_once(": ")?;
269 let (_, status) = rest.rsplit_once(" - ")?;
270 let name = name.trim();
271 if name.is_empty() || name.contains(' ') && !name.starts_with("claude.ai") {
272 return None;
273 }
274 Some((
275 name.to_owned(),
276 status.contains("Connected") && !status.contains("Failed"),
277 ))
278 })
279 .collect()
280}
281
282pub(crate) fn mcp_add_command(want: &str) -> String {
286 if want == "bobbin" {
287 "bobbin init && bobbin index && claude mcp add bobbin -- \"$(command -v bobbin)\" serve \"$PWD\"".to_owned()
288 } else {
289 format!("claude mcp add {want} -- \"$(command -v {want})\" serve")
290 }
291}
292
293pub(crate) fn mcp_findings(
294 expected: &[&str],
295 listed: &[(String, bool)],
296 work_dir: bool,
297) -> Vec<Finding> {
298 expected
299 .iter()
300 .map(|want| match listed.iter().find(|(name, _)| name == want) {
301 Some((_, true)) => Finding::new(Level::Ok, format!("mcp {want}"), "registered and connected"),
302 Some((_, false)) => Finding::new(
303 Level::Warn,
304 format!("mcp {want}"),
305 "registered but NOT connecting; agents in this directory get no tools from it. Run `claude mcp list` for the error",
306 ),
307 None if work_dir => Finding::new(
308 Level::Ok,
309 format!("mcp {want}"),
310 format!("not registered here, as expected: registration is per repository, and this is the Tambo work directory. In each repository you want searchable, run: `{}`", mcp_add_command(want)),
311 ),
312 None => Finding::new(
313 Level::Warn,
314 format!("mcp {want}"),
315 format!("not registered for this directory; agents here get no {want} tools. After install, from inside this repository: `{}`", mcp_add_command(want)),
316 ),
317 })
318 .collect()
319}
320
321fn check_mcp(scope: &Scope) -> Vec<Finding> {
322 let expected = expected_mcp_servers(scope);
323 if expected.is_empty() {
324 return Vec::new();
325 }
326 if which_all("claude", env::var_os("PATH").as_deref()).is_empty() {
327 return vec![Finding::new(
328 Level::Warn,
329 "mcp",
330 "cannot check MCP registration: the `claude` CLI is not on PATH",
331 )];
332 }
333 match Command::new("claude")
334 .args(["mcp", "list"])
335 .stdin(Stdio::null())
336 .output()
337 {
338 Ok(out) if out.status.success() => mcp_findings(
339 &expected,
340 &parse_mcp_list(&String::from_utf8_lossy(&out.stdout)),
341 scope.work_dir,
342 ),
343 Ok(out) => vec![Finding::new(
344 Level::Warn,
345 "mcp",
346 format!(
347 "`claude mcp list` failed ({}); MCP registration unknown",
348 out.status
349 ),
350 )],
351 Err(error) => vec![Finding::new(
352 Level::Warn,
353 "mcp",
354 format!("could not run `claude mcp list`: {error}; MCP registration unknown"),
355 )],
356 }
357}
358
359pub(crate) fn classify_write_probe(code: u16, body: &str, token_set: bool) -> Finding {
361 let token = if token_set {
362 "token from environment or configured file"
363 } else {
364 "no token (environment/file empty or absent)"
365 };
366 let data: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::Value::Null);
367 let error = data["error"].as_str().unwrap_or("");
368 match code {
369 403 if data["reason"] == "server_is_read_only" => Finding::new(
370 Level::Warn, "quipu write",
371 "READ-ONLY: no credential can authorize writes; use the intended writable server",
372 ),
373 400 if error.starts_with("invalid episode JSON:") && error.contains("missing field") => Finding::new(
374 Level::Ok,
375 "quipu write",
376 format!("authorized ({token}); the probe was refused as an empty episode, nothing written"),
377 ),
378 401 => Finding::new(
379 Level::Warn,
380 "quipu write",
381 format!("{}: REFUSED with HTTP {code} ({token}). Obtain an accepted credential from the server administrator; install at ~/.config/quipu/token (mode 0400), or set QUIPU_AUTH_TOKEN_FILE. QUIPU_AUTH_TOKEN overrides the file", if token_set { "BAD-TOKEN" } else { "NO-TOKEN" }),
382 ),
383 0 => Finding::new(Level::Warn, "quipu write", "write probe got no HTTP answer; write path unknown"),
384 other => Finding::new(
385 Level::Warn,
386 "quipu write",
387 format!("write probe returned an unexpected HTTP {other} ({token}); write path unknown"),
388 ),
389 }
390}
391
392fn check_graph(server: &str) -> Vec<Finding> {
393 let mut findings = vec![check_quipu_server(server)];
394 let reachable = findings[0].detail.contains("is live");
395 let credential_ok = match crate::quipu_credential::verify(server) {
399 Ok(source) => {
400 findings.push(Finding::new(
401 Level::Ok,
402 "quipu credential",
403 format!("canonical file 0400/0700 and authenticated /shapes read accepted; source: {source}"),
404 ));
405 true
406 }
407 Err(error) => {
408 findings.push(Finding::new(
409 Level::Fail,
410 "quipu credential",
411 format!("{error:#}"),
412 ));
413 false
414 }
415 };
416 if !reachable {
417 return findings;
418 }
419 let base = server.trim_end_matches('/');
420 match adapter::camayoc_root()
423 .ok()
424 .map(|root| root.join("ontology/core.ttl"))
425 .filter(|path| path.exists())
426 .map(|path| adapter::camayoc_aegis_namespace(&path))
427 {
428 Some(Ok(namespace)) => {
429 let query = format!("SELECT ?p WHERE {{ <{namespace}Verification> ?p ?o }} LIMIT 1");
433 findings.push(
434 match adapter::curl_json(&format!("{base}/query"), &serde_json::json!({ "query": query })) {
435 Ok(answer) if answer["count"].as_u64().unwrap_or(0) > 0 => Finding::new(
436 Level::Ok,
437 "quipu namespace",
438 format!("{namespace} on {server}: read OK, camayoc's ontology is loaded here"),
439 ),
440 Ok(_) => Finding::new(
441 Level::Warn,
442 "quipu namespace",
443 format!("{namespace} on {server}: read OK but camayoc's ontology is NOT loaded here. Either it was never bootstrapped on this server, or this is the wrong server or namespace"),
444 ),
445 Err(error) => Finding::new(
446 Level::Warn,
447 "quipu namespace",
448 format!("{namespace} on {server}: read FAILED: {}", one_line(&format!("{error:#}"))),
449 ),
450 },
451 );
452 }
453 Some(Err(error)) => findings.push(Finding::new(
454 Level::Warn,
455 "quipu namespace",
456 format!(
457 "cannot resolve camayoc's namespace: {}",
458 one_line(&format!("{error:#}"))
459 ),
460 )),
461 None => findings.push(Finding::new(
462 Level::Ok,
463 "quipu namespace",
464 "camayoc not installed yet; namespace resolves after install",
465 )),
466 }
467 if !credential_ok {
468 return findings;
469 }
470 findings.push(match adapter::quipu_write_probe(base) {
471 Ok((code, body, token_set)) => classify_write_probe(code, &body, token_set),
472 Err(error) => Finding::new(
473 Level::Warn,
474 "quipu write",
475 format!(
476 "write probe could not run: {}",
477 one_line(&format!("{error:#}"))
478 ),
479 ),
480 });
481 findings
482}
483
484fn check_bin_dir_on_path(bin: Option<&Path>, path: Option<&std::ffi::OsStr>) -> Vec<Finding> {
485 let Some(bin) = bin else {
486 return vec![Finding::new(
487 Level::Fail,
488 "install directory",
489 "neither CARGO_HOME nor HOME is set; Tambo has nowhere to install binaries",
490 )];
491 };
492 let on_path = path.is_some_and(|path| env::split_paths(path).any(|entry| entry == bin));
493 if on_path {
494 vec![Finding::new(
495 Level::Ok,
496 "install directory",
497 format!("{} is on PATH", bin.display()),
498 )]
499 } else {
500 vec![Finding::new(
501 Level::Fail,
502 "install directory",
503 format!(
504 "{} is not on PATH, so installed tools cannot be read back; add `export PATH=\"{}:$PATH\"` to your shell profile",
505 bin.display(),
506 bin.display()
507 ),
508 )]
509 }
510}
511
512fn check_prerequisites(scope: &Scope, path: Option<&std::ffi::OsStr>) -> Vec<Finding> {
513 let mut needed: Vec<(&'static str, Vec<&'static str>)> = Vec::new();
514 let mut need = |command: &'static str, by: &'static str| match needed
515 .iter_mut()
516 .find(|(name, _)| *name == command)
517 {
518 Some((_, users)) if !users.contains(&by) => users.push(by),
519 Some(_) => {}
520 None => needed.push((command, vec![by])),
521 };
522 for &tool in &scope.tools {
523 for &command in adapter::prerequisites(tool, scope.quipu_flavor) {
524 need(command, tool.as_str());
525 }
526 }
527 if let Some(mode) = scope.crew {
528 for command in crew_prerequisites(mode) {
529 need(command, "crew");
530 }
531 }
532 needed
533 .into_iter()
534 .map(|(command, users)| match which_all(command, path).first() {
535 Some(found) => Finding::new(
536 Level::Ok,
537 format!("prerequisite {command}"),
538 found.display().to_string(),
539 ),
540 None => Finding::new(
541 Level::Fail,
542 format!("prerequisite {command}"),
543 format!(
544 "not on PATH, needed by {}; {}",
545 users.join(", "),
546 prerequisite_hint(command)
547 ),
548 ),
549 })
550 .collect()
551}
552
553fn check_tool(tool: ToolName, flavor: QuipuFlavor, path: Option<&std::ffi::OsStr>) -> Vec<Finding> {
554 let mut findings = Vec::new();
555 let adapter = adapter::adapter(tool, flavor);
556 let desired = adapter.desired_version();
557 match adapter.version() {
558 Ok(installed) if adapter.is_current(&installed) => findings.push(Finding::new(
559 Level::Ok,
560 tool.as_str(),
561 format!("installed and current ({})", one_line(&installed)),
562 )),
563 Ok(installed) => findings.push(Finding::new(
564 Level::Warn,
565 tool.as_str(),
566 format!(
567 "installed ({}) differs from the reviewed {desired}; `tambo check-updates` explains the drift",
568 one_line(&installed)
569 ),
570 )),
571 Err(_) => match adapter::release_blocker(tool, flavor) {
572 Some(blocker) => findings.push(Finding::new(
573 Level::Fail,
574 tool.as_str(),
575 format!("not installed and cannot be installed here: {blocker}. {}", source_hint(tool)),
576 )),
577 None => findings.push(Finding::new(
578 Level::Ok,
579 tool.as_str(),
580 format!("not installed yet; install will fetch {desired}"),
581 )),
582 },
583 }
584 for &program in adapter::programs(tool) {
585 let copies = which_all(program, path);
586 if copies.len() > 1 {
587 let listed = copies
588 .iter()
589 .map(|copy| copy.display().to_string())
590 .collect::<Vec<_>>()
591 .join(", ");
592 findings.push(Finding::new(
593 Level::Warn,
594 format!("{program} on PATH"),
595 format!(
596 "{} copies, the first one runs: {listed}. Remove the stale ones so a new install is the one you run",
597 copies.len()
598 ),
599 ));
600 }
601 }
602 findings
603}
604
605fn source_hint(tool: ToolName) -> String {
606 match adapter::source_install_command(tool) {
607 Some(command) => format!("Build it from source first, then rerun: `{command}`"),
608 None => "Install it by hand first, then rerun; Tambo adopts an installed tool that passes verification".to_owned(),
609 }
610}
611
612fn check_quipu_server(server: &str) -> Finding {
613 let reachable = Command::new("curl")
614 .args([
615 "-sf",
616 "-m",
617 "2",
618 &format!("{}/health", server.trim_end_matches('/')),
619 ])
620 .stdout(Stdio::null())
621 .stderr(Stdio::null())
622 .status()
623 .is_ok_and(|status| status.success());
624 match reachable {
625 false => Finding::new(
626 Level::Ok,
627 "quipu server",
628 format!(
629 "nothing answers at {server}; camayoc verification uses its own scratch server and never touches this address"
630 ),
631 ),
632 true => Finding::new(
633 Level::Ok,
634 "quipu server",
635 format!(
636 "{server} (explicit remote configuration) is live. camayoc verification does not write to it (it uses its own scratch server); run camayoc's scripts/bootstrap.sh yourself to set this server up"
637 ),
638 ),
639 }
640}
641
642pub fn report<W: Write>(findings: &[Finding], installed: bool, output: &mut W) -> Result<bool> {
646 for finding in findings {
647 let tag = match finding.level {
648 Level::Ok => "ok ",
649 Level::Warn => "warn",
650 Level::Fail => "FAIL",
651 };
652 writeln!(output, "{tag} {}: {}", finding.subject, finding.detail)?;
653 }
654 let failures = findings
655 .iter()
656 .filter(|finding| finding.level == Level::Fail)
657 .count();
658 let warnings = findings
659 .iter()
660 .filter(|finding| finding.level == Level::Warn)
661 .count();
662 let plural = if warnings == 1 { "" } else { "s" };
663 if failures == 0 && installed {
664 writeln!(
665 output,
666 "doctor: installed and verified ({warnings} warning{plural}); recheck with `tambo verify`"
667 )?;
668 } else if failures == 0 {
669 writeln!(
670 output,
671 "doctor: ready to install ({warnings} warning{plural})"
672 )?;
673 } else {
674 let earlier = if installed {
677 "installed and verified earlier, but "
678 } else {
679 ""
680 };
681 writeln!(
682 output,
683 "doctor: {earlier}{failures} blocker{} — fix the FAIL lines, then rerun `tambo doctor`",
684 if failures == 1 { "" } else { "s" }
685 )?;
686 }
687 Ok(failures == 0)
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use std::{ffi::OsString, fs, path::PathBuf};
694
695 const MCP_LIST: &str = "Checking MCP server health…\n\n\
697claude.ai Gmail: https://gmailmcp.example/mcp/v1 - ✔ Connected\n\
698bobbin: http://bobbin-mcp.example/mcp (HTTP) - ✔ Connected\n\
699yupana: yupana serve - ✗ Failed to connect\n";
700
701 #[test]
702 fn mcp_list_parses_connected_and_failed_servers() {
703 let listed = parse_mcp_list(MCP_LIST);
704 assert!(listed.contains(&("bobbin".to_owned(), true)));
705 assert!(listed.contains(&("yupana".to_owned(), false)));
706 assert!(!listed.iter().any(|(name, _)| name.starts_with("Checking")));
707 }
708
709 #[test]
710 fn mcp_findings_distinguish_connected_failing_and_missing() {
711 let listed = parse_mcp_list(MCP_LIST);
712 let got = mcp_findings(&["bobbin", "yupana", "quipu"], &listed, false);
713 assert_eq!(got[0].level, Level::Ok);
714 assert_eq!(got[1].level, Level::Warn);
715 assert!(got[1].detail.contains("NOT connecting"));
716 assert_eq!(got[2].level, Level::Warn);
717 assert!(got[2].detail.contains("not registered"));
718 assert!(mcp_findings(&["bobbin"], &[], false)
720 .iter()
721 .all(|f| f.level == Level::Warn));
722 }
723
724 #[test]
725 fn write_probe_is_authorized_only_on_a_validation_refusal() {
726 let ok = classify_write_probe(
727 400,
728 r#"{"error":"invalid episode JSON: missing field `name`"}"#,
729 true,
730 );
731 assert_eq!(ok.level, Level::Ok);
732 assert!(ok.detail.contains("nothing written"));
733
734 let refused = classify_write_probe(401, r#"{"error":"unauthorized"}"#, false);
735 assert_eq!(refused.level, Level::Warn);
736 assert!(refused.detail.contains("REFUSED") && refused.detail.contains("NO-TOKEN"));
737
738 assert!(classify_write_probe(401, "{}", true)
739 .detail
740 .contains("BAD-TOKEN"));
741 assert!(
742 classify_write_probe(403, r#"{"reason":"server_is_read_only"}"#, true)
743 .detail
744 .contains("READ-ONLY")
745 );
746 assert_eq!(
747 classify_write_probe(400, r#"{"error":"episode failed"}"#, true).level,
748 Level::Warn
749 );
750 assert_eq!(
752 classify_write_probe(400, "bad gateway html", true).level,
753 Level::Warn
754 );
755 assert_eq!(classify_write_probe(0, "", true).level, Level::Warn);
756 assert_eq!(classify_write_probe(200, "{}", true).level, Level::Warn);
757 }
758
759 fn executable(dir: &Path, name: &str) -> PathBuf {
760 let path = dir.join(name);
761 fs::write(&path, "#!/bin/sh\n").unwrap();
762 #[cfg(unix)]
763 {
764 use std::os::unix::fs::PermissionsExt;
765 fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
766 }
767 path
768 }
769
770 #[test]
771 fn which_all_lists_every_copy_in_path_order() {
772 let first = tempfile::tempdir().unwrap();
773 let second = tempfile::tempdir().unwrap();
774 let a = executable(first.path(), "yupana");
775 let b = executable(second.path(), "yupana");
776 fs::write(second.path().join("not-executable"), "").unwrap();
777 let path = env::join_paths([first.path(), second.path(), first.path()]).unwrap();
778 assert_eq!(which_all("yupana", Some(&path)), vec![a, b]);
779 assert!(which_all("not-executable", Some(&path)).is_empty());
780 assert!(which_all("yupana", None).is_empty());
781 }
782
783 #[test]
784 fn missing_install_directory_on_path_is_a_blocker() {
785 let bin = PathBuf::from("/opt/tambo-test/bin");
786 let without = OsString::from("/usr/bin:/bin");
787 let with = env::join_paths([Path::new("/usr/bin"), &bin]).unwrap();
788 let missing = check_bin_dir_on_path(Some(&bin), Some(&without));
789 assert_eq!(missing[0].level, Level::Fail);
790 assert!(missing[0].detail.contains("export PATH="));
791 assert_eq!(
792 check_bin_dir_on_path(Some(&bin), Some(&with))[0].level,
793 Level::Ok
794 );
795 }
796
797 #[test]
798 fn missing_prerequisite_names_the_tools_that_need_it() {
799 let empty = tempfile::tempdir().unwrap();
800 let path = env::join_paths([empty.path()]).unwrap();
801 let findings = check_prerequisites(&Scope::everything(), Some(&path));
802 let go = findings
803 .iter()
804 .find(|finding| finding.subject == "prerequisite go")
805 .expect("desire-path needs go");
806 assert_eq!(go.level, Level::Fail);
807 assert!(go.detail.contains("desire-path"));
808 let curl = findings
809 .iter()
810 .find(|finding| finding.subject == "prerequisite curl")
811 .unwrap();
812 assert!(curl.detail.contains("quipu") && curl.detail.contains("bobbin"));
813 assert_eq!(
814 findings
815 .iter()
816 .filter(|finding| finding.subject == "prerequisite curl")
817 .count(),
818 1,
819 "each command is reported once"
820 );
821 }
822
823 #[test]
824 fn report_fails_only_on_blockers() {
825 let mut out = Vec::new();
826 let warn_only = [Finding::new(Level::Warn, "x", "y")];
827 assert!(report(&warn_only, false, &mut out).unwrap());
828 let blocked = [Finding::new(Level::Fail, "x", "y")];
829 assert!(!report(&blocked, false, &mut out).unwrap());
830 let text = String::from_utf8(out).unwrap();
831 assert!(text.contains("ready to install (1 warning)"));
832 assert!(text.contains("FAIL x: y"));
833 }
834
835 #[test]
838 fn missing_registration_hint_uses_the_absolute_path_form() {
839 for work_dir in [false, true] {
840 let got = mcp_findings(&["bobbin"], &[], work_dir);
841 assert!(
842 got[0]
843 .detail
844 .contains(r#"claude mcp add bobbin -- "$(command -v bobbin)" serve "$PWD""#),
845 "{}",
846 got[0].detail
847 );
848 assert!(
849 !got[0].detail.contains("-- bobbin serve"),
850 "{}",
851 got[0].detail
852 );
853 }
854 let readme = include_str!("../README.md");
855 assert!(
856 readme.contains(r#"claude mcp add bobbin -- "$(command -v bobbin)" serve "$PWD""#),
857 "doctor and README must give the same registration"
858 );
859 }
860
861 #[test]
864 fn summary_reports_an_install_that_happened() {
865 let plan = Plan::for_profile(Profile::Retrieval);
866 let mut state = State::read(Path::new("/nonexistent/state.json")).unwrap();
867 assert!(!installed(&plan, &state), "empty state is not installed");
868 for tool in &plan.tools {
869 state.tools.insert(
870 tool.as_str().to_owned(),
871 crate::model::ToolState {
872 version: "1".into(),
873 applied: true,
874 verified: true,
875 },
876 );
877 }
878 assert!(installed(&plan, &state));
879 let first = plan.tools[0].as_str().to_owned();
880 state.tools.get_mut(&first).unwrap().verified = false;
881 assert!(
882 !installed(&plan, &state),
883 "one unverified tool is not installed"
884 );
885
886 let mut out = Vec::new();
887 assert!(report(&[], true, &mut out).unwrap());
888 let text = String::from_utf8(out).unwrap();
889 assert!(text.contains("installed and verified"), "{text}");
890 assert!(!text.contains("ready to install"), "{text}");
891 let blocked = [Finding::new(Level::Fail, "x", "y")];
892 let mut out = Vec::new();
893 assert!(!report(&blocked, true, &mut out).unwrap());
894 let text = String::from_utf8(out).unwrap();
895 assert!(
896 text.contains("installed and verified earlier, but 1 blocker"),
897 "{text}"
898 );
899 }
900
901 #[test]
904 fn missing_registration_does_not_warn_in_the_work_dir() {
905 assert!(is_work_dir(Path::new("caboodle-plan.toml")));
906 assert!(is_work_dir(Path::new("./caboodle-plan.toml")));
907 assert!(!is_work_dir(Path::new("/elsewhere/caboodle-plan.toml")));
908 let here = mcp_findings(&["bobbin"], &[], true);
909 assert_eq!(here[0].level, Level::Ok, "{}", here[0].detail);
910 assert!(
911 here[0].detail.contains("per repository"),
912 "{}",
913 here[0].detail
914 );
915 let repo = mcp_findings(&["bobbin"], &[], false);
916 assert_eq!(repo[0].level, Level::Warn);
917 }
918}