use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::core::paths::FrameworkPaths;
use super::discover::{TRUSTY_MEMORY_DEFAULT_ADDR, TRUSTY_SEARCH_DEFAULT_ADDR, discover_addr};
#[path = "doctor_output_style.rs"]
mod doctor_output_style;
use doctor_output_style::check_output_style;
#[path = "doctor_fs_checks.rs"]
mod doctor_fs_checks;
use doctor_fs_checks::{check_agents, check_instructions, check_skill_source, check_skills};
#[path = "doctor_deploy_validate.rs"]
mod doctor_deploy_validate;
use doctor_deploy_validate::check_deployment_completeness;
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>,
repos_root: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> DoctorReport {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let paths = match project_dir {
Some(dir) => FrameworkPaths::for_managed_workspace(dir),
None => FrameworkPaths::default(),
};
let skills_root: &Path = project_dir.unwrap_or(&home);
let mut checks = vec![
check_instructions(project_dir),
check_agents(&paths),
check_skills(skills_root),
check_skill_source(&paths),
check_output_style(project_dir, &home),
check_deployment_completeness(&paths),
];
checks.push(check_memory(&home).await);
checks.push(check_search(&home).await);
checks.push(check_worktrees(repos_root, active_workspace_paths).await);
checks.push(check_gh_account().await);
DoctorReport::from_checks(checks)
}
async fn check_gh_account() -> DoctorCheck {
let (active, accounts) = tokio::task::spawn_blocking(|| {
let active = crate::core::gh_account::active_gh_account();
let accounts = crate::core::gh_account::logged_in_gh_accounts();
(active, accounts)
})
.await
.unwrap_or((None, Vec::new()));
build_gh_account_check(active, accounts)
}
fn build_gh_account_check(active: Option<String>, accounts: Vec<String>) -> DoctorCheck {
if accounts.len() > 1 {
let list = accounts.join(", ");
let active_str = active.as_deref().unwrap_or("unknown");
return DoctorCheck::new(
"gh_account",
CheckStatus::Warn,
format!(
"{} github.com accounts logged in ({list}); active is `{active_str}`. \
`gh` (and `gh pr merge --admin`) uses the ACTIVE account — if merges \
fail on permissions, run `gh auth switch` to the repo owner.",
accounts.len()
),
);
}
match active {
Some(login) => DoctorCheck::new(
"gh_account",
CheckStatus::Ok,
format!("active gh account: `{login}`"),
),
None if accounts.is_empty() => DoctorCheck::new(
"gh_account",
CheckStatus::Warn,
"gh is not authenticated (no github.com account) — `gh` calls will fail; \
run `gh auth login`"
.to_string(),
),
None => DoctorCheck::new(
"gh_account",
CheckStatus::Warn,
format!(
"gh authenticated ({}) but no active account is set — run `gh auth switch`",
accounts.join(", ")
),
),
}
}
async fn check_worktrees(
repos_root: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> DoctorCheck {
let Some(root) = repos_root else {
return DoctorCheck::new(
"worktrees",
CheckStatus::Ok,
"no managed workspace root configured — worktree scan skipped",
);
};
if !root.is_dir() {
return DoctorCheck::new(
"worktrees",
CheckStatus::Ok,
format!("{} does not exist — no worktrees to check", root.display()),
);
}
let root = root.to_path_buf();
let active_set: std::collections::HashSet<PathBuf> = active_workspace_paths
.iter()
.map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
.collect();
let orphans = tokio::task::spawn_blocking(move || {
crate::session_manager::prune::find_orphaned_worktrees(&root, &active_set)
})
.await
.unwrap_or_else(|e| {
tracing::error!("doctor: worktree scan panicked: {e}");
Vec::new()
});
if orphans.is_empty() {
DoctorCheck::new("worktrees", CheckStatus::Ok, "no orphaned worktrees found")
} else {
DoctorCheck::new(
"worktrees",
CheckStatus::Warn,
format!(
"{} orphaned worktree dir(s) found — run `tm session prune --worktrees` to remove",
orphans.len()
),
)
}
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[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 agents_check_scopes_to_managed_workspace_when_project_given() {
let project = tempfile::tempdir().unwrap();
let report = run_doctor(Some(project.path()), None, &[]).await;
let agents_check = report
.checks
.iter()
.find(|c| c.name == "agents")
.expect("agents check present");
assert_eq!(agents_check.status, CheckStatus::Fail);
let expected_dir = project.path().join(".claude").join("agents");
assert!(
agents_check
.message
.contains(&expected_dir.display().to_string()),
"message must name the workspace-scoped agents dir: {}",
agents_check.message
);
}
#[tokio::test]
async fn agents_check_ok_when_managed_workspace_roster_populated() {
let project = tempfile::tempdir().unwrap();
let agents_dir = project.path().join(".claude").join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("engineer.md"), "agent").unwrap();
std::fs::write(
agents_dir.join(crate::core::agent_manifest::MANIFEST_FILE),
"{}",
)
.unwrap();
let report = run_doctor(Some(project.path()), None, &[]).await;
let agents_check = report
.checks
.iter()
.find(|c| c.name == "agents")
.expect("agents check present");
assert_eq!(agents_check.status, CheckStatus::Ok);
}
#[tokio::test]
async fn run_doctor_produces_ten_checks() {
let report = run_doctor(None, None, &[]).await;
assert_eq!(report.checks.len(), 10);
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
[
"instructions",
"agents",
"skills",
"skill_source",
"output_style",
"deployment",
"memory",
"search",
"worktrees",
"gh_account"
]
);
}
#[test]
fn build_gh_account_check_single_ok() {
let check =
build_gh_account_check(Some("bobmatnyc".to_string()), vec!["bobmatnyc".to_string()]);
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(check.name, "gh_account");
assert!(check.message.contains("bobmatnyc"));
}
#[test]
fn build_gh_account_check_multi_warn() {
let check = build_gh_account_check(
Some("bob-duetto".to_string()),
vec!["bob-duetto".to_string(), "bobmatnyc".to_string()],
);
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("bob-duetto"));
assert!(check.message.contains("bobmatnyc"));
assert!(check.message.contains("gh auth switch"));
}
#[test]
fn build_gh_account_check_unauthenticated_warn() {
let check = build_gh_account_check(None, Vec::new());
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("not authenticated"));
assert!(check.message.contains("gh auth login"));
}
#[test]
fn gh_account_check_is_advisory_only() {
for check in [
build_gh_account_check(Some("a".to_string()), vec!["a".to_string()]),
build_gh_account_check(
Some("a".to_string()),
vec!["a".to_string(), "b".to_string()],
),
build_gh_account_check(None, Vec::new()),
build_gh_account_check(None, vec!["a".to_string()]),
] {
assert_ne!(
check.status,
CheckStatus::Fail,
"gh_account must never Fail"
);
}
}
#[tokio::test]
async fn worktrees_no_repos_root_is_ok() {
let check = check_worktrees(None, &[]).await;
assert_eq!(check.status, CheckStatus::Ok);
assert!(check.message.contains("no managed workspace"));
}
#[tokio::test]
async fn worktrees_no_orphans_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let wt = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("session-abc");
std::fs::create_dir_all(&wt).unwrap();
let canonical_wt = std::fs::canonicalize(&wt).unwrap_or(wt);
let active = vec![canonical_wt];
let check = check_worktrees(Some(root), &active).await;
assert_eq!(check.status, CheckStatus::Ok);
}
#[tokio::test]
async fn worktrees_with_orphan_is_warn() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let wt1 = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("session-live");
let wt2 = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("session-dead");
std::fs::create_dir_all(&wt1).unwrap();
std::fs::create_dir_all(&wt2).unwrap();
let active = vec![wt1];
let check = check_worktrees(Some(root), &active).await;
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("1 orphaned"));
assert!(check.message.contains("tm session prune --worktrees"));
}
}