use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::BackendState;
use crate::components::{HookBinding, McpKind, McpServer, PluginComponents};
use crate::doctor::{CheckStatus, DoctorCheck};
use crate::error::{Error, Result};
use crate::host::{Plugin, Source};
pub(crate) fn read_json_config(checks: &mut Vec<DoctorCheck>, name: &'static str, path: &Path) -> Option<Value> {
match fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(v) => {
checks.push(DoctorCheck { name, status: CheckStatus::Ok(format!("{} parses", path.display())) });
Some(v)
}
Err(e) => {
checks.push(DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("{} does not parse: {e}", path.display()),
fix: "fix the JSON syntax or remove the file".into(),
},
});
None
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
checks.push(DoctorCheck { name, status: CheckStatus::Warn(format!("{} does not exist yet (run setup)", path.display())) });
None
}
Err(e) => {
checks.push(DoctorCheck { name, status: CheckStatus::Warn(format!("could not read {}: {e}", path.display())) });
None
}
}
}
pub(crate) fn components(checks: &mut Vec<DoctorCheck>, plugin: &Plugin, source: &Source) -> Option<PluginComponents> {
match plugin.components(source) {
Ok(comp) => Some(comp),
Err(e) => {
checks.push(DoctorCheck {
name: "plugin components",
status: CheckStatus::Fail {
problem: format!("could not read the plugin tree: {e}"),
fix: "rebuild the host binary".into(),
},
});
None
}
}
}
pub(crate) const NON_PORTABLE: &str = "${CLAUDE_PLUGIN_ROOT} expands only inside Claude Code (use a bare command name)";
pub(crate) const UNRENDERABLE: &str = "this harness cannot host that transport";
pub(crate) struct Skipped<'a> {
pub(crate) name: &'a str,
pub(crate) why: &'static str,
}
pub(crate) fn skipped_mcp<'a>(servers: &'a [McpServer], writable: &[&str]) -> Vec<Skipped<'a>> {
servers
.iter()
.filter(|s| !writable.contains(&s.name.as_str()))
.map(|s| Skipped { name: &s.name, why: if s.is_portable() { UNRENDERABLE } else { NON_PORTABLE } })
.collect()
}
pub(crate) fn note_skipped(check: DoctorCheck, skipped: &[Skipped<'_>]) -> DoctorCheck {
let detail = match &check.status {
_ if skipped.is_empty() => return check,
CheckStatus::Fail { .. } => return check,
CheckStatus::Ok(detail) | CheckStatus::Warn(detail) => detail.clone(),
};
let mut groups: Vec<(&'static str, Vec<&str>)> = Vec::new();
for entry in skipped {
match groups.iter_mut().find(|(why, _)| *why == entry.why) {
Some((_, names)) => names.push(entry.name),
None => groups.push((entry.why, vec![entry.name])),
}
}
let dropped = groups.iter().map(|(why, names)| format!("skipped {}: {why}", names.join(", "))).collect::<Vec<_>>().join("; ");
DoctorCheck { name: check.name, status: CheckStatus::Warn(format!("{detail}; {dropped}")) }
}
pub(crate) fn skipped_hooks(hooks: &[HookBinding]) -> Vec<Skipped<'_>> {
hooks
.iter()
.filter(|h| !h.is_portable())
.map(|h| Skipped { name: h.event.as_str(), why: NON_PORTABLE })
.collect()
}
pub(crate) fn check_mcp_registered(
servers: &[McpServer],
root: Option<&Value>,
key_path: &[&str],
where_: &str,
fix: &str,
) -> DoctorCheck {
let name = "mcp server registered";
let portable: Vec<&str> = servers.iter().filter(|s| s.is_portable()).map(|s| s.name.as_str()).collect();
let skipped = skipped_mcp(servers, &portable);
if portable.is_empty() {
return note_skipped(DoctorCheck { name, status: CheckStatus::Ok(NO_MCP.into()) }, &skipped);
}
let obj = navigate(root, key_path);
let missing: Vec<&str> = portable.iter().copied().filter(|n| obj.is_none_or(|o| !o.contains_key(*n))).collect();
let check = if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} registered", portable.join(", "))) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("mcp server(s) {where_}: {}", missing.join(", ")),
fix: fix.into(),
},
}
};
note_skipped(check, &skipped)
}
pub(crate) const NO_MCP: &str = "no portable mcp servers to register";
pub(crate) fn check_mcp_command(servers: &[McpServer]) -> DoctorCheck {
let name = "mcp command on PATH";
let missing: Vec<String> = servers
.iter()
.filter(|s| s.is_portable() && matches!(s.kind, McpKind::Stdio))
.map(|s| s.command.clone())
.filter(|c| !c.is_empty() && !c.contains('/') && !c.contains('\\') && !c.contains('$') && which::which(c).is_err())
.collect();
if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok("all referenced mcp commands resolve".into()) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("mcp command(s) not on PATH: {}", missing.join(", ")),
fix: "install the missing binaries into a PATH directory".into(),
},
}
}
}
fn navigate<'a>(root: Option<&'a Value>, key_path: &[&str]) -> Option<&'a Map<String, Value>> {
let mut cur = root?;
for key in key_path {
cur = cur.get(key)?;
}
cur.as_object()
}
pub(crate) fn compose(states: impl IntoIterator<Item = BackendState>) -> BackendState {
let mut any = false;
let mut disabled = false;
let mut absent = false;
let mut present = false; let mut needs_repair = false;
for state in states {
any = true;
match state {
BackendState::Disabled => disabled = true,
BackendState::Absent => absent = true,
BackendState::NeedsRepair => {
needs_repair = true;
present = true;
}
BackendState::Healthy => present = true,
}
}
if !any {
return BackendState::Healthy;
}
if disabled {
return BackendState::Disabled;
}
if absent && !present {
return BackendState::Absent;
}
if needs_repair || absent {
return BackendState::NeedsRepair;
}
BackendState::Healthy
}
pub(crate) fn probe_files(expected: &[(PathBuf, Vec<u8>)], is_ours: impl Fn(&Path, &[u8]) -> bool) -> Result<Option<BackendState>> {
let (mut considered, mut matched, mut mismatched, mut missing) = (0usize, 0usize, 0usize, 0usize);
for (path, want) in expected {
match fs::read(path) {
Ok(existing) if existing == *want => {
considered += 1;
matched += 1;
}
Ok(existing) => {
if is_ours(path, &existing) {
considered += 1;
mismatched += 1;
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
considered += 1;
missing += 1;
}
Err(source) => return Err(Error::Io { context: format!("reading {}", path.display()), source }),
}
}
if considered == 0 {
return Ok(None);
}
Ok(Some(if mismatched > 0 || (missing > 0 && matched > 0) {
BackendState::NeedsRepair
} else if missing == considered {
BackendState::Absent
} else {
BackendState::Healthy
}))
}
pub(crate) fn probe_json_subtree(path: &Path, key_path: &[&str], rendered: Option<Value>) -> Result<Option<BackendState>> {
let Some(rendered) = rendered else {
return Ok(None);
};
let root = match fs::read(path) {
Ok(bytes) => serde_json::from_slice::<Value>(&bytes)
.map_err(|e| Error::Config { path: path.display().to_string(), detail: e.to_string() })?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Some(BackendState::Absent)),
Err(source) => return Err(Error::Io { context: format!("reading {}", path.display()), source }),
};
Ok(Some(match value_at(&root, key_path) {
None => BackendState::Absent,
Some(existing) if *existing == rendered => BackendState::Healthy,
Some(_) => BackendState::NeedsRepair,
}))
}
pub(crate) fn probe_json_entries(path: &Path, entries: &[(Vec<String>, Value)]) -> Result<Option<BackendState>> {
if entries.is_empty() {
return Ok(None);
}
let root = match fs::read(path) {
Ok(bytes) => Some(
serde_json::from_slice::<Value>(&bytes)
.map_err(|e| Error::Config { path: path.display().to_string(), detail: e.to_string() })?,
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(source) => return Err(Error::Io { context: format!("reading {}", path.display()), source }),
};
let mut present = 0usize;
for (key_path, entry) in entries {
let keys: Vec<&str> = key_path.iter().map(String::as_str).collect();
if root.as_ref().and_then(|r| array_at(r, &keys)).is_some_and(|arr| arr.iter().any(|e| e == entry)) {
present += 1;
}
}
Ok(Some(if present == 0 {
BackendState::Absent
} else if present == entries.len() {
BackendState::Healthy
} else {
BackendState::NeedsRepair
}))
}
fn value_at<'a>(root: &'a Value, key_path: &[&str]) -> Option<&'a Value> {
let mut cur = root;
for key in key_path {
cur = cur.get(key)?;
}
Some(cur)
}
fn array_at<'a>(root: &'a Value, key_path: &[&str]) -> Option<&'a Vec<Value>> {
value_at(root, key_path)?.as_array()
}
#[cfg(test)]
#[path = "../../tests/unit/report.rs"]
mod report_tests;