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) -> 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()
),
)
}
}
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);
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 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);
}
}