lifeloop-cli 0.5.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! CLI integration tests for `lifeloop status`.
//!
//! `status` is a read-only workspace lifecycle overview. These tests
//! drive the binary end-to-end with `--state-dir` pointing at a temp
//! directory so the renewal / host-context snapshots are controlled,
//! mirroring the pattern in `tests/cli_manifest.rs`.

use std::fs;
use std::process::{Command, Stdio};

use tempfile::tempdir;

fn lifeloop_bin() -> std::path::PathBuf {
    std::path::PathBuf::from(env!("CARGO_BIN_EXE_lifeloop"))
}

fn run(args: &[&str]) -> (i32, String, String) {
    let out = Command::new(lifeloop_bin())
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .expect("spawn lifeloop");
    (
        out.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
    )
}

#[test]
fn status_emits_adapter_registry_and_omits_absent_snapshots() {
    // An empty state dir: adapters always present; the workspace
    // snapshots are absent, so both keys are omitted entirely (not `null`)
    // rather than faked.
    let dir = tempdir().unwrap();
    let state_dir = dir.path().to_str().unwrap();
    let (code, stdout, stderr) = run(&["status", "--state-dir", state_dir]);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).expect("status output is JSON");

    // Assert `status` reflects the live registry rather than a hard-coded
    // size, so growing/shrinking the registry can't silently desync this.
    let expected_count = lifeloop::manifest_registry().len() as u64;
    let adapters = &v["adapters"];
    assert_eq!(adapters["count"].as_u64(), Some(expected_count));
    // The conformance breakdown sums to the adapter count.
    let by: &serde_json::Map<_, _> = adapters["by_conformance"].as_object().unwrap();
    let summed: u64 = by.values().map(|n| n.as_u64().unwrap()).sum();
    assert_eq!(
        summed, expected_count,
        "by_conformance must cover every adapter"
    );
    assert!(by.contains_key("v1_conformance"));
    let ids: Vec<&str> = adapters["adapters"]
        .as_array()
        .unwrap()
        .iter()
        .map(|e| e["adapter_id"].as_str().unwrap())
        .collect();
    assert!(ids.contains(&"codex") && ids.contains(&"claude"));

    // No snapshot files written ⇒ keys omitted entirely, not hollow/null.
    assert!(v.get("host_context").is_none(), "absent ⇒ key omitted");
    assert!(v.get("renewal").is_none(), "absent ⇒ key omitted");
    assert_eq!(v["state_dir"].as_str(), Some(state_dir));
}

#[test]
fn status_surfaces_renewal_snapshot_when_present() {
    let dir = tempdir().unwrap();
    let renewal = serde_json::json!({
        "schema_version": "lifeloop.v0.3",
        "state": "fulfilled",
        "client_id": "client-abc",
        "adapter_id": "codex",
        "updated_at_epoch_s": 1_700_000_000u64,
        "pending_token_present": false,
    });
    fs::write(
        dir.path().join("renewal-status.json"),
        serde_json::to_vec(&renewal).unwrap(),
    )
    .unwrap();

    let (code, stdout, stderr) = run(&["status", "--state-dir", dir.path().to_str().unwrap()]);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(v["renewal"]["state"].as_str(), Some("fulfilled"));
    assert_eq!(v["renewal"]["adapter_id"].as_str(), Some("codex"));
}

#[test]
fn status_surfaces_host_compaction_signal_when_present() {
    let dir = tempdir().unwrap();
    let host_ctx = serde_json::json!({
        "schema_version": "1",
        "host": "claude",
        "session_id": "sess-1",
        "compacted": true,
        "observed_at_epoch_s": 1_700_000_000u64,
    });
    fs::write(
        dir.path().join("host-context-status.json"),
        serde_json::to_vec(&host_ctx).unwrap(),
    )
    .unwrap();

    let (code, stdout, stderr) = run(&["status", "--state-dir", dir.path().to_str().unwrap()]);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(v["host_context"]["compacted"].as_bool(), Some(true));
    assert_eq!(v["host_context"]["host"].as_str(), Some("claude"));
}

