lifeloop-cli 0.5.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Lifecycle integration profile abstraction tests (#26).
//!
//! These tests pin two things:
//!
//! 1. **Parity** — the back-compat free functions
//!    (`render_applied_assets`, `merge_claude_settings`,
//!    `merge_codex_hooks`, ...) produce byte-identical output to the
//!    explicit `*_with_profile(..., &CCD_COMPAT_PROFILE)` form. This
//!    is the safety net for the refactor: any in-tree caller that
//!    used the legacy API gets exactly the same bytes after #26 as
//!    before.
//!
//! 2. **Genericity** — a consumer-defined profile (not built in) renders,
//!    merges, and reports status through the same `*_with_profile`
//!    machinery without editing core code, and an invalid profile fails
//!    closed. This is what keeps the renderer profile-agnostic now that the
//!    only built-in profile is CCD-compat.

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};

// ============================================================================
// CCD-compat parity: the back-compat API equals the profile-explicit form
// ============================================================================

#[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);
}

// ============================================================================
// Profile struct — invariants
// ============================================================================

#[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() {
    // Migration tombstone (Codex adversarial review of the ccd-renewal
    // excision): the removed ccd-renewal profile rendered Codex hooks with
    // a `--client-cmd` command prefix. Applying ccd-compat over such a
    // legacy install must detect those as MANAGED and scrub them, not leave
    // them as orphaned user-owned `on-agent-end` hooks.
    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:?}"
    );
}

// ============================================================================
// Custom profiles — the renderer stays profile-agnostic
// ============================================================================

/// A consumer-defined profile used to prove the `*_with_profile` machinery
/// is not hardwired to the built-in CCD-compat shape.
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");
    // The custom profile only declares two events — the CCD-compat
    // managed event count (5) must NOT leak through.
    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() {
    // A profile's own rendered output reads `Present` under that profile
    // and `Drifted` under a different one — the status machinery is keyed
    // on the profile, not hardwired to CCD-compat.
    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()
    );
}