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_mpm_hook_additions_has_five_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("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",
"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_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_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", "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:?}"
);
}
}