use super::*;
use crate::core::skill_deployer::deploy_skills;
use crate::core::skill_drift::{deployed_path, skill_reference};
use std::collections::BTreeMap;
use std::fs;
use tempfile::TempDir;
fn reference_of(pairs: &[(&str, &str)]) -> SkillReference {
SkillReference {
assets: pairs
.iter()
.map(|(s, c)| (s.to_string(), c.to_string()))
.collect::<BTreeMap<_, _>>(),
origin: "this binary's embedded bundled assets".to_string(),
}
}
fn deploy_real(dest: &Path, stem: &str, body: &str, reference: Option<(&str, &str)>) -> TempDir {
let src = TempDir::new().unwrap();
fs::write(src.path().join(format!("{stem}.md")), body).unwrap();
if let Some((ref_name, ref_body)) = reference {
let refs = src.path().join(stem).join("references");
fs::create_dir_all(&refs).unwrap();
fs::write(refs.join(ref_name), ref_body).unwrap();
}
deploy_skills(src.path(), dest).unwrap();
src
}
fn hand_edit(dest: &Path, manifest_key: &str, new_content: &str) {
fs::write(deployed_path(dest, manifest_key), new_content).unwrap();
}
fn paths_under(tmp: &TempDir) -> FrameworkPaths {
let mut paths = FrameworkPaths::under(tmp.path());
paths.trusty_mpm_root = None;
paths
}
#[test]
fn repair_deploys_a_bundled_skill_that_reached_no_tier() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let managed = paths.skill_deploy_dir();
let _managed_src = deploy_real(&managed, "tm-workflow", "v1", None);
let _home_src = deploy_real(&paths.claude_skills_dir(), "tm-workflow", "v1", None);
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v1"), ("tm-prose-style", "brand new")]),
&paths,
None,
false,
backups.path(),
);
let written: Vec<&RepairOutcome> = outcomes
.iter()
.filter(|o| o.stem == "tm-prose-style" && o.changed())
.collect();
assert_eq!(written.len(), 1, "outcomes: {outcomes:?}");
assert_eq!(written[0].tier, "managed config");
assert_eq!(
fs::read_to_string(managed.join("tm-prose-style").join("SKILL.md")).unwrap(),
"brand new"
);
assert!(
!paths.claude_skills_dir().join("tm-prose-style").exists(),
"the repair must not deploy a bundled skill to the operator home tier"
);
assert!(
SkillManifest::load(&managed)
.unwrap()
.checksum_matches("tm-prose-style", "brand new")
);
}
#[test]
fn repair_rewrites_drifted_and_verifies() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "v1", None);
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
false,
backups.path(),
);
let repaired: Vec<&RepairOutcome> = outcomes.iter().filter(|o| o.changed()).collect();
assert_eq!(repaired.len(), 1, "outcomes: {outcomes:?}");
assert_eq!(repaired[0].stem, "tm-workflow");
let on_disk = fs::read_to_string(dest.join("tm-workflow").join("SKILL.md")).unwrap();
assert_eq!(on_disk, "v2");
assert!(
SkillManifest::load(&dest)
.unwrap()
.checksum_matches("tm-workflow", "v2")
);
}
#[test]
fn repair_writes_a_nested_reference_key_to_its_own_path() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(
&dest,
"documentation-style",
"entry v1",
Some(("spec.md", "reference v1")),
);
let outcomes = repair_skills(
&reference_of(&[
("documentation-style", "entry v1"),
("documentation-style/references/spec.md", "reference v2"),
]),
&paths,
None,
false,
backups.path(),
);
let repaired: Vec<&RepairOutcome> = outcomes.iter().filter(|o| o.changed()).collect();
assert_eq!(repaired.len(), 1, "outcomes: {outcomes:?}");
assert_eq!(repaired[0].stem, "documentation-style/references/spec.md");
assert_eq!(
fs::read_to_string(deployed_path(
&dest,
"documentation-style/references/spec.md"
))
.unwrap(),
"reference v2"
);
assert_eq!(
fs::read_to_string(deployed_path(&dest, "documentation-style")).unwrap(),
"entry v1"
);
}
#[test]
fn repair_refuses_a_tier_with_an_unreadable_manifest() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "v1", None);
fs::write(
dest.join(crate::core::skill_manifest::SKILL_MANIFEST_FILE),
"{ not json",
)
.unwrap();
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
true,
backups.path(),
);
assert!(
outcomes.iter().all(|o| !o.changed()),
"nothing may be written into a tier with an unreadable ledger: {outcomes:?}"
);
assert_eq!(
fs::read_to_string(deployed_path(&dest, "tm-workflow")).unwrap(),
"v1",
"the deployed file must be untouched"
);
}
#[test]
fn repair_never_touches_a_frozen_skill_by_default() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "what tm wrote", None);
hand_edit(&dest, "tm-workflow", "hand-edited by the operator");
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
false,
backups.path(),
);
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].action, RepairAction::SkippedFrozen);
let on_disk = fs::read_to_string(dest.join("tm-workflow").join("SKILL.md")).unwrap();
assert_eq!(on_disk, "hand-edited by the operator");
}
#[test]
fn repair_backs_up_before_overwriting() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "what tm wrote", None);
hand_edit(&dest, "tm-workflow", "hand-edited by the operator");
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
true,
backups.path(),
);
let RepairAction::Repaired { backup } = &outcomes[0].action else {
panic!("expected a repair, got {:?}", outcomes[0].action);
};
let backup = backup.as_ref().expect("an existing file must be backed up");
assert_eq!(
fs::read_to_string(backup).unwrap(),
"hand-edited by the operator",
"the backup must hold the operator's original content"
);
assert_eq!(
fs::read_to_string(dest.join("tm-workflow").join("SKILL.md")).unwrap(),
"v2"
);
}
#[cfg(unix)]
#[test]
fn repair_reports_verification_failure() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "v1", None);
let skill_dir = dest.join("tm-workflow");
fs::set_permissions(&skill_dir, fs::Permissions::from_mode(0o500)).unwrap();
let deniable = fs::write(skill_dir.join(".probe"), "x").is_err();
if !deniable {
let _ = fs::remove_file(skill_dir.join(".probe"));
fs::set_permissions(&skill_dir, fs::Permissions::from_mode(0o755)).unwrap();
return;
}
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
true,
backups.path(),
);
fs::set_permissions(&skill_dir, fs::Permissions::from_mode(0o755)).unwrap();
assert!(
matches!(outcomes[0].action, RepairAction::Failed(_)),
"got {:?}",
outcomes[0].action
);
assert!(!outcomes[0].changed());
assert_eq!(
fs::read_to_string(skill_dir.join("SKILL.md")).unwrap(),
"v1"
);
}
#[test]
fn repair_skips_unverifiable_skills() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "my-custom-skill", "operator content", None);
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
true,
backups.path(),
);
assert!(
matches!(outcomes[0].action, RepairAction::SkippedUnverifiable(_)),
"got {:?}",
outcomes[0].action
);
assert_eq!(
fs::read_to_string(dest.join("my-custom-skill").join("SKILL.md")).unwrap(),
"operator content"
);
}
#[test]
fn repair_is_a_noop_when_everything_matches() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let _src = deploy_real(&paths.claude_skills_dir(), "tm-workflow", "v2", None);
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
false,
backups.path(),
);
assert!(outcomes.is_empty(), "outcomes: {outcomes:?}");
assert!(
!backups.path().join("operator-home").exists(),
"a no-op run must not create backup directories"
);
}
#[test]
fn repair_deletes_nothing() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "v1", None);
fs::write(dest.join("README.md"), "operator note").unwrap();
fs::create_dir_all(dest.join("unrelated-dir")).unwrap();
fs::write(dest.join("unrelated-dir").join("keep.txt"), "keep").unwrap();
repair_skills(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
true,
backups.path(),
);
assert!(dest.join("README.md").exists());
assert!(dest.join("unrelated-dir").join("keep.txt").exists());
assert!(dest.join("tm-workflow").join("SKILL.md").exists());
}
#[test]
fn backup_root_follows_the_remediation_convention() {
let now = chrono::DateTime::parse_from_rfc3339("2026-08-02T13:45:06Z")
.unwrap()
.with_timezone(&chrono::Utc);
let root = backup_root_for(Path::new("/home/x"), now);
assert_eq!(
root,
Path::new("/home/x/.trusty-mpm/backup-doctor-remediation-20260802-134506")
);
}
#[test]
fn embedded_reference_is_usable_as_a_repair_source() {
let reference = skill_reference(None, Some(crate::core::content_source::test_support::rc()));
assert!(
reference.assets.contains_key("tm-workflow"),
"the binary must embed tm-workflow for --fix-skills to repair it"
);
assert!(!reference.assets["tm-workflow"].is_empty());
}
#[test]
fn repair_leaves_a_missing_tier_directory_alone() {
let home = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = paths_under(&home);
let backups = TempDir::new().unwrap();
let outcomes = repair_skills(
&reference_of(&[("tm-workflow", "v1")]),
&paths,
Some(project.path()),
false,
backups.path(),
);
assert!(
outcomes.is_empty(),
"a tier with no directory has no findings: {outcomes:?}"
);
let skills_dir = project.path().join(".claude").join("skills");
assert!(
!skills_dir.exists(),
"repair must not create a skills directory it was only inspecting"
);
}
#[test]
fn dry_run_reports_the_same_set_it_would_repair() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "v1", None);
let reference = reference_of(&[("tm-workflow", "v2")]);
let previewed = repair_skills_in_mode(
&reference,
&paths,
None,
false,
backups.path(),
RepairMode::DryRun,
);
assert_eq!(previewed.len(), 1, "{previewed:?}");
assert_eq!(
previewed[0].action,
RepairAction::WouldRepair { existed: true }
);
assert_eq!(previewed[0].path, dest.join("tm-workflow").join("SKILL.md"));
assert!(
!previewed[0].changed(),
"a preview never counts as a change"
);
let applied = repair_skills(&reference, &paths, None, false, backups.path());
let previewed_keys: Vec<_> = previewed
.iter()
.map(|o| (o.tier, &o.stem, &o.path))
.collect();
let applied_keys: Vec<_> = applied.iter().map(|o| (o.tier, &o.stem, &o.path)).collect();
assert_eq!(
previewed_keys, applied_keys,
"the preview and the apply disagreed about what to repair"
);
}
#[test]
fn dry_run_writes_absolutely_nothing() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "v1", None);
let skill = dest.join("tm-workflow").join("SKILL.md");
let manifest_before =
fs::read(dest.join(crate::core::skill_manifest::SKILL_MANIFEST_FILE)).unwrap();
repair_skills_in_mode(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
true,
backups.path(),
RepairMode::DryRun,
);
assert_eq!(
fs::read_to_string(&skill).unwrap(),
"v1",
"the dry run rewrote the skill"
);
assert_eq!(
fs::read(dest.join(crate::core::skill_manifest::SKILL_MANIFEST_FILE)).unwrap(),
manifest_before,
"the dry run rewrote the ownership ledger"
);
assert!(
!backups.path().join("operator-home").exists(),
"the dry run took a backup, which is itself a write"
);
}
#[test]
fn dry_run_still_refuses_a_frozen_skill() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = paths.claude_skills_dir();
let _src = deploy_real(&dest, "tm-workflow", "v1", None);
hand_edit(&dest, "tm-workflow", "the operator's own edit");
let previewed = repair_skills_in_mode(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
None,
false,
backups.path(),
RepairMode::DryRun,
);
assert_eq!(previewed.len(), 1, "{previewed:?}");
assert_eq!(previewed[0].action, RepairAction::SkippedFrozen);
assert_eq!(
fs::read_to_string(dest.join("tm-workflow").join("SKILL.md")).unwrap(),
"the operator's own edit"
);
}
#[test]
fn a_deferred_project_stem_is_not_refreshed() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let dest = crate::core::skill_deploy_tiers::project_skill_tier(project.path());
let _src = deploy_real(&dest, "tm-workflow", "v1", None);
let deferred: BTreeSet<String> = ["tm-workflow".to_string()].into_iter().collect();
let outcomes = repair_skills_in_mode_deferring(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
Some(project.path()),
false,
backups.path(),
RepairMode::Apply,
&deferred,
);
let deferred_outcome = outcomes
.iter()
.find(|o| o.stem == "tm-workflow" && o.tier == "project")
.unwrap_or_else(|| panic!("expected a project-tier outcome: {outcomes:?}"));
assert_eq!(
deferred_outcome.action,
RepairAction::SkippedUnverifiable(DEFERRED_TO_STRAY_SWEEP.to_string()),
"{outcomes:?}"
);
assert_eq!(
fs::read_to_string(deployed_path(&dest, "tm-workflow")).unwrap(),
"v1",
"the copy the sweep is removing must keep the bytes the sweep verified"
);
assert!(
fs::read_dir(backups.path())
.expect("backup root")
.next()
.is_none(),
"a deferred stem is never backed up, because it is never overwritten"
);
}
#[test]
fn a_deferred_stem_is_still_repaired_at_the_user_tier() {
let tmp = TempDir::new().unwrap();
let backups = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = paths_under(&tmp);
let home = paths.claude_skills_dir();
let _home_src = deploy_real(&home, "tm-workflow", "v1", None);
let project_dest = crate::core::skill_deploy_tiers::project_skill_tier(project.path());
let _project_src = deploy_real(&project_dest, "tm-workflow", "v1", None);
let deferred: BTreeSet<String> = ["tm-workflow".to_string()].into_iter().collect();
let outcomes = repair_skills_in_mode_deferring(
&reference_of(&[("tm-workflow", "v2")]),
&paths,
Some(project.path()),
false,
backups.path(),
RepairMode::Apply,
&deferred,
);
assert_eq!(
fs::read_to_string(deployed_path(&home, "tm-workflow")).unwrap(),
"v2",
"the user-tier copy is nobody's stray and must still be repaired: {outcomes:?}"
);
assert_eq!(
fs::read_to_string(deployed_path(&project_dest, "tm-workflow")).unwrap(),
"v1",
"the project-tier copy is the deferred one: {outcomes:?}"
);
}