use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, btree_map::Entry};
use crate::path_safety::normalize_under_root;
pub fn doctor(target: &str, json: bool) -> Result<()> {
let workspace_root = std::env::current_dir()
.context("failed to resolve workspace root")?
.canonicalize()
.context("failed to canonicalize workspace root")?;
let config_path = locate_toolmap(&workspace_root, target)?;
let config = load_tool_map_config(&config_path)
.with_context(|| format!("failed to load MCP tool map from {}", config_path.display()))?;
let map = ToolMap::from_config(&config).context("tool map contains duplicate tool names")?;
let report = ToolMapReport::from_map(&config_path, &map);
if json {
println!(
"{}",
serde_json::to_string_pretty(&report).context("failed to encode JSON report")?
);
} else {
print_report(&report);
}
Ok(())
}
#[derive(Debug, Clone, Deserialize)]
struct ToolRef {
name: String,
component: String,
entry: String,
#[serde(default)]
timeout_ms: Option<u64>,
#[serde(default)]
max_retries: Option<u32>,
#[serde(default)]
retry_backoff_ms: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
struct ToolMapConfig {
tools: Vec<ToolRef>,
}
#[derive(Debug, Clone)]
struct ToolMap {
tools: BTreeMap<String, ToolRef>,
}
impl ToolMap {
fn from_config(config: &ToolMapConfig) -> Result<Self> {
let mut tools = BTreeMap::new();
for tool in &config.tools {
match tools.entry(tool.name.clone()) {
Entry::Vacant(slot) => {
slot.insert(tool.clone());
}
Entry::Occupied(_) => {
bail!("tool map contains duplicate tool names");
}
}
}
Ok(Self { tools })
}
fn iter(&self) -> impl Iterator<Item = (&String, &ToolRef)> {
self.tools.iter()
}
}
fn load_tool_map_config(path: &Path) -> Result<ToolMapConfig> {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read MCP tool map {}", path.display()))?;
if is_json(path, &content) {
Ok(serde_json::from_str(&content)
.with_context(|| format!("invalid MCP tool map JSON {}", path.display()))?)
} else {
Ok(serde_yaml_bw::from_str(&content)
.with_context(|| format!("invalid MCP tool map YAML {}", path.display()))?)
}
}
fn is_json(path: &Path, content: &str) -> bool {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if matches!(ext, "json") {
return true;
}
if matches!(ext, "yaml" | "yml") {
return false;
}
}
content
.chars()
.find(|c| !c.is_whitespace())
.is_some_and(|c| c == '{' || c == '[')
}
fn locate_toolmap(workspace_root: &Path, target: &str) -> Result<PathBuf> {
let initial = PathBuf::from(target);
if initial.is_absolute() {
bail!("tool map path must be relative to the workspace root");
}
let candidates = [initial.clone(), PathBuf::from("providers").join(&initial)];
for candidate in candidates {
let joined = workspace_root.join(&candidate);
if joined.is_file() {
return normalize_under_root(workspace_root, &candidate);
}
if joined.is_dir() {
let safe_dir = normalize_under_root(workspace_root, &candidate)?;
for name in [
"toolmap.yaml",
"toolmap.yml",
"toolmap.json",
"mcp.yaml",
"mcp.json",
] {
let file = safe_dir.join(name);
if file.is_file() {
return Ok(file);
}
}
}
}
bail!("unable to find MCP tool map at `{target}`")
}
#[derive(Debug, Serialize)]
struct ToolMapReport {
tool_map_path: String,
tool_count: usize,
tools: Vec<ToolHealth>,
warnings: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ToolHealth {
name: String,
entry: String,
component: String,
resolved_path: String,
exists: bool,
size_bytes: Option<u64>,
timeout_ms: Option<u64>,
max_retries: u32,
retry_backoff_ms: u64,
}
impl ToolMapReport {
fn from_map(config_path: &Path, map: &ToolMap) -> Self {
let base_dir = config_path
.parent()
.map(|parent| parent.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let mut warnings = Vec::new();
let mut tools = Vec::new();
for (_, tool) in map.iter() {
let resolved_path = resolve_component_path(&base_dir, &tool.component);
let (exists, size) = match fs::metadata(&resolved_path) {
Ok(meta) if meta.is_file() => (true, Some(meta.len())),
_ => {
warnings.push(format!(
"tool `{}` component missing at {}",
tool.name,
resolved_path.display()
));
(false, None)
}
};
tools.push(ToolHealth {
name: tool.name.clone(),
entry: tool.entry.clone(),
component: tool.component.clone(),
resolved_path: resolved_path.display().to_string(),
exists,
size_bytes: size,
timeout_ms: tool.timeout_ms,
max_retries: tool.max_retries.unwrap_or(0),
retry_backoff_ms: tool.retry_backoff_ms.unwrap_or(200),
});
}
Self {
tool_map_path: config_path.display().to_string(),
tool_count: tools.len(),
tools,
warnings,
}
}
}
fn resolve_component_path(base_dir: &Path, component: &str) -> PathBuf {
let raw = PathBuf::from(component);
if raw.is_absolute() {
raw
} else {
base_dir.join(raw)
}
}
fn print_report(report: &ToolMapReport) {
println!("MCP tool map: {}", report.tool_map_path);
println!("Tools: {}", report.tool_count);
for tool in &report.tools {
println!("- {}", tool.name);
println!(" entry: {}", tool.entry);
println!(
" component: {}{}",
tool.resolved_path,
if tool.exists { "" } else { " (missing)" }
);
println!(
" timeout: {}",
tool.timeout_ms
.map(|ms| format!("{ms} ms"))
.unwrap_or_else(|| "not set".into())
);
println!(
" retries: {} (backoff {} ms)",
tool.max_retries, tool.retry_backoff_ms
);
if let Some(size) = tool.size_bytes {
println!(" size: {size} bytes");
}
}
if !report.warnings.is_empty() {
println!("\nWarnings:");
for warning in &report.warnings {
println!(" - {warning}");
}
}
}
#[cfg(test)]
mod tests {
use super::{
ToolMap, ToolMapConfig, ToolMapReport, ToolRef, is_json, load_tool_map_config,
locate_toolmap,
};
use tempfile::tempdir;
fn sample_tool(name: &str) -> ToolRef {
ToolRef {
name: name.to_string(),
component: "component.wasm".to_string(),
entry: "run".to_string(),
timeout_ms: Some(500),
max_retries: Some(2),
retry_backoff_ms: Some(100),
}
}
#[test]
fn json_detection_prefers_extension_and_content() {
assert!(is_json(std::path::Path::new("toolmap.json"), "tools: []"));
assert!(!is_json(
std::path::Path::new("toolmap.yaml"),
"{\"tools\":[]}"
));
assert!(is_json(
std::path::Path::new("toolmap"),
" {\"tools\": []}"
));
}
#[test]
fn duplicate_tool_names_are_rejected() {
let config = ToolMapConfig {
tools: vec![sample_tool("demo"), sample_tool("demo")],
};
let err = ToolMap::from_config(&config).unwrap_err();
assert!(err.to_string().contains("duplicate tool names"));
}
#[test]
fn load_tool_map_config_supports_json_and_yaml() {
let dir = tempdir().unwrap();
let json_path = dir.path().join("toolmap.json");
let yaml_path = dir.path().join("toolmap.yaml");
std::fs::write(
&json_path,
r#"{"tools":[{"name":"demo","component":"component.wasm","entry":"run"}]}"#,
)
.unwrap();
std::fs::write(
&yaml_path,
"tools:\n - name: demo\n component: component.wasm\n entry: run\n",
)
.unwrap();
assert_eq!(load_tool_map_config(&json_path).unwrap().tools.len(), 1);
assert_eq!(load_tool_map_config(&yaml_path).unwrap().tools.len(), 1);
}
#[test]
fn locate_toolmap_finds_directory_default_files() {
let dir = tempdir().unwrap();
let provider_dir = dir.path().join("demo");
std::fs::create_dir_all(&provider_dir).unwrap();
let toolmap = provider_dir.join("toolmap.yaml");
std::fs::write(&toolmap, "tools: []\n").unwrap();
let located = locate_toolmap(dir.path(), "demo").unwrap();
assert_eq!(located, toolmap.canonicalize().unwrap());
}
#[test]
fn report_marks_missing_components_as_warnings() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("toolmap.yaml");
std::fs::write(&config_path, "tools: []\n").unwrap();
let map = ToolMap::from_config(&ToolMapConfig {
tools: vec![sample_tool("demo")],
})
.unwrap();
let report = ToolMapReport::from_map(&config_path, &map);
assert_eq!(report.tool_count, 1);
assert_eq!(report.tools[0].name, "demo");
assert!(!report.tools[0].exists);
assert_eq!(report.warnings.len(), 1);
}
}