use std::path::Path;
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_roteiro");
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("run git");
assert!(status.success(), "git {args:?} failed");
}
fn roteiro(dir: &Path, args: &[&str]) -> std::process::Output {
Command::new(BIN)
.args(args)
.current_dir(dir)
.output()
.expect("run roteiro")
}
fn json(out: &std::process::Output) -> serde_json::Value {
assert!(out.status.success(), "command failed: {out:?}");
serde_json::from_slice(&out.stdout).expect("valid JSON")
}
#[test]
fn the_maintenance_seam_reclaims_superseded_cache_objects() {
let dir = std::env::temp_dir().join(format!("roteiro-ctx-gc-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(dir.join("src")).expect("mkdir");
std::fs::write(dir.join("src/lib.rs"), "pub fn only() -> u32 { 1 }\n").expect("write");
git(&dir, &["init", "-q"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let first = roteiro(&dir, &["context", "--refresh"]);
assert!(first.status.success(), "initial refresh: {first:?}");
let objects = dir.join(".git/roteiro/objects");
let live = cache_files(&objects);
assert!(!live.is_empty(), "the sync should have cached something");
let (shard, name) = live.first().expect("a live entry").clone();
let (head, tail) = name.rsplit_once("-v").expect("a key carries a version tag");
let env = tail.rsplit_once("-e").expect("…and an env tag").1;
let superseded = objects.join(&shard).join(format!("{head}-v1-e{env}.json"));
std::fs::write(&superseded, br#"{"nodes":[],"edges":[]}"#).expect("plant");
let read = roteiro(&dir, &["context", "file:src/lib.rs"]);
assert!(read.status.success(), "an ordinary read: {read:?}");
assert!(
superseded.exists(),
"an ordinary read must not reclaim anything",
);
let out = roteiro(&dir, &["context", "--refresh"]);
assert!(out.status.success(), "refresh failed: {out:?}");
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("object cache swept: 1 superseded object(s) freed"),
"the seam must say what it reclaimed: {text}",
);
assert!(!superseded.exists(), "the superseded entry must be gone");
assert_eq!(
cache_files(&objects),
live,
"and every live entry must survive: {text}",
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_sweep_summary_names_every_class_it_retained() {
let dir = std::env::temp_dir().join(format!("roteiro-ctx-classes-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(dir.join("src")).expect("mkdir");
std::fs::write(dir.join("src/lib.rs"), "pub fn only() -> u32 { 1 }\n").expect("write");
git(&dir, &["init", "-q"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
assert!(
roteiro(&dir, &["context", "--refresh"]).status.success(),
"initial refresh",
);
let objects = dir.join(".git/roteiro/objects");
let live = cache_files(&objects);
assert_eq!(live.len(), 1, "one tracked file, one entry: {live:?}");
let (shard, name) = live.first().expect("a live entry").clone();
let (head, tail) = name.rsplit_once("-v").expect("a key carries a version tag");
let (version, env) = tail.rsplit_once("-e").expect("…and an env tag");
let version: u32 = version.parse().expect("a numeric version");
let plant = |file: String, label: &str| {
std::fs::write(
objects.join(&shard).join(file),
br#"{"nodes":[],"edges":[]}"#,
)
.unwrap_or_else(|e| panic!("plant the {label} entry: {e}"));
};
plant(format!("{head}-v1-e{env}.json"), "superseded");
plant(format!("{head}-v{}-e{env}.json", version + 1), "newer");
plant("no-version-tag-here.json".to_owned(), "unrecognised");
let out = roteiro(&dir, &["context", "--refresh"]);
assert!(out.status.success(), "refresh failed: {out:?}");
let text = String::from_utf8_lossy(&out.stdout);
let summary_line = |prefix: &str| {
text.lines()
.find(|l| l.starts_with(prefix))
.unwrap_or_else(|| panic!("no line starting `{prefix}` in:\n{text}"))
.to_owned()
};
let totals = summary_line("object cache swept:");
assert!(
totals.starts_with("object cache swept: 1 superseded object(s) freed (")
&& totals.contains("), 3 retained ("),
"totals must name both halves: {totals}",
);
assert_eq!(
summary_line(" retained:"),
" retained: 1 at this build's generation, 0 at an older generation kept as insurance, \
1 written by a newer build, 1 whose key this build does not recognise",
);
assert!(
summary_line(" an unrecognised key").contains("a bug in the key parser"),
"an unrecognised key must point at what to investigate:\n{text}",
);
assert_eq!(cache_files(&objects).len(), 3, "{text}");
assert!(
!objects
.join(&shard)
.join(format!("{head}-v1-e{env}.json"))
.exists(),
"the superseded entry must be gone",
);
std::fs::remove_dir_all(&dir).ok();
}
fn cache_files(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for shard in std::fs::read_dir(root).expect("read object cache root") {
let shard = shard.expect("shard entry");
if !shard.file_type().expect("shard file 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 name = entry.expect("cache entry").file_name();
let name = name.to_string_lossy();
if let Some(stem) = name.strip_suffix(".json") {
out.push((prefix.clone(), stem.to_owned()));
}
}
}
out.sort();
out
}
#[test]
fn dependency_change_invalidates_dependent_context() {
let dir = std::env::temp_dir().join(format!("roteiro-ctx-cli-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(dir.join("src")).expect("mkdir");
std::fs::write(dir.join("src/callee.rs"), "pub fn callee() -> u32 { 1 }\n").expect("write");
std::fs::write(
dir.join("src/caller.rs"),
"use crate::callee::callee;\npub fn caller() -> u32 { callee() }\n",
)
.expect("write");
std::fs::write(dir.join("src/lib.rs"), "pub mod callee;\npub mod caller;\n").expect("write");
git(&dir, &["init", "-q"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let caller_key = "sym:rust:src/caller.rs#caller";
let first = json(&roteiro(&dir, &["context", caller_key, "--json"]));
let fp_before = first["fingerprint"]
.as_str()
.expect("fingerprint")
.to_owned();
let calls_callee = first["outgoing"]
.as_array()
.into_iter()
.flatten()
.any(|e| e["node"].as_str().is_some_and(|n| n.contains("#callee")));
assert!(calls_callee, "caller should call callee: {first}");
let clean = json(&roteiro(&dir, &["context", "--refresh", "--json"]));
assert_eq!(clean["rebuilt"], 0, "nothing changed yet: {clean}");
assert!(clean["reused"].as_u64().expect("reused") >= 1);
std::fs::write(dir.join("src/callee.rs"), "pub fn callee() -> u32 { 42 }\n").expect("write");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "change callee"]);
let after = json(&roteiro(&dir, &["context", "--refresh", "--json"]));
assert!(
after["rebuilt"].as_u64().expect("rebuilt") >= 1,
"the dependent's context must be rebuilt: {after}",
);
let refetched = json(&roteiro(&dir, &["context", caller_key, "--json"]));
assert_ne!(
fp_before,
refetched["fingerprint"].as_str().expect("fingerprint"),
"dependent fingerprint must change when its dependency changes",
);
std::fs::remove_dir_all(&dir).ok();
}