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() {
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");
let expected_count = lifeloop::manifest_registry().len() as u64;
let adapters = &v["adapters"];
assert_eq!(adapters["count"].as_u64(), Some(expected_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"));
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() {
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();
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}`");
}
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"
);
}