use lifeloop::IntegrationMode;
use lifeloop::source_files::{
ApplyError, ApplyOutcome, SourceFileAdapter, TEMPLATE_VERSION, apply, render_for,
};
#[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");
assert!(body.starts_with("# AGENTS.md\n\nUser-authored guidance lives here.\n"));
assert!(body.contains("LIFELOOP:BEGIN managed-section"));
}
#[test]
fn apply_updates_drifted_managed_section() {
let rendered = render_for(SourceFileAdapter::Claude, IntegrationMode::NativeHook, None);
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");
assert!(body.starts_with("# CLAUDE.md\n\nUser content above.\n\n"));
assert!(body.ends_with("User content below.\n"));
assert!(!body.contains("outdated body"));
assert!(body.contains("integration_mode: `native_hook`"));
}
#[test]
fn apply_is_idempotent_on_canonical_section() {
let rendered = render_for(SourceFileAdapter::Codex, IntegrationMode::NativeHook, None);
let first = apply(None, &rendered).expect("first apply").body.unwrap();
let second = apply(Some(&first), &rendered).expect("second apply");
assert_eq!(second.outcome, ApplyOutcome::NoOp);
assert!(second.body.is_none());
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());
}
#[test]
fn apply_replaces_stale_template_version_deterministically() {
let rendered = render_for(
SourceFileAdapter::Hermes,
IntegrationMode::ReferenceAdapter,
None,
);
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}")));
let again = apply(Some(&body), &rendered).expect("second apply");
assert_eq!(again.outcome, ApplyOutcome::NoOp);
}
#[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 { .. }));
}
#[test]
fn client_slot_is_rendered_verbatim_and_not_parsed() {
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));
let body = apply(None, &rendered).unwrap().body.unwrap();
let again = apply(Some(&body), &rendered).unwrap();
assert_eq!(again.outcome, ApplyOutcome::NoOp);
}
#[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"));
let first = apply(None, &rendered).unwrap().body.unwrap();
let second = apply(Some(&first), &rendered).unwrap();
assert_eq!(second.outcome, ApplyOutcome::NoOp);
}
}
}