use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use uuid::Uuid;
use traverse_runtime::events::{BrokerClock, JournalConfig, JournalError};
use traverse_runtime::trace::{
DurableTraceJournal, PrivateTraceEntry, PublicTraceEntry, TraceJournalError, TraceOutcome,
};
fn test_root(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("traverse-trace-journal-{name}-{}", Uuid::new_v4()))
}
fn sample_public(id: &str) -> PublicTraceEntry {
PublicTraceEntry::new(
id.to_string(),
"durable.trace.tests.subject".to_string(),
"Local".to_string(),
TraceOutcome::Success,
12,
"2026-08-24T00:00:00Z".to_string(),
)
}
fn sample_private(id: &str) -> PrivateTraceEntry {
PrivateTraceEntry::new(
id.to_string(),
"super-secret raw input",
"super-secret raw output",
12,
)
}
struct TestClock {
now: Mutex<SystemTime>,
}
impl TestClock {
fn at_secs(secs: u64) -> Arc<Self> {
Arc::new(Self {
now: Mutex::new(SystemTime::UNIX_EPOCH + Duration::from_secs(secs)),
})
}
fn advance(&self, secs: u64) {
let Ok(mut guard) = self.now.lock() else {
return;
};
*guard += Duration::from_secs(secs);
}
}
impl BrokerClock for TestClock {
fn now(&self) -> SystemTime {
self.now
.lock()
.map_or(SystemTime::UNIX_EPOCH, |guard| *guard)
}
}
#[test]
fn recorded_trace_survives_a_full_close_and_reopen() -> Result<(), String> {
let root = test_root("survives-reopen");
let clock = TestClock::at_secs(1_000);
{
let mut journal =
DurableTraceJournal::open_with_clock(&root, JournalConfig::default(), clock.clone())
.map_err(|e| e.to_string())?;
journal
.record(&sample_public("trace-a"), Some(&sample_private("trace-a")))
.map_err(|e| e.to_string())?;
}
let reopened = DurableTraceJournal::open_with_clock(&root, JournalConfig::default(), clock)
.map_err(|e| e.to_string())?;
assert_eq!(
reopened.recovery_report().recovered_trace_ids,
vec!["trace-a".to_string()]
);
fs::remove_dir_all(&root).ok();
Ok(())
}
#[test]
fn recovery_discards_only_a_torn_final_record() -> Result<(), String> {
let root = test_root("torn-tail");
let clock = TestClock::at_secs(1_000);
{
let mut journal =
DurableTraceJournal::open_with_clock(&root, JournalConfig::default(), clock.clone())
.map_err(|e| e.to_string())?;
journal
.record(&sample_public("trace-a"), Some(&sample_private("trace-a")))
.map_err(|e| e.to_string())?;
}
let segment_path = fs::read_dir(&root)
.map_err(|e| e.to_string())?
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.find(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
.ok_or_else(|| "expected exactly one segment file".to_string())?;
let mut bytes = fs::read(&segment_path).map_err(|e| e.to_string())?;
bytes.extend_from_slice(b"{\"seq\":2,\"truncated");
fs::write(&segment_path, &bytes).map_err(|e| e.to_string())?;
let reopened = DurableTraceJournal::open_with_clock(&root, JournalConfig::default(), clock)
.map_err(|e| e.to_string())?;
assert_eq!(
reopened.recovery_report().recovered_trace_ids,
vec!["trace-a".to_string()]
);
fs::remove_dir_all(&root).ok();
Ok(())
}
#[test]
fn recovery_fails_loudly_on_a_corrupt_completed_record() -> Result<(), String> {
let root = test_root("corrupt-completed");
fs::create_dir_all(&root).map_err(|e| e.to_string())?;
fs::write(root.join("segment-1.jsonl"), b"not-json\n").map_err(|e| e.to_string())?;
let result = DurableTraceJournal::open_with_clock(
&root,
JournalConfig::default(),
TestClock::at_secs(1_000),
);
match result {
Ok(_) => return Err("expected open() to fail on a corrupt completed record".to_string()),
Err(TraceJournalError::Journal(JournalError::Corrupt { .. })) => {}
Err(other) => return Err(format!("expected Corrupt journal error, got {other}")),
}
fs::remove_dir_all(&root).ok();
Ok(())
}
#[test]
fn durable_record_never_contains_raw_input_or_output_payloads() -> Result<(), String> {
let root = test_root("no-raw-payloads");
let clock = TestClock::at_secs(1_000);
let mut journal = DurableTraceJournal::open_with_clock(&root, JournalConfig::default(), clock)
.map_err(|e| e.to_string())?;
let public = sample_public("trace-secret");
let private = sample_private("trace-secret");
journal
.record(&public, Some(&private))
.map_err(|e| e.to_string())?;
let segment_path = fs::read_dir(&root)
.map_err(|e| e.to_string())?
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.find(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
.ok_or_else(|| "expected exactly one segment file".to_string())?;
let on_disk = fs::read_to_string(&segment_path).map_err(|e| e.to_string())?;
assert!(
!on_disk.contains("super-secret raw input"),
"durable record must never contain the raw input payload"
);
assert!(
!on_disk.contains("super-secret raw output"),
"durable record must never contain the raw output payload"
);
assert!(
on_disk.contains(&private.inputs_hash),
"durable record must contain the canonical input hash"
);
assert!(
on_disk.contains(&private.outputs_hash),
"durable record must contain the canonical output hash"
);
fs::remove_dir_all(&root).ok();
Ok(())
}
#[test]
fn prune_reclaims_oldest_segments_first_and_reports_evidence() -> Result<(), String> {
let root = test_root("prune-oldest-first");
let clock = TestClock::at_secs(1_000);
let config = JournalConfig {
max_segment_bytes: 1, max_segment_age_secs: 600,
retention_max_age_secs: Some(50),
retention_max_total_bytes: None,
};
let mut journal = DurableTraceJournal::open_with_clock(&root, config, clock.clone())
.map_err(|e| e.to_string())?;
journal
.record(&sample_public("trace-old"), None)
.map_err(|e| e.to_string())?;
clock.advance(100); journal
.record(&sample_public("trace-new"), None)
.map_err(|e| e.to_string())?;
let evidence = journal.prune().map_err(|e| e.to_string())?;
assert_eq!(evidence.workspace_root, root);
assert_eq!(
evidence.deleted_segment_paths.len(),
1,
"exactly the aged-out oldest segment must be reclaimed, not the active one"
);
fs::remove_dir_all(&root).ok();
Ok(())
}
#[test]
fn two_workspace_roots_are_fully_isolated_from_each_other() -> Result<(), String> {
let root_a = test_root("workspace-a");
let root_b = test_root("workspace-b");
let clock = TestClock::at_secs(1_000);
let mut journal_a =
DurableTraceJournal::open_with_clock(&root_a, JournalConfig::default(), clock.clone())
.map_err(|e| e.to_string())?;
let mut journal_b =
DurableTraceJournal::open_with_clock(&root_b, JournalConfig::default(), clock)
.map_err(|e| e.to_string())?;
journal_a
.record(&sample_public("trace-workspace-a"), None)
.map_err(|e| e.to_string())?;
journal_b
.record(&sample_public("trace-workspace-b"), None)
.map_err(|e| e.to_string())?;
assert_eq!(
journal_a.recovery_report().recovered_trace_ids,
Vec::<String>::new(),
"recovery report is a frozen open-time snapshot, unaffected by later records"
);
let reopened_a = DurableTraceJournal::open_with_clock(
&root_a,
JournalConfig::default(),
TestClock::at_secs(2_000),
)
.map_err(|e| e.to_string())?;
let reopened_b = DurableTraceJournal::open_with_clock(
&root_b,
JournalConfig::default(),
TestClock::at_secs(2_000),
)
.map_err(|e| e.to_string())?;
assert_eq!(
reopened_a.recovery_report().recovered_trace_ids,
vec!["trace-workspace-a".to_string()]
);
assert_eq!(
reopened_b.recovery_report().recovered_trace_ids,
vec!["trace-workspace-b".to_string()]
);
fs::remove_dir_all(&root_a).ok();
fs::remove_dir_all(&root_b).ok();
Ok(())
}