use super::*;
use crate::core::skill_manifest::{SKILL_MANIFEST_FILE, SkillManifestEntry};
use trusty_agents_common::agents::manifest::checksum;
fn fixture(base: &Path, stems: &[&str]) -> (FrameworkPaths, PathBuf) {
let paths = FrameworkPaths::under(base);
let source = paths.skill_source_dir();
std::fs::create_dir_all(&source).expect("fixture: bundled source dir");
for stem in stems {
std::fs::write(source.join(format!("{stem}.md")), "# bundled\n")
.expect("fixture: write bundled skill");
}
let project = base.join("project");
std::fs::create_dir_all(&project).expect("fixture: project dir");
(paths, project)
}
fn tier(project: &Path) -> PathBuf {
project.join(".claude").join("skills")
}
fn project_skill(project: &Path, stem: &str) -> String {
let dir = tier(project).join(stem);
std::fs::create_dir_all(dir.join("references")).expect("fixture: project skill dir");
let body = format!("# {stem}\n");
std::fs::write(dir.join("SKILL.md"), &body).expect("fixture: write SKILL.md");
std::fs::write(dir.join("references").join("extra.md"), "# extra\n")
.expect("fixture: write reference");
body
}
fn record(project: &Path, stem: &str, content: &str) {
let dir = tier(project);
let mut manifest = SkillManifest::load(&dir).expect("fixture: load ledger");
manifest.managed.insert(
stem.to_string(),
SkillManifestEntry {
checksum: checksum(content),
deployed_at: "2026-09-01T00:00:00Z".to_string(),
},
);
manifest.managed.insert(
format!("{stem}/references/extra.md"),
SkillManifestEntry {
checksum: checksum("# extra\n"),
deployed_at: "2026-09-01T00:00:00Z".to_string(),
},
);
manifest.save(&dir).expect("fixture: save ledger");
}
fn backups(base: &Path) -> PathBuf {
base.join("backup-doctor-remediation-test")
}
#[test]
fn a_managed_stray_is_removed_and_a_custom_skill_is_kept() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing", "tm-workflow"]);
let body = project_skill(&project, "tm-ticketing");
project_skill(&project, "our-house-style");
record(&project, "tm-ticketing", &body);
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
assert_eq!(
steps.len(),
1,
"only the bundled name is a stray: {steps:?}"
);
assert!(
matches!(steps[0].status, StepStatus::Applied { .. }),
"{steps:?}"
);
assert!(
!tier(&project).join("tm-ticketing").exists(),
"the stray must be gone from the project tier"
);
assert!(
tier(&project)
.join("our-house-style")
.join("SKILL.md")
.is_file(),
"the operator's own skill must survive the sweep"
);
let manifest = SkillManifest::load(&tier(&project)).expect("ledger");
assert!(
!manifest.is_managed("tm-ticketing"),
"the removed stem must be dropped from the ledger: {:?}",
manifest.managed.keys().collect::<Vec<_>>()
);
assert!(
!manifest.is_managed("tm-ticketing/references/extra.md"),
"and so must its reference files: {:?}",
manifest.managed.keys().collect::<Vec<_>>()
);
}
#[test]
fn a_removed_stray_is_backed_up_whole() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let body = project_skill(&project, "tm-ticketing");
record(&project, "tm-ticketing", &body);
let root = backups(tmp.path());
let steps = remove_project_tier_strays(&paths, Some(&project), &root, RepairMode::Apply);
let StepStatus::Applied { backup: Some(path) } = &steps[0].status else {
panic!("expected an applied removal with a backup: {steps:?}");
};
assert_eq!(path, &root.join("project").join("tm-ticketing"));
assert_eq!(
std::fs::read_to_string(path.join("SKILL.md")).expect("backed-up entry point"),
body
);
assert_eq!(
std::fs::read_to_string(path.join("references").join("extra.md"))
.expect("backed-up reference file"),
"# extra\n"
);
}
#[test]
fn an_unrecorded_bundled_name_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
project_skill(&project, "tm-ticketing");
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
assert!(
matches!(steps[0].status, StepStatus::Refused(_)),
"an unrecorded copy is never removed: {steps:?}"
);
assert!(
tier(&project)
.join("tm-ticketing")
.join("SKILL.md")
.is_file()
);
}
#[test]
fn a_hand_edited_stray_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let body = project_skill(&project, "tm-ticketing");
record(&project, "tm-ticketing", &body);
let entry = tier(&project).join("tm-ticketing").join("SKILL.md");
std::fs::write(&entry, "# hand-edited\n").expect("hand-edit the deployed copy");
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
let StepStatus::Refused(why) = &steps[0].status else {
panic!("expected a refusal: {steps:?}");
};
assert!(
why.contains("edited after it was deployed"),
"the refusal must say what it saw: {why}"
);
assert!(entry.is_file(), "the edit must survive");
}
#[test]
fn a_stray_holding_an_operator_file_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let body = project_skill(&project, "tm-ticketing");
record(&project, "tm-ticketing", &body);
let note = tier(&project)
.join("tm-ticketing")
.join("references")
.join("our-notes.md");
std::fs::write(¬e, "# our notes\n").expect("operator adds a reference file");
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
let StepStatus::Refused(why) = &steps[0].status else {
panic!("an operator file in the subtree must stop the removal: {steps:?}");
};
assert!(
why.contains("our-notes.md"),
"the refusal must name the file it protected: {why}"
);
assert!(note.is_file(), "the operator's file must survive");
assert!(
tier(&project)
.join("tm-ticketing")
.join("SKILL.md")
.is_file(),
"and so must the rest of the directory"
);
}
#[test]
fn a_bundled_named_entry_that_is_not_a_skill_directory_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing", "tm-workflow"]);
std::fs::create_dir_all(tier(&project)).expect("tier dir");
std::fs::create_dir_all(tier(&project).join("tm-ticketing")).expect("empty bundled-named dir");
std::fs::write(tier(&project).join("tm-workflow"), "not a skill\n")
.expect("bundled-named file");
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
assert_eq!(steps.len(), 2, "both entries must be reported: {steps:?}");
for step in &steps {
let StepStatus::Refused(why) = &step.status else {
panic!("a tm-unclassifiable entry is refused, never removed: {steps:?}");
};
assert!(
why.contains("not a skill directory"),
"the refusal must say why: {why}"
);
}
assert!(tier(&project).join("tm-ticketing").is_dir());
assert!(tier(&project).join("tm-workflow").is_file());
}
#[test]
fn a_dry_run_removes_nothing() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let body = project_skill(&project, "tm-ticketing");
record(&project, "tm-ticketing", &body);
let root = backups(tmp.path());
let steps = remove_project_tier_strays(&paths, Some(&project), &root, RepairMode::DryRun);
assert!(matches!(steps[0].status, StepStatus::Planned), "{steps:?}");
assert!(
tier(&project)
.join("tm-ticketing")
.join("SKILL.md")
.is_file()
);
assert!(!root.exists(), "a dry run writes no backup either");
assert!(
tier(&project)
.join(format!("{SKILL_MANIFEST_FILE}.lock"))
.exists(),
"the ledger lock is taken in a dry run too, and its sidecar stays behind"
);
assert!(
SkillManifest::load(&tier(&project))
.expect("ledger")
.is_managed("tm-ticketing"),
"and leaves the ledger alone"
);
}
#[test]
fn a_tier_bundled_skills_deploy_to_is_never_swept() {
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).expect("project dir");
let paths = FrameworkPaths::for_managed_project(tmp.path().join(".trusty-mpm"), &project);
let source = paths.skill_source_dir();
std::fs::create_dir_all(&source).expect("bundled source dir");
std::fs::write(source.join("tm-ticketing.md"), "# bundled\n").expect("write bundled skill");
assert_eq!(
paths.claude_skills_dir(),
tier(&project),
"fixture must collide the two tiers"
);
let dir = tier(&project).join("tm-ticketing");
std::fs::create_dir_all(&dir).expect("deployed skill dir");
std::fs::write(dir.join("SKILL.md"), "# bundled\n").expect("write deployed skill");
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
assert!(
matches!(steps[0].status, StepStatus::Refused(_)),
"{steps:?}"
);
assert!(
dir.join("SKILL.md").is_file(),
"the bundled roster must survive"
);
}
#[test]
#[cfg(unix)]
fn a_symlinked_project_tier_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let home_tier = paths.claude_skills_dir();
std::fs::create_dir_all(home_tier.join("tm-ticketing")).expect("home tier skill dir");
let live = home_tier.join("tm-ticketing").join("SKILL.md");
std::fs::write(&live, "# live home copy\n").expect("write home tier skill");
std::fs::create_dir_all(project.join(".claude")).expect("project .claude");
std::os::unix::fs::symlink(&home_tier, tier(&project)).expect("symlink the project tier");
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
let StepStatus::Refused(why) = &steps[0].status else {
panic!("a symlinked tier must be refused: {steps:?}");
};
assert!(why.contains("symlink"), "the refusal must say why: {why}");
assert!(
live.is_file(),
"the operator's live home-tier skill must be untouched"
);
}
#[test]
#[cfg(unix)]
fn a_tier_resolving_onto_the_managed_deploy_dir_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let managed_skills = paths.skill_deploy_dir();
std::fs::create_dir_all(managed_skills.join("tm-ticketing")).expect("managed deploy dir");
let live = managed_skills.join("tm-ticketing").join("SKILL.md");
std::fs::write(&live, "# managed copy\n").expect("write managed skill");
let managed_root = managed_skills.parent().expect("managed root");
std::os::unix::fs::symlink(managed_root, project.join(".claude"))
.expect("symlink .claude at the managed config dir");
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
let StepStatus::Refused(why) = &steps[0].status else {
panic!("a tier resolving onto the managed deploy dir must be refused: {steps:?}");
};
assert!(
why.contains("deployed to"),
"the refusal must name the boundary it held: {why}"
);
assert!(live.is_file(), "the managed roster must survive");
}
#[test]
fn nothing_to_sweep_reports_nothing() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let root = backups(tmp.path());
assert!(remove_project_tier_strays(&paths, None, &root, RepairMode::Apply).is_empty());
assert!(
remove_project_tier_strays(&paths, Some(&project), &root, RepairMode::Apply).is_empty(),
"an unprovisioned project tier holds no stray"
);
}
#[test]
fn a_bare_fix_skills_leaves_a_planned_stray_alone() {
use crate::core::skill_drift::SkillReference;
use crate::core::skill_repair::repair_skills_in_mode_deferring;
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let body = project_skill(&project, "tm-ticketing");
record(&project, "tm-ticketing", &body);
let root = backups(tmp.path());
let entry = tier(&project).join("tm-ticketing").join("SKILL.md");
let ledger = tier(&project).join(SKILL_MANIFEST_FILE);
let ledger_before = std::fs::read_to_string(&ledger).expect("ledger before the run");
let strays = remove_project_tier_strays(&paths, Some(&project), &root, RepairMode::DryRun);
assert!(
matches!(strays[0].status, StepStatus::Planned),
"{strays:?}"
);
let reference = SkillReference {
assets: [("tm-ticketing".to_string(), "# refreshed\n".to_string())]
.into_iter()
.collect(),
origin: "test".to_string(),
};
let outcomes = repair_skills_in_mode_deferring(
&reference,
&paths,
Some(&project),
false,
&root,
RepairMode::Apply,
&stems_being_removed(&strays),
);
assert_eq!(
std::fs::read_to_string(&entry).expect("the stray's entry point"),
body,
"a bare --fix-skills must not rewrite a copy it only planned to remove: {outcomes:?}"
);
assert_eq!(
std::fs::read_to_string(&ledger).expect("ledger after"),
ledger_before,
"and must not re-stamp its ledger checksum: {outcomes:?}"
);
assert!(
!root.exists(),
"and must write no backup, having overwritten nothing: {outcomes:?}"
);
}
#[test]
fn swept_stems_are_the_planned_and_applied_ones() {
let step = |name: &str, status: StepStatus| RepairStep {
check: CHECK,
path: PathBuf::from("/tier").join(name),
what: String::new(),
status,
};
let steps = vec![
step("planned", StepStatus::Planned),
step("applied", StepStatus::Applied { backup: None }),
step("refused", StepStatus::Refused("kept".to_string())),
RepairStep {
check: CHECK,
path: PathBuf::from("/tier"),
what: String::new(),
status: StepStatus::Failed("tier-wide".to_string()),
},
];
let expected: std::collections::BTreeSet<String> =
["applied".to_string(), "planned".to_string()]
.into_iter()
.collect();
assert_eq!(
stems_being_removed(&steps),
expected,
"a refusal is not a removal, and a tier-wide step is not a stem"
);
}
#[cfg(unix)]
#[test]
fn an_unlistable_project_tier_is_refused() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let body = project_skill(&project, "tm-ticketing");
record(&project, "tm-ticketing", &body);
let dir = tier(&project);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o300))
.expect("drop read permission");
if std::fs::read_dir(&dir).is_ok() {
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("restore");
return;
}
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("restore");
assert_eq!(steps.len(), 1, "one tier-wide step, not silence: {steps:?}");
let StepStatus::Refused(why) = &steps[0].status else {
panic!("an unlistable tier must be refused: {steps:?}");
};
assert!(
why.contains("could not be listed"),
"the refusal must say what tm could not do: {why}"
);
assert!(
why.contains(&dir.display().to_string()),
"and must name the tier: {why}"
);
assert!(
tier(&project).join("tm-ticketing").is_dir(),
"and nothing is removed from a tier tm could not read"
);
}
#[cfg(unix)]
#[test]
fn a_symlink_inside_a_stray_stops_the_removal() {
let tmp = tempfile::tempdir().expect("tempdir");
let (paths, project) = fixture(tmp.path(), &["tm-ticketing"]);
let body = project_skill(&project, "tm-ticketing");
record(&project, "tm-ticketing", &body);
let outside = tmp.path().join("operator-notes.md");
std::fs::write(&outside, "# extra\n").expect("write the link target");
let claimed = tier(&project)
.join("tm-ticketing")
.join("references")
.join("extra.md");
std::fs::remove_file(&claimed).expect("remove the real reference file");
std::os::unix::fs::symlink(&outside, &claimed).expect("symlink at the claimed path");
let steps = remove_project_tier_strays(
&paths,
Some(&project),
&backups(tmp.path()),
RepairMode::Apply,
);
let stray = steps
.iter()
.find(|s| s.path.ends_with("tm-ticketing"))
.unwrap_or_else(|| panic!("expected a step for the stray: {steps:?}"));
assert!(
!matches!(stray.status, StepStatus::Applied { .. }),
"a subtree holding a symlink must not be removed: {steps:?}"
);
assert!(
tier(&project).join("tm-ticketing").is_dir(),
"the directory must survive"
);
assert!(
std::fs::symlink_metadata(&claimed)
.expect("the link")
.is_symlink(),
"and so must the link itself"
);
assert!(outside.is_file(), "and its target");
}