lifeloop-cli 0.1.1

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Integration tests for [`lifeloop::source_files`]. Covers the five
//! required acceptance cases (create, update, no-op, stale-section
//! replacement, fail-closed) plus the boundary errors apply guards
//! against.

use lifeloop::IntegrationMode;
use lifeloop::source_files::{
    ApplyError, ApplyOutcome, SourceFileAdapter, TEMPLATE_VERSION, apply, render_for,
};

// ---------- Create ---------------------------------------------------------

#[test]
fn apply_creates_file_when_missing() {
    let rendered = render_for(SourceFileAdapter::Claude, IntegrationMode::NativeHook, None);
    let result = apply(None, &rendered).expect("apply create");
    assert_eq!(result.outcome, ApplyOutcome::Created);
    let body = result.body.expect("body present on create");
    assert!(body.contains("LIFELOOP:BEGIN managed-section"));
    assert!(body.contains("LIFELOOP:END managed-section"));
    assert!(body.contains("adapter: `claude`"));
    assert!(body.contains("integration_mode: `native_hook`"));
    assert!(body.contains("CLIENT-SLOT:BEGIN"));
}

#[test]
fn render_path_per_adapter() {
    assert_eq!(SourceFileAdapter::Claude.relative_path(), "CLAUDE.md");
    assert_eq!(SourceFileAdapter::Codex.relative_path(), "AGENTS.md");
    assert_eq!(SourceFileAdapter::Gemini.relative_path(), "GEMINI.md");
    assert_eq!(SourceFileAdapter::Hermes.relative_path(), "HERMES.md");
    assert_eq!(SourceFileAdapter::OpenClaw.relative_path(), "OPENCLAW.md");
}

#[test]
fn apply_appends_section_when_user_file_has_no_managed_block() {
    let rendered = render_for(SourceFileAdapter::Codex, IntegrationMode::NativeHook, None);
    let user_file = "# AGENTS.md\n\nUser-authored guidance lives here.\n";
    let result = apply(Some(user_file), &rendered).expect("apply append");
    assert_eq!(result.outcome, ApplyOutcome::Created);
    let body = result.body.expect("body present");
    // Original prefix preserved verbatim.
    assert!(body.starts_with("# AGENTS.md\n\nUser-authored guidance lives here.\n"));
    assert!(body.contains("LIFELOOP:BEGIN managed-section"));
}

// ---------- Update (drift) -------------------------------------------------

#[test]
fn apply_updates_drifted_managed_section() {
    let rendered = render_for(SourceFileAdapter::Claude, IntegrationMode::NativeHook, None);
    // Simulate drift: same template_version, same adapter, different body.
    let drifted = format!(
        "# CLAUDE.md\n\nUser content above.\n\n\
         <!-- LIFELOOP:BEGIN managed-section v={TEMPLATE_VERSION} adapter=claude -->\n\
         outdated body\n\
         <!-- LIFELOOP:END managed-section -->\n\n\
         User content below.\n",
    );
    let result = apply(Some(&drifted), &rendered).expect("apply update");
    assert_eq!(result.outcome, ApplyOutcome::Updated);
    let body = result.body.expect("body present");
    // User content top and bottom is preserved.
    assert!(body.starts_with("# CLAUDE.md\n\nUser content above.\n\n"));
    assert!(body.ends_with("User content below.\n"));
    // Stale body content is gone.
    assert!(!body.contains("outdated body"));
    // New managed body is in.
    assert!(body.contains("integration_mode: `native_hook`"));
}

// ---------- No-op ---------------------------------------------------------

#[test]
fn apply_is_idempotent_on_canonical_section() {
    let rendered = render_for(SourceFileAdapter::Codex, IntegrationMode::NativeHook, None);
    // First, create the file.
    let first = apply(None, &rendered).expect("first apply").body.unwrap();
    // Second apply against canonical body must be NoOp with body = None.
    let second = apply(Some(&first), &rendered).expect("second apply");
    assert_eq!(second.outcome, ApplyOutcome::NoOp);
    assert!(second.body.is_none());
    // And a third doesn't drift either.
    let third = apply(Some(&first), &rendered).expect("third apply");
    assert_eq!(third.outcome, ApplyOutcome::NoOp);
}

#[test]
fn apply_noop_with_user_content_around_canonical_section() {
    let rendered = render_for(SourceFileAdapter::Claude, IntegrationMode::NativeHook, None);
    let canonical = rendered.managed_block.clone();
    let wrapped = format!("# CLAUDE.md\n\nuser prose\n\n{canonical}\nmore user prose\n");
    let result = apply(Some(&wrapped), &rendered).expect("apply");
    assert_eq!(result.outcome, ApplyOutcome::NoOp);
    assert!(result.body.is_none());
}

// ---------- Stale section replacement -------------------------------------

