use super::*;
use std::fs;
fn write_mixed_settings(project: &Path) -> PathBuf {
let claude = project.join(".claude");
fs::create_dir_all(&claude).unwrap();
let path = claude.join("settings.json");
fs::write(
&path,
serde_json::json!({
"model": "opus",
"hooks": {
"PreToolUse": [
{ "hooks": [{ "type": "command", "command": "tm hook" }] },
{ "hooks": [{ "type": "command", "command": "claude-mpm hook" }] }
]
}
})
.to_string(),
)
.unwrap();
path
}
fn temp_repo() -> Option<(tempfile::TempDir, PathBuf)> {
let dir = crate::test_support::hermetic_temp_dir();
let path = dir.path().to_path_buf();
let ok = std::process::Command::new("git")
.args(["init", "-q", "-b", "main"])
.arg(&path)
.status()
.ok()?;
ok.success().then_some((dir, path))
}
#[test]
fn mode_defaults_to_dry_run() {
assert_eq!(RepairMode::from_apply_flag(false), RepairMode::DryRun);
assert_eq!(RepairMode::from_apply_flag(true), RepairMode::Apply);
}
#[test]
fn hooks_repair_applies_and_backs_up() {
let tmp = tempfile::tempdir().unwrap();
let path = write_mixed_settings(tmp.path());
let steps = repair_hooks_contamination(tmp.path(), RepairMode::Apply);
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].check, "hooks_contamination");
assert_eq!(steps[0].path, path);
assert!(steps[0].changed(), "{:?}", steps[0]);
let after: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let groups = after["hooks"]["PreToolUse"].as_array().unwrap();
let commands: Vec<&str> = groups
.iter()
.flat_map(|g| g["hooks"].as_array().unwrap())
.map(|e| e["command"].as_str().unwrap())
.collect();
assert_eq!(
commands,
vec!["claude-mpm hook"],
"only tm's own entry may be removed"
);
assert_eq!(after["model"], "opus", "unrelated keys must survive");
let StepStatus::Applied { backup: Some(bak) } = &steps[0].status else {
panic!("expected a backup path, got {:?}", steps[0].status);
};
assert!(
fs::read_to_string(bak).unwrap().contains("tm hook"),
"the backup must carry what was removed"
);
}
#[test]
fn hooks_repair_dry_run_changes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let path = write_mixed_settings(tmp.path());
let before = fs::read(&path).unwrap();
let steps = repair_hooks_contamination(tmp.path(), RepairMode::DryRun);
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].status, StepStatus::Planned);
assert!(!steps[0].changed());
assert!(
steps[0].what.contains("PreToolUse"),
"the preview must name what it would remove: {}",
steps[0].what
);
assert_eq!(fs::read(&path).unwrap(), before, "dry run rewrote the file");
let stray: Vec<_> = fs::read_dir(tmp.path().join(".claude"))
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".bak"))
.collect();
assert!(stray.is_empty(), "dry run wrote a backup: {stray:?}");
}
#[test]
fn hooks_repair_leaves_foreign_entries_alone() {
let tmp = tempfile::tempdir().unwrap();
let claude = tmp.path().join(".claude");
fs::create_dir_all(&claude).unwrap();
let path = claude.join("settings.json");
let body = serde_json::json!({
"hooks": { "Stop": [{ "hooks": [{ "command": "claude-mpm hook" }] }] }
})
.to_string();
fs::write(&path, &body).unwrap();
let steps = repair_hooks_contamination(tmp.path(), RepairMode::Apply);
assert!(steps.is_empty(), "{steps:?}");
assert_eq!(fs::read_to_string(&path).unwrap(), body);
}
#[test]
fn push_guard_repair_installs_when_missing() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let steps = repair_push_guard(&repo, RepairMode::Apply);
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].check, "push_guard");
assert!(steps[0].changed(), "{:?}", steps[0]);
assert!(
fs::read_to_string(&steps[0].path)
.unwrap()
.contains(crate::core::push_guard::HOOK_MARKER),
"the guard must actually be on disk at the reported path"
);
assert!(repair_push_guard(&repo, RepairMode::Apply).is_empty());
}
#[test]
fn push_guard_repair_dry_run_writes_nothing() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let steps = repair_push_guard(&repo, RepairMode::DryRun);
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].status, StepStatus::Planned);
assert!(
!steps[0].path.exists(),
"dry run installed the hook at {}",
steps[0].path.display()
);
}
#[test]
fn push_guard_repair_refuses_a_foreign_hook() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let hooks = crate::core::push_guard::effective_hooks_dir(&repo).unwrap();
fs::create_dir_all(&hooks).unwrap();
let hook = hooks.join("pre-push");
fs::write(&hook, "#!/bin/sh\n# somebody else's hook\n").unwrap();
for mode in [RepairMode::DryRun, RepairMode::Apply] {
let steps = repair_push_guard(&repo, mode);
assert_eq!(steps.len(), 1, "{steps:?}");
assert!(
matches!(steps[0].status, StepStatus::Refused(_)),
"{mode:?} must refuse a foreign hook, got {:?}",
steps[0].status
);
}
assert_eq!(
fs::read_to_string(&hook).unwrap(),
"#!/bin/sh\n# somebody else's hook\n",
"the foreign hook was modified"
);
}
#[test]
fn legacy_sources_are_refused_never_deleted() {
let tmp = tempfile::tempdir().unwrap();
let skills = tmp.path().join(".claude").join("skills");
for stem in ["tm-workflow", "tm-doctor"] {
let dir = skills.join(stem);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), "hand-edited by the operator").unwrap();
}
fs::create_dir_all(tmp.path().join(".trusty-mpm").join("claude-config")).unwrap();
let steps = refuse_legacy_sources(tmp.path());
assert_eq!(steps.len(), 3, "{steps:?}");
for step in &steps {
assert_eq!(step.check, "legacy_sources");
assert!(
matches!(step.status, StepStatus::Refused(_)),
"every legacy_sources finding must be refused, got {:?}",
step.status
);
assert!(!step.changed());
assert!(step.path.exists(), "{} was deleted", step.path.display());
}
assert!(
skills.join("tm-workflow").join("SKILL.md").is_file(),
"the hand-edited copy must survive verbatim"
);
}
#[test]
fn legacy_sources_ignores_a_foreign_skill() {
let tmp = tempfile::tempdir().unwrap();
let skills = tmp.path().join(".claude").join("skills");
fs::create_dir_all(skills.join("my-own-skill")).unwrap();
assert!(refuse_legacy_sources(tmp.path()).is_empty());
}
#[test]
fn legacy_sources_is_empty_on_a_clean_home() {
let tmp = tempfile::tempdir().unwrap();
assert!(refuse_legacy_sources(tmp.path()).is_empty());
}