use std::path::{Path, PathBuf};
use std::process::Command;
use rto_graph::{
DEFAULT_KEEP_GENERATIONS, FactSet, FileNodeExtractor, ObjectCache, Repo, Store,
sweep_superseded, sync,
};
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(dir)
.status()
.expect("failed to run git (is it installed?)");
assert!(status.success(), "git {args:?} failed");
}
fn fixture(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("roteiro-cachegc-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("create temp dir");
git(&dir, &["init", "-q"]);
std::fs::write(dir.join("a.txt"), "alpha\n").expect("write");
std::fs::write(dir.join("b.txt"), "beta\n").expect("write");
std::fs::write(dir.join("c.txt"), "gamma\n").expect("write");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "initial"]);
dir
}
fn cache_for(repo: &Repo) -> ObjectCache {
ObjectCache::open(repo.common_dir().join("roteiro/objects")).expect("open cache")
}
fn keys(cache: &ObjectCache) -> Vec<String> {
let mut out = Vec::new();
let shards = std::fs::read_dir(cache.root()).expect("read cache root");
for shard in shards {
let shard = shard.expect("shard entry");
if !shard.file_type().expect("shard type").is_dir() {
continue;
}
let prefix = shard.file_name().to_string_lossy().into_owned();
for entry in std::fs::read_dir(shard.path()).expect("read shard") {
let entry = entry.expect("cache entry");
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(rest) = name.strip_suffix(".json") {
out.push(format!("{prefix}{rest}"));
}
}
}
out.sort();
out
}
#[test]
fn a_sweep_that_keeps_nothing_still_costs_the_live_set_no_hits() {
let dir = fixture("live-set");
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let ex = FileNodeExtractor;
let mut store = Store::open_in_memory().expect("store");
let cold = sync(&mut store, &repo, &cache, &ex).expect("cold sync");
assert_eq!(cold.blobs_extracted, 3, "cold sync extracts every blob");
let live = keys(&cache);
assert_eq!(live.len(), 3, "one cache entry per blob: {live:?}");
let swept = sweep_superseded(&cache, 0).expect("sweep");
assert_eq!(
(swept.sweep.removed, swept.sweep.failed, swept.sweep.raced),
(0, 0, 0),
"nothing at the current generation is superseded: {swept:?}",
);
assert_eq!(swept.sweep.retained, 3, "{swept:?}");
assert!(swept.sweep.retained_bytes > 0, "{swept:?}");
assert_eq!(
keys(&cache),
live,
"the cache is byte-for-byte the same set"
);
let mut fresh = Store::open_in_memory().expect("second store");
let warm = sync(&mut fresh, &repo, &cache, &ex).expect("post-sweep sync");
assert_eq!(
(warm.blobs_extracted, warm.blobs_cached),
(0, 3),
"every blob must still be served from the cache after the sweep",
);
}
fn split_version(key: &str) -> (&str, u32, &str) {
let (head, tail) = key.rsplit_once("-v").expect("a key carries a version tag");
let (version, env) = tail.rsplit_once("-e").expect("…and an environment tag");
(head, version.parse().expect("a numeric version"), env)
}
#[test]
fn a_superseded_generation_is_reclaimed_and_neither_live_namespace_is() {
let dir = fixture("two-generations");
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let ex = FileNodeExtractor;
let mut store = Store::open_in_memory().expect("store");
sync(&mut store, &repo, &cache, &ex).expect("cold sync");
let live = keys(&cache);
assert_eq!(live.len(), 3, "one cache entry per blob: {live:?}");
let mut sibling = Vec::new();
let mut superseded = Vec::new();
for key in &live {
let (head, version, env) = split_version(key);
sibling.push(format!("{head}-v{}-e{env}", version + 700));
for namespace in [0, 700] {
superseded.push(format!("{head}-v{}-e{env}", 1 + namespace));
}
}
for key in sibling.iter().chain(&superseded) {
cache.put(key, &FactSet::new()).expect("plant");
}
assert_eq!(keys(&cache).len(), 12, "3 live + 3 sibling + 6 superseded");
let swept = sweep_superseded(&cache, DEFAULT_KEEP_GENERATIONS).expect("sweep");
assert_eq!(
(
swept.sweep.scanned,
swept.sweep.retained,
swept.sweep.removed
),
(12, 6, 6),
"exactly the superseded generation goes: {swept:?}",
);
let mut expected: Vec<String> = live.iter().chain(&sibling).cloned().collect();
expected.sort();
assert_eq!(
keys(&cache),
expected,
"both live namespaces survive; nothing else does",
);
let mut fresh = Store::open_in_memory().expect("second store");
let warm = sync(&mut fresh, &repo, &cache, &ex).expect("post-sweep sync");
assert_eq!(
(warm.blobs_extracted, warm.blobs_cached),
(0, 3),
"reclaiming the old generation cost the live one nothing",
);
}