use lifeloop::host_assets::{
self as ha, AssetStatus, CCD_COMPAT_PROFILE, HostAdapter, IntegrationMode, LifecycleProfile,
claude_settings_status_with_profile, codex_hooks_status_with_profile,
merge_claude_settings_with_profile, merge_codex_hooks_with_profile,
render_applied_assets_with_profile, render_source_assets_with_profile,
};
use serde_json::{Value, json};
#[test]
fn render_applied_assets_matches_ccd_compat_profile_byte_for_byte() {
for host in HostAdapter::ALL.iter().copied() {
for mode in IntegrationMode::ALL.iter().copied() {
let legacy = ha::render_applied_assets(host, mode);
let explicit = render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE);
assert_eq!(
legacy, explicit,
"render_applied_assets({host:?}, {mode:?}) must match CCD_COMPAT_PROFILE"
);
}
}
}
#[test]
fn render_source_assets_matches_ccd_compat_profile_byte_for_byte() {
for host in HostAdapter::ALL.iter().copied() {
let legacy = ha::render_source_assets(host);
let explicit = render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE);
assert_eq!(
legacy, explicit,
"render_source_assets({host:?}) must match CCD_COMPAT_PROFILE"
);
}
}
#[test]
fn merge_claude_settings_matches_ccd_compat_profile_byte_for_byte() {
let input = json!({
"theme": "dark",
"hooks": {
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
}
});
let legacy = ha::merge_claude_settings(input.clone()).expect("legacy merge");
let explicit =
merge_claude_settings_with_profile(input, &CCD_COMPAT_PROFILE).expect("explicit merge");
assert_eq!(legacy, explicit);
}
#[test]
fn merge_codex_hooks_matches_ccd_compat_profile_byte_for_byte() {
let input = json!({
"hooks": {
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
}
});
let legacy = ha::merge_codex_hooks(input.clone()).expect("legacy merge");
let explicit =
merge_codex_hooks_with_profile(input, &CCD_COMPAT_PROFILE).expect("explicit merge");
assert_eq!(legacy, explicit);
}
#[test]
fn lifecycle_profile_id_is_stable_for_each_built_in() {
assert_eq!(CCD_COMPAT_PROFILE.id, "ccd-compat");
}
#[test]
fn lifecycle_profile_command_helpers_concat_prefix_and_arg() {
let cmd = CCD_COMPAT_PROFILE.claude_command("on-session-start");
assert!(cmd.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix));
assert!(cmd.ends_with("on-session-start"));
let cmd = CCD_COMPAT_PROFILE.codex_command("on-agent-end");
assert!(cmd.starts_with(CCD_COMPAT_PROFILE.codex_command_prefix));
assert!(cmd.ends_with("on-agent-end"));
}
#[test]
fn built_in_codex_profile_does_not_require_git_for_workspace_path() {
let cmd = CCD_COMPAT_PROFILE.codex_command("on-agent-end");
assert!(
!cmd.contains("git rev-parse"),
"ccd-compat must not hard-require Git to resolve the workspace path: {cmd}"
);
assert!(
cmd.contains("LIFELOOP_WORKSPACE_DIR")
&& cmd.contains("CODEX_PROJECT_DIR")
&& cmd.contains("$PWD"),
"ccd-compat must render a substrate-neutral workspace fallback: {cmd}"
);
}
#[test]
fn codex_merge_scrubs_legacy_git_path_commands_for_built_in_profile() {
let legacy_prefix = "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ";
let old_command = format!("{legacy_prefix}on-agent-end");
let merged = merge_codex_hooks_with_profile(
json!({
"hooks": {
"Stop": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": old_command }]
}]
}
}),
&CCD_COMPAT_PROFILE,
)
.expect("merge");
let stop_hooks = merged["hooks"]["Stop"][0]["hooks"].as_array().unwrap();
assert!(
stop_hooks
.iter()
.all(|hook| !hook["command"].as_str().unwrap().contains("git rev-parse")),
"ccd-compat merge must scrub legacy Git-based Codex hooks: {stop_hooks:?}"
);
assert_eq!(
stop_hooks
.iter()
.filter(|hook| hook["command"].as_str()
== Some(CCD_COMPAT_PROFILE.codex_command("on-agent-end").as_str()))
.count(),
1,
"ccd-compat merge must leave exactly one current managed hook"
);
}
#[test]
fn codex_merge_scrubs_removed_ccd_renewal_hooks_when_migrating_to_ccd_compat() {
let renewal_prefix = "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"${LIFELOOP_WORKSPACE_DIR:-${CODEX_PROJECT_DIR:-$PWD}}\" --host codex --client-cmd \"${CCD_BIN:-ccd}\" --hook ";
let stale = format!("{renewal_prefix}on-agent-end");
let merged = merge_codex_hooks_with_profile(
json!({
"hooks": {
"Stop": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": stale }]
}]
}
}),
&CCD_COMPAT_PROFILE,
)
.expect("merge");
let stop_hooks = merged["hooks"]["Stop"][0]["hooks"].as_array().unwrap();
assert!(
stop_hooks
.iter()
.all(|hook| !hook["command"].as_str().unwrap().contains("--client-cmd")),
"ccd-compat merge must scrub removed ccd-renewal (--client-cmd) hooks: {stop_hooks:?}"
);
}
fn custom_profile() -> LifecycleProfile {
static CUSTOM_CLAUDE_EVENTS: &[(&str, &str, &str)] = &[
("SessionStart", "on-session-start", "*"),
("Stop", "on-agent-end", "*"),
];
static CUSTOM_CODEX_EVENTS: &[(&str, &str, &str, &str)] = &[(
"SessionStart",
"on-session-start",
"*",
"Custom client status",
)];
LifecycleProfile {
id: "custom-test",
claude_command_prefix: "custom-broker --hook ",
claude_legacy_substrings: &[],
claude_managed_events: CUSTOM_CLAUDE_EVENTS,
codex_command_prefix: "custom-broker --codex --hook ",
codex_managed_events: CUSTOM_CODEX_EVENTS,
}
}
#[test]
fn custom_profile_renders_with_its_own_command_prefix() {
let custom = custom_profile();
let assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&custom,
);
let parsed: Value = serde_json::from_str(&assets[0].contents).expect("valid json");
let cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("command");
assert_eq!(cmd, "custom-broker --hook on-session-start");
assert_eq!(
parsed["hooks"].as_object().expect("object").len(),
2,
"custom profile must drive the event set, not leak CCD-compat events"
);
}
#[test]
fn status_reporting_separates_distinct_profiles() {
let custom = custom_profile();
let claude_assets = render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&custom,
);
let claude_body = &claude_assets[0].contents;
assert_eq!(
claude_settings_status_with_profile(Some(claude_body), &custom),
AssetStatus::Present,
"custom profile's own output must be Present under that profile"
);
assert_eq!(
claude_settings_status_with_profile(Some(claude_body), &CCD_COMPAT_PROFILE),
AssetStatus::Drifted,
"custom profile output must NOT be Present under CCD_COMPAT_PROFILE"
);
let codex_assets = render_applied_assets_with_profile(
HostAdapter::Codex,
IntegrationMode::NativeHook,
&custom,
);
let codex_hooks = codex_assets
.iter()
.find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
.expect("hooks.json present");
assert_eq!(
codex_hooks_status_with_profile(Some(&codex_hooks.contents), &custom),
AssetStatus::Present
);
assert_eq!(
codex_hooks_status_with_profile(Some(&codex_hooks.contents), &CCD_COMPAT_PROFILE),
AssetStatus::Drifted
);
}
#[test]
fn invalid_profile_prefixes_fail_closed() {
static CUSTOM_CLAUDE_EVENTS: &[(&str, &str, &str)] =
&[("SessionStart", "on-session-start", "*")];
static CUSTOM_CODEX_EVENTS: &[(&str, &str, &str, &str)] =
&[("SessionStart", "on-session-start", "*", "status")];
let invalid = LifecycleProfile {
id: "invalid-empty-prefix",
claude_command_prefix: "",
claude_legacy_substrings: &[],
claude_managed_events: CUSTOM_CLAUDE_EVENTS,
codex_command_prefix: "",
codex_managed_events: CUSTOM_CODEX_EVENTS,
};
let existing = serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": "user-owned" }]
}]
}
});
assert!(invalid.validate().is_err());
assert!(merge_claude_settings_with_profile(existing.clone(), &invalid).is_none());
assert!(merge_codex_hooks_with_profile(existing, &invalid).is_none());
assert!(
render_applied_assets_with_profile(
HostAdapter::Claude,
IntegrationMode::NativeHook,
&invalid
)
.is_empty()
);
}