use super::*;
use serde_json::json;
use tempfile::TempDir;
fn tm_settings() -> Value {
json!({
"outputStyle": "trusty-mpm",
"hooks": {
"PreToolUse": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": "/usr/local/bin/tm hook", "timeout": 5 }]
}]
}
})
}
fn claude_mpm_settings() -> Value {
json!({
"hooks": {
"SessionStart": [{
"matcher": "",
"hooks": [{ "type": "command", "command": "claude-mpm hooks fire SessionStart", "timeout": 5 }]
}]
}
})
}
fn mixed_group_settings() -> Value {
json!({
"outputStyle": "trusty-mpm",
"hooks": {
"PreToolUse": [{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "/usr/local/bin/tm hook", "timeout": 5 },
{ "type": "command", "command": "claude-mpm hooks fire PreToolUse", "timeout": 5 }
]
}]
}
})
}
#[test]
fn contains_tm_hooks_true_for_tm_entry() {
assert!(contains_tm_hooks(&tm_settings()));
}
#[test]
fn contains_tm_hooks_false_for_foreign_or_absent() {
assert!(!contains_tm_hooks(&claude_mpm_settings()));
assert!(!contains_tm_hooks(&json!({})));
assert!(!contains_tm_hooks(&json!({ "hooks": {} })));
}
#[test]
fn tm_hook_event_names_lists_only_contaminated_events() {
let mut val = tm_settings();
val["hooks"]["SessionStart"] = claude_mpm_settings()["hooks"]["SessionStart"].clone();
let names = tm_hook_event_names(&val);
assert_eq!(names, vec!["PreToolUse".to_string()]);
}
#[test]
fn foreign_hook_event_names_lists_claude_mpm_entries() {
let mut val = claude_mpm_settings();
val["hooks"]["PreToolUse"] = tm_settings()["hooks"]["PreToolUse"].clone();
let names = foreign_hook_event_names(&val);
assert_eq!(names, vec!["SessionStart".to_string()]);
}
#[test]
fn tm_hook_event_names_flags_mixed_group() {
let names = tm_hook_event_names(&mixed_group_settings());
assert_eq!(
names,
vec!["PreToolUse".to_string()],
"a mixed group carrying a tm entry must be flagged as contaminated"
);
}
#[test]
fn foreign_hook_event_names_flags_mixed_group() {
let names = foreign_hook_event_names(&mixed_group_settings());
assert_eq!(
names,
vec!["PreToolUse".to_string()],
"a mixed group carrying a foreign entry must be flagged as a conflict"
);
}
#[test]
fn clean_settings_file_force_strips_tm_entry_from_mixed_group_preserves_foreign() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
let original = serde_json::to_string_pretty(&mixed_group_settings()).unwrap();
std::fs::write(&path, &original).unwrap();
let outcome = clean_settings_file(&path, true).unwrap().unwrap();
assert_eq!(outcome.removed_events, vec!["PreToolUse".to_string()]);
let cleaned: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(
!contains_tm_hooks(&cleaned),
"the tm-owned entry must be gone after cleaning"
);
let groups = cleaned["hooks"]["PreToolUse"]
.as_array()
.expect("the PreToolUse group must survive — it still carries a foreign entry");
assert_eq!(groups.len(), 1, "the group itself must not be dropped");
let inner = groups[0]["hooks"].as_array().unwrap();
assert_eq!(inner.len(), 1, "only the tm entry must be removed");
assert_eq!(
inner[0]["command"].as_str().unwrap(),
"claude-mpm hooks fire PreToolUse",
"the foreign entry must survive untouched"
);
assert_eq!(
groups[0]["matcher"], "*",
"the group's other keys (matcher) must be preserved"
);
}
#[test]
fn clean_settings_file_dry_run_reports_without_writing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
let original = serde_json::to_string_pretty(&tm_settings()).unwrap();
std::fs::write(&path, &original).unwrap();
let outcome = clean_settings_file(&path, false).unwrap().unwrap();
assert_eq!(outcome.removed_events, vec!["PreToolUse".to_string()]);
assert!(outcome.backup_path.is_none(), "dry run must not back up");
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(after, original, "dry run must never mutate the file");
assert!(
!dir.path().join("settings.json.bak").exists(),
"dry run must never write a backup file"
);
}
#[test]
fn clean_settings_file_force_writes_backup_and_strips() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
let original = serde_json::to_string_pretty(&tm_settings()).unwrap();
std::fs::write(&path, &original).unwrap();
let outcome = clean_settings_file(&path, true).unwrap().unwrap();
assert_eq!(outcome.removed_events, vec!["PreToolUse".to_string()]);
let backup = outcome.backup_path.expect("force must write a backup");
assert!(backup.exists());
let backup_contents = std::fs::read_to_string(&backup).unwrap();
assert_eq!(
backup_contents, original,
"backup must be byte-identical to the pre-clean file"
);
let cleaned: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(
!contains_tm_hooks(&cleaned),
"cleaned file must no longer contain tm hooks"
);
}
#[test]
fn clean_settings_file_preserves_non_tm_keys() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
let mut settings = tm_settings();
settings["hooks"]["SessionStart"] = claude_mpm_settings()["hooks"]["SessionStart"].clone();
std::fs::write(&path, serde_json::to_string_pretty(&settings).unwrap()).unwrap();
clean_settings_file(&path, true).unwrap();
let cleaned: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
cleaned["outputStyle"], "trusty-mpm",
"unrelated top-level keys must survive the clean"
);
assert!(
cleaned["hooks"]["SessionStart"].is_array(),
"the foreign claude-mpm SessionStart group must be preserved untouched"
);
assert!(
cleaned["hooks"].get("PreToolUse").is_none(),
"the tm-owned PreToolUse group must be removed"
);
}
#[test]
fn clean_settings_file_missing_file_is_noop() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("does-not-exist.json");
let result = clean_settings_file(&path, true).unwrap();
assert!(result.is_none());
assert!(!path.exists(), "a missing file must never be created");
assert!(
!crate::core::settings_lock::lock_sidecar(&path).exists(),
"no sidecar may be created for a file that does not exist"
);
}
#[test]
fn clean_settings_file_no_tm_hooks_is_noop() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
let original = serde_json::to_string_pretty(&claude_mpm_settings()).unwrap();
std::fs::write(&path, &original).unwrap();
let result = clean_settings_file(&path, true).unwrap();
assert!(
result.is_none(),
"a file with only foreign hooks must never be reported or touched"
);
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(after, original);
}
#[test]
fn clean_settings_file_malformed_json_is_noop() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
let original = "{ not valid json !!";
std::fs::write(&path, original).unwrap();
let result = clean_settings_file(&path, true).unwrap();
assert!(
result.is_none(),
"malformed JSON must never be reported or touched"
);
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, original,
"malformed JSON file must be byte-for-byte unchanged"
);
let has_backup = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().contains(".bak-"));
assert!(
!has_backup,
"a malformed file must never get a backup written"
);
}
#[test]
fn clean_settings_file_non_object_json_is_noop() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
let original = serde_json::to_string_pretty(&json!(["not", "an", "object"])).unwrap();
std::fs::write(&path, &original).unwrap();
let result = clean_settings_file(&path, true).unwrap();
assert!(
result.is_none(),
"non-object top-level JSON must never be reported or touched"
);
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, original,
"non-object JSON file must be byte-for-byte unchanged"
);
let has_backup = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().contains(".bak-"));
assert!(
!has_backup,
"a non-object JSON file must never get a backup written"
);
}
use super::super::build_tree::tests::{INCIDENT_EXE, incident_settings};
#[test]
fn build_tree_hook_commands_lists_the_incident_commands() {
let found = build_tree_hook_commands(&incident_settings());
assert_eq!(
found,
vec![
format!("{INCIDENT_EXE} hook"),
format!("{INCIDENT_EXE} hook --pm-guard"),
],
"every distinct build-tree hook command must be named, once each"
);
}
#[test]
fn build_tree_hook_commands_is_empty_for_an_installed_binary() {
assert!(build_tree_hook_commands(&tm_settings()).is_empty());
assert!(build_tree_hook_commands(&claude_mpm_settings()).is_empty());
}
#[test]
fn build_tree_statusline_command_in_settings_is_reported() {
assert_eq!(
build_tree_statusline_command(&incident_settings()),
Some(format!("{INCIDENT_EXE} statusline"))
);
}
#[test]
fn build_tree_statusline_command_in_settings_ignores_an_installed_binary() {
let val = json!({
"statusLine": { "type": "command", "command": "/usr/local/bin/tm statusline" }
});
assert_eq!(build_tree_statusline_command(&val), None);
assert_eq!(build_tree_statusline_command(&tm_settings()), None);
}
#[test]
fn strips_pm_guard_entry_is_true_only_for_a_removable_guard() {
assert!(strips_pm_guard_entry(&incident_settings()));
let installed = json!({
"hooks": {
"PreToolUse": [{
"hooks": [{ "command": "/usr/local/bin/tm hook --pm-guard" }]
}]
}
});
assert!(!strips_pm_guard_entry(&installed));
assert!(!strips_pm_guard_entry(&tm_settings()));
assert!(!strips_pm_guard_entry(&json!({})));
}
#[test]
fn clean_settings_file_flags_a_removed_pm_guard_entry() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, incident_settings().to_string()).unwrap();
let outcome = clean_settings_file(&path, false).unwrap().unwrap();
assert!(
outcome.removed_pm_guard,
"the incident file's guard is removable: {outcome:?}"
);
std::fs::write(&path, tm_settings().to_string()).unwrap();
let outcome = clean_settings_file(&path, false).unwrap().unwrap();
assert!(!outcome.removed_pm_guard, "{outcome:?}");
}
#[test]
fn tm_hook_event_names_flags_the_build_tree_incident() {
let mut events = tm_hook_event_names(&incident_settings());
events.sort();
assert_eq!(
events,
vec![
"PostToolUse",
"PreToolUse",
"SessionEnd",
"SessionStart",
"Stop",
"SubagentStop",
]
);
}
#[test]
fn clean_settings_file_force_removes_the_build_tree_incident() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(
&path,
serde_json::to_string_pretty(&incident_settings()).unwrap(),
)
.unwrap();
let outcome = clean_settings_file(&path, true)
.unwrap()
.expect("the incident shape must be reported as contamination");
assert!(outcome.backup_path.is_some(), "a --force pass must back up");
let cleaned: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(
build_tree_hook_commands(&cleaned).is_empty(),
"no build-tree hook command may survive: {cleaned}"
);
assert_eq!(
cleaned["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
json!("trusty-memory prompt-context")
);
assert_eq!(cleaned["outputStyle"], json!("trusty-mpm"));
assert_eq!(
build_tree_statusline_command(&cleaned),
Some(format!("{INCIDENT_EXE} statusline"))
);
}