use super::*;
use crate::events::{EventResult, log_event, read_events, rotate_events_if_needed};
use chrono::TimeZone;
fn at(day: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 9, day, 12, 0, 0).unwrap()
}
fn hit(n: u64, session: &str) -> BuildEvent {
let mut event = BuildEvent::new_for_test(&format!("crate_{n}"), EventResult::LocalHit);
event.ts = at(1) + chrono::Duration::seconds(n as i64);
event.session_id = session.to_string();
event.compile_time_ms = n;
event.reflinked_bytes = n;
event.hardlinked_bytes = 2 * n;
event.copied_bytes = 3 * n;
event
}
fn totals(since: DateTime<Utc>) -> Totals {
Totals::empty(since)
}
fn expected(events: &[BuildEvent]) -> Totals {
Totals::from_events(events).unwrap()
}
fn log_and_rotate(config: &Config, events: &[BuildEvent], max_size: u64, keep_lines: usize) {
for event in events {
log_event(&config.event_log_path(), event).unwrap();
rotate_events_if_needed(
&config.event_log_path(),
max_size,
keep_lines,
&config.cache_dir,
)
.unwrap();
}
}
#[test]
fn from_events_uses_the_stats_definitions() {
let mut prefetched = hit(10, "");
prefetched.result = EventResult::PrefetchHit;
let mut remote = hit(100, "");
remote.result = EventResult::RemoteHit;
let mut miss = hit(1000, "");
miss.result = EventResult::Miss;
let events = [hit(1, ""), prefetched, remote, miss];
let t = Totals::from_events(&events).unwrap();
assert_eq!(t.since, at(1) + chrono::Duration::seconds(1));
assert_eq!(t.hits, 3, "a miss is not a hit");
assert_eq!(t.hit_compile_time_ms, 111, "a miss avoids nothing");
assert_eq!(t.zero_copy_bytes, 1111 + 2 * 1111);
assert_eq!(t.copied_bytes, 3 * 1111);
assert_eq!(t.pruned_bytes(), 0);
assert_eq!(Totals::from_events(&[]), None);
}
#[test]
fn absorb_adds_every_field_and_keeps_the_earlier_start() {
let mut a = Totals {
since: at(5),
hits: 1,
hit_compile_time_ms: 2,
zero_copy_bytes: 3,
copied_bytes: 4,
pruned_automatic_bytes: 5,
pruned_requested_bytes: 6,
};
let b = Totals {
since: at(3),
hits: 10,
hit_compile_time_ms: 20,
zero_copy_bytes: 30,
copied_bytes: 40,
pruned_automatic_bytes: 50,
pruned_requested_bytes: 60,
};
a.absorb(&b);
assert_eq!(
a,
Totals {
since: at(3),
hits: 11,
hit_compile_time_ms: 22,
zero_copy_bytes: 33,
copied_bytes: 44,
pruned_automatic_bytes: 55,
pruned_requested_bytes: 66,
}
);
assert_eq!(a.pruned_bytes(), 121);
let mut later = totals(at(9));
later.absorb(&totals(at(7)));
assert_eq!(later.since, at(7));
}
#[test]
fn merge_keeps_whichever_side_exists() {
let mut one = totals(at(2));
one.hits = 1;
let mut two = totals(at(4));
two.hits = 2;
assert_eq!(merge(None, None), None);
assert_eq!(merge(Some(one.clone()), None), Some(one.clone()));
assert_eq!(merge(None, Some(two.clone())), Some(two.clone()));
let both = merge(Some(two), Some(one)).unwrap();
assert_eq!((both.since, both.hits), (at(2), 3));
}
#[test]
fn rotation_folds_what_it_drops_and_counts_nothing_twice() {
let dir = tempfile::tempdir().unwrap();
let config = crate::test_support::test_config(dir.path().to_path_buf());
let events: Vec<BuildEvent> = (1..=60).map(|n| hit(n, "")).collect();
let line_len = serde_json::to_string(&events[59]).unwrap().len() as u64 + 1;
log_and_rotate(&config, &events[..30], line_len * 20, 5);
let ledger = read(&config.cache_dir).expect("a rotation happened");
let kept = read_events(&config.event_log_path()).unwrap();
assert!(!kept.is_empty(), "rotation keeps a tail");
assert!(ledger.hits > 0, "and drops a head");
assert_eq!(ledger.hits as usize + kept.len(), 30);
assert_eq!(
ledger.since, events[0].ts,
"counting began at the first event"
);
assert_eq!(lifetime(&config), Some(expected(&events[..30])));
log_and_rotate(&config, &events[30..], line_len * 20, 5);
assert_eq!(lifetime(&config), Some(expected(&events)));
}
#[test]
fn a_session_tail_kept_by_rotation_is_not_folded() {
let dir = tempfile::tempdir().unwrap();
let config = crate::test_support::test_config(dir.path().to_path_buf());
let mut events: Vec<BuildEvent> = (1..=40).map(|n| hit(n, "old")).collect();
events.extend((41..=70).map(|n| hit(n, "current")));
let line_len = serde_json::to_string(&events[69]).unwrap().len() as u64 + 1;
for event in &events {
log_event(&config.event_log_path(), event).unwrap();
}
rotate_events_if_needed(
&config.event_log_path(),
line_len * 64,
5,
&config.cache_dir,
)
.unwrap();
let kept = read_events(&config.event_log_path()).unwrap();
assert_eq!(kept.len(), 30, "the whole current build stays");
assert_eq!(read(&config.cache_dir), Some(expected(&events[..40])));
assert_eq!(lifetime(&config), Some(expected(&events)));
}
#[test]
fn plain_rotation_leaves_the_ledger_alone() {
let dir = tempfile::tempdir().unwrap();
let config = crate::test_support::test_config(dir.path().to_path_buf());
for n in 1..=30 {
log_event(&config.event_log_path(), &hit(n, "")).unwrap();
}
crate::events::rotate_if_needed(&config.event_log_path(), 1, 5).unwrap();
assert_eq!(read_events(&config.event_log_path()).unwrap().len(), 1);
assert!(!ledger_path(&config.cache_dir).exists());
}
#[test]
fn lines_without_build_events_fold_nothing() {
let dir = tempfile::tempdir().unwrap();
let heartbeat = r#"{"event":"heartbeat","ts":"2026-09-01T12:00:00Z","crate_name":"a","pid":1,"elapsed_s":30}"#;
fold_dropped_lines(dir.path(), &[heartbeat, "{\"torn\n", "\n"]).unwrap();
assert!(!ledger_path(dir.path()).exists());
let line = format!("{}\n", serde_json::to_string(&hit(7, "")).unwrap());
fold_dropped_lines(dir.path(), &[heartbeat, &line]).unwrap();
assert_eq!(read(dir.path()).unwrap().hits, 1);
}
#[test]
fn gc_runs_split_by_who_started_them() {
let dir = tempfile::tempdir().unwrap();
let config = crate::test_support::test_config(dir.path().to_path_buf());
let run = |bytes_freed| crate::store::GcStats {
bytes_freed,
..Default::default()
};
let started = Utc::now();
crate::report::record_gc_run(&config, "daemon", SweepOrigin::Automatic, &run(100)).unwrap();
crate::report::record_gc_run(&config, "daemon", SweepOrigin::Requested, &run(30)).unwrap();
crate::report::record_gc_run(&config, "auto", SweepOrigin::Automatic, &run(5)).unwrap();
crate::report::record_gc_run(&config, "manual", SweepOrigin::Requested, &run(1)).unwrap();
let ledger = read(&config.cache_dir).unwrap();
assert_eq!(ledger.pruned_automatic_bytes, 105);
assert_eq!(ledger.pruned_requested_bytes, 31);
assert_eq!(ledger.hits, 0);
assert!(ledger.since >= started, "counting began with the first run");
assert_eq!(lifetime(&config), Some(ledger));
}
#[test]
fn nothing_recorded_reads_as_none() {
let dir = tempfile::tempdir().unwrap();
let config = crate::test_support::test_config(dir.path().to_path_buf());
assert_eq!(read(&config.cache_dir), None);
assert_eq!(lifetime(&config), None);
log_event(&config.event_log_path(), &hit(4, "")).unwrap();
assert_eq!(lifetime(&config), Some(expected(&[hit(4, "")])));
}
#[test]
fn a_gc_run_that_freed_nothing_starts_no_ledger() {
let dir = tempfile::tempdir().unwrap();
record_pruned(dir.path(), SweepOrigin::Automatic, 0, at(2)).unwrap();
assert!(!ledger_path(dir.path()).exists());
record_pruned(dir.path(), SweepOrigin::Automatic, 1, at(3)).unwrap();
record_pruned(dir.path(), SweepOrigin::Automatic, 0, at(1)).unwrap();
assert_eq!(
read(dir.path()).unwrap().since,
at(3),
"a no-op run is not counted"
);
}
#[test]
fn a_corrupt_ledger_reads_as_none_and_the_next_write_starts_over() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(ledger_path(dir.path()), "{ not json").unwrap();
assert_eq!(read(dir.path()), None);
record_pruned(dir.path(), SweepOrigin::Requested, 9, at(2)).unwrap();
let mut only_this_run = totals(at(2));
only_this_run.pruned_requested_bytes = 9;
assert_eq!(read(dir.path()), Some(only_this_run));
assert_eq!(
std::fs::read_to_string(corrupt_path(dir.path())).unwrap(),
"{ not json",
"the corrupt ledger is kept aside"
);
std::fs::write(ledger_path(dir.path()), r#"{"schema":1,"hits":"many"}"#).unwrap();
assert_eq!(read(dir.path()), None);
}
#[test]
fn a_newer_ledger_is_neither_read_nor_overwritten() {
let dir = tempfile::tempdir().unwrap();
let path = ledger_path(dir.path());
let newer = r#"{"schema":2,"since":"2026-09-01T12:00:00Z","hits":5}"#;
std::fs::write(&path, newer).unwrap();
assert_eq!(read(dir.path()), None);
record_pruned(dir.path(), SweepOrigin::Automatic, 9, at(2)).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), newer);
let reshaped = r#"{"schema":2,"totals":{"hits":5}}"#;
std::fs::write(&path, reshaped).unwrap();
record_pruned(dir.path(), SweepOrigin::Automatic, 9, at(2)).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), reshaped);
assert!(!corrupt_path(dir.path()).exists());
std::fs::write(&path, newer.replace("\"schema\":2", "\"schema\":1")).unwrap();
record_pruned(dir.path(), SweepOrigin::Automatic, 9, at(2)).unwrap();
let ledger = read(dir.path()).unwrap();
assert_eq!((ledger.since, ledger.hits), (at(1), 5));
assert_eq!(ledger.pruned_automatic_bytes, 9);
}
#[test]
fn ledger_file_shape() {
let dir = tempfile::tempdir().unwrap();
record_pruned(dir.path(), SweepOrigin::Requested, 7, at(1)).unwrap();
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(ledger_path(dir.path())).unwrap()).unwrap();
assert_eq!(
value,
serde_json::json!({
"schema": 1,
"since": "2026-09-01T12:00:00Z",
"hits": 0,
"hit_compile_time_ms": 0,
"zero_copy_bytes": 0,
"copied_bytes": 0,
"pruned_automatic_bytes": 0,
"pruned_requested_bytes": 7,
})
);
}
#[test]
fn concurrent_updates_are_all_kept() {
let dir = tempfile::tempdir().unwrap();
let threads: Vec<_> = (0..8)
.map(|_| {
let cache_dir = dir.path().to_path_buf();
std::thread::spawn(move || {
for _ in 0..20 {
record_pruned(&cache_dir, SweepOrigin::Automatic, 1, Utc::now()).unwrap();
}
})
})
.collect();
for thread in threads {
thread.join().unwrap();
}
assert_eq!(read(dir.path()).unwrap().pruned_automatic_bytes, 160);
}
#[test]
fn lifetime_includes_volume_shard_ledgers() {
let dir = tempfile::tempdir().unwrap();
let mut config = crate::test_support::test_config(dir.path().join("main"));
let shard = dir.path().join("shard");
std::fs::create_dir_all(&shard).unwrap();
std::fs::write(shard.join("index.db"), b"").unwrap();
config.volume_stores = vec![crate::config::VolumeStore {
volume: "/mnt/vol/".into(),
store: shard.clone(),
max_size: None,
}];
record_pruned(&config.cache_dir, SweepOrigin::Automatic, 4, at(6)).unwrap();
record_pruned(&shard, SweepOrigin::Requested, 3, at(2)).unwrap();
let total = lifetime(&config).unwrap();
assert_eq!(total.since, at(2));
assert_eq!(total.pruned_automatic_bytes, 4);
assert_eq!(total.pruned_requested_bytes, 3);
}