#[test]
fn apply_replaces_stale_template_version_deterministically() {
    let rendered = render_for(
        SourceFileAdapter::Hermes,
        IntegrationMode::ReferenceAdapter,
        None,
    );
    // Pretend we wrote with template version 0 long ago. The current
    // TEMPLATE_VERSION must be >= 1 by construction.
    const _: () = assert!(TEMPLATE_VERSION >= 1);
    let stale = "# HERMES.md\n\n\
         <!-- LIFELOOP:BEGIN managed-section v=0 adapter=hermes -->\n\
         legacy v0 body\n\
         <!-- LIFELOOP:END managed-section -->\n"
        .to_string();
    let result = apply(Some(&stale), &rendered).expect("apply stale");
    assert_eq!(result.outcome, ApplyOutcome::StaleReplaced);
    let body = result.body.expect("body present");
    assert!(!body.contains("legacy v0 body"));
    assert!(body.contains(&format!("v={TEMPLATE_VERSION}")));
    // Determinism: a second apply on the just-written body is NoOp.
    let again = apply(Some(&body), &rendered).expect("second apply");
    assert_eq!(again.outcome, ApplyOutcome::NoOp);
}

// ---------- Fail-closed cases ---------------------------------------------

#[test]
fn apply_fails_closed_on_unbalanced_begin_marker() {
    let rendered = render_for(SourceFileAdapter::Claude, IntegrationMode::NativeHook, None);
    let bad = format!(
        "# CLAUDE.md\n\n\
         <!-- LIFELOOP:BEGIN managed-section v={TEMPLATE_VERSION} adapter=claude -->\n\
         body without an end marker\n\
         user might expect this to be theirs\n",
    );
    let err = apply(Some(&bad), &rendered).expect_err("must fail closed");
    assert_eq!(err, ApplyError::UnbalancedMarkers);
}

#[test]
fn apply_fails_closed_on_multiple_managed_sections() {
    let rendered = render_for(SourceFileAdapter::Claude, IntegrationMode::NativeHook, None);
    let bad = format!(
        "<!-- LIFELOOP:BEGIN managed-section v={TEMPLATE_VERSION} adapter=claude -->\n\
         first\n\
         <!-- LIFELOOP:END managed-section -->\n\n\
         <!-- LIFELOOP:BEGIN managed-section v={TEMPLATE_VERSION} adapter=claude -->\n\
         second\n\
         <!-- LIFELOOP:END managed-section -->\n",
    );
    let err = apply(Some(&bad), &rendered).expect_err("must reject multi-section");
    assert_eq!(err, ApplyError::MultipleManagedSections);
}

#[test]
fn apply_fails_closed_on_adapter_mismatch() {
    let rendered = render_for(SourceFileAdapter::Claude, IntegrationMode::NativeHook, None);
    let foreign = format!(
        "<!-- LIFELOOP:BEGIN managed-section v={TEMPLATE_VERSION} adapter=codex -->\n\
         body\n\
         <!-- LIFELOOP:END managed-section -->\n",
    );
    let err = apply(Some(&foreign), &rendered).expect_err("must reject mismatch");
    match err {
        ApplyError::AdapterMismatch {
            existing,
            rendering,
        } => {
            assert_eq!(existing, "codex");
            assert_eq!(rendering, "claude");
        }
        other => panic!("expected AdapterMismatch, got {other:?}"),
    }
}

#[test]
fn apply_refuses_to_downgrade_newer_template_version() {
    let rendered = render_for(SourceFileAdapter::Claude, IntegrationMode::NativeHook, None);
    let newer = format!(
        "<!-- LIFELOOP:BEGIN managed-section v={future} adapter=claude -->\n\
         future body\n\
         <!-- LIFELOOP:END managed-section -->\n",
        future = TEMPLATE_VERSION + 99,
    );
    let err = apply(Some(&newer), &rendered).expect_err("must refuse downgrade");
    assert!(matches!(err, ApplyError::NewerTemplateVersion { .. }));
}

// ---------- Client slot ----------------------------------------------------

#[test]
fn client_slot_is_rendered_verbatim_and_not_parsed() {
    // The slot might contain marker-looking text in prose; Lifeloop must
    // still treat it as opaque content. We don't assert that nested
    // markers are tolerated — that is documented as a fail-closed case
    // above and would require user action — but the slot text itself
    // must round-trip byte-for-byte.
    let slot = "Client-owned guidance: please run tests before merging.\n";
    let rendered = render_for(
        SourceFileAdapter::Codex,
        IntegrationMode::NativeHook,
        Some(slot),
    );
    assert!(rendered.managed_block.contains(slot));
    // No-op idempotence with a slot present.
    let body = apply(None, &rendered).unwrap().body.unwrap();
    let again = apply(Some(&body), &rendered).unwrap();
    assert_eq!(again.outcome, ApplyOutcome::NoOp);
}

// ---------- Cross-adapter coverage ----------------------------------------

#[test]
fn render_covers_all_adapters_and_modes() {
    for adapter in SourceFileAdapter::ALL {
        for mode in IntegrationMode::ALL {
            let rendered = render_for(*adapter, *mode, None);
            assert_eq!(rendered.adapter_id, adapter.as_str());
            assert_eq!(rendered.template_version, TEMPLATE_VERSION);
            assert!(rendered.managed_block.contains("LIFELOOP:BEGIN"));
            assert!(rendered.managed_block.contains("LIFELOOP:END"));
            // Idempotence holds for every adapter/mode pair.
            let first = apply(None, &rendered).unwrap().body.unwrap();
            let second = apply(Some(&first), &rendered).unwrap();
            assert_eq!(second.outcome, ApplyOutcome::NoOp);
        }
    }
}