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());
}
fn deploy_styles(home: &Path) {
crate::core::output_style_deployer::deploy_output_styles(&home.join(".claude")).unwrap();
}
fn style_path(home: &Path, index: usize) -> PathBuf {
home.join(".claude")
.join("output-styles")
.join(crate::core::bundle::OUTPUT_STYLES[index].file_name)
}
#[test]
fn output_style_remedy_names_the_fix_command() {
assert_eq!(OUTPUT_STYLE_REMEDY, "tm doctor --fix --yes");
assert!(
!OUTPUT_STYLE_REMEDY.contains("tm install"),
"#5866: `tm install` has no output-style step"
);
}
#[test]
fn output_style_repair_is_empty_when_in_sync() {
let home = tempfile::tempdir().unwrap();
deploy_styles(home.path());
assert!(
repair_output_style(home.path(), RepairMode::DryRun).is_empty(),
"an in-sync tier produces no step, so `--fix` prints nothing about it"
);
}
#[test]
fn output_style_repair_plans_the_drifted_file() {
let home = tempfile::tempdir().unwrap();
deploy_styles(home.path());
let target = style_path(home.path(), 0);
fs::write(&target, "stale text").unwrap();
let steps = repair_output_style(home.path(), RepairMode::DryRun);
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].check, "output_style_staleness");
assert_eq!(steps[0].path, target);
assert_eq!(steps[0].status, StepStatus::Planned);
}
#[test]
fn output_style_repair_dry_run_writes_nothing() {
let home = tempfile::tempdir().unwrap();
deploy_styles(home.path());
let target = style_path(home.path(), 0);
fs::write(&target, "stale text").unwrap();
repair_output_style(home.path(), RepairMode::DryRun);
assert_eq!(
fs::read_to_string(&target).unwrap(),
"stale text",
"a dry run must leave the file exactly as it found it"
);
}
#[test]
fn output_style_repair_applies_and_reports_from_disk() {
let home = tempfile::tempdir().unwrap();
deploy_styles(home.path());
let drifted = style_path(home.path(), 0);
let missing = style_path(home.path(), 1);
fs::write(&drifted, "stale text").unwrap();
fs::remove_file(&missing).unwrap();
let steps = repair_output_style(home.path(), RepairMode::Apply);
assert_eq!(steps.len(), 2, "{steps:?}");
assert!(
steps.iter().all(RepairStep::changed),
"both a drifted and an absent style must be written: {steps:?}"
);
assert_eq!(
fs::read_to_string(&drifted).unwrap(),
crate::core::bundle::OUTPUT_STYLES[0].content
);
assert_eq!(
fs::read_to_string(&missing).unwrap(),
crate::core::bundle::OUTPUT_STYLES[1].content
);
assert!(
repair_output_style(home.path(), RepairMode::DryRun).is_empty(),
"the repair must actually clear the finding it reported"
);
}
#[cfg(unix)]
#[test]
fn one_unreadable_style_does_not_fail_a_sibling_that_was_written() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
deploy_styles(home.path());
let drifted = style_path(home.path(), 0);
let blocked = style_path(home.path(), 1);
fs::write(&drifted, "stale text").unwrap();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap();
if fs::read(&blocked).is_ok() {
eprintln!("skipping: cannot deny read on this platform/privilege level");
return;
}
let steps = repair_output_style(home.path(), RepairMode::Apply);
let _ = fs::set_permissions(&blocked, fs::Permissions::from_mode(0o600));
let drifted_step = steps
.iter()
.find(|s| s.path == drifted)
.unwrap_or_else(|| panic!("the drifted style must produce a step: {steps:?}"));
assert_eq!(
drifted_step.status,
StepStatus::Applied { backup: None },
"a style that was rewritten must report as written: {steps:?}"
);
assert_eq!(
fs::read_to_string(&drifted).unwrap(),
crate::core::bundle::OUTPUT_STYLES[0].content,
"and the report must match what is on disk"
);
let blocked_step = steps
.iter()
.find(|s| s.path == blocked)
.unwrap_or_else(|| panic!("the unreadable style must produce a step: {steps:?}"));
assert!(
matches!(blocked_step.status, StepStatus::Refused(_)),
"the unreadable style owns its own outcome: {steps:?}"
);
assert!(
!steps
.iter()
.any(|s| matches!(s.status, StepStatus::Failed(_))),
"no step may inherit a sibling's failure: {steps:?}"
);
}
#[cfg(unix)]
#[test]
fn output_style_repair_refuses_an_unreadable_file() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
deploy_styles(home.path());
let target = style_path(home.path(), 0);
fs::set_permissions(&target, fs::Permissions::from_mode(0o000)).unwrap();
if fs::read(&target).is_ok() {
eprintln!("skipping: cannot deny read on this platform/privilege level");
return;
}
let steps = repair_output_style(home.path(), RepairMode::Apply);
let _ = fs::set_permissions(&target, fs::Permissions::from_mode(0o600));
assert_eq!(steps.len(), 1, "{steps:?}");
assert!(
matches!(steps[0].status, StepStatus::Refused(_)),
"an unreadable file must be refused, never overwritten: {steps:?}"
);
assert!(!steps[0].changed());
}