1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use greentic_mcp::{ToolMap, load_tool_map_config};
6use serde::Serialize;
7
8use crate::path_safety::normalize_under_root;
9
10pub fn doctor(target: &str, json: bool) -> Result<()> {
11 let workspace_root = std::env::current_dir()
12 .context("failed to resolve workspace root")?
13 .canonicalize()
14 .context("failed to canonicalize workspace root")?;
15 let config_path = locate_toolmap(&workspace_root, target)?;
16 let config = load_tool_map_config(&config_path)
17 .with_context(|| format!("failed to load MCP tool map from {}", config_path.display()))?;
18 let map = ToolMap::from_config(&config).context("tool map contains duplicate tool names")?;
19 let report = ToolMapReport::from_map(&config_path, &map);
20
21 if json {
22 println!(
23 "{}",
24 serde_json::to_string_pretty(&report).context("failed to encode JSON report")?
25 );
26 } else {
27 print_report(&report);
28 }
29
30 Ok(())
31}
32
33fn locate_toolmap(workspace_root: &Path, target: &str) -> Result<PathBuf> {
34 let initial = PathBuf::from(target);
35 if initial.is_absolute() {
36 bail!("tool map path must be relative to the workspace root");
37 }
38
39 let candidates = [initial.clone(), PathBuf::from("providers").join(&initial)];
40
41 for candidate in candidates {
42 let joined = workspace_root.join(&candidate);
43 if joined.is_file() {
44 return normalize_under_root(workspace_root, &candidate);
45 }
46 if joined.is_dir() {
47 let safe_dir = normalize_under_root(workspace_root, &candidate)?;
48 for name in [
49 "toolmap.yaml",
50 "toolmap.yml",
51 "toolmap.json",
52 "mcp.yaml",
53 "mcp.json",
54 ] {
55 let file = safe_dir.join(name);
56 if file.is_file() {
57 return Ok(file);
58 }
59 }
60 }
61 }
62
63 bail!("unable to find MCP tool map at `{target}`")
64}
65
66#[derive(Debug, Serialize)]
67struct ToolMapReport {
68 tool_map_path: String,
69 tool_count: usize,
70 tools: Vec<ToolHealth>,
71 warnings: Vec<String>,
72}
73
74#[derive(Debug, Serialize)]
75struct ToolHealth {
76 name: String,
77 entry: String,
78 component: String,
79 resolved_path: String,
80 exists: bool,
81 size_bytes: Option<u64>,
82 timeout_ms: Option<u64>,
83 max_retries: u32,
84 retry_backoff_ms: u64,
85}
86
87impl ToolMapReport {
88 fn from_map(config_path: &Path, map: &ToolMap) -> Self {
89 let base_dir = config_path
90 .parent()
91 .map(|parent| parent.to_path_buf())
92 .unwrap_or_else(|| PathBuf::from("."));
93
94 let mut warnings = Vec::new();
95 let mut tools = Vec::new();
96
97 for (_, tool) in map.iter() {
98 let resolved_path = resolve_component_path(&base_dir, &tool.component);
99 let (exists, size) = match fs::metadata(&resolved_path) {
100 Ok(meta) if meta.is_file() => (true, Some(meta.len())),
101 _ => {
102 warnings.push(format!(
103 "tool `{}` component missing at {}",
104 tool.name,
105 resolved_path.display()
106 ));
107 (false, None)
108 }
109 };
110
111 tools.push(ToolHealth {
112 name: tool.name.clone(),
113 entry: tool.entry.clone(),
114 component: tool.component.clone(),
115 resolved_path: resolved_path.display().to_string(),
116 exists,
117 size_bytes: size,
118 timeout_ms: tool.timeout_ms,
119 max_retries: tool.max_retries.unwrap_or(0),
120 retry_backoff_ms: tool.retry_backoff_ms.unwrap_or(200),
121 });
122 }
123
124 Self {
125 tool_map_path: config_path.display().to_string(),
126 tool_count: tools.len(),
127 tools,
128 warnings,
129 }
130 }
131}
132
133fn resolve_component_path(base_dir: &Path, component: &str) -> PathBuf {
134 let raw = PathBuf::from(component);
135 if raw.is_absolute() {
136 raw
137 } else {
138 base_dir.join(raw)
139 }
140}
141
142fn print_report(report: &ToolMapReport) {
143 println!("MCP tool map: {}", report.tool_map_path);
144 println!("Tools: {}", report.tool_count);
145 for tool in &report.tools {
146 println!("- {}", tool.name);
147 println!(" entry: {}", tool.entry);
148 println!(
149 " component: {}{}",
150 tool.resolved_path,
151 if tool.exists { "" } else { " (missing)" }
152 );
153 println!(
154 " timeout: {}",
155 tool.timeout_ms
156 .map(|ms| format!("{ms} ms"))
157 .unwrap_or_else(|| "not set".into())
158 );
159 println!(
160 " retries: {} (backoff {} ms)",
161 tool.max_retries, tool.retry_backoff_ms
162 );
163 if let Some(size) = tool.size_bytes {
164 println!(" size: {size} bytes");
165 }
166 }
167 if !report.warnings.is_empty() {
168 println!("\nWarnings:");
169 for warning in &report.warnings {
170 println!(" - {warning}");
171 }
172 }
173}