1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, btree_map::Entry};
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
33#[derive(Debug, Clone, Deserialize)]
34struct ToolRef {
35 name: String,
36 component: String,
37 entry: String,
38 #[serde(default)]
39 timeout_ms: Option<u64>,
40 #[serde(default)]
41 max_retries: Option<u32>,
42 #[serde(default)]
43 retry_backoff_ms: Option<u64>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47struct ToolMapConfig {
48 tools: Vec<ToolRef>,
49}
50
51#[derive(Debug, Clone)]
52struct ToolMap {
53 tools: BTreeMap<String, ToolRef>,
54}
55
56impl ToolMap {
57 fn from_config(config: &ToolMapConfig) -> Result<Self> {
58 let mut tools = BTreeMap::new();
59 for tool in &config.tools {
60 match tools.entry(tool.name.clone()) {
61 Entry::Vacant(slot) => {
62 slot.insert(tool.clone());
63 }
64 Entry::Occupied(_) => {
65 bail!("tool map contains duplicate tool names");
66 }
67 }
68 }
69 Ok(Self { tools })
70 }
71
72 fn iter(&self) -> impl Iterator<Item = (&String, &ToolRef)> {
73 self.tools.iter()
74 }
75}
76
77fn load_tool_map_config(path: &Path) -> Result<ToolMapConfig> {
78 let content = fs::read_to_string(path)
79 .with_context(|| format!("failed to read MCP tool map {}", path.display()))?;
80 if is_json(path, &content) {
81 Ok(serde_json::from_str(&content)
82 .with_context(|| format!("invalid MCP tool map JSON {}", path.display()))?)
83 } else {
84 Ok(serde_yaml_bw::from_str(&content)
85 .with_context(|| format!("invalid MCP tool map YAML {}", path.display()))?)
86 }
87}
88
89fn is_json(path: &Path, content: &str) -> bool {
90 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
91 if matches!(ext, "json") {
92 return true;
93 }
94 if matches!(ext, "yaml" | "yml") {
95 return false;
96 }
97 }
98 content
99 .chars()
100 .find(|c| !c.is_whitespace())
101 .is_some_and(|c| c == '{' || c == '[')
102}
103
104fn locate_toolmap(workspace_root: &Path, target: &str) -> Result<PathBuf> {
105 let initial = PathBuf::from(target);
106 if initial.is_absolute() {
107 bail!("tool map path must be relative to the workspace root");
108 }
109
110 let candidates = [initial.clone(), PathBuf::from("providers").join(&initial)];
111
112 for candidate in candidates {
113 let joined = workspace_root.join(&candidate);
114 if joined.is_file() {
115 return normalize_under_root(workspace_root, &candidate);
116 }
117 if joined.is_dir() {
118 let safe_dir = normalize_under_root(workspace_root, &candidate)?;
119 for name in [
120 "toolmap.yaml",
121 "toolmap.yml",
122 "toolmap.json",
123 "mcp.yaml",
124 "mcp.json",
125 ] {
126 let file = safe_dir.join(name);
127 if file.is_file() {
128 return Ok(file);
129 }
130 }
131 }
132 }
133
134 bail!("unable to find MCP tool map at `{target}`")
135}
136
137#[derive(Debug, Serialize)]
138struct ToolMapReport {
139 tool_map_path: String,
140 tool_count: usize,
141 tools: Vec<ToolHealth>,
142 warnings: Vec<String>,
143}
144
145#[derive(Debug, Serialize)]
146struct ToolHealth {
147 name: String,
148 entry: String,
149 component: String,
150 resolved_path: String,
151 exists: bool,
152 size_bytes: Option<u64>,
153 timeout_ms: Option<u64>,
154 max_retries: u32,
155 retry_backoff_ms: u64,
156}
157
158impl ToolMapReport {
159 fn from_map(config_path: &Path, map: &ToolMap) -> Self {
160 let base_dir = config_path
161 .parent()
162 .map(|parent| parent.to_path_buf())
163 .unwrap_or_else(|| PathBuf::from("."));
164
165 let mut warnings = Vec::new();
166 let mut tools = Vec::new();
167
168 for (_, tool) in map.iter() {
169 let resolved_path = resolve_component_path(&base_dir, &tool.component);
170 let (exists, size) = match fs::metadata(&resolved_path) {
171 Ok(meta) if meta.is_file() => (true, Some(meta.len())),
172 _ => {
173 warnings.push(format!(
174 "tool `{}` component missing at {}",
175 tool.name,
176 resolved_path.display()
177 ));
178 (false, None)
179 }
180 };
181
182 tools.push(ToolHealth {
183 name: tool.name.clone(),
184 entry: tool.entry.clone(),
185 component: tool.component.clone(),
186 resolved_path: resolved_path.display().to_string(),
187 exists,
188 size_bytes: size,
189 timeout_ms: tool.timeout_ms,
190 max_retries: tool.max_retries.unwrap_or(0),
191 retry_backoff_ms: tool.retry_backoff_ms.unwrap_or(200),
192 });
193 }
194
195 Self {
196 tool_map_path: config_path.display().to_string(),
197 tool_count: tools.len(),
198 tools,
199 warnings,
200 }
201 }
202}
203
204fn resolve_component_path(base_dir: &Path, component: &str) -> PathBuf {
205 let raw = PathBuf::from(component);
206 if raw.is_absolute() {
207 raw
208 } else {
209 base_dir.join(raw)
210 }
211}
212
213fn print_report(report: &ToolMapReport) {
214 println!("MCP tool map: {}", report.tool_map_path);
215 println!("Tools: {}", report.tool_count);
216 for tool in &report.tools {
217 println!("- {}", tool.name);
218 println!(" entry: {}", tool.entry);
219 println!(
220 " component: {}{}",
221 tool.resolved_path,
222 if tool.exists { "" } else { " (missing)" }
223 );
224 println!(
225 " timeout: {}",
226 tool.timeout_ms
227 .map(|ms| format!("{ms} ms"))
228 .unwrap_or_else(|| "not set".into())
229 );
230 println!(
231 " retries: {} (backoff {} ms)",
232 tool.max_retries, tool.retry_backoff_ms
233 );
234 if let Some(size) = tool.size_bytes {
235 println!(" size: {size} bytes");
236 }
237 }
238 if !report.warnings.is_empty() {
239 println!("\nWarnings:");
240 for warning in &report.warnings {
241 println!(" - {warning}");
242 }
243 }
244}