use super::*;
use tempfile::TempDir;
#[test]
fn project_managed_hook_additions_combines_all_three_sources() {
let additions = project_managed_hook_additions();
let hooks = additions["hooks"]
.as_object()
.expect("hooks must be an object");
assert_eq!(
hooks["UserPromptSubmit"][0]["hooks"][0]["command"],
serde_json::json!("trusty-memory prompt-context")
);
let pre = hooks["PreToolUse"]
.as_array()
.expect("PreToolUse must be an array");
assert_eq!(pre.len(), 2, "PreToolUse must carry guard + triad groups");
let pre_commands: Vec<&str> = pre
.iter()
.map(|g| g["hooks"][0]["command"].as_str().unwrap())
.collect();
assert!(
pre_commands.iter().any(|c| c.ends_with(" hook --pm-guard")),
"PM-guard command must be present: {pre_commands:?}"
);
assert!(
pre_commands.iter().any(|c| c.ends_with(" hook")),
"lifecycle-triad command must be present: {pre_commands:?}"
);
let session_start = hooks["SessionStart"]
.as_array()
.expect("SessionStart must be an array");
assert_eq!(session_start.len(), 2);
for event in ["PostToolUse", "Stop", "SubagentStop", "SessionEnd"] {
assert!(
hooks
.get(event)
.and_then(serde_json::Value::as_array)
.is_some_and(|a| !a.is_empty()),
"{event} must carry the lifecycle-triad group"
);
}
}
#[test]
fn project_managed_hook_additions_is_stable_across_calls() {
let first = project_managed_hook_additions();
let second = project_managed_hook_additions();
assert_eq!(first, second);
}
#[test]
fn is_project_managed_hook_command_recognises_all_three_sources() {
assert!(is_project_managed_hook_command("trusty-mpm hook"));
assert!(is_project_managed_hook_command("/opt/bin/tm hook"));
assert!(is_project_managed_hook_command(
"trusty-memory prompt-context"
));
assert!(is_project_managed_hook_command("trusty-memory inbox-check"));
assert!(is_project_managed_hook_command(
"/opt/bin/tm hook --pm-guard"
));
assert!(
!is_project_managed_hook_command("claude-mpm hooks fire PreToolUse"),
"a foreign command must never be recognised as project-managed"
);
assert!(
!is_project_managed_hook_command("some-other-tool run"),
"an unrelated command must never be recognised as project-managed"
);
}
#[test]
fn write_project_hooks_writes_lifecycle_triad() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
super::super::settings::write_project_hooks(project).expect("write succeeds");
let value: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(project.join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
let hooks = value["hooks"].as_object().expect("hooks must be an object");
for event in [
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"SessionStart",
"SessionEnd",
] {
let groups = hooks
.get(event)
.and_then(serde_json::Value::as_array)
.unwrap_or_else(|| panic!("{event} must be present"));
assert!(
groups.iter().any(|g| g["hooks"][0]["command"]
.as_str()
.is_some_and(|c| c.ends_with(" hook"))),
"{event} must carry the lifecycle-triad `... hook` command"
);
}
}
#[test]
fn write_project_hooks_preserves_foreign_hooks() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let claude_dir = project.join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": "claude-mpm hooks fire PreToolUse", "timeout": 5 }]
}]
}
}))
.unwrap(),
)
.unwrap();
super::super::settings::write_project_hooks(project).expect("write succeeds");
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(claude_dir.join("settings.json")).unwrap())
.unwrap();
let pre = value["hooks"]["PreToolUse"]
.as_array()
.expect("PreToolUse must be an array");
let commands: Vec<&str> = pre
.iter()
.map(|g| g["hooks"][0]["command"].as_str().unwrap())
.collect();
assert!(
commands.contains(&"claude-mpm hooks fire PreToolUse"),
"the foreign entry must survive: {commands:?}"
);
assert!(
commands.iter().any(|c| c.ends_with(" hook --pm-guard")),
"the PM-guard entry must also be present: {commands:?}"
);
super::super::settings::write_project_hooks(project).expect("second write succeeds");
let value2: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(claude_dir.join("settings.json")).unwrap())
.unwrap();
let pre2 = value2["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(
pre2.len(),
pre.len(),
"re-running must not duplicate any group"
);
}
#[test]
fn write_project_hooks_writes_via_atomic_path() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let claude_dir = project.join(".claude");
let settings_path = claude_dir.join("settings.json");
let bak_path = claude_dir.join("settings.json.bak");
let tmp_path = claude_dir.join("settings.json.tmp");
super::super::settings::write_project_hooks(project).expect("first write succeeds");
assert!(settings_path.exists(), "settings.json must be created");
assert!(
!bak_path.exists(),
"no backup expected on first write (file did not previously exist)"
);
assert!(
!tmp_path.exists(),
"the .tmp file must be renamed away, never left behind"
);
let first_content = std::fs::read_to_string(&settings_path).unwrap();
super::super::settings::write_project_hooks(project).expect("second write succeeds");
assert!(
bak_path.exists(),
"write_json_atomic must back up the prior file before replacing it"
);
assert!(
!tmp_path.exists(),
"the .tmp file must be renamed away, never left behind"
);
let backup_content = std::fs::read_to_string(&bak_path).unwrap();
assert_eq!(
backup_content, first_content,
"the backup must be a byte-identical copy of the pre-second-write content"
);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(value["hooks"]["SessionStart"].is_array());
}