use super::*;
use tempfile::TempDir;
#[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));
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_is_absolute_or_fallback() {
let cmd = mpm_hook_command(None);
assert!(
cmd.ends_with(" hook"),
"hook command must end with ' hook', got: {cmd:?}"
);
}
#[test]
fn test_hook_command_rejects_ephemeral_exe_override() {
let ephemeral = PathBuf::from("/Users/x/trusty-tools/target/debug/deps/trusty_mpm-deadbeef");
let cmd = mpm_hook_command(Some(&ephemeral));
assert!(
!cmd.contains("target/debug/deps"),
"ephemeral build path must never be baked into the hook command, got: {cmd:?}"
);
assert!(
cmd.ends_with(" hook"),
"hook command must still end with ' hook', got: {cmd:?}"
);
}
#[test]
fn test_hook_command_rejects_system_temp_exe_override() {
let mut baked: Vec<String> = Vec::new();
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"),
] {
let cmd = mpm_hook_command(Some(&ephemeral));
if cmd.contains(&ephemeral.display().to_string()) {
baked.push(cmd.clone());
}
assert!(
cmd.ends_with(" hook"),
"hook command must still end with ' hook', got: {cmd:?}"
);
}
assert!(
baked.is_empty(),
"a system temp path must never be baked into the hook command (#4485), but these \
commands carry one: {baked:#?}"
);
}
#[test]
fn test_mpm_hook_additions_has_six_events() {
let v = mpm_hook_additions();
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));
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(&cfg).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(&cfg).unwrap();
let after_first = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
ensure_managed_hooks(&cfg).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(&cfg).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 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:?}"
);
}
}