use super::*;
use tempfile::TempDir;
#[test]
fn project_managed_hook_additions_combines_all_three_sources() {
let additions = project_managed_hook_additions(
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("a stable hook exe resolves in the test environment");
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(
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("a stable hook exe resolves in the test environment");
let second = project_managed_hook_additions(
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("a stable hook exe resolves in the test environment");
assert_eq!(first, second);
}
#[test]
fn project_managed_hook_additions_omits_prompt_context_when_disabled() {
let enabled = project_managed_hook_additions(
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("a stable hook exe resolves in the test environment");
let disabled = project_managed_hook_additions(
Some(std::path::Path::new("/usr/local/bin/tm")),
false,
false,
)
.expect("a stable hook exe resolves in the test environment");
let off = disabled["hooks"].as_object().expect("hooks is an object");
assert!(
!off.contains_key("UserPromptSubmit"),
"UserPromptSubmit must be absent when the toggle is off: {off:?}"
);
let on = enabled["hooks"].as_object().unwrap();
for event in [
"SessionStart",
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"SessionEnd",
] {
assert_eq!(
off.get(event),
on.get(event),
"{event} must be unchanged by the prompt_context toggle"
);
}
assert_eq!(
off.len(),
on.len() - 1,
"exactly one event key may differ between the two builds"
);
}
#[test]
fn project_managed_hook_events_is_a_superset_of_every_variant() {
let owned = project_managed_hook_events();
for enabled in [true, false] {
for key in project_managed_hook_additions(
Some(std::path::Path::new("/usr/local/bin/tm")),
enabled,
false,
)
.expect("a stable hook exe resolves in the test environment")["hooks"]
.as_object()
.unwrap()
.keys()
{
assert!(
owned.contains(key),
"{key} (toggle={enabled}) must be in the owned strip domain: {owned:?}"
);
}
}
assert!(
owned.iter().any(|e| e == "UserPromptSubmit"),
"UserPromptSubmit must stay in the strip domain even though the \
disabled build never writes it: {owned:?}"
);
}
#[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,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.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,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.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,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.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 snapshots = || -> Vec<String> {
std::fs::read_dir(&claude_dir)
.expect("read .claude")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| {
crate::core::standalone::hooks::backup::is_snapshot_of("settings.json", name)
})
.collect()
};
let staging = || -> Vec<String> {
std::fs::read_dir(&claude_dir)
.expect("read .claude")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect()
};
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("first write succeeds");
assert!(settings_path.exists(), "settings.json must be created");
assert!(
snapshots().is_empty(),
"no snapshot expected on first write (file did not previously exist): {:?}",
snapshots()
);
assert!(
staging().is_empty(),
"the staging file must be renamed away, never left behind: {:?}",
staging()
);
let first_content = std::fs::read_to_string(&settings_path).unwrap();
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
true,
)
.expect("second write succeeds");
let taken = snapshots();
assert_eq!(
taken.len(),
1,
"the writer must snapshot the prior file before replacing it: {taken:?}"
);
assert!(
staging().is_empty(),
"the staging file must be renamed away, never left behind: {:?}",
staging()
);
assert!(!bak_path.exists(), "a launch must not drop a .bak");
let backup_content = std::fs::read_to_string(claude_dir.join(&taken[0])).unwrap();
assert_eq!(
backup_content, first_content,
"the snapshot 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());
}
#[test]
fn write_project_hooks_omits_prompt_context_when_disabled() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
false,
false,
)
.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");
assert!(
!hooks.contains_key("UserPromptSubmit"),
"UserPromptSubmit must not be written when disabled: {hooks:?}"
);
let raw = serde_json::to_string(&value).unwrap();
assert!(
!raw.contains("prompt-context"),
"no prompt-context command may survive anywhere in the file: {raw}"
);
let session_start = hooks["SessionStart"].as_array().unwrap();
let ss_commands: Vec<&str> = session_start
.iter()
.map(|g| g["hooks"][0]["command"].as_str().unwrap())
.collect();
assert!(
ss_commands.contains(&"trusty-memory inbox-check"),
"SessionStart must keep the inbox-check hook: {ss_commands:?}"
);
let pre_commands: Vec<&str> = hooks["PreToolUse"]
.as_array()
.unwrap()
.iter()
.map(|g| g["hooks"][0]["command"].as_str().unwrap())
.collect();
assert!(
pre_commands.iter().any(|c| c.ends_with(" hook --pm-guard")),
"the PM guard must still be registered: {pre_commands:?}"
);
for event in [
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"SessionStart",
"SessionEnd",
] {
assert!(
hooks[event]
.as_array()
.unwrap()
.iter()
.any(|g| g["hooks"][0]["command"]
.as_str()
.is_some_and(|c| c.ends_with(" hook"))),
"{event} must still carry the lifecycle-triad command"
);
}
}
#[test]
fn write_project_hooks_strips_stale_prompt_context_when_disabled() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let settings_path = project.join(".claude").join("settings.json");
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("enabled write succeeds");
let seeded: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(
seeded["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
serde_json::json!("trusty-memory prompt-context"),
"precondition: the enabled write must have seeded the entry"
);
let mut with_foreign = seeded.clone();
with_foreign["hooks"]["UserPromptSubmit"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({
"matcher": "",
"hooks": [{ "type": "command", "command": "my-own-tool inject", "timeout": 5 }]
}));
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&with_foreign).unwrap(),
)
.unwrap();
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
false,
false,
)
.expect("disabled write succeeds");
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
let raw = serde_json::to_string(&value).unwrap();
assert!(
!raw.contains("trusty-memory prompt-context"),
"the stale entry from the earlier enabled launch must be stripped: {raw}"
);
let remaining: Vec<&str> = value["hooks"]["UserPromptSubmit"]
.as_array()
.expect("the operator's own group keeps the event key alive")
.iter()
.map(|g| g["hooks"][0]["command"].as_str().unwrap())
.collect();
assert_eq!(
remaining,
vec!["my-own-tool inject"],
"a foreign UserPromptSubmit entry must survive the strip"
);
}
#[test]
fn write_project_hooks_re_enabling_restores_the_hook() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let settings_path = project.join(".claude").join("settings.json");
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("write 1");
let first = std::fs::read_to_string(&settings_path).unwrap();
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
false,
false,
)
.expect("write 2");
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("write 3");
let third = std::fs::read_to_string(&settings_path).unwrap();
assert_eq!(
third, first,
"re-enabling must reproduce the enabled write byte for byte"
);
}
#[test]
fn write_project_hooks_enabled_output_is_unchanged_by_the_toggle() {
assert!(
crate::core::config::MpmConfig::default()
.hooks
.prompt_context,
"the shipped default must keep the hook enabled"
);
let mut from_additions: Vec<String> = project_managed_hook_additions(
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("a stable hook exe resolves in the test environment")["hooks"]
.as_object()
.unwrap()
.keys()
.cloned()
.collect();
let mut owned = project_managed_hook_events();
from_additions.sort();
owned.sort();
assert_eq!(
owned, from_additions,
"with the hook enabled the widened strip domain must be a no-op"
);
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let settings_path = project.join(".claude").join("settings.json");
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("write 1");
let first = std::fs::read_to_string(&settings_path).unwrap();
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("write 2");
assert_eq!(
std::fs::read_to_string(&settings_path).unwrap(),
first,
"the enabled write must be idempotent"
);
}
#[test]
fn project_managed_hook_additions_includes_divert_when_enabled() {
let off = project_managed_hook_additions(
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("a stable hook exe resolves in the test environment");
let on =
project_managed_hook_additions(Some(std::path::Path::new("/usr/local/bin/tm")), true, true)
.expect("a stable hook exe resolves in the test environment");
let off_pre = off["hooks"]["PreToolUse"].as_array().expect("array");
let on_pre = on["hooks"]["PreToolUse"].as_array().expect("array");
assert_eq!(
on_pre.len(),
off_pre.len() + 2,
"the toggle must add exactly two PreToolUse groups"
);
assert_eq!(
&on_pre[..off_pre.len()],
&off_pre[..],
"the pre-existing PreToolUse groups must be byte-identical and in place"
);
let added: Vec<&serde_json::Value> = on_pre[off_pre.len()..].iter().collect();
let matchers: Vec<&str> = added
.iter()
.map(|g| g["matcher"].as_str().unwrap())
.collect();
assert_eq!(matchers, vec!["Read", "Bash"]);
for group in &added {
let cmd = group["hooks"][0]["command"].as_str().unwrap();
assert!(
cmd.ends_with(" hook --divert-check"),
"the divert group must invoke the divert-check hook: {cmd}"
);
assert!(
!cmd.contains("KEY") && !cmd.contains("TOKEN") && !cmd.contains("SECRET"),
"no credential may appear in the hook command string: {cmd}"
);
}
let off_obj = off["hooks"].as_object().unwrap();
let on_obj = on["hooks"].as_object().unwrap();
assert_eq!(off_obj.len(), on_obj.len(), "no new event key may appear");
for (event, value) in off_obj {
if event == "PreToolUse" {
continue;
}
assert_eq!(
on_obj.get(event),
Some(value),
"{event} must be unchanged by the divert toggle"
);
}
}
#[test]
fn project_managed_hook_additions_omits_divert_when_disabled() {
let off = project_managed_hook_additions(
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("a stable hook exe resolves in the test environment");
let text = off.to_string();
assert!(
!text.contains("--divert-check"),
"a disabled toggle must write no diversion hook: {text}"
);
}
#[test]
fn is_project_managed_hook_command_recognises_divert_check() {
assert!(is_project_managed_hook_command(
"/opt/bin/tm hook --divert-check"
));
assert!(is_project_managed_hook_command("tm hook --divert-check"));
assert!(
!crate::core::standalone::hooks::is_mpm_hook_command("/opt/bin/tm hook --divert-check"),
"the triad predicate must not claim it; the divert arm is what covers it"
);
assert!(!is_project_managed_hook_command(
"claude-mpm hooks fire PreToolUse --divert"
));
}
#[test]
fn write_project_hooks_writes_divert_groups_when_enabled() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let settings_path = project.join(".claude").join("settings.json");
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
true,
)
.expect("write 1");
let first = std::fs::read_to_string(&settings_path).unwrap();
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
true,
)
.expect("write 2");
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
true,
)
.expect("write 3");
assert_eq!(
std::fs::read_to_string(&settings_path).unwrap(),
first,
"repeated launches must not duplicate the divert groups"
);
let value: serde_json::Value = serde_json::from_str(&first).unwrap();
let pre = value["hooks"]["PreToolUse"].as_array().expect("array");
let divert: Vec<&serde_json::Value> = pre
.iter()
.filter(|g| {
g["hooks"][0]["command"]
.as_str()
.is_some_and(|c| c.ends_with(" hook --divert-check"))
})
.collect();
assert_eq!(divert.len(), 2, "exactly two divert groups: {pre:?}");
assert!(
!first.contains("TRUSTY_DIVERT_WORKER_MODEL") && !first.contains("api_key"),
"settings.json must carry no divert config and no credential: {first}"
);
}
#[test]
fn write_project_hooks_strips_stale_divert_when_disabled() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let settings_path = project.join(".claude").join("settings.json");
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
true,
)
.expect("enabled write");
assert!(
std::fs::read_to_string(&settings_path)
.unwrap()
.contains("--divert-check"),
"precondition: the enabled write must land the groups"
);
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new("/usr/local/bin/tm")),
true,
false,
)
.expect("disabled write");
let after = std::fs::read_to_string(&settings_path).unwrap();
assert!(
!after.contains("--divert-check"),
"the disabled write must strip the stale divert groups: {after}"
);
let value: serde_json::Value = serde_json::from_str(&after).unwrap();
let pre = value["hooks"]["PreToolUse"].as_array().expect("array");
assert_eq!(
pre.len(),
2,
"PM guard + lifecycle triad must remain: {pre:?}"
);
}
#[test]
fn write_project_hooks_writes_prompt_feedback_when_enabled() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let settings_path = project.join(".claude").join("settings.json");
for pass in 1..=3 {
super::super::settings::write_project_hooks_with_prompt_feedback(
project,
Some(std::path::Path::new(TEST_EXE)),
true,
false,
true,
)
.unwrap_or_else(|e| panic!("write {pass}: {e}"));
}
let written = std::fs::read_to_string(&settings_path).unwrap();
let value: serde_json::Value = serde_json::from_str(&written).unwrap();
for event in ["Stop", "SubagentStop"] {
let groups = value["hooks"][event].as_array().expect("array");
let captures: Vec<&serde_json::Value> = groups
.iter()
.filter(|g| {
g["hooks"][0]["command"]
.as_str()
.is_some_and(|c| c.ends_with(" hook --prompt-feedback"))
})
.collect();
assert_eq!(
captures.len(),
1,
"exactly one {event} capture group after three launches: {groups:?}"
);
}
}
#[test]
fn write_project_hooks_strips_stale_prompt_feedback_when_disabled() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let settings_path = project.join(".claude").join("settings.json");
super::super::settings::write_project_hooks_with_prompt_feedback(
project,
Some(std::path::Path::new(TEST_EXE)),
true,
false,
true,
)
.expect("enabled write");
assert!(
std::fs::read_to_string(&settings_path)
.unwrap()
.contains("--prompt-feedback"),
"precondition: the enabled write must land the groups"
);
super::super::settings::write_project_hooks_with_prompt_feedback(
project,
Some(std::path::Path::new(TEST_EXE)),
true,
false,
false,
)
.expect("disabled write");
let after = std::fs::read_to_string(&settings_path).unwrap();
assert!(
!after.contains("--prompt-feedback"),
"the disabled write must strip the stale capture groups: {after}"
);
let value: serde_json::Value = serde_json::from_str(&after).unwrap();
let stop = value["hooks"]["Stop"].as_array().expect("array");
assert_eq!(stop.len(), 1, "the lifecycle triad must remain: {stop:?}");
}
const TEST_EXE: &str = "/usr/local/bin/tm";
fn snapshot_names(dir: &std::path::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| crate::core::standalone::hooks::backup::is_snapshot_of("settings.json", n))
.collect();
names.sort();
names
}
fn write(project: &std::path::Path, prompt_context: bool, divert: bool) {
super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new(TEST_EXE)),
prompt_context,
divert,
)
.expect("write succeeds");
}
#[test]
fn write_project_hooks_snapshots_the_file_it_replaces() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let claude_dir = project.join(".claude");
let settings_path = claude_dir.join("settings.json");
write(project, true, false);
let before = std::fs::read_to_string(&settings_path).unwrap();
assert!(
snapshot_names(&claude_dir).is_empty(),
"nothing existed before the first write, so nothing was snapshotted"
);
write(project, true, true);
let snapshots = snapshot_names(&claude_dir);
assert_eq!(
snapshots.len(),
1,
"expected one snapshot, got {snapshots:?}"
);
assert_eq!(
std::fs::read_to_string(claude_dir.join(&snapshots[0])).unwrap(),
before,
"the snapshot must hold the file as it was before the rewrite"
);
}
#[test]
fn write_project_hooks_prunes_snapshots_to_three() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let claude_dir = project.join(".claude");
write(project, true, false);
write(project, true, true);
let oldest = snapshot_names(&claude_dir);
assert_eq!(oldest.len(), 1, "the first rewrite snapshots once");
write(project, false, true);
write(project, false, false);
write(project, true, false);
let snapshots = snapshot_names(&claude_dir);
assert_eq!(
snapshots.len(),
3,
"four rewrites must leave exactly three snapshots, got {snapshots:?}"
);
assert!(
!snapshots.contains(&oldest[0]),
"the oldest snapshot must be the one pruned, still present in {snapshots:?}"
);
}
#[test]
fn write_project_hooks_takes_no_snapshot_when_nothing_changes() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let claude_dir = project.join(".claude");
let settings_path = claude_dir.join("settings.json");
write(project, true, false);
let before = std::fs::read_to_string(&settings_path).unwrap();
write(project, true, false);
assert!(
!claude_dir.join("settings.json.bak").exists(),
"an identical rewrite must return before the atomic write, which would \
have left its own backup"
);
assert!(
snapshot_names(&claude_dir).is_empty(),
"an identical rewrite must take no snapshot"
);
assert_eq!(
std::fs::read_to_string(&settings_path).unwrap(),
before,
"an identical rewrite must leave the file alone"
);
}
#[test]
fn write_project_hooks_takes_no_snapshot_when_the_exe_is_refused() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let claude_dir = project.join(".claude");
write(project, true, false);
let before = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let refusal = Err(
crate::core::standalone::hooks::StableHookExeError::Ephemeral(std::path::PathBuf::from(
"/x/target-7244/debug/deps/some_test-cd3ba8f0",
)),
);
super::super::settings::write_project_hooks_with(project, refusal)
.expect_err("a refusal must reach the caller, not be written");
assert!(
snapshot_names(&claude_dir).is_empty(),
"a refused write must take no snapshot"
);
assert_eq!(
std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(),
before,
"a refused write must leave the file alone"
);
}
#[cfg(unix)]
#[test]
fn write_project_hooks_aborts_when_the_snapshot_fails() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let claude_dir = project.join(".claude");
let settings_path = claude_dir.join("settings.json");
write(project, true, false);
let before = std::fs::read_to_string(&settings_path).unwrap();
std::fs::set_permissions(&claude_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let result = super::super::settings::write_project_hooks(
project,
Some(std::path::Path::new(TEST_EXE)),
true,
true,
);
std::fs::set_permissions(&claude_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let err = result.expect_err("an unsnapshottable rewrite must fail");
assert!(
matches!(
err,
crate::core::session_launch::PrepError::HookSnapshot { .. }
),
"expected HookSnapshot, got {err:?}"
);
assert!(
err.to_string().contains("settings.json"),
"the error must name the file whose rewrite was abandoned, got: {err}"
);
assert_eq!(
std::fs::read_to_string(&settings_path).unwrap(),
before,
"the settings file must be untouched when its snapshot could not be taken"
);
}
const EPHEMERAL_EXE: &str = "/repo/target-7247/debug/deps/test_session_lifecycle-cd3ba8f03938239b";
#[test]
fn pm_guard_and_divert_commands_end_in_a_known_argv_tail() {
use crate::core::standalone::hooks::{
is_build_tree_hook_command, is_build_tree_statusline_command,
};
let bin = super::super::settings::resolve_statusline_binary();
let mut produced: Vec<String> = vec![
super::super::settings::pm_guard_hook_value()[0]["hooks"][0]["command"]
.as_str()
.expect("the PM-guard command is a string")
.to_string(),
];
for group in super::super::divert_hooks::divert_hook_groups() {
produced.push(
group["hooks"][0]["command"]
.as_str()
.expect("a divert command is a string")
.to_string(),
);
}
for cmd in &produced {
let argv = cmd
.strip_prefix(&bin)
.unwrap_or_else(|| panic!("{cmd} must start with the resolved binary {bin}"));
assert!(
is_build_tree_hook_command(&format!("{EPHEMERAL_EXE}{argv}")),
"argv {argv:?} is not in the #7262 classifier's vocabulary"
);
}
let statusline = super::super::settings::resolve_statusline_command();
let argv = statusline
.strip_prefix(&bin)
.unwrap_or_else(|| panic!("{statusline} must start with the resolved binary {bin}"));
assert!(
is_build_tree_statusline_command(&format!("{EPHEMERAL_EXE}{argv}")),
"statusLine argv {argv:?} is not in the #7262 classifier's vocabulary"
);
}
#[test]
fn the_tier_marker_recognises_the_pm_guard() {
assert!(super::is_project_tier_marker_command(&format!(
"/usr/local/bin/tm{PM_GUARD_SUFFIX}"
)));
assert!(super::is_project_tier_marker_command(
"trusty-memory inbox-check"
));
}
#[test]
fn the_tier_marker_ignores_a_user_tier_lifecycle_command() {
assert!(!super::is_project_tier_marker_command(
"/usr/local/bin/tm hook"
));
assert!(!super::is_project_tier_marker_command(
"/opt/other-harness/run --stop"
));
}