#[test]
fn status_reconciles_stale_pending_continuation_against_missing_token() {
    // Codex review regression: a snapshot recording `pending_continuation`
    // with `pending_token_present: true` but NO live pending-token file
    // must not be echoed verbatim — `status` reconciles through the same
    // path as `renewal status`, so the vanished token surfaces as a
    // failed/state_conflict result, never a phantom "token present".
    let dir = tempdir().unwrap();
    let renewal = serde_json::json!({
        "schema_version": "lifeloop.v0.3",
        "state": "pending_continuation",
        "client_id": "client-abc",
        "adapter_id": "codex",
        "updated_at_epoch_s": 1_700_000_000u64,
        "pending_token_present": true,
    });
    fs::write(
        dir.path().join("renewal-status.json"),
        serde_json::to_vec(&renewal).unwrap(),
    )
    .unwrap();
    // No <client>-renewal-pending.json file exists.

    let (code, stdout, stderr) = run(&["status", "--state-dir", dir.path().to_str().unwrap()]);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(
        v["renewal"]["pending_token_present"].as_bool(),
        Some(false),
        "vanished token must be reconciled away, not echoed: {}",
        v["renewal"],
    );
    assert_eq!(
        v["renewal"]["state"].as_str(),
        Some("failed"),
        "pending_continuation with no token reconciles to failed: {}",
        v["renewal"],
    );
}

#[test]
fn status_unknown_flag_is_usage_error() {
    let (code, _stdout, stderr) = run(&["status", "--bogus"]);
    assert_eq!(code, 2, "expected usage exit (2), got {code}");
    assert!(stderr.contains("unknown flag"), "stderr=`{stderr}`");
}

// ---------------------------------------------------------------------------
// State-directory resolution (`renewal::default_state_dir`, surfaced via the
// `state_dir` field of `status`). Without `--state-dir`, the dir is derived
// from the workspace `--path`: git's per-worktree `lifeloop/renewal` path when
// the path is inside a repo, else a `.lifeloop/renewal` fallback.
// ---------------------------------------------------------------------------

/// Run `status` localized to `path` with the ambient `GIT_*` location vars
/// stripped, so the child `git rev-parse` resolves against `--path` rather
/// than any outer repo a test harness (e.g. lefthook) may have set.
fn run_status_at_path(path: &std::path::Path) -> (i32, String, String) {
    let out = Command::new(lifeloop_bin())
        .args(["status", "--path", &path.display().to_string()])
        .env_remove("GIT_DIR")
        .env_remove("GIT_COMMON_DIR")
        .env_remove("GIT_WORK_TREE")
        .env_remove("GIT_INDEX_FILE")
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .expect("spawn lifeloop");
    (
        out.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
    )
}

fn git(path: &std::path::Path, args: &[&str]) {
    let status = Command::new("git")
        .args(args)
        .current_dir(path)
        .env_remove("GIT_DIR")
        .env_remove("GIT_COMMON_DIR")
        .env_remove("GIT_WORK_TREE")
        .env_remove("GIT_INDEX_FILE")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .expect("spawn git");
    assert!(status.success(), "git {args:?} failed");
}

#[test]
fn status_state_dir_resolves_to_git_lifeloop_renewal_inside_repo() {
    let tmp = tempdir().unwrap();
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).unwrap();
    git(&repo, &["init"]);

    let (code, stdout, stderr) = run_status_at_path(&repo);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).expect("status output is JSON");
    let state_dir = v["state_dir"].as_str().expect("state_dir present");
    assert!(
        state_dir.ends_with("lifeloop/renewal"),
        "in-repo state dir must live under git's lifeloop/renewal path: {state_dir}"
    );
    assert!(
        state_dir.contains(".git"),
        "in-repo state dir must resolve through the git dir: {state_dir}"
    );
}

#[test]
fn status_state_dir_falls_back_to_dot_lifeloop_outside_repo() {
    let tmp = tempdir().unwrap();
    let plain = tmp.path().join("plain");
    fs::create_dir(&plain).unwrap();

    let (code, stdout, stderr) = run_status_at_path(&plain);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).expect("status output is JSON");
    let state_dir = v["state_dir"].as_str().expect("state_dir present");
    assert_eq!(
        state_dir,
        plain
            .join(".lifeloop")
            .join("renewal")
            .display()
            .to_string(),
        "non-repo state dir must fall back to <path>/.lifeloop/renewal"
    );
}