use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
use super::*;
use crate::session_manager::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use crate::session_manager::store::{SessionStore, StoreError};
fn record(id: ManagedSessionId, task: &str) -> SessionRecord {
SessionRecord {
id,
tmux_name: format!("tm-5007-{id}"),
cwd: PathBuf::from("/tmp"),
task: task.to_string(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
async fn corrupt_with_stale_tail(dir: &TempDir) -> (usize, usize, Vec<ManagedSessionId>) {
let ids = vec![ManagedSessionId::new(), ManagedSessionId::new()];
let long_task = "x".repeat(400);
let mut store = SessionStore::load(dir.path()).await.expect("load");
store
.upsert_many(ids.iter().map(|id| record(*id, &long_task)).collect())
.await
.expect("seed long");
let path = dir.path().join("sessions.json");
let long = std::fs::read_to_string(&path).expect("read long");
let mut store = SessionStore::load(dir.path()).await.expect("reload");
store
.upsert_many(ids.iter().map(|id| record(*id, "short")).collect())
.await
.expect("seed short");
let short = std::fs::read_to_string(&path).expect("read short");
assert!(
long.len() > short.len(),
"fixture invariant: the earlier document must be the longer one"
);
let tail = &long[short.len()..];
std::fs::write(&path, format!("{short}{tail}")).expect("write corrupt");
(short.len(), tail.len(), ids)
}
#[tokio::test]
async fn load_reports_a_trailing_tail_as_corruption_with_a_byte_offset() {
let dir = TempDir::new().expect("tempdir");
let (valid, trailing, _) = corrupt_with_stale_tail(&dir).await;
let err = SessionStore::load(dir.path())
.await
.expect_err("a store with a stale tail must not load");
let StoreError::Corrupt(integrity) = &err else {
panic!("expected StoreError::Corrupt, got {err:?}");
};
assert_eq!(
integrity.valid_prefix_bytes,
Some(valid),
"the diagnosis must locate the end of the valid document"
);
assert_eq!(
integrity.trailing_bytes(),
trailing,
"the diagnosis must count the stale tail"
);
let msg = err.to_string();
assert!(
msg.contains("sessions.json"),
"the message must name the file: {msg}"
);
assert!(
msg.contains(&format!("byte {valid}")),
"the message must name the byte offset: {msg}"
);
assert!(
msg.contains("tm repair session-store"),
"the message must name the repair command: {msg}"
);
}
#[tokio::test]
async fn corruption_is_distinguished_from_transient_io() {
let dir = TempDir::new().expect("tempdir");
corrupt_with_stale_tail(&dir).await;
let err = SessionStore::load(dir.path()).await.expect_err("corrupt");
assert!(err.is_corruption(), "a corrupt store is corruption");
let io = StoreError::Io(std::io::Error::other("nfs hiccup"));
assert!(
!io.is_corruption(),
"a transient I/O error must NOT be classified as corruption"
);
}
#[tokio::test]
async fn load_propagates_a_read_error_instead_of_reporting_an_empty_store() {
let dir = TempDir::new().expect("tempdir");
std::fs::create_dir(dir.path().join("sessions.json")).expect("mkdir over the store path");
let err = SessionStore::load(dir.path())
.await
.expect_err("an unreadable store must not load as empty");
assert!(
!matches!(err, StoreError::NotFound(_)),
"an unreadable store is not a missing session: {err:?}"
);
}
#[tokio::test]
async fn load_still_treats_an_absent_store_as_empty() {
let dir = TempDir::new().expect("tempdir");
let mut store = SessionStore::load(dir.path()).await.expect("load");
assert!(store.all().await.expect("all").is_empty());
}
#[tokio::test]
async fn store_records_and_clears_a_reload_failure() {
let dir = TempDir::new().expect("tempdir");
let mut store = SessionStore::load(dir.path()).await.expect("load");
store
.upsert(record(ManagedSessionId::new(), "task"))
.await
.expect("seed");
assert!(store.degradation().is_none(), "a fresh store is healthy");
let path = dir.path().join("sessions.json");
let good = std::fs::read_to_string(&path).expect("read good");
std::fs::write(&path, format!("{good}{good}")).expect("append a stale tail");
assert!(store.reload_if_changed().await.is_err(), "reload must fail");
let degraded = store.degradation().expect("the failure must be recorded");
assert!(degraded.corrupt, "a parse failure is corruption");
assert!(
degraded.message.contains("sessions.json"),
"the recorded message names the file: {}",
degraded.message
);
std::fs::write(&path, &good).expect("restore");
store.reload_if_changed().await.expect("reload succeeds");
assert!(
store.degradation().is_none(),
"a successful reload must clear the degradation"
);
}
#[test]
#[serial_test::serial]
fn log_reload_fallback_separates_corruption_from_a_transient_error() {
use tracing_subscriber::layer::SubscriberExt;
let corrupt = StoreError::Serialize("trailing characters".into());
let transient = StoreError::Io(std::io::Error::other("nfs hiccup"));
let buffer = trusty_common::log_buffer::LogBuffer::new(64);
let subscriber = tracing_subscriber::registry().with(
trusty_common::log_buffer::LogBufferLayer::new(buffer.clone()),
);
tracing::subscriber::with_default(subscriber, || {
crate::session_manager::store_health::log_reload_fallback(&corrupt, 3);
crate::session_manager::store_health::log_reload_fallback(&transient, 3);
});
let lines = buffer.tail(64);
let corrupt_line = lines
.iter()
.find(|l| l.contains("CORRUPT"))
.unwrap_or_else(|| panic!("no line reported the corrupt store: {lines:#?}"));
assert!(
corrupt_line.contains("ERROR"),
"a corrupt store never clears on its own and blocks every write, so it must be \
ERROR, not a level an operator can scroll past: {corrupt_line}"
);
let transient_line = lines
.iter()
.find(|l| l.contains("reload failed"))
.unwrap_or_else(|| panic!("no line reported the transient failure: {lines:#?}"));
assert!(
transient_line.contains("WARN"),
"a possibly-transient read failure must stay WARN, or corruption stops standing \
out from an NFS hiccup: {transient_line}"
);
}
#[tokio::test]
async fn two_stores_over_one_path_do_not_share_a_staging_file() {
let dir = TempDir::new().expect("tempdir");
let a = SessionStore::load(dir.path()).await.expect("load A");
let b = SessionStore::load(dir.path()).await.expect("load B");
assert_ne!(
a.staging_path_for_test(),
b.staging_path_for_test(),
"two writers of one store must never share a staging file"
);
assert_ne!(
a.staging_path_for_test(),
dir.path().join("sessions.json"),
"the staging file must not be the store itself"
);
assert_eq!(
a.staging_path_for_test().parent(),
Some(dir.path()),
"the staging file must be a sibling so the rename stays on one filesystem"
);
}
#[tokio::test]
async fn repair_backs_up_then_truncates_to_the_valid_document() {
let dir = TempDir::new().expect("tempdir");
let (valid, trailing, ids) = corrupt_with_stale_tail(&dir).await;
let path = dir.path().join("sessions.json");
let before = std::fs::read_to_string(&path).expect("read corrupt");
let now = Utc
.with_ymd_and_hms(2026, 8, 6, 9, 1, 17)
.single()
.expect("timestamp");
let outcome = repair(&path, false, now).expect("repair");
assert_eq!(outcome.kept_bytes, valid);
assert_eq!(outcome.discarded_bytes, trailing);
assert_eq!(outcome.sessions, 2);
assert!(
outcome.forced_orphans.is_empty(),
"the incident's tail names no unknown session"
);
assert_eq!(
std::fs::read_to_string(&outcome.backup).expect("read backup"),
before,
"the backup must hold the original bytes verbatim"
);
let mut store = SessionStore::load(dir.path())
.await
.expect("the repaired store must load");
let all = store.all().await.expect("all");
assert_eq!(all.len(), 2, "both sessions survive the repair");
for id in &ids {
assert!(store.get(id).await.is_ok(), "{id} survived");
}
store
.upsert(record(ManagedSessionId::new(), "post-repair write"))
.await
.expect("a repaired store must accept writes");
}
#[tokio::test]
async fn repair_refuses_an_orphaned_tail_without_force() {
let dir = TempDir::new().expect("tempdir");
let mut store = SessionStore::load(dir.path()).await.expect("load");
store
.upsert(record(ManagedSessionId::new(), "kept"))
.await
.expect("seed");
let path = dir.path().join("sessions.json");
let good = std::fs::read_to_string(&path).expect("read good");
let orphan = ManagedSessionId::new().to_string();
let corrupt = format!("{good}\n{{\"sessions\":{{\"{orphan}\":null}}}}");
std::fs::write(&path, &corrupt).expect("write corrupt");
let err = repair(&path, false, Utc::now()).expect_err("must refuse");
let RepairError::OrphanedSessions { orphans, .. } = &err else {
panic!("expected OrphanedSessions, got {err:?}");
};
assert!(
orphans.contains(&orphan),
"the refusal must name the at-risk id: {orphans:?}"
);
assert_eq!(
std::fs::read_to_string(&path).expect("re-read"),
corrupt,
"a refused repair must not modify the file"
);
}
#[tokio::test]
async fn repair_with_force_discards_an_orphaned_tail() {
let dir = TempDir::new().expect("tempdir");
let mut store = SessionStore::load(dir.path()).await.expect("load");
store
.upsert(record(ManagedSessionId::new(), "kept"))
.await
.expect("seed");
let path = dir.path().join("sessions.json");
let good = std::fs::read_to_string(&path).expect("read good");
let orphan = ManagedSessionId::new().to_string();
std::fs::write(
&path,
format!("{good}\n{{\"sessions\":{{\"{orphan}\":null}}}}"),
)
.expect("write corrupt");
let outcome = repair(&path, true, Utc::now()).expect("forced repair");
assert!(
outcome.forced_orphans.contains(&orphan),
"a forced repair must report what it discarded: {:?}",
outcome.forced_orphans
);
assert!(
SessionStore::load(dir.path()).await.is_ok(),
"the forced repair still produces a loadable store"
);
}
#[tokio::test]
async fn plan_refuses_a_healthy_store() {
let dir = TempDir::new().expect("tempdir");
let mut store = SessionStore::load(dir.path()).await.expect("load");
store
.upsert(record(ManagedSessionId::new(), "task"))
.await
.expect("seed");
let err = plan(&dir.path().join("sessions.json")).expect_err("healthy");
assert!(matches!(err, RepairError::NotCorrupt(_)), "got {err:?}");
}
#[test]
fn repair_rejects_a_file_with_no_valid_prefix() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("sessions.json");
std::fs::write(&path, r#"{"sessions": {"a": "#).expect("write");
let err = plan(&path).expect_err("no valid prefix");
assert!(matches!(err, RepairError::NoValidPrefix { .. }), "{err:?}");
}
#[test]
fn valid_prefix_bytes_measures_a_whole_document() {
let doc = r#"{"sessions":{}}"#;
assert_eq!(valid_prefix_bytes(doc), Some(doc.len()));
assert_eq!(
valid_prefix_bytes(&format!("{doc}garbage")),
Some(doc.len()),
"trailing junk must not move the document boundary"
);
}
#[test]
fn valid_prefix_bytes_is_none_for_a_truncated_head() {
assert_eq!(valid_prefix_bytes(r#"{"sessions": {"#), None);
}
#[test]
fn trailing_bytes_counts_the_discarded_tail() {
let base = StoreIntegrity {
path: PathBuf::from("/x/sessions.json"),
total_bytes: 146_201,
valid_prefix_bytes: Some(145_090),
repairable_by_truncation: true,
detail: "trailing characters".into(),
line: 3755,
column: 2,
};
assert_eq!(base.trailing_bytes(), 1111, "the incident's own numbers");
assert_eq!(
StoreIntegrity {
valid_prefix_bytes: None,
..base
}
.trailing_bytes(),
146_201,
"with no valid prefix the whole file is unusable"
);
}
#[test]
fn diagnostic_names_the_file_the_offset_and_the_repair_command() {
let msg = StoreIntegrity {
path: PathBuf::from("/x/sessions.json"),
total_bytes: 146_201,
valid_prefix_bytes: Some(145_090),
repairable_by_truncation: true,
detail: "trailing characters at line 3755 column 2".into(),
line: 3755,
column: 2,
}
.diagnostic();
assert!(msg.contains("/x/sessions.json"), "{msg}");
assert!(msg.contains("byte 145090"), "{msg}");
assert!(msg.contains("1111 trailing byte(s)"), "{msg}");
assert!(msg.contains("tm repair session-store"), "{msg}");
}
#[test]
fn diagnostic_does_not_advertise_a_repair_that_would_refuse() {
let raw = r#"{"sessions": 5}"#;
let integrity =
validate(&PathBuf::from("/x/sessions.json"), raw).expect_err("valid JSON, but not a store");
assert_eq!(
integrity.trailing_bytes(),
0,
"the whole file is one complete document"
);
assert!(
!integrity.repairable_by_truncation,
"there is nothing for a truncation to cut"
);
let msg = integrity.diagnostic();
assert!(
!msg.contains("tm repair session-store"),
"the message must not name a command that refuses this file: {msg}"
);
assert!(
!msg.contains("trailing byte(s)"),
"and must not describe trailing junk that is not there: {msg}"
);
assert!(
msg.contains("not a session store"),
"it must say what is actually wrong: {msg}"
);
}
#[tokio::test]
async fn plan_and_the_store_agree_that_valid_json_is_not_a_store() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("sessions.json");
std::fs::write(&path, r#"{"sessions": 5}"#).expect("write");
assert!(
SessionStore::load(dir.path()).await.is_err(),
"the daemon cannot load this file"
);
let err = plan(&path).expect_err("neither may the repair call it healthy");
assert!(
matches!(err, RepairError::NotAStore { .. }),
"expected NotAStore, got {err:?}"
);
assert_eq!(
std::fs::read_to_string(&path).expect("re-read"),
r#"{"sessions": 5}"#,
"a refused plan must not modify the file"
);
}
#[test]
fn plan_refuses_to_cut_down_to_a_head_that_is_not_a_store() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("sessions.json");
std::fs::write(&path, r#"{"sessions":{"a":{}}}trailing"#).expect("write");
let err = plan(&path).expect_err("the head is not a loadable store");
assert!(
matches!(err, RepairError::NotAStore { .. }),
"expected NotAStore, got {err:?}"
);
}
#[test]
fn analyze_locates_the_valid_prefix_of_a_trailing_tail() {
let raw = r#"{"sessions":{}}trailing"#;
let err = serde_json::from_str::<serde_json::Value>(raw).expect_err("must fail");
let got = analyze(&PathBuf::from("/x/sessions.json"), raw, &err);
assert_eq!(got.valid_prefix_bytes, Some(15));
assert_eq!(got.total_bytes, raw.len());
assert_eq!(got.trailing_bytes(), raw.len() - 15);
}
#[test]
fn analyze_reports_no_valid_prefix_for_a_broken_head() {
let raw = r#"{"sessions": {"#;
let err = serde_json::from_str::<serde_json::Value>(raw).expect_err("must fail");
let got = analyze(&PathBuf::from("/x/sessions.json"), raw, &err);
assert_eq!(got.valid_prefix_bytes, None);
}
#[test]
fn backup_path_is_a_timestamped_sibling() {
let now = Utc
.with_ymd_and_hms(2026, 8, 6, 9, 1, 17)
.single()
.expect("timestamp");
let got = backup_path(&PathBuf::from("/x/session-manager/sessions.json"), now);
assert_eq!(
got,
PathBuf::from("/x/session-manager/sessions.json.corrupt-backup-20260806-090117")
);
}
#[test]
fn kept_ids_come_from_the_map_keys_not_the_document_text() {
let key = "aaaaaaaa-1111-2222-3333-444444444444";
let mentioned = "bbbbbbbb-1111-2222-3333-444444444444";
let raw = loadable_store_json(&[(key, &format!("see {mentioned}"))]);
let data = validate(&PathBuf::from("/x/sessions.json"), &raw).expect("a loadable store");
let ids = kept_ids(&data);
assert!(ids.contains(key), "{ids:?}");
assert!(
!ids.contains(mentioned),
"a uuid inside a task string is not a session key: {ids:?}"
);
}
#[test]
fn orphan_scan_sees_an_id_that_straddles_the_cut() {
let id = "aaaaaaaa-2222-3333-4444-555555555555";
let raw = format!("HEAD{id}TAIL");
let valid = "HEAD".len() + 24;
assert!(
raw[valid..].starts_with("555555555555"),
"fixture invariant: the cut must split the id"
);
let got = orphaned_ids(&raw, valid, &BTreeSet::new());
assert!(
got.contains(&id.to_string()),
"an id spanning the cut must still be reported: {got:?}"
);
}
#[test]
fn uuid_tokens_finds_ids_in_a_json_fragment() {
let frag = r#""aaaaAAAA-1111-2222-3333-444444444444": {"state": "active"#;
let found = uuid_tokens(frag);
assert!(
found.contains("aaaaaaaa-1111-2222-3333-444444444444"),
"{found:?}"
);
}
#[test]
fn uuid_tokens_ignores_non_uuid_text() {
assert!(uuid_tokens("no identifiers here, just prose and 1234-5678").is_empty());
assert!(
uuid_tokens("zzzzzzzz-1111-2222-3333-444444444444").is_empty(),
"non-hex characters are not a uuid"
);
}