1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command as ProcessCommand;
4
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, btree_map::Entry};
8
9use crate::path_safety::normalize_under_root;
10
11pub fn doctor(target: Option<&str>, json: bool) -> Result<()> {
12 let Some(target) = target else {
13 let generator = GeneratorStatus::detect();
15 if json {
16 println!(
17 "{}",
18 serde_json::to_string_pretty(&generator).context("failed to encode JSON report")?
19 );
20 } else {
21 print_generator_status(&generator);
22 }
23 return Ok(());
24 };
25
26 let workspace_root = std::env::current_dir()
27 .context("failed to resolve workspace root")?
28 .canonicalize()
29 .context("failed to canonicalize workspace root")?;
30 let config_path = locate_toolmap(&workspace_root, target)?;
31 let config = load_tool_map_config(&config_path)
32 .with_context(|| format!("failed to load MCP tool map from {}", config_path.display()))?;
33 let map = ToolMap::from_config(&config).context("tool map contains duplicate tool names")?;
34 let report = ToolMapReport::from_map(&config_path, &map);
35
36 if json {
37 println!(
38 "{}",
39 serde_json::to_string_pretty(&report).context("failed to encode JSON report")?
40 );
41 } else {
42 print_report(&report);
43 }
44
45 Ok(())
46}
47
48#[derive(Debug, Clone, Deserialize)]
49struct ToolRef {
50 name: String,
51 component: String,
52 entry: String,
53 #[serde(default)]
54 timeout_ms: Option<u64>,
55 #[serde(default)]
56 max_retries: Option<u32>,
57 #[serde(default)]
58 retry_backoff_ms: Option<u64>,
59}
60
61#[derive(Debug, Clone, Deserialize)]
62struct ToolMapConfig {
63 tools: Vec<ToolRef>,
64}
65
66#[derive(Debug, Serialize)]
67struct GeneratorStatus {
68 binary_name: String,
69 resolved_path: Option<String>,
70 version: Option<String>,
71 cargo_available: bool,
72 wasm_target_installed: bool,
73}
74
75impl GeneratorStatus {
76 fn absent() -> Self {
77 Self {
78 binary_name: "greentic-mcp-gen".to_string(),
79 resolved_path: None,
80 version: None,
81 cargo_available: cargo_available(),
82 wasm_target_installed: wasm_target_installed(),
83 }
84 }
85
86 fn detect() -> Self {
87 match crate::passthrough::resolve_external_tool("greentic-mcp-gen") {
88 Ok(path) => {
89 let version = ProcessCommand::new(&path)
90 .arg("--version")
91 .output()
92 .ok()
93 .filter(|out| out.status.success())
94 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
95 .filter(|s| !s.is_empty());
96 Self {
97 binary_name: "greentic-mcp-gen".to_string(),
98 resolved_path: Some(path.display().to_string()),
99 version,
100 cargo_available: cargo_available(),
101 wasm_target_installed: wasm_target_installed(),
102 }
103 }
104 Err(_) => Self::absent(),
105 }
106 }
107}
108
109fn cargo_available() -> bool {
111 ProcessCommand::new("cargo")
112 .arg("--version")
113 .output()
114 .map(|out| out.status.success())
115 .unwrap_or(false)
116}
117
118fn wasm_target_installed() -> bool {
120 ProcessCommand::new("rustup")
121 .args(["target", "list", "--installed"])
122 .output()
123 .ok()
124 .filter(|out| out.status.success())
125 .map(|out| String::from_utf8_lossy(&out.stdout).contains("wasm32-wasip2"))
126 .unwrap_or(false)
127}
128
129#[derive(Debug, Clone)]
130struct ToolMap {
131 tools: BTreeMap<String, ToolRef>,
132}
133
134impl ToolMap {
135 fn from_config(config: &ToolMapConfig) -> Result<Self> {
136 let mut tools = BTreeMap::new();
137 for tool in &config.tools {
138 match tools.entry(tool.name.clone()) {
139 Entry::Vacant(slot) => {
140 slot.insert(tool.clone());
141 }
142 Entry::Occupied(_) => {
143 bail!("tool map contains duplicate tool names");
144 }
145 }
146 }
147 Ok(Self { tools })
148 }
149
150 fn iter(&self) -> impl Iterator<Item = (&String, &ToolRef)> {
151 self.tools.iter()
152 }
153}
154
155fn load_tool_map_config(path: &Path) -> Result<ToolMapConfig> {
156 let content = fs::read_to_string(path)
157 .with_context(|| format!("failed to read MCP tool map {}", path.display()))?;
158 if is_json(path, &content) {
159 Ok(serde_json::from_str(&content)
160 .with_context(|| format!("invalid MCP tool map JSON {}", path.display()))?)
161 } else {
162 Ok(serde_yaml_bw::from_str(&content)
163 .with_context(|| format!("invalid MCP tool map YAML {}", path.display()))?)
164 }
165}
166
167fn is_json(path: &Path, content: &str) -> bool {
168 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
169 if matches!(ext, "json") {
170 return true;
171 }
172 if matches!(ext, "yaml" | "yml") {
173 return false;
174 }
175 }
176 content
177 .chars()
178 .find(|c| !c.is_whitespace())
179 .is_some_and(|c| c == '{' || c == '[')
180}
181
182fn locate_toolmap(workspace_root: &Path, target: &str) -> Result<PathBuf> {
183 let initial = PathBuf::from(target);
184 if initial.is_absolute() {
185 bail!("tool map path must be relative to the workspace root");
186 }
187
188 let candidates = [initial.clone(), PathBuf::from("providers").join(&initial)];
189
190 for candidate in candidates {
191 let joined = workspace_root.join(&candidate);
192 if joined.is_file() {
193 return normalize_under_root(workspace_root, &candidate);
194 }
195 if joined.is_dir() {
196 let safe_dir = normalize_under_root(workspace_root, &candidate)?;
197 for name in [
198 "toolmap.yaml",
199 "toolmap.yml",
200 "toolmap.json",
201 "mcp.yaml",
202 "mcp.json",
203 ] {
204 let file = safe_dir.join(name);
205 if file.is_file() {
206 return Ok(file);
207 }
208 }
209 }
210 }
211
212 bail!("unable to find MCP tool map at `{target}`")
213}
214
215#[derive(Debug, Serialize)]
216struct ToolMapReport {
217 tool_map_path: String,
218 tool_count: usize,
219 tools: Vec<ToolHealth>,
220 warnings: Vec<String>,
221 generator: GeneratorStatus,
222}
223
224#[derive(Debug, Serialize)]
225struct ToolHealth {
226 name: String,
227 entry: String,
228 component: String,
229 resolved_path: String,
230 exists: bool,
231 size_bytes: Option<u64>,
232 timeout_ms: Option<u64>,
233 max_retries: u32,
234 retry_backoff_ms: u64,
235}
236
237impl ToolMapReport {
238 fn from_map(config_path: &Path, map: &ToolMap) -> Self {
239 let base_dir = config_path
240 .parent()
241 .map(|parent| parent.to_path_buf())
242 .unwrap_or_else(|| PathBuf::from("."));
243
244 let mut warnings = Vec::new();
245 let mut tools = Vec::new();
246
247 for (_, tool) in map.iter() {
248 let resolved_path = resolve_component_path(&base_dir, &tool.component);
249 let (exists, size) = match fs::metadata(&resolved_path) {
250 Ok(meta) if meta.is_file() => (true, Some(meta.len())),
251 _ => {
252 warnings.push(format!(
253 "tool `{}` component missing at {}",
254 tool.name,
255 resolved_path.display()
256 ));
257 (false, None)
258 }
259 };
260
261 tools.push(ToolHealth {
262 name: tool.name.clone(),
263 entry: tool.entry.clone(),
264 component: tool.component.clone(),
265 resolved_path: resolved_path.display().to_string(),
266 exists,
267 size_bytes: size,
268 timeout_ms: tool.timeout_ms,
269 max_retries: tool.max_retries.unwrap_or(0),
270 retry_backoff_ms: tool.retry_backoff_ms.unwrap_or(200),
271 });
272 }
273
274 Self {
275 tool_map_path: config_path.display().to_string(),
276 tool_count: tools.len(),
277 tools,
278 warnings,
279 generator: GeneratorStatus::detect(),
280 }
281 }
282}
283
284fn resolve_component_path(base_dir: &Path, component: &str) -> PathBuf {
285 let raw = PathBuf::from(component);
286 if raw.is_absolute() {
287 raw
288 } else {
289 base_dir.join(raw)
290 }
291}
292
293fn print_report(report: &ToolMapReport) {
294 println!("MCP tool map: {}", report.tool_map_path);
295 println!("Tools: {}", report.tool_count);
296 for tool in &report.tools {
297 println!("- {}", tool.name);
298 println!(" entry: {}", tool.entry);
299 println!(
300 " component: {}{}",
301 tool.resolved_path,
302 if tool.exists { "" } else { " (missing)" }
303 );
304 println!(
305 " timeout: {}",
306 tool.timeout_ms
307 .map(|ms| format!("{ms} ms"))
308 .unwrap_or_else(|| "not set".into())
309 );
310 println!(
311 " retries: {} (backoff {} ms)",
312 tool.max_retries, tool.retry_backoff_ms
313 );
314 if let Some(size) = tool.size_bytes {
315 println!(" size: {size} bytes");
316 }
317 }
318 if !report.warnings.is_empty() {
319 println!("\nWarnings:");
320 for warning in &report.warnings {
321 println!(" - {warning}");
322 }
323 }
324 print_generator_status(&report.generator);
325}
326
327fn print_generator_status(generator: &GeneratorStatus) {
328 let locale = crate::i18n::select_locale(None);
329 println!(
330 "{}",
331 crate::i18n::t(&locale, "cli.command.mcp.doctor.generator.header")
332 );
333 match (&generator.resolved_path, &generator.version) {
334 (Some(path), Some(version)) => println!(
335 " {}",
336 crate::i18n::tf(
337 &locale,
338 "cli.command.mcp.doctor.generator.found",
339 &[("path", path.clone()), ("version", version.clone())],
340 )
341 ),
342 (Some(path), None) => println!(
343 " {}",
344 crate::i18n::tf(
345 &locale,
346 "cli.command.mcp.doctor.generator.found",
347 &[("path", path.clone()), ("version", "unknown".to_string())],
348 )
349 ),
350 _ => println!(
351 " {}",
352 crate::i18n::t(&locale, "cli.command.mcp.doctor.generator.missing")
353 ),
354 }
355 let toolchain_ready = generator.cargo_available && generator.wasm_target_installed;
356 if toolchain_ready {
357 println!(
358 " {}",
359 crate::i18n::t(&locale, "cli.command.mcp.doctor.toolchain.ready")
360 );
361 }
362 if !toolchain_ready && !generator.cargo_available {
363 println!(
364 " {}",
365 crate::i18n::t(&locale, "cli.command.mcp.doctor.toolchain.cargo_missing")
366 );
367 }
368 if !toolchain_ready && !generator.wasm_target_installed {
369 println!(
370 " {}",
371 crate::i18n::t(&locale, "cli.command.mcp.doctor.toolchain.wasm_missing")
372 );
373 }
374}
375
376#[cfg(test)]
377mod generator_tests {
378 use super::*;
379
380 #[test]
381 fn absent_generator_status_has_no_path_or_version() {
382 let status = GeneratorStatus::absent();
383 assert_eq!(status.binary_name, "greentic-mcp-gen");
384 assert!(status.resolved_path.is_none());
385 assert!(status.version.is_none());
386 }
387
388 #[test]
389 fn generator_status_serializes_expected_fields() {
390 let json = serde_json::to_value(GeneratorStatus::absent()).unwrap();
391 assert!(json.get("binary_name").is_some());
392 assert!(json.get("resolved_path").is_some()); assert!(json.get("version").is_some());
394 assert!(json.get("cargo_available").is_some());
395 assert!(json.get("wasm_target_installed").is_some());
396 }
397
398 #[test]
399 fn doctor_without_toolmap_returns_ok() {
400 assert!(super::doctor(None, true).is_ok());
403 assert!(super::doctor(None, false).is_ok());
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::{
410 ToolMap, ToolMapConfig, ToolMapReport, ToolRef, is_json, load_tool_map_config,
411 locate_toolmap,
412 };
413 use tempfile::tempdir;
414
415 fn sample_tool(name: &str) -> ToolRef {
416 ToolRef {
417 name: name.to_string(),
418 component: "component.wasm".to_string(),
419 entry: "run".to_string(),
420 timeout_ms: Some(500),
421 max_retries: Some(2),
422 retry_backoff_ms: Some(100),
423 }
424 }
425
426 #[test]
427 fn json_detection_prefers_extension_and_content() {
428 assert!(is_json(std::path::Path::new("toolmap.json"), "tools: []"));
429 assert!(!is_json(
430 std::path::Path::new("toolmap.yaml"),
431 "{\"tools\":[]}"
432 ));
433 assert!(is_json(
434 std::path::Path::new("toolmap"),
435 " {\"tools\": []}"
436 ));
437 }
438
439 #[test]
440 fn duplicate_tool_names_are_rejected() {
441 let config = ToolMapConfig {
442 tools: vec![sample_tool("demo"), sample_tool("demo")],
443 };
444
445 let err = ToolMap::from_config(&config).unwrap_err();
446 assert!(err.to_string().contains("duplicate tool names"));
447 }
448
449 #[test]
450 fn load_tool_map_config_supports_json_and_yaml() {
451 let dir = tempdir().unwrap();
452 let json_path = dir.path().join("toolmap.json");
453 let yaml_path = dir.path().join("toolmap.yaml");
454 std::fs::write(
455 &json_path,
456 r#"{"tools":[{"name":"demo","component":"component.wasm","entry":"run"}]}"#,
457 )
458 .unwrap();
459 std::fs::write(
460 &yaml_path,
461 "tools:\n - name: demo\n component: component.wasm\n entry: run\n",
462 )
463 .unwrap();
464
465 assert_eq!(load_tool_map_config(&json_path).unwrap().tools.len(), 1);
466 assert_eq!(load_tool_map_config(&yaml_path).unwrap().tools.len(), 1);
467 }
468
469 #[test]
470 fn locate_toolmap_finds_directory_default_files() {
471 let dir = tempdir().unwrap();
472 let provider_dir = dir.path().join("demo");
473 std::fs::create_dir_all(&provider_dir).unwrap();
474 let toolmap = provider_dir.join("toolmap.yaml");
475 std::fs::write(&toolmap, "tools: []\n").unwrap();
476
477 let located = locate_toolmap(dir.path(), "demo").unwrap();
478 assert_eq!(located, toolmap.canonicalize().unwrap());
479 }
480
481 #[test]
482 fn report_marks_missing_components_as_warnings() {
483 let dir = tempdir().unwrap();
484 let config_path = dir.path().join("toolmap.yaml");
485 std::fs::write(&config_path, "tools: []\n").unwrap();
486 let map = ToolMap::from_config(&ToolMapConfig {
487 tools: vec![sample_tool("demo")],
488 })
489 .unwrap();
490
491 let report = ToolMapReport::from_map(&config_path, &map);
492 assert_eq!(report.tool_count, 1);
493 assert_eq!(report.tools[0].name, "demo");
494 assert!(!report.tools[0].exists);
495 assert_eq!(report.warnings.len(), 1);
496 }
497}