#[cfg(test)]
use anyhow::Context as _;
#[cfg(test)]
pub(crate) fn read_entries(path: &std::path::Path) -> anyhow::Result<Vec<JournalEntry>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read journal {}", path.display()))?;
content
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).context("invalid JSON"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tempfile::TempDir;
#[test]
fn journal_path_appends_atomwrite_journal_json() {
let target = Path::new("/tmp/foo.txt");
let jp = journal_path(target);
assert!(jp.ends_with(".atomwrite.journal.foo.txt.atomwrite.journal.json"));
}
#[test]
fn journal_started_creates_sidecar_and_records_op_id() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("file.txt");
let before = blake3::hash(b"old");
let after = blake3::hash(b"new");
let op_id = journal_started(&target, JournalOp::Write, Some(before), after).unwrap();
assert_eq!(op_id.len(), 16);
let jp = journal_path(&target);
assert!(jp.exists());
let entries = read_entries(&jp).unwrap();
assert_eq!(entries.len(), 1);
let JournalEntry::Started {
op_id: recorded_id,
op,
target: t,
checksum_before: cb,
checksum_after: ca,
pid,
started_at_unix,
} = &entries[0]
else {
panic!("expected Started entry");
};
assert_eq!(recorded_id, &op_id);
assert_eq!(*op, JournalOp::Write);
assert_eq!(t, &target.display().to_string());
assert_eq!(cb.as_deref(), Some(before.to_hex().to_string().as_str()));
assert_eq!(ca, &after.to_hex().to_string());
assert_eq!(*pid, std::process::id());
assert!(*started_at_unix > 0);
}
#[test]
fn journal_committed_after_started_does_not_orphan() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("file.txt");
let op_id = journal_started(&target, JournalOp::Edit, None, blake3::hash(b"x")).unwrap();
journal_committed(&target, &op_id).unwrap();
let reports = recover_orphan_journals(tmp.path()).unwrap();
assert!(
reports.is_empty(),
"expected zero orphans, got {:?}",
reports
);
}
#[test]
fn orphan_detected_when_started_without_committed() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("file.txt");
let op_id = journal_started(
&target,
JournalOp::Write,
Some(blake3::hash(b"old")),
blake3::hash(b"new"),
)
.unwrap();
let reports = recover_orphan_journals(tmp.path()).unwrap();
assert_eq!(reports.len(), 1);
let r = &reports[0];
assert_eq!(r.op_id, op_id);
assert_eq!(r.op, JournalOp::Write);
assert_eq!(r.target, target.display().to_string());
assert!(r.checksum_before.is_some());
assert_eq!(r.pid, std::process::id());
}
#[test]
fn journal_aborted_clears_orphan() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("file.txt");
let op_id = journal_started(&target, JournalOp::Replace, None, blake3::hash(b"x")).unwrap();
journal_aborted(&target, &op_id, "caller cancelled").unwrap();
let reports = recover_orphan_journals(tmp.path()).unwrap();
assert!(reports.is_empty());
}
#[test]
fn generate_op_id_is_16_hex_chars_and_unique() {
let a = generate_op_id();
let b = generate_op_id();
assert_eq!(a.len(), 16);
assert_eq!(b.len(), 16);
assert_ne!(a, b);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn recover_on_empty_dir_returns_empty() {
let tmp = TempDir::new().unwrap();
let reports = recover_orphan_journals(tmp.path()).unwrap();
assert!(reports.is_empty());
}
#[test]
fn recover_on_missing_dir_returns_empty() {
let missing = std::env::temp_dir().join("atomwrite-test-missing-dir-xyz");
let _ = fs::remove_dir_all(&missing);
let reports = recover_orphan_journals(&missing).unwrap();
assert!(reports.is_empty());
}
#[test]
fn l1_never_policy_always_returns_false() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("big.bin");
std::fs::write(&target, vec![0u8; 5_000_000]).unwrap();
assert!(!should_create_sidecar(
&target,
JournalOp::Write,
WalPolicy::Never
));
assert!(!should_create_sidecar(
&target,
JournalOp::Edit,
WalPolicy::Never
));
}
#[test]
fn l1_always_policy_always_returns_true() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("small.txt");
std::fs::write(&target, "x").unwrap();
assert!(should_create_sidecar(
&target,
JournalOp::Write,
WalPolicy::Always
));
assert!(should_create_sidecar(
&target,
JournalOp::Set,
WalPolicy::Always
));
}
#[test]
fn l1_auto_policy_returns_true_for_large_file() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("huge.bin");
std::fs::write(&target, vec![0u8; (L1_LARGE_FILE_BYTES + 1) as usize]).unwrap();
assert!(should_create_sidecar(
&target,
JournalOp::Write,
WalPolicy::Auto
));
}
#[test]
fn l1_auto_policy_returns_true_for_edit_op() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("code.rs");
std::fs::write(&target, "fn x() {}").unwrap();
assert!(should_create_sidecar(
&target,
JournalOp::Edit,
WalPolicy::Auto
));
assert!(should_create_sidecar(
&target,
JournalOp::Replace,
WalPolicy::Auto
));
}
#[test]
fn l1_auto_policy_skips_trivial_file() {
let tmp = TempDir::new().unwrap();
let parent = tmp.path().join("gitty");
std::fs::create_dir_all(parent.join(".git")).unwrap();
let target = parent.join("small.txt");
std::fs::write(&target, "hi").unwrap();
assert!(!should_create_sidecar(
&target,
JournalOp::Write,
WalPolicy::Auto
));
}
#[test]
fn l4_h1_ttl_default_zero_returns_false() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
assert!(!heuristics::h1_ttl(now));
}
#[test]
fn l4_h2_lru_within_cap_returns_true_when_count_low() {
let result = heuristics::h2_lru_within_cap(50, 25);
assert!(
result,
"sidecar within the LRU cap must be preserved (default cap=100)"
);
}
#[test]
fn l4_h2_lru_returns_true_when_count_at_or_below_default_cap() {
let result = heuristics::h2_lru_within_cap(100, 99);
assert!(result, "sidecar at the LRU cap boundary must be preserved");
}
#[test]
fn l4_h3_rate_limit_returns_false_below_threshold() {
let result = heuristics::h3_rate_limit();
assert!(
!result,
"first call in a fresh window must not be throttled (default K=10/min)"
);
}
#[test]
fn l4_h4_sentinel_returns_true_when_file_exists() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("data.txt");
std::fs::write(tmp.path().join(".atomwrite_no_wal"), "").unwrap();
assert!(heuristics::h4_sentinel(&target));
}
#[test]
fn l4_h4_sentinel_returns_false_when_absent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("data.txt");
assert!(!heuristics::h4_sentinel(&target));
}
#[test]
fn l4_h5_archive_returns_false_for_recent_journal_under_default() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let one_day_ago = now.saturating_sub(86_400);
let result = heuristics::h5_archive(one_day_ago);
assert!(
!result,
"1-day-old journal is below the default 7-day archive threshold"
);
}
#[test]
fn l4_h5_archive_returns_true_for_journal_older_than_7_days() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let eight_days_ago = now.saturating_sub(8 * 86_400);
let result = heuristics::h5_archive(eight_days_ago);
assert!(
result,
"8-day-old journal is past the 7-day archive threshold"
);
}
#[test]
fn l4_engine_returns_false_when_all_heuristics_disabled() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("file.txt");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let very_high_rank: u64 = 10_000;
let very_high_count: u64 = 10_000;
assert!(!heuristics::h2_lru_within_cap(
very_high_count,
very_high_rank
));
assert!(!heuristics::h5_archive(now));
let _ = heuristics_should_preserve(&target, now, 0, 0);
}
#[test]
fn l3_auto_heal_on_empty_workspace_reports_zero() {
let tmp = TempDir::new().unwrap();
let report = auto_heal_on_startup(tmp.path(), 3600, 100).unwrap();
assert_eq!(report.removed, 0);
assert_eq!(report.preserved, 0);
assert_eq!(report.malformed, 0);
assert_eq!(report.threshold_secs, 3600);
}
#[test]
fn l3_auto_heal_reaps_old_committed_preserves_started() {
let tmp = TempDir::new().unwrap();
let committed_path = tmp
.path()
.join(".atomwrite.journal.committed.atomwrite.journal.json");
let started_path = tmp
.path()
.join(".atomwrite.journal.started.atomwrite.journal.json");
let old_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.saturating_sub(10_000);
std::fs::write(
&committed_path,
format!(
"{{\"phase\":\"started\",\"op_id\":\"a\",\"op\":\"write\",\"target\":\"x\",\"checksum_before\":null,\"checksum_after\":\"b\",\"pid\":1,\"started_at_unix\":{old_unix}}}\n\
{{\"phase\":\"committed\",\"op_id\":\"a\",\"committed_at_unix\":{old_unix}}}\n"
),
)
.unwrap();
let started_unix = old_unix;
std::fs::write(
&started_path,
format!(
"{{\"phase\":\"started\",\"op_id\":\"b\",\"op\":\"write\",\"target\":\"y\",\"checksum_before\":null,\"checksum_after\":\"c\",\"pid\":1,\"started_at_unix\":{started_unix}}}\n"
),
)
.unwrap();
let report = auto_heal_on_startup(tmp.path(), 1, 100).unwrap();
assert_eq!(report.removed, 1, "exactly the old Committed is reaped");
assert_eq!(report.preserved, 1, "Started is preserved");
assert!(!committed_path.exists(), "Committed sidecar is gone");
assert!(started_path.exists(), "Started sidecar survives");
assert!(report.bytes_reclaimed > 0);
}
#[test]
fn l3_auto_heal_respects_budget() {
let tmp = TempDir::new().unwrap();
for i in 0..50 {
let path = tmp
.path()
.join(format!(".atomwrite.journal.file{i}.atomwrite.journal.json"));
std::fs::write(
&path,
format!("{{\"phase\":\"committed\",\"op_id\":\"x{i}\",\"committed_at_unix\":1}}\n"),
)
.unwrap();
}
let start = Instant::now();
let report = auto_heal_on_startup(tmp.path(), 1, 100).unwrap();
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 1000,
"50-sidecar heal should complete in <1s (budget was 100ms, allowed slack for slow hosts)"
);
assert_eq!(report.removed, 50);
}
#[test]
fn l4_release_records_committed_at_unix() {
let mut g = JournalGuard {
path: PathBuf::from("/tmp/.atomwrite.journal.x.atomwrite.journal.json"),
keep_on_drop: true,
op_id: Some("op_test".into()),
committed_at_unix: None,
};
g.release();
let recorded = g.committed_at_unix.expect("release must record timestamp");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
assert!(now.abs_diff(recorded) <= 2);
}
#[test]
fn l4_drop_preserves_sidecar_when_h4_sentinel_votes() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".atomwrite_no_wal"), "").unwrap();
let sidecar = tmp
.path()
.join(".atomwrite.journal.x.atomwrite.journal.json");
std::fs::write(&sidecar, "stub").unwrap();
{
let mut g = JournalGuard {
path: sidecar.clone(),
keep_on_drop: true,
op_id: Some("op".into()),
committed_at_unix: None,
};
g.release();
}
assert!(
sidecar.exists(),
"L4 (h4_sentinel via .atomwrite_no_wal) must preserve the sidecar on drop"
);
}
#[test]
fn l4_drop_removes_sidecar_when_no_heuristic_preserves() {
let tmp = TempDir::new().unwrap();
let sidecar = tmp
.path()
.join(".atomwrite.journal.x.atomwrite.journal.json");
std::fs::write(&sidecar, "stub").unwrap();
{
let mut g = JournalGuard {
path: sidecar.clone(),
keep_on_drop: true,
op_id: Some("op".into()),
committed_at_unix: None,
};
g.release();
}
assert!(
!sidecar.exists(),
"L2 must reap the sidecar when no L4 heuristic votes to preserve"
);
}
}