use super::*;
#[test]
fn install_claude_hooks_at_writes_only_the_managed_config_dir() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("claude-config");
std::fs::create_dir_all(&config_dir).unwrap();
let changed = install_claude_hooks_at(
&config_dir,
Some(std::path::PathBuf::from(
crate::test_support::STABLE_HOOK_EXE,
)),
false,
)
.unwrap();
assert_eq!(changed, 1, "first install must report one file changed");
let settings_path = config_dir.join("settings.json");
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
let hooks = val.get("hooks").expect("hooks key must be present");
assert!(hooks.get("PreToolUse").is_some());
assert!(hooks.get("PostToolUse").is_some());
assert!(hooks.get("Stop").is_some());
let cmd = hooks["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(cmd.ends_with(" hook"), "got: {cmd:?}");
}
#[test]
fn install_claude_hooks_at_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("claude-config");
std::fs::create_dir_all(&config_dir).unwrap();
let first = install_claude_hooks_at(
&config_dir,
Some(std::path::PathBuf::from(
crate::test_support::STABLE_HOOK_EXE,
)),
false,
)
.unwrap();
assert_eq!(first, 1);
let settings_path = config_dir.join("settings.json");
let after_first = std::fs::read_to_string(&settings_path).unwrap();
let second = install_claude_hooks_at(
&config_dir,
Some(std::path::PathBuf::from(
crate::test_support::STABLE_HOOK_EXE,
)),
false,
)
.unwrap();
assert_eq!(second, 0, "second install must report no changes");
let after_second = std::fs::read_to_string(&settings_path).unwrap();
assert_eq!(
after_first, after_second,
"re-running install must not rewrite an already-current file"
);
}
#[test]
fn install_claude_hooks_at_never_touches_a_sibling_project_dir() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("claude-config");
std::fs::create_dir_all(&config_dir).unwrap();
let sibling_project = tmp.path().join("some-other-project");
let sibling_settings = sibling_project.join(".claude").join("settings.json");
std::fs::create_dir_all(sibling_settings.parent().unwrap()).unwrap();
let sibling_original = r#"{"outputStyle":"claude-mpm"}"#;
std::fs::write(&sibling_settings, sibling_original).unwrap();
install_claude_hooks_at(
&config_dir,
Some(std::path::PathBuf::from(
crate::test_support::STABLE_HOOK_EXE,
)),
false,
)
.unwrap();
let sibling_after = std::fs::read_to_string(&sibling_settings).unwrap();
assert_eq!(
sibling_after, sibling_original,
"a sibling project's settings.json must be byte-for-byte unchanged \
by `tm install` — hooks belong solely in the tm-owned config dir"
);
let sibling_val: serde_json::Value = serde_json::from_str(&sibling_after).unwrap();
assert!(
sibling_val.get("hooks").is_none(),
"the sibling project must never gain a `hooks` key from `tm install`"
);
}
fn installed_events(config_dir: &std::path::Path) -> Vec<String> {
let text = std::fs::read_to_string(config_dir.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let mut events: Vec<String> = val["hooks"].as_object().unwrap().keys().cloned().collect();
events.sort();
events
}
#[test]
fn opt_in_off_installs_exactly_the_six_lifecycle_events() {
let tmp = tempfile::tempdir().unwrap();
let exe = Some(std::path::PathBuf::from(
crate::test_support::STABLE_HOOK_EXE,
));
install_claude_hooks_at(tmp.path(), exe, false).unwrap();
let mut expected = vec![
"PostToolUse",
"PreToolUse",
"SessionEnd",
"SessionStart",
"Stop",
"SubagentStop",
];
expected.sort_unstable();
assert_eq!(installed_events(tmp.path()), expected);
}
#[test]
fn opt_in_on_installs_one_notification_entry_idempotently() {
let tmp = tempfile::tempdir().unwrap();
let exe = || {
Some(std::path::PathBuf::from(
crate::test_support::STABLE_HOOK_EXE,
))
};
assert_eq!(install_claude_hooks_at(tmp.path(), exe(), true).unwrap(), 1);
let first = std::fs::read(tmp.path().join("settings.json")).unwrap();
assert_eq!(install_claude_hooks_at(tmp.path(), exe(), true).unwrap(), 0);
assert_eq!(
std::fs::read(tmp.path().join("settings.json")).unwrap(),
first
);
assert!(installed_events(tmp.path()).contains(&"Notification".to_string()));
let val: serde_json::Value = serde_json::from_slice(&first).unwrap();
let groups = val["hooks"]["Notification"].as_array().unwrap();
assert_eq!(groups.len(), 1, "exactly one Notification group");
assert_eq!(
groups[0]["hooks"][0]["command"],
format!("{} hook", crate::test_support::STABLE_HOOK_EXE)
);
}
#[test]
fn install_with_the_guard_off_strips_an_existing_guard_entry() {
let tmp = tempfile::tempdir().unwrap();
let guard = format!("{} hook --pm-guard", crate::test_support::STABLE_HOOK_EXE);
let seeded = serde_json::json!({
"hooks": { "PreToolUse": [
{ "matcher": "", "hooks": [{ "type": "command", "command": guard }] },
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "/opt/foreign/x" }] }
] }
});
std::fs::write(tmp.path().join("settings.json"), seeded.to_string()).unwrap();
let exe = Some(std::path::PathBuf::from(
crate::test_support::STABLE_HOOK_EXE,
));
assert_eq!(
install_claude_hooks_at_with_pm_guard(tmp.path(), exe, false, false).unwrap(),
1
);
let val: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path().join("settings.json")).unwrap()).unwrap();
let commands: Vec<&str> = val["hooks"]["PreToolUse"]
.as_array()
.unwrap()
.iter()
.filter_map(|g| g["hooks"][0]["command"].as_str())
.collect();
assert!(!commands.contains(&guard.as_str()), "{commands:?}");
assert!(commands.contains(&"/opt/foreign/x"), "{commands:?}");
assert!(
commands.iter().any(|c| c.ends_with(" hook")),
"{commands:?}"
);
}
#[test]
fn install_without_content_fails_naming_tm_content_install() {
use trusty_mpm::core::content_source::DevOverride;
let cache = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let paths = trusty_mpm::core::paths::FrameworkPaths::under(home.path());
let err = install_to_resolving(
&paths,
false,
|| Ok(test_roster()),
|| trusty_mpm::core::content_source::framework_content_in(cache.path(), DevOverride::Off),
)
.expect_err("no content installed");
assert!(err.to_string().contains("tm content install"), "{err}");
assert!(err.to_string().contains("tm content update"), "{err}");
assert!(
!paths.framework.exists(),
"a failed install must leave the framework tree unwritten"
);
}
#[test]
fn install_with_a_failed_first_use_fetch_fails_closed() {
use trusty_mpm::content::bundle_cache::{CacheError, Fallback};
use trusty_mpm::content::first_use::resolve_or_fetch_in;
use trusty_mpm::core::content_source::{AgentRoster, DevOverride};
let cache = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let paths = trusty_mpm::core::paths::FrameworkPaths::under(home.path());
let mut fetches = 0;
let err = install_to_resolving(
&paths,
false,
|| {
let content = resolve_or_fetch_in(cache.path(), DevOverride::Off, |_| {
fetches += 1;
Err(CacheError::Network {
url: "https://api.github.com".to_owned(),
reason: "network is unreachable".to_owned(),
tag: None,
fallback: Fallback::None,
})
})?;
AgentRoster::load(&content)
},
|| unreachable!("the roster gate refuses first"),
)
.expect_err("the fetch failed");
let msg = err.to_string();
assert_eq!(fetches, 1, "one fetch attempt");
assert!(msg.contains("network is unreachable"), "{msg}");
assert!(msg.contains("run `tm content update`"), "{msg}");
assert!(msg.contains("tm content install --from"), "{msg}");
assert!(!paths.framework.exists(), "nothing written");
assert!(
!cache
.path()
.join(trusty_common::content::LOCK_FILE_NAME)
.exists()
);
}
#[test]
fn install_without_the_bundled_docs_fails_naming_the_doc() {
use trusty_mpm::core::framework_content::REQUIRED_INSTRUCTIONS;
let checkout = tempfile::tempdir().unwrap();
let root = checkout.path();
for (_, rel) in trusty_common::content::DEV_CLASS_SOURCES {
std::fs::create_dir_all(root.join(rel)).unwrap();
}
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
for rel in REQUIRED_INSTRUCTIONS {
let path = root.join("content/instructions").join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, test_content_ref().required(rel)).unwrap();
}
std::fs::write(root.join("content/skills/tm.md"), "skill").unwrap();
let content = trusty_mpm::core::content_source::FrameworkContent::load(
&trusty_agents_common::agent_content::checkout_content(root).unwrap(),
)
.expect("a source without docs still loads");
let home = tempfile::tempdir().unwrap();
let paths = trusty_mpm::core::paths::FrameworkPaths::under(home.path());
let err =
install_to_with(&paths, false, test_roster_ref(), &content).expect_err("no bundled docs");
assert!(
err.to_string()
.contains("instructions/docs/WHAT-IS-TRUSTY-MPM.md"),
"{err}"
);
assert!(
!paths.framework.exists(),
"a failed install must leave the framework tree unwritten"
);
}