use super::*;
use tempfile::TempDir;
const STABLE_TEST_EXE: &str = "/usr/local/bin/tm";
fn assert_never_baked(result: &Result<String, StableHookExeError>, rejected: &Path) {
let Ok(cmd) = result else {
return;
};
assert!(
cmd.ends_with(" hook"),
"a resolved hook command must end with ' hook', got: {cmd:?}"
);
assert!(
!cmd.contains(&rejected.display().to_string()),
"{} must never be baked into a hook command, got: {cmd:?}",
rejected.display()
);
}
#[test]
fn test_hook_command_uses_absolute_path() {
let fake_exe = PathBuf::from("/usr/local/bin/trusty-mpm");
let cmd = mpm_hook_command(Some(&fake_exe)).expect("an installed-looking override resolves");
assert!(
cmd.starts_with('/'),
"hook command must start with '/' (absolute path), got: {cmd:?}"
);
assert!(
cmd.ends_with(" hook"),
"hook command must end with ' hook', got: {cmd:?}"
);
assert_eq!(cmd, "/usr/local/bin/trusty-mpm hook");
}
#[test]
fn test_hook_command_without_override_never_names_the_test_binary() {
let exe = std::env::current_exe().expect("current_exe resolvable under cargo test");
assert_never_baked(&mpm_hook_command(None), &exe);
}
#[test]
fn test_hook_command_rejects_ephemeral_exe_override() {
for ephemeral in [
PathBuf::from("/Users/x/trusty-tools/target/debug/deps/trusty_mpm-deadbeef"),
PathBuf::from("/Users/x/trusty-tools/target-7224/debug/deps/trusty_mpm-deadbeef"),
] {
assert_never_baked(&mpm_hook_command(Some(&ephemeral)), &ephemeral);
}
}
#[test]
fn test_hook_command_rejects_system_temp_exe_override() {
for ephemeral in [
PathBuf::from("/private/tmp/claude-502/-Users-x-proj/9f1c/scratchpad/base-bins/trusty-mpm"),
PathBuf::from("/tmp/trusty-mpm"),
std::env::temp_dir().join("claude-4485/base-bins/trusty-mpm"),
] {
assert_never_baked(&mpm_hook_command(Some(&ephemeral)), &ephemeral);
}
}
#[test]
fn resolve_stable_hook_exe_with_refuses_a_foreign_binary_stem() {
let foreign = PathBuf::from("/usr/local/bin/test_session_lifecycle-cd3ba8f03938239b");
let err = resolve_stable_hook_exe_with(Some(foreign.clone()), |_| None)
.expect_err("a non-tm binary must never become the hook command");
assert!(
matches!(&err, StableHookExeError::ForeignBinary(p) if *p == foreign),
"expected ForeignBinary({}), got {err:?}",
foreign.display()
);
}
#[test]
fn resolve_stable_hook_exe_with_refuses_an_ephemeral_exe() {
let ephemeral =
PathBuf::from("/Users/x/trusty-tools/target-7224/debug/deps/trusty_mpm-cd3ba8f0");
let err = resolve_stable_hook_exe_with(Some(ephemeral.clone()), |_| None)
.expect_err("a build artifact must never become the hook command");
assert!(
matches!(&err, StableHookExeError::Ephemeral(p) if *p == ephemeral),
"expected Ephemeral({}), got {err:?}",
ephemeral.display()
);
}
#[test]
fn resolve_stable_hook_exe_with_refuses_an_ephemeral_path_lookup_hit() {
let from_path = PathBuf::from("/Users/x/trusty-tools/target-7244/debug/tm");
let refused = PathBuf::from("/Users/x/trusty-tools/target-7224/debug/deps/tm-cd3ba8f0");
let err = resolve_stable_hook_exe_with(Some(refused.clone()), |_| Some(from_path.clone()))
.expect_err("a build-tree binary on PATH must never become the hook command");
assert!(
matches!(&err, StableHookExeError::Ephemeral(p) if *p == refused),
"expected Ephemeral({}), got {err:?}",
refused.display()
);
}
#[test]
fn resolve_stable_hook_exe_with_refuses_a_foreign_path_lookup_hit() {
let from_path = PathBuf::from("/usr/local/bin/session_manager_mvp-cd3ba8f0");
let err = resolve_stable_hook_exe_with(None, |_| Some(from_path.clone()))
.expect_err("a non-tm binary on PATH must never become the hook command");
assert!(
matches!(&err, StableHookExeError::Unresolved),
"with no running path to blame the refusal is Unresolved, got {err:?}"
);
}
#[test]
fn resolve_stable_hook_exe_with_falls_back_to_the_installed_binary() {
let installed = PathBuf::from(STABLE_TEST_EXE);
let refused = PathBuf::from("/Users/x/trusty-tools/target-7224/debug/deps/tm-cd3ba8f0");
let resolved = resolve_stable_hook_exe_with(Some(refused.clone()), |_| Some(installed.clone()))
.expect("an installed binary on PATH resolves");
assert_eq!(
resolved, installed,
"the PATH-resolved install must win over a refused running path"
);
}
#[test]
fn is_mpm_bin_stem_path_accepts_the_shipped_names() {
for name in [
"tm",
"trusty-mpm",
"trusty_mpm",
"tm-1a2b3c4d5e6f7a8b",
"trusty_mpm-1a2b3c4d",
] {
assert!(
is_mpm_bin_stem_path(&PathBuf::from("/usr/local/bin").join(name)),
"{name} is a binary this crate ships and must be recognised"
);
}
for name in [
"test_session_lifecycle-cd3ba8f03938239b",
"session_manager_mvp",
"session_manager_mvp-deadbeef",
"tm-cli",
"trusty-mpmx",
"claude",
] {
assert!(
!is_mpm_bin_stem_path(&PathBuf::from("/usr/local/bin").join(name)),
"{name} is not a binary this crate ships and must be refused"
);
}
}
#[test]
fn write_project_hooks_writes_nothing_when_the_exe_cannot_be_resolved() {
let tmp = TempDir::new().expect("tempdir");
let existing = tmp.path().join("settings.json");
let before = r#"{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"/usr/local/bin/tm hook --pm-guard"}]}]}}"#;
std::fs::write(&existing, before).expect("seed settings");
let refusal = Err(StableHookExeError::ForeignBinary(PathBuf::from(
"/x/target-7224/debug/deps/test_session_lifecycle-cd3ba8f0",
)));
let err = write_project_hooks_with(&existing, refusal)
.expect_err("a refusal must reach the caller, not be written");
assert!(
err.to_string().contains("test_session_lifecycle"),
"the error must name the binary it refused, got: {err}"
);
assert_eq!(
std::fs::read_to_string(&existing).expect("read back"),
before,
"an existing settings file must be byte-identical after a refusal"
);
let missing = tmp.path().join("absent").join("settings.json");
let refusal = Err(StableHookExeError::Unresolved);
write_project_hooks_with(&missing, refusal).expect_err("a refusal must reach the caller");
assert!(
!missing.exists() && !missing.parent().expect("parent").exists(),
"a refusal must not create the settings file or its directory"
);
}
#[test]
fn test_mpm_hook_additions_has_six_events() {
let v = mpm_hook_additions_with_exe(Some(Path::new(STABLE_TEST_EXE)))
.expect("a pinned installed-looking exe always resolves");
let hooks = v.get("hooks").expect("missing 'hooks' key");
assert!(hooks.get("PreToolUse").is_some(), "missing PreToolUse");
assert!(hooks.get("PostToolUse").is_some(), "missing PostToolUse");
assert!(hooks.get("Stop").is_some(), "missing Stop");
assert!(
hooks.get("SubagentStop").is_some(),
"missing SubagentStop (#2610)"
);
assert!(
hooks.get("SessionStart").is_some(),
"missing SessionStart (#1744)"
);
assert!(
hooks.get("SessionEnd").is_some(),
"missing SessionEnd (#1744)"
);
}
#[test]
fn test_mpm_hook_additions_with_exe_embeds_absolute_path() {
let fake_exe = PathBuf::from("/home/user/.cargo/bin/trusty-mpm");
let v = mpm_hook_additions_with_exe(Some(&fake_exe))
.expect("an installed-looking override resolves");
let hooks = v.get("hooks").expect("missing 'hooks' key");
for event in &[
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"SessionStart",
"SessionEnd",
] {
let cmd = hooks[event][0]["hooks"][0]["command"]
.as_str()
.unwrap_or_default();
assert!(
cmd.starts_with('/'),
"event {event}: command must be absolute, got: {cmd:?}"
);
}
}
#[test]
fn test_ensure_managed_hooks_writes_triad() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
std::fs::write(cfg.join("settings.json"), "{}\n").unwrap();
ensure_managed_hooks_with_exe(&cfg, Some(std::path::Path::new("/usr/local/bin/tm"))).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let hooks = val
.get("hooks")
.expect("settings.json must contain 'hooks'");
assert!(
hooks.get("PreToolUse").is_some(),
"settings.json must contain hooks.PreToolUse after ensure_managed_hooks"
);
assert!(
hooks.get("PostToolUse").is_some(),
"settings.json must contain hooks.PostToolUse after ensure_managed_hooks"
);
assert!(
hooks.get("Stop").is_some(),
"settings.json must contain hooks.Stop after ensure_managed_hooks"
);
assert!(
hooks.get("SessionStart").is_some(),
"settings.json must contain hooks.SessionStart after ensure_managed_hooks (#1744)"
);
assert!(
hooks.get("SessionEnd").is_some(),
"settings.json must contain hooks.SessionEnd after ensure_managed_hooks (#1744)"
);
let pre = hooks["PreToolUse"].as_array().unwrap();
let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
assert!(
cmd.ends_with(" hook"),
"hook command must end with ' hook', got: {cmd:?}"
);
}
#[test]
fn test_ensure_managed_hooks_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
std::fs::write(cfg.join("settings.json"), "{}\n").unwrap();
ensure_managed_hooks_with_exe(&cfg, Some(std::path::Path::new("/usr/local/bin/tm"))).unwrap();
let after_first = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
ensure_managed_hooks_with_exe(&cfg, Some(std::path::Path::new("/usr/local/bin/tm"))).unwrap();
let after_second = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
assert_eq!(
after_first, after_second,
"ensure_managed_hooks must be idempotent: calling twice must not change settings.json"
);
let val: serde_json::Value = serde_json::from_str(&after_second).unwrap();
let pre = val["hooks"]["PreToolUse"].as_array().unwrap();
let pre_hook_count = pre
.iter()
.filter(|g| {
g.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|cmds| {
cmds.iter().any(|c| {
c.get("command")
.and_then(|v| v.as_str())
.is_some_and(|s| s.ends_with(" hook"))
})
})
})
.count();
assert_eq!(
pre_hook_count, 1,
"PreToolUse must have exactly one trusty-mpm hook group after two calls"
);
}
#[test]
fn test_ensure_managed_hooks_preserves_existing_keys() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
std::fs::write(
cfg.join("settings.json"),
r#"{"outputStyle":"trusty-mpm","someOtherKey":42}"#,
)
.unwrap();
ensure_managed_hooks_with_exe(&cfg, Some(std::path::Path::new("/usr/local/bin/tm"))).unwrap();
let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val.get("outputStyle").and_then(|v| v.as_str()),
Some("trusty-mpm"),
"outputStyle key must be preserved"
);
assert_eq!(
val.get("someOtherKey").and_then(|v| v.as_i64()),
Some(42),
"someOtherKey must be preserved"
);
assert!(val.get("hooks").is_some(), "hooks must be present");
}
#[test]
fn test_strip_mpm_hook_entries_removes_only_mpm_entries() {
let mut val = serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "trusty-mpm hook" }]
},
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "other-tool run" }]
}
],
"SessionStart": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "trusty-mpm hook" }]
}
]
}
});
let changed = strip_mpm_hook_entries(&mut val);
assert!(changed, "must report change when MPM entries removed");
let pre = val["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre.len(), 1, "non-MPM entry must survive");
assert_eq!(
pre[0]["hooks"][0]["command"].as_str().unwrap(),
"other-tool run"
);
assert!(
val["hooks"].get("SessionStart").is_none(),
"empty event key must be removed"
);
}
#[test]
fn test_strip_mpm_hook_entries_removes_only_tm_entry_from_mixed_group() {
let mut val = serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "trusty-mpm hook" },
{ "type": "command", "command": "claude-mpm hooks fire PreToolUse" }
]
}
]
}
});
let changed = strip_mpm_hook_entries(&mut val);
assert!(changed, "must report change when the tm entry is stripped");
let groups = val["hooks"]["PreToolUse"]
.as_array()
.expect("the mixed group must survive — it still carries a foreign entry");
assert_eq!(groups.len(), 1, "the group must not be dropped wholesale");
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"
);
}
#[test]
fn test_strip_mpm_hook_entries_recognises_abspath_variants() {
let mut val = serde_json::json!({
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "/home/user/.cargo/bin/trusty-mpm hook" }]
}
]
}
});
let changed = strip_mpm_hook_entries(&mut val);
assert!(changed);
assert!(val.get("hooks").is_none(), "hooks key must be removed");
}
#[test]
fn test_write_project_hooks_targets_project_dir() {
let tmp = TempDir::new().unwrap();
let project_settings = tmp.path().join(".claude").join("settings.json");
let fake_exe = PathBuf::from("/fake/bin/trusty-mpm");
let wrote = write_project_hooks(&project_settings, Some(&fake_exe)).unwrap();
assert!(wrote, "must report file was written");
assert!(
project_settings.exists(),
"project settings file must exist"
);
let text = std::fs::read_to_string(&project_settings).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let hooks = val.get("hooks").expect("hooks key must be present");
assert!(hooks.get("PreToolUse").is_some());
let cmd = hooks["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(
cmd.starts_with('/'),
"command must be absolute, got: {cmd:?}"
);
}
#[test]
fn test_write_project_hooks_is_idempotent() {
let tmp = TempDir::new().unwrap();
let settings = tmp.path().join("settings.json");
let fake_exe = PathBuf::from("/fake/bin/trusty-mpm");
write_project_hooks(&settings, Some(&fake_exe)).unwrap();
let content_first = std::fs::read_to_string(&settings).unwrap();
let wrote_second = write_project_hooks(&settings, Some(&fake_exe)).unwrap();
assert!(!wrote_second, "second call must be a no-op");
let content_second = std::fs::read_to_string(&settings).unwrap();
assert_eq!(content_first, content_second, "file must not change");
}
#[test]
fn test_is_mpm_hook_command_recognises_tm_bin_name() {
assert!(is_mpm_hook_command("tm hook"), "bare 'tm' must match");
assert!(
is_mpm_hook_command("/opt/old/bin/tm hook"),
"absolute path ending in '/tm' must match"
);
assert!(
is_mpm_hook_command("trusty-mpm hook"),
"bare 'trusty-mpm' must still match"
);
assert!(
is_mpm_hook_command("/opt/new/bin/trusty-mpm hook"),
"absolute path ending in '/trusty-mpm' must still match"
);
assert!(
!is_mpm_hook_command("tm status"),
"must stay scoped to the ' hook' subcommand, not any 'tm' invocation"
);
assert!(
!is_mpm_hook_command("other-tool hook"),
"unrelated binaries named neither 'tm' nor 'trusty-mpm' must not match"
);
}
#[test]
fn test_mpm_bin_names_matches_own_binary_names_set() {
let mut mpm_bin_names: Vec<&str> = MPM_BIN_NAMES.to_vec();
mpm_bin_names.sort_unstable();
let mut own_binary_names: Vec<&str> = crate::core::own_binary_names::OWN_BINARY_NAMES.to_vec();
own_binary_names.sort_unstable();
assert_eq!(
mpm_bin_names, own_binary_names,
"MPM_BIN_NAMES and OWN_BINARY_NAMES must stay set-equal even though their order differs"
);
}
#[test]
fn test_mpm_bin_names_prefers_full_name_over_short_alias() {
assert_eq!(
MPM_BIN_NAMES,
&["trusty-mpm", "tm"],
"resolve_stable_hook_exe takes the first PATH hit — 'trusty-mpm' must stay first"
);
}
#[test]
fn test_is_mpm_hook_command_recognises_stale_hash_and_mvp_variants() {
assert!(
is_mpm_hook_command("/x/target/debug/deps/trusty_mpm-1a2b3c4d5e6f7a8b hook"),
"underscore dep artifact with hex hash must match"
);
assert!(
is_mpm_hook_command("/x/target/debug/deps/tm-1a2b3c4d5e6f7a8b hook"),
"tm alias dep artifact with hex hash must match"
);
assert!(
is_mpm_hook_command("/x/target/debug/deps/trusty-mpm-1a2b3c4d hook"),
"dash-spelled artifact with hex hash must match"
);
assert!(
is_mpm_hook_command("session_manager_mvp hook"),
"bare defunct MVP name must match"
);
assert!(
is_mpm_hook_command("/x/deps/session_manager_mvp-deadbeef hook"),
"hash-suffixed defunct MVP name must match"
);
assert!(
!is_mpm_hook_command("/usr/bin/tm-cli hook"),
"look-alike 'tm-cli' (non-hex suffix) must not be mis-identified as mpm"
);
assert!(
!is_mpm_hook_command("/usr/bin/trusty-mpmx hook"),
"unrelated 'trusty-mpmx' must not match"
);
}
#[test]
fn test_is_mpm_hook_command_rejects_hash_suffixed_binary_outside_deps_dir() {
assert!(
!is_mpm_hook_command("/usr/local/bin/tm-a1b2c3d4 hook"),
"hash-suffixed binary outside any deps/ dir must NOT be treated as tm-owned"
);
assert!(
!is_mpm_hook_command("/opt/foreign-tool/trusty_mpm-deadbeef hook"),
"coincidental stem+hash match outside deps/ must NOT be treated as tm-owned"
);
assert!(
is_mpm_hook_command("/some/other/deps/tm-a1b2c3d4 hook"),
"hash-suffixed binary under a deps/ dir must still match"
);
}
#[test]
fn test_is_claude_mpm_hook_command_recognises_foreign_signatures() {
assert!(is_claude_mpm_hook_command(
"claude-mpm hooks fire PreToolUse"
));
assert!(is_claude_mpm_hook_command(
"/Users/x/.claude-mpm/scripts/hook_handler.sh PreToolUse"
));
assert!(is_claude_mpm_hook_command("claude_mpm hooks fire Stop"));
assert!(is_claude_mpm_hook_command("CLAUDE-MPM HOOKS FIRE STOP"));
assert!(!is_claude_mpm_hook_command("trusty-memory prompt-context"));
assert!(!is_claude_mpm_hook_command("some-other-tool run"));
}
#[test]
fn is_claude_mpm_hook_command_recognises_the_bare_claude_hook_shim() {
assert!(is_claude_mpm_hook_command("claude-hook"));
assert!(is_claude_mpm_hook_command("claude-hook PreToolUse"));
assert!(is_claude_mpm_hook_command("claude_hook Stop"));
assert!(is_claude_mpm_hook_command(
"/Users/x/.local/share/uv/tools/claude-mpm/bin/claude-hook PreToolUse"
));
assert!(!is_claude_mpm_hook_command("/usr/bin/env claude-hook"));
assert!(!is_claude_mpm_hook_command("some-tool claude-hook"));
assert!(!is_claude_mpm_hook_command("claude-hooks-lint run"));
}
#[test]
fn is_claude_mpm_hook_command_finds_the_shim_behind_a_spaced_path() {
assert!(is_claude_mpm_hook_command(
"/Users/Some Name/.local/bin/claude-hook PreToolUse"
));
assert!(is_claude_mpm_hook_command(
"/Users/x/Library/Application Support/foo/claude_hook Stop"
));
assert!(!is_claude_mpm_hook_command(
"/usr/bin/foo --config /opt/claude-hook"
));
assert!(!is_claude_mpm_hook_command(
"/Users/Some Name/bin/other PreToolUse"
));
}
#[test]
fn test_is_claude_mpm_hook_command_never_overlaps_tm() {
let tm_commands = [
"tm hook",
"/opt/bin/trusty-mpm hook",
"/x/target/debug/deps/trusty_mpm-1a2b3c4d5e6f7a8b hook",
"session_manager_mvp hook",
];
for cmd in tm_commands {
assert!(is_mpm_hook_command(cmd), "sanity: {cmd} must be tm-owned");
assert!(
!is_claude_mpm_hook_command(cmd),
"tm-owned command {cmd} must never also be classified as claude-mpm"
);
}
let foreign_commands = [
"claude-mpm hooks fire PreToolUse",
"/Users/x/.claude-mpm/scripts/hook_handler.sh PreToolUse",
];
for cmd in foreign_commands {
assert!(
is_claude_mpm_hook_command(cmd),
"sanity: {cmd} must be claude-mpm-owned"
);
assert!(
!is_mpm_hook_command(cmd),
"claude-mpm command {cmd} must never also be classified as tm-owned"
);
}
}
#[test]
fn test_write_project_hooks_collapses_stale_hash_and_mvp_entries() {
let tmp = TempDir::new().unwrap();
let settings = tmp.path().join("settings.json");
std::fs::write(
&settings,
serde_json::json!({
"hooks": {
"PreToolUse": [
{ "matcher": "*", "hooks": [{ "type": "command",
"command": "/a/target/debug/deps/trusty_mpm-1111111111111111 hook" }] },
{ "matcher": "*", "hooks": [{ "type": "command",
"command": "/b/target/debug/deps/tm-2222222222222222 hook" }] },
{ "matcher": "*", "hooks": [{ "type": "command",
"command": "/c/deps/session_manager_mvp-deadbeef hook" }] },
{ "matcher": "*", "hooks": [{ "type": "command",
"command": "tm hook" }] },
{ "matcher": "*", "hooks": [{ "type": "command",
"command": "trusty-memory inbox-check" }] }
]
}
})
.to_string(),
)
.unwrap();
let exe = PathBuf::from("/home/user/.cargo/bin/trusty-mpm");
write_project_hooks(&settings, Some(&exe)).unwrap();
let text = std::fs::read_to_string(&settings).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let pre = val["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(
pre.len(),
2,
"all stale MPM variants must collapse to one canonical group, found {}: {pre:?}",
pre.len()
);
let cmds: Vec<&str> = pre
.iter()
.filter_map(|g| g["hooks"][0]["command"].as_str())
.collect();
assert!(
cmds.contains(&"trusty-memory inbox-check"),
"non-MPM group must survive, got: {cmds:?}"
);
for stale in [
"trusty_mpm-1111111111111111",
"tm-2222222222222222",
"session_manager_mvp",
] {
assert!(
!cmds.iter().any(|c| c.contains(stale)),
"stale variant {stale:?} must be stripped, got: {cmds:?}"
);
}
assert!(
cmds.iter().any(|c| c.contains("/.cargo/bin/trusty-mpm")),
"canonical resolved exe path must be present, got: {cmds:?}"
);
for _ in 0..5 {
write_project_hooks(&settings, Some(&exe)).unwrap();
}
let text2 = std::fs::read_to_string(&settings).unwrap();
let val2: serde_json::Value = serde_json::from_str(&text2).unwrap();
for event in &[
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"SessionStart",
"SessionEnd",
] {
let arr = val2["hooks"][*event].as_array().unwrap();
let mpm_groups = arr
.iter()
.filter(|g| {
g["hooks"].as_array().is_some_and(|hs| {
hs.iter()
.any(|h| h["command"].as_str().is_some_and(|c| c.ends_with(" hook")))
})
})
.count();
assert_eq!(
mpm_groups, 1,
"event {event} must have exactly one MPM group after N writes, found {mpm_groups}"
);
}
}
#[test]
fn test_write_project_hooks_replaces_stale_exe_path_group() {
let tmp = TempDir::new().unwrap();
let settings = tmp.path().join("settings.json");
std::fs::write(
&settings,
serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": "trusty-memory inbox-check" }]
}]
}
})
.to_string(),
)
.unwrap();
let exe_v1 = PathBuf::from("/opt/old/bin/tm");
let exe_v2 = PathBuf::from("/opt/new/bin/trusty-mpm");
write_project_hooks(&settings, Some(&exe_v1)).unwrap();
write_project_hooks(&settings, Some(&exe_v2)).unwrap();
let text = std::fs::read_to_string(&settings).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let hooks = val["hooks"].as_object().expect("hooks must be present");
let pre = hooks["PreToolUse"]
.as_array()
.expect("PreToolUse must be present");
assert_eq!(
pre.len(),
2,
"PreToolUse must have exactly 2 groups (1 non-MPM + 1 MPM), found {}: {pre:?}",
pre.len()
);
let pre_commands: Vec<&str> = pre
.iter()
.filter_map(|g| g["hooks"][0]["command"].as_str())
.collect();
assert!(
pre_commands.contains(&"trusty-memory inbox-check"),
"pre-existing non-MPM hook group must be preserved, got: {pre_commands:?}"
);
assert!(
pre_commands
.iter()
.any(|c| c.contains("/opt/new/bin/trusty-mpm")),
"must contain the SECOND call's exe path, got: {pre_commands:?}"
);
assert!(
!pre_commands.iter().any(|c| c.contains("/opt/old/bin/tm")),
"must NOT contain the FIRST call's stale exe path, got: {pre_commands:?}"
);
for event in &[
"PostToolUse",
"Stop",
"SubagentStop",
"SessionStart",
"SessionEnd",
] {
let arr = hooks[*event]
.as_array()
.unwrap_or_else(|| panic!("event {event} must be present after two writes"));
assert_eq!(
arr.len(),
1,
"event {event} must have exactly 1 group, found {}: {arr:?}",
arr.len()
);
let cmd = arr[0]["hooks"][0]["command"].as_str().unwrap();
assert!(
cmd.contains("/opt/new/bin/trusty-mpm"),
"event {event} must carry the SECOND call's exe path, got: {cmd:?}"
);
assert!(
!cmd.contains("/opt/old/bin/tm"),
"event {event} must NOT carry the stale FIRST exe path, got: {cmd:?}"
);
}
}
fn plant_mixed_settings(path: &Path) {
std::fs::create_dir_all(path.parent().expect("settings path has a parent"))
.expect("settings parent dir creatable");
let json = serde_json::json!({
"hooks": {
"PreToolUse": [
{ "hooks": [ { "type": "command", "command": "/opt/bin/trusty-mpm hook" } ] },
{ "hooks": [ { "type": "command", "command": "/usr/bin/other-tool run" } ] }
]
}
});
std::fs::write(
path,
serde_json::to_string_pretty(&json).expect("json serialises"),
)
.expect("settings file writable");
}
fn pre_tool_use_commands(path: &Path) -> Vec<String> {
let text = std::fs::read_to_string(path).expect("settings file readable");
let val: serde_json::Value = serde_json::from_str(&text).expect("settings file parses");
val["hooks"]["PreToolUse"]
.as_array()
.expect("PreToolUse is an array")
.iter()
.filter_map(|g| g["hooks"][0]["command"].as_str().map(str::to_string))
.collect()
}
#[test]
fn remove_global_hooks_at_strips_only_the_two_global_files() {
let home = TempDir::new().expect("tempdir");
let global = home.path().join(".claude").join("settings.json");
let global_local = home.path().join(".claude").join("settings.local.json");
plant_mixed_settings(&global);
plant_mixed_settings(&global_local);
let changed = remove_global_trusty_mpm_hooks_at(home.path()).expect("strip succeeds");
assert_eq!(changed, 2, "both global settings files carried a tm group");
for path in [&global, &global_local] {
assert_eq!(
pre_tool_use_commands(path),
vec!["/usr/bin/other-tool run".to_string()],
"only the foreign group may survive in {}",
path.display()
);
}
}
#[test]
fn remove_global_hooks_at_ignores_project_settings_below_home() {
let home = TempDir::new().expect("tempdir");
let global = home.path().join(".claude").join("settings.json");
let project = home
.path()
.join("code")
.join("acme")
.join("widget")
.join(".claude")
.join("settings.json");
plant_mixed_settings(&global);
plant_mixed_settings(&project);
let project_before = std::fs::read_to_string(&project).expect("project settings readable");
let changed = remove_global_trusty_mpm_hooks_at(home.path()).expect("strip succeeds");
assert_eq!(changed, 1, "only the global settings file may be rewritten");
assert_eq!(
std::fs::read_to_string(&project).expect("project settings still readable"),
project_before,
"a project settings file below home must be left untouched: {}",
project.display()
);
assert_eq!(
pre_tool_use_commands(&global),
vec!["/usr/bin/other-tool run".to_string()],
"the global file must still have its tm group stripped"
);
}
#[test]
fn remove_global_hooks_at_reports_zero_when_no_global_settings_exist() {
let home = TempDir::new().expect("tempdir");
assert_eq!(
remove_global_trusty_mpm_hooks_at(home.path()).expect("strip succeeds"),
0
);
}
fn snapshot_names(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.expect("readable dir")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| backup::is_snapshot_of("settings.json", n))
.collect();
names.sort();
names
}
#[test]
fn write_project_hooks_snapshots_the_file_it_replaces() {
let tmp = TempDir::new().expect("tempdir");
let settings = tmp.path().join("settings.json");
write_project_hooks(&settings, Some(Path::new(STABLE_TEST_EXE))).expect("first write");
let before = std::fs::read_to_string(&settings).expect("read back");
assert!(
snapshot_names(tmp.path()).is_empty(),
"the file did not exist before the first write, so nothing was snapshotted"
);
write_project_hooks(&settings, Some(Path::new("/opt/homebrew/bin/tm"))).expect("second write");
let snapshots = snapshot_names(tmp.path());
assert_eq!(
snapshots.len(),
1,
"expected one snapshot, got {snapshots:?}"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join(&snapshots[0])).expect("read snapshot"),
before,
"the snapshot must hold the file as it was before the rewrite"
);
assert_ne!(
std::fs::read_to_string(&settings).expect("read back"),
before,
"the rewrite itself must still have happened"
);
}
#[test]
fn write_project_hooks_takes_no_snapshot_when_the_exe_is_refused() {
let tmp = TempDir::new().expect("tempdir");
let settings = tmp.path().join("settings.json");
std::fs::write(&settings, "{}").expect("seed settings");
let refusal = Err(StableHookExeError::Unresolved);
write_project_hooks_with(&settings, refusal).expect_err("a refusal must reach the caller");
assert!(
snapshot_names(tmp.path()).is_empty(),
"a refused write must take no snapshot"
);
}
#[test]
fn write_project_hooks_takes_no_snapshot_when_nothing_changes() {
let tmp = TempDir::new().expect("tempdir");
let settings = tmp.path().join("settings.json");
assert!(
write_project_hooks(&settings, Some(Path::new(STABLE_TEST_EXE))).expect("first write"),
"the first write creates the file"
);
assert!(
!write_project_hooks(&settings, Some(Path::new(STABLE_TEST_EXE))).expect("second write"),
"an identical rewrite must report no change"
);
assert!(
snapshot_names(tmp.path()).is_empty(),
"a no-op rewrite must take no snapshot"
);
}
#[cfg(unix)]
#[test]
fn write_project_hooks_aborts_the_rewrite_when_the_snapshot_fails() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path().join("claude");
std::fs::create_dir(&dir).expect("create dir");
let settings = dir.join("settings.json");
write_project_hooks(&settings, Some(Path::new(STABLE_TEST_EXE))).expect("first write");
let before = std::fs::read_to_string(&settings).expect("read back");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).expect("chmod");
let result = write_project_hooks(&settings, Some(Path::new("/opt/homebrew/bin/tm")));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).expect("chmod back");
let err = result.expect_err("an unsnapshottable rewrite must fail");
assert!(
err.to_string().contains("snapshot"),
"the error must name the stage that refused, got: {err}"
);
assert_eq!(
std::fs::read_to_string(&settings).expect("read back"),
before,
"the settings file must be untouched when its snapshot could not be taken"
);
}
use build_tree::tests::{INCIDENT_EXE, incident_settings};
#[test]
fn is_mpm_hook_command_claims_the_build_tree_incident_shape() {
assert!(is_mpm_hook_command(&format!("{INCIDENT_EXE} hook")));
assert!(is_mpm_hook_command(&format!(
"{INCIDENT_EXE} hook --pm-guard"
)));
assert!(is_mpm_hook_command(&format!(
"{INCIDENT_EXE} hook --divert-check"
)));
assert!(!is_mpm_hook_command("claude-mpm hooks fire PreToolUse"));
assert!(is_mpm_hook_command("/usr/local/bin/tm hook"));
}
#[test]
fn write_project_hooks_replaces_a_build_tree_group_instead_of_duplicating_it() {
let tmp = TempDir::new().unwrap();
let settings = tmp.path().join("settings.json");
std::fs::write(
&settings,
serde_json::to_string_pretty(&incident_settings()).unwrap(),
)
.unwrap();
let exe = PathBuf::from(STABLE_TEST_EXE);
assert!(write_project_hooks(&settings, Some(&exe)).unwrap());
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
for event in [
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"SessionStart",
"SessionEnd",
] {
let groups = val["hooks"][event].as_array().unwrap();
assert_eq!(
groups.len(),
1,
"{event} must collapse to one group, got {groups:?}"
);
assert_eq!(
groups[0]["hooks"][0]["command"],
serde_json::json!(format!("{STABLE_TEST_EXE} hook"))
);
}
assert!(
!val["hooks"].to_string().contains("test_session_lifecycle"),
"no build-tree command may survive the rewrite: {}",
val["hooks"]
);
assert_eq!(
val["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
serde_json::json!("trusty-memory prompt-context")
);
assert_eq!(
val["statusLine"]["command"],
serde_json::json!(format!("{INCIDENT_EXE} statusline"))
);
for _ in 0..3 {
assert!(!write_project_hooks(&settings, Some(&exe)).unwrap());
}
}
#[test]
fn lifecycle_event_names_match_the_written_block() {
let block = mpm_hook_additions_with_exe(Some(std::path::Path::new(STABLE_TEST_EXE)))
.expect("an installed-looking path resolves");
let mut written: Vec<String> = block["hooks"]
.as_object()
.expect("the block carries a hooks object")
.keys()
.cloned()
.collect();
let mut declared: Vec<String> = MPM_LIFECYCLE_HOOK_EVENTS
.iter()
.map(|event| (*event).to_string())
.collect();
written.sort();
declared.sort();
assert_eq!(
declared, written,
"MPM_LIFECYCLE_HOOK_EVENTS has drifted from mpm_hook_additions_with_exe"
);
}