use std::path::{Path, PathBuf};
use std::time::Duration;
use trusty_mpm_core::agent_manifest::MANIFEST_FILE;
use trusty_mpm_core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use trusty_mpm_core::paths::FrameworkPaths;
use crate::discover::{TRUSTY_MEMORY_DEFAULT_ADDR, TRUSTY_SEARCH_DEFAULT_ADDR, discover_addr};
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
const EXPECTED_SEARCH_INDEX: &str = "trusty-mpm";
pub async fn run_doctor(project_dir: Option<&Path>) -> DoctorReport {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let paths = FrameworkPaths::default();
let mut checks = vec![
check_instructions(project_dir),
check_agents(&paths),
check_skills(&home),
];
checks.push(check_memory(&home).await);
checks.push(check_search(&home).await);
DoctorReport::from_checks(checks)
}
fn check_instructions(project_dir: Option<&Path>) -> DoctorCheck {
let Some(project) = project_dir else {
return DoctorCheck::new(
"instructions",
CheckStatus::Warn,
"no project directory supplied — cannot verify last-instructions.md",
);
};
let stash = project.join(".trusty-mpm").join("last-instructions.md");
match std::fs::metadata(&stash) {
Ok(meta) if meta.len() > 0 => DoctorCheck::new(
"instructions",
CheckStatus::Ok,
format!("instruction pipeline ran — {} present", stash.display()),
),
Ok(_) => DoctorCheck::new(
"instructions",
CheckStatus::Fail,
format!("{} exists but is empty", stash.display()),
),
Err(_) => DoctorCheck::new(
"instructions",
CheckStatus::Warn,
format!(
"{} not found — launch a session in this project to run the pipeline",
stash.display()
),
),
}
}
fn check_agents(paths: &FrameworkPaths) -> DoctorCheck {
let dir = paths.claude_agents_dir();
let md_count = count_files_with_extension(&dir, "md");
if md_count == 0 {
return DoctorCheck::new(
"agents",
CheckStatus::Fail,
format!(
"no agent files in {} — run `tm install` to deploy agents",
dir.display()
),
);
}
let manifest = dir.join(MANIFEST_FILE);
if manifest.exists() {
DoctorCheck::new(
"agents",
CheckStatus::Ok,
format!(
"{md_count} agent(s) deployed in {} with manifest",
dir.display()
),
)
} else {
DoctorCheck::new(
"agents",
CheckStatus::Warn,
format!(
"{md_count} agent(s) in {} but {MANIFEST_FILE} is missing",
dir.display()
),
)
}
}
fn check_skills(home: &Path) -> DoctorCheck {
let dir = home.join(".claude").join("skills");
match std::fs::read_dir(&dir) {
Ok(entries) => {
let count = entries.flatten().count();
if count == 0 {
DoctorCheck::new(
"skills",
CheckStatus::Warn,
format!(
"{} exists but is empty — no skills deployed yet",
dir.display()
),
)
} else {
DoctorCheck::new(
"skills",
CheckStatus::Ok,
format!("{count} skill entr(ies) in {}", dir.display()),
)
}
}
Err(_) => DoctorCheck::new(
"skills",
CheckStatus::Fail,
format!("{} does not exist", dir.display()),
),
}
}
async fn check_memory(home: &Path) -> DoctorCheck {
let dir = home.join(".trusty-memory");
let default = TRUSTY_MEMORY_DEFAULT_ADDR
.parse()
.expect("static default is valid");
let env = std::env::var("TRUSTY_MEMORY_ADDR").ok();
let addr = discover_addr(&dir, default, env.as_deref()).await;
match http_get_ok(&format!("http://{addr}/health")).await {
Ok(true) => DoctorCheck::new(
"memory",
CheckStatus::Ok,
format!("trusty-memory healthy at {addr}"),
),
Ok(false) => DoctorCheck::new(
"memory",
CheckStatus::Fail,
format!("trusty-memory at {addr} returned a non-2xx status"),
),
Err(e) => DoctorCheck::new(
"memory",
CheckStatus::Fail,
format!("trusty-memory unreachable at {addr}: {e}"),
),
}
}
async fn check_search(home: &Path) -> DoctorCheck {
let dir = home.join(".trusty-search");
let default = TRUSTY_SEARCH_DEFAULT_ADDR
.parse()
.expect("static default is valid");
let env = std::env::var("TRUSTY_SEARCH_ADDR").ok();
let addr = discover_addr(&dir, default, env.as_deref()).await;
match http_get_ok(&format!("http://{addr}/health")).await {
Ok(true) => {}
Ok(false) => {
return DoctorCheck::new(
"search",
CheckStatus::Fail,
format!("trusty-search at {addr} returned a non-2xx status"),
);
}
Err(e) => {
return DoctorCheck::new(
"search",
CheckStatus::Fail,
format!("trusty-search unreachable at {addr}: {e}"),
);
}
}
match http_get_json(&format!("http://{addr}/indexes")).await {
Ok(body) if index_present(&body, EXPECTED_SEARCH_INDEX) => DoctorCheck::new(
"search",
CheckStatus::Ok,
format!("trusty-search healthy at {addr}, `{EXPECTED_SEARCH_INDEX}` index present"),
),
Ok(_) => DoctorCheck::new(
"search",
CheckStatus::Warn,
format!(
"trusty-search healthy at {addr} but the `{EXPECTED_SEARCH_INDEX}` index is missing"
),
),
Err(e) => DoctorCheck::new(
"search",
CheckStatus::Warn,
format!("trusty-search healthy at {addr} but listing indexes failed: {e}"),
),
}
}
fn index_present(body: &serde_json::Value, name: &str) -> bool {
let array = body
.as_array()
.or_else(|| body.get("indexes").and_then(|v| v.as_array()));
let Some(array) = array else {
return false;
};
array.iter().any(|entry| {
if entry.as_str() == Some(name) {
return true;
}
["id", "name", "index_id"]
.iter()
.any(|key| entry.get(key).and_then(|v| v.as_str()) == Some(name))
})
}
async fn http_get_ok(url: &str) -> anyhow::Result<bool> {
let client = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
let resp = client.get(url).send().await?;
Ok(resp.status().is_success())
}
async fn http_get_json(url: &str) -> anyhow::Result<serde_json::Value> {
let client = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
let body = client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body)
}
fn count_files_with_extension(dir: &Path, ext: &str) -> usize {
match std::fs::read_dir(dir) {
Ok(entries) => entries
.flatten()
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x.eq_ignore_ascii_case(ext))
.unwrap_or(false)
})
.count(),
Err(_) => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instructions_present_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".trusty-mpm");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("last-instructions.md"), "PM instructions").unwrap();
let check = check_instructions(Some(tmp.path()));
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn instructions_missing_is_warn() {
let tmp = tempfile::tempdir().unwrap();
let check = check_instructions(Some(tmp.path()));
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn instructions_no_project_is_warn() {
let check = check_instructions(None);
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn agents_missing_dir_is_fail() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let check = check_agents(&paths);
assert_eq!(check.status, CheckStatus::Fail);
}
#[test]
fn agents_without_manifest_is_warn() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.claude_agents_dir();
std::fs::create_dir_all(&agents).unwrap();
std::fs::write(agents.join("engineer.md"), "agent").unwrap();
let check = check_agents(&paths);
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn agents_with_manifest_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.claude_agents_dir();
std::fs::create_dir_all(&agents).unwrap();
std::fs::write(agents.join("engineer.md"), "agent").unwrap();
std::fs::write(agents.join(MANIFEST_FILE), "{}").unwrap();
let check = check_agents(&paths);
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn skills_missing_dir_is_fail() {
let tmp = tempfile::tempdir().unwrap();
let check = check_skills(tmp.path());
assert_eq!(check.status, CheckStatus::Fail);
}
#[test]
fn skills_empty_dir_is_warn() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude").join("skills")).unwrap();
let check = check_skills(tmp.path());
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn skills_populated_dir_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let skills = tmp.path().join(".claude").join("skills");
std::fs::create_dir_all(&skills).unwrap();
std::fs::write(skills.join("tm-doctor.md"), "skill").unwrap();
let check = check_skills(tmp.path());
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn index_present_matches_each_shape() {
let strings = serde_json::json!(["other", "trusty-mpm"]);
assert!(index_present(&strings, "trusty-mpm"));
let objects = serde_json::json!({"indexes": [{"id": "trusty-mpm"}]});
assert!(index_present(&objects, "trusty-mpm"));
let named = serde_json::json!([{"name": "trusty-mpm"}]);
assert!(index_present(&named, "trusty-mpm"));
let missing = serde_json::json!(["a", "b"]);
assert!(!index_present(&missing, "trusty-mpm"));
}
#[tokio::test]
async fn memory_unreachable_is_fail() {
unsafe {
std::env::set_var("TRUSTY_MEMORY_ADDR", "127.0.0.1:0");
}
let tmp = tempfile::tempdir().unwrap();
let check = check_memory(tmp.path()).await;
unsafe {
std::env::remove_var("TRUSTY_MEMORY_ADDR");
}
assert_eq!(check.status, CheckStatus::Fail);
}
#[tokio::test]
async fn search_unreachable_is_fail() {
unsafe {
std::env::set_var("TRUSTY_SEARCH_ADDR", "127.0.0.1:0");
}
let tmp = tempfile::tempdir().unwrap();
let check = check_search(tmp.path()).await;
unsafe {
std::env::remove_var("TRUSTY_SEARCH_ADDR");
}
assert_eq!(check.status, CheckStatus::Fail);
}
#[tokio::test]
async fn run_doctor_produces_five_checks() {
let report = run_doctor(None).await;
assert_eq!(report.checks.len(), 5);
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
["instructions", "agents", "skills", "memory", "search"]
);
}
}