use std::path::Path;
use crate::core::agent_manifest::MANIFEST_FILE;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::paths::FrameworkPaths;
pub(super) 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()
),
),
}
}
pub(super) fn check_agents(paths: &FrameworkPaths, project_dir: Option<&Path>) -> DoctorCheck {
let dir = paths.agent_deploy_dir();
let md_count = count_files_with_extension(&dir, "md");
let roster = match project_dir {
Some(project) => format!(
"{} delegatable",
crate::core::delegation_authority::resolve_roster(project).len()
),
None => "delegatable roster unknown (no project directory)".to_string(),
};
if md_count == 0 {
return DoctorCheck::new(
"agents",
CheckStatus::Fail,
format!(
"no agent files in {} — run `tm install` to deploy agents; {roster}",
dir.display()
),
);
}
let manifest = dir.join(MANIFEST_FILE);
if manifest.exists() {
DoctorCheck::new(
"agents",
CheckStatus::Ok,
format!(
"{md_count} agent file(s) deployed in {} with manifest; {roster}",
dir.display()
),
)
} else {
DoctorCheck::new(
"agents",
CheckStatus::Warn,
format!(
"{md_count} agent file(s) in {} but {MANIFEST_FILE} is missing; {roster}",
dir.display()
),
)
}
}
pub(super) fn check_skills(home: &Path) -> DoctorCheck {
let dir = home.join(".claude").join("skills");
if !dir.is_dir() {
return DoctorCheck::new(
"skills",
CheckStatus::Fail,
format!("{} does not exist", dir.display()),
);
}
let count = count_skill_entries(&dir);
if count == 0 {
DoctorCheck::new(
"skills",
CheckStatus::Warn,
format!(
"{} exists but has no deployed skills — no skills deployed yet",
dir.display()
),
)
} else {
DoctorCheck::new(
"skills",
CheckStatus::Ok,
format!("{count} skill(s) in {}", dir.display()),
)
}
}
fn count_skill_entries(dir: &Path) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
entries
.flatten()
.filter(|entry| {
let path = entry.path();
if path.is_dir() {
path.join("SKILL.md").is_file()
} else {
path.extension()
.and_then(|x| x.to_str())
.map(|x| x.eq_ignore_ascii_case("md"))
.unwrap_or(false)
}
})
.count()
}
pub(super) fn check_skill_source(paths: &FrameworkPaths) -> DoctorCheck {
let dir = paths.skill_source_dir();
if !dir.is_dir() {
return DoctorCheck::new(
"skill_source",
CheckStatus::Fail,
format!(
"skill source directory {} does not exist — skill deploy will no-op",
dir.display()
),
);
}
let md_count = count_files_with_extension(&dir, "md");
if md_count == 0 {
DoctorCheck::new(
"skill_source",
CheckStatus::Warn,
format!(
"{} exists but has no skill files — skill deploy will no-op",
dir.display()
),
)
} else {
DoctorCheck::new(
"skill_source",
CheckStatus::Ok,
format!("{md_count} skill file(s) available in {}", dir.display()),
)
}
}
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 instructions_empty_file_is_fail() {
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"), "").unwrap();
let check = check_instructions(Some(tmp.path()));
assert_eq!(check.status, CheckStatus::Fail);
}
#[test]
fn agents_missing_dir_is_fail() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let check = check_agents(&paths, None);
assert_eq!(check.status, CheckStatus::Fail);
}
#[test]
fn agents_fail_path_still_reports_the_roster() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let project = tempfile::tempdir().unwrap();
let project_tier = project.path().join(".claude").join("agents");
std::fs::create_dir_all(&project_tier).unwrap();
std::fs::write(
project_tier.join("ticketing.md"),
"---\nname: ticketing\nrole: ticketing\n---\n",
)
.unwrap();
let check = check_agents(&paths, Some(project.path()));
assert_eq!(check.status, CheckStatus::Fail, "no deployed agent files");
assert!(
check.message.contains("delegatable"),
"the Fail path must still report the routing fact: {}",
check.message
);
let bare = check_agents(&paths, None);
assert_eq!(bare.status, CheckStatus::Fail);
assert!(
bare.message.contains("delegatable roster unknown"),
"an undetermined roster must be named, not omitted: {}",
bare.message
);
}
#[test]
fn agents_without_manifest_is_warn() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.agent_deploy_dir();
std::fs::create_dir_all(&agents).unwrap();
std::fs::write(agents.join("engineer.md"), "agent").unwrap();
let check = check_agents(&paths, None);
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.agent_deploy_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, None);
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn agents_message_reports_the_delegatable_roster() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.agent_deploy_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 project = tempfile::tempdir().unwrap();
let project_tier = project.path().join(".claude").join("agents");
std::fs::create_dir_all(&project_tier).unwrap();
std::fs::write(
project_tier.join("ticketing.md"),
"---\nname: ticketing\nrole: ticketing\n---\n",
)
.unwrap();
let check = check_agents(&paths, Some(project.path()));
assert!(
check.message.contains("1 agent file(s) deployed"),
"the deploy-tier file count must still be reported: {}",
check.message
);
assert!(
check.message.contains("delegatable"),
"the delegatable roster size must be reported: {}",
check.message
);
assert!(
!check.message.contains("unknown"),
"a project was supplied, so the roster is knowable: {}",
check.message
);
}
#[test]
fn agents_roster_is_unknown_without_a_project_dir() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let agents = paths.agent_deploy_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, None);
assert!(
check.message.contains("delegatable roster unknown"),
"an undetermined roster must be reported as unknown: {}",
check.message
);
}
#[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 skills_dir_with_real_deploy_format_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let skills = tmp.path().join(".claude").join("skills");
let skill_dir = skills.join("tm-doctor");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "skill").unwrap();
let check = check_skills(tmp.path());
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn skills_dir_with_only_non_skill_entries_is_warn() {
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(".DS_Store"), b"\x00\x01").unwrap();
std::fs::create_dir_all(skills.join("not-a-skill")).unwrap();
let check = check_skills(tmp.path());
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn skill_source_missing_dir_is_fail() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let check = check_skill_source(&paths);
assert_eq!(check.status, CheckStatus::Fail);
}
#[test]
fn skill_source_empty_dir_is_warn() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
std::fs::create_dir_all(paths.skill_source_dir()).unwrap();
let check = check_skill_source(&paths);
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn skill_source_populated_dir_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let source = paths.skill_source_dir();
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("tm-doctor.md"), "skill").unwrap();
let check = check_skill_source(&paths);
assert_eq!(check.status, CheckStatus::Ok);
}
}