use std::path::{Path, PathBuf};
use std::process::Command;
use rto_graph::{
DEFAULT_MEMORY_SCOPE, EdgeKind, FileNodeExtractor, MemoryKind, MemoryWrite, ObjectCache,
Registry, Repo, Store, sync, sync_worktree,
};
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 write(dir: &Path, rel: &str, content: &str) {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("mkdir");
}
std::fs::write(path, content).expect("write file");
}
fn fresh_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("roteiro-sync-{}-{}", std::process::id(), name));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn cache_for(repo: &Repo) -> ObjectCache {
ObjectCache::open(repo.common_dir().join("roteiro/objects")).expect("open cache")
}
#[test]
fn cold_then_noop_then_single_file_change() {
let dir = fresh_dir("incremental");
git(&dir, &["init", "-q"]);
write(&dir, "a.txt", "alpha\n");
write(&dir, "b.txt", "beta\n");
write(&dir, "src/c.rs", "fn main() {}\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "initial"]);
let repo = Repo::discover(&dir).expect("discover");
assert!(repo.git_dir().exists(), "git dir should resolve");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
let ex = FileNodeExtractor;
let r1 = sync(&mut store, &repo, &cache, &ex).expect("cold sync");
assert!(!r1.no_op);
assert_eq!(r1.blobs_total, 3);
assert_eq!(r1.blobs_extracted, 3);
assert_eq!(r1.blobs_cached, 0);
assert_eq!(r1.nodes, 3);
assert!(store.get_node("file:a.txt").expect("get").is_some());
assert!(store.get_node("file:src/c.rs").expect("get").is_some());
let r2 = sync(&mut store, &repo, &cache, &ex).expect("noop sync");
assert!(r2.no_op);
assert_eq!(r2.blobs_extracted, 0);
assert_eq!(r2.nodes, 3);
assert_eq!(r1.tree, r2.tree);
write(&dir, "b.txt", "beta changed\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "edit b"]);
let r3 = sync(&mut store, &repo, &cache, &ex).expect("incremental sync");
assert!(!r3.no_op);
assert_eq!(r3.blobs_total, 3, "the whole tree is still reported");
assert_eq!(
r3.blobs_extracted, 1,
"only the changed blob is re-extracted"
);
assert_eq!(
r3.blobs_cached, 0,
"the incremental path processes only the changed blob; unchanged facts \
are carried forward from the store, not re-read"
);
assert_eq!(
r3.nodes, 3,
"unchanged file nodes survive the incremental sync"
);
assert_ne!(r1.tree, r3.tree);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn removed_file_drops_its_node() {
let dir = fresh_dir("removal");
git(&dir, &["init", "-q"]);
write(&dir, "keep.txt", "keep\n");
write(&dir, "gone.txt", "gone\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "two files"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
let ex = FileNodeExtractor;
sync(&mut store, &repo, &cache, &ex).expect("sync");
assert!(store.get_node("file:gone.txt").expect("get").is_some());
git(&dir, &["rm", "-q", "gone.txt"]);
git(&dir, &["commit", "-q", "-m", "remove gone"]);
let r = sync(&mut store, &repo, &cache, &ex).expect("sync after rm");
assert_eq!(r.nodes, 1);
assert!(store.get_node("file:gone.txt").expect("get").is_none());
assert!(store.get_node("file:keep.txt").expect("get").is_some());
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn duplicate_content_at_distinct_paths_stays_distinct() {
let dir = fresh_dir("dup-content");
git(&dir, &["init", "-q"]);
write(&dir, "a.txt", "same\n");
write(&dir, "b.txt", "same\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "duplicate content"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
let ex = FileNodeExtractor;
let r = sync(&mut store, &repo, &cache, &ex).expect("sync");
assert_eq!(r.blobs_total, 2);
assert_eq!(
r.blobs_extracted, 2,
"identical-content files must not share a cache entry"
);
assert_eq!(r.nodes, 2);
assert!(store.get_node("file:a.txt").expect("get a").is_some());
assert!(store.get_node("file:b.txt").expect("get b").is_some());
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn rust_extraction_produces_derived_graph_with_cross_file_calls() {
let dir = fresh_dir("rust-derive");
git(&dir, &["init", "-q"]);
write(
&dir,
"src/main.rs",
"mod util;\nfn main() {\n util::helper();\n}\n",
);
write(&dir, "src/util.rs", "pub fn helper() -> u32 {\n 42\n}\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "two rust files"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
let r = sync(&mut store, &repo, &cache, &Registry::default()).expect("sync");
assert!(!r.no_op);
assert!(
store
.get_node("sym:rust:src/main.rs#main")
.expect("m")
.is_some()
);
assert!(
store
.get_node("sym:rust:src/util.rs#helper")
.expect("h")
.is_some()
);
let defines = store.edges_from("file:src/main.rs").expect("defines");
assert!(
defines
.iter()
.any(|e| e.kind == EdgeKind::Defines && e.dst == "sym:rust:src/main.rs#main"),
);
let calls = store
.edges_from("sym:rust:src/main.rs#main")
.expect("calls");
assert!(
calls
.iter()
.any(|e| e.kind == EdgeKind::Calls && e.dst == "sym:rust:src/util.rs#helper"),
"cross-file call main -> helper should resolve",
);
let r2 = sync(&mut store, &repo, &cache, &Registry::default()).expect("resync");
assert!(r2.no_op);
assert_eq!(r2.nodes, r.nodes);
assert_eq!(r2.edges, r.edges);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn dirty_overlay_previews_uncommitted_edits() {
let dir = fresh_dir("dirty-overlay");
git(&dir, &["init", "-q"]);
write(&dir, "main.rs", "fn main() {}\n");
write(&dir, "util.rs", "pub fn helper() {}\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "committed"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
let r0 = sync_worktree(&mut store, &repo, &cache, &Registry::default()).expect("clean");
assert_eq!(r0.blobs_dirty, 0);
assert!(
store
.get_node("sym:rust:util.rs#helper")
.expect("h")
.is_some()
);
assert!(
store
.get_node("sym:rust:util.rs#added")
.expect("a")
.is_none()
);
write(&dir, "util.rs", "pub fn helper() {}\npub fn added() {}\n");
let r1 = sync_worktree(&mut store, &repo, &cache, &Registry::default()).expect("dirty");
assert!(!r1.no_op);
assert_eq!(r1.blobs_dirty, 1);
assert_eq!(
r1.blobs_extracted, 0,
"committed blobs still come from cache"
);
assert!(
store
.get_node("sym:rust:util.rs#added")
.expect("added")
.is_some(),
"uncommitted symbol should be previewed",
);
let r2 = sync_worktree(&mut store, &repo, &cache, &Registry::default()).expect("resync");
assert!(r2.no_op);
assert_eq!(r2.blobs_dirty, 1);
std::fs::remove_file(dir.join("util.rs")).expect("rm");
let r3 = sync_worktree(&mut store, &repo, &cache, &Registry::default()).expect("deleted");
assert!(!r3.no_op);
assert!(
store
.get_node("sym:rust:util.rs#helper")
.expect("h2")
.is_none()
);
let r4 = sync(&mut store, &repo, &cache, &Registry::default()).expect("committed");
assert!(!r4.no_op);
assert_eq!(r4.blobs_dirty, 0);
assert!(
store
.get_node("sym:rust:util.rs#helper")
.expect("h3")
.is_some()
);
assert!(
store
.get_node("sym:rust:util.rs#added")
.expect("a3")
.is_none()
);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn cache_is_reused_across_independent_stores() {
let dir = fresh_dir("cache-reuse");
git(&dir, &["init", "-q"]);
write(&dir, "one.txt", "1\n");
write(&dir, "two.txt", "2\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let ex = FileNodeExtractor;
let mut store_a = Store::open_in_memory().expect("store a");
let ra = sync(&mut store_a, &repo, &cache, &ex).expect("sync a");
assert_eq!(ra.blobs_extracted, 2);
assert_eq!(ra.blobs_cached, 0);
let mut store_b = Store::open_in_memory().expect("store b");
let rb = sync(&mut store_b, &repo, &cache, &ex).expect("sync b");
assert_eq!(rb.blobs_extracted, 0, "shared blobs are cache hits");
assert_eq!(rb.blobs_cached, 2);
assert_eq!(rb.nodes, 2);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn diff_trees_reports_added_modified_deleted_and_prunes_unchanged() {
let dir = fresh_dir("difftrees");
git(&dir, &["init", "-q"]);
write(&dir, "src/a.rs", "fn a() {}\n");
write(&dir, "src/b.rs", "fn b() {}\n");
write(&dir, "vendor/keep.rs", "fn keep() {}\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let t1 = Repo::discover(&dir)
.expect("discover")
.head_tree_id()
.expect("t1");
write(&dir, "src/a.rs", "fn a() { let _x = 1; }\n");
std::fs::remove_file(dir.join("src/b.rs")).expect("rm b");
write(&dir, "src/c.rs", "fn c() {}\n");
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "changes"]);
let repo = Repo::discover(&dir).expect("rediscover");
let t2 = repo.head_tree_id().expect("t2");
let diff = repo.diff_trees(&t1, &t2).expect("diff");
let changed: Vec<&str> = diff.changed.iter().map(|b| b.path.as_str()).collect();
assert_eq!(
changed,
["src/a.rs", "src/c.rs"],
"a modified + c added, sorted"
);
assert_eq!(diff.deleted, ["src/b.rs"], "b deleted");
assert!(
!changed.contains(&"vendor/keep.rs"),
"the unchanged subtree is pruned, not reported"
);
let c_oid = &diff
.changed
.iter()
.find(|b| b.path == "src/c.rs")
.unwrap()
.oid;
let head = repo.walk_blobs().expect("walk");
let c_head = head
.iter()
.find(|b| b.path == "src/c.rs")
.expect("c in head");
assert_eq!(&c_head.oid, c_oid, "diff oid matches the tree blob oid");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn incremental_sync_matches_a_full_rebuild() {
let dir = fresh_dir("incr-equiv");
git(&dir, &["init", "-q"]);
write(&dir, "a.rs", "pub fn helper() -> u32 {\n 1\n}\n");
write(&dir, "b.rs", "pub fn caller() {\n helper();\n}\n"); write(&dir, "gone.rs", "pub fn gone() {}\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let reg = Registry::default();
let mut incr = Store::open(&dir.join(".git/incr.db")).expect("open incr");
sync(&mut incr, &repo, &cache, ®).expect("cold sync");
std::fs::remove_file(dir.join("gone.rs")).expect("rm");
write(&dir, "d.rs", "pub fn helper() -> u32 {\n 2\n}\n");
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "flip"]);
let repo = Repo::discover(&dir).expect("rediscover");
let r = sync(&mut incr, &repo, &cache, ®).expect("incremental sync");
assert!(
r.blobs_extracted + r.blobs_cached <= 1,
"incremental should process only changed blobs, saw {}",
r.blobs_extracted + r.blobs_cached
);
let mut full = Store::open(&dir.join(".git/full.db")).expect("open full");
sync(&mut full, &repo, &cache, ®).expect("full sync");
let canon = |fs: rto_graph::FactSet| -> (Vec<String>, Vec<String>) {
let mut ns: Vec<String> = fs
.nodes
.iter()
.map(|n| format!("{}|{}|{}", n.key, n.kind.as_str(), n.provenance.as_str()))
.collect();
let mut es: Vec<String> = fs
.edges
.iter()
.map(|e| {
format!(
"{}|{}|{}|{}",
e.kind.as_str(),
e.src,
e.dst,
e.provenance.as_str()
)
})
.collect();
ns.sort();
es.sort();
(ns, es)
};
let ci = canon(incr.export_factset().expect("export incr"));
let cf = canon(full.export_factset().expect("export full"));
assert_eq!(ci, cf, "incremental sync must equal a full rebuild");
assert!(
!cf.1
.iter()
.any(|e| e.starts_with("calls|") && e.contains("#caller") && e.contains("#helper")),
"the ambiguous calls edge must be dropped: {:?}",
cf.1
);
assert!(
!cf.0.iter().any(|n| n.contains("gone.rs")),
"deleted file dropped"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn untracked_overlay_includes_new_files_respecting_gitignore() {
let dir = fresh_dir("untracked-overlay");
git(&dir, &["init", "-q"]);
write(&dir, "main.rs", "fn main() {}\n");
write(&dir, ".gitignore", "ignored.rs\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "committed"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
let r0 = sync_worktree(&mut store, &repo, &cache, &Registry::default()).expect("clean");
assert_eq!(r0.blobs_dirty, 0);
write(&dir, "fresh.rs", "pub fn brand_new() {}\n");
write(&dir, "ignored.rs", "pub fn hidden() {}\n");
let r = sync_worktree(&mut store, &repo, &cache, &Registry::default()).expect("untracked");
assert!(!r.no_op);
assert_eq!(
r.blobs_dirty, 1,
"only the untracked (non-ignored) file is new"
);
assert!(
store
.get_node("sym:rust:fresh.rs#brand_new")
.expect("q")
.is_some(),
"untracked new file should be overlaid into the graph",
);
assert!(
store
.get_node("sym:rust:ignored.rs#hidden")
.expect("q2")
.is_none(),
"git-ignored file must not be ingested",
);
let untracked = repo.untracked_files().expect("untracked");
assert!(untracked.contains(&"fresh.rs".to_owned()));
assert!(
!untracked.iter().any(|p| p == "ignored.rs"),
"ignored excluded"
);
assert!(
!untracked.iter().any(|p| p == "main.rs"),
"tracked excluded"
);
std::fs::remove_file(dir.join("fresh.rs")).expect("rm");
sync_worktree(&mut store, &repo, &cache, &Registry::default()).expect("removed");
assert!(
store
.get_node("sym:rust:fresh.rs#brand_new")
.expect("q3")
.is_none(),
"removing the untracked file drops its symbols",
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn scope_aware_calls_disambiguate_ambiguous_names() {
let dir = fresh_dir("scope-calls");
git(&dir, &["init", "-q"]);
write(
&dir,
"src/lib.rs",
"mod a {\n pub fn render() {}\n}\n\
struct S;\n\
impl S {\n \
fn render(&self) {}\n \
fn go(&self) {\n self.render();\n }\n}\n\
fn drive() {\n a::render();\n}\n",
);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "ambiguous render"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
sync(&mut store, &repo, &cache, &Registry::default()).expect("sync");
let calls_from = |key: &str| -> Vec<String> {
let mut v: Vec<String> = store
.edges_from(key)
.expect("edges")
.into_iter()
.filter(|e| e.kind == EdgeKind::Calls)
.map(|e| e.dst)
.collect();
v.sort();
v
};
assert_eq!(
calls_from("sym:rust:src/lib.rs#drive"),
["sym:rust:src/lib.rs#a::render"],
"a::render() must bind to the module fn, not the method",
);
assert_eq!(
calls_from("sym:rust:src/lib.rs#S::go"),
["sym:rust:src/lib.rs#S::render"],
"self.render() must bind to S::render, not the module fn",
);
std::fs::remove_dir_all(&dir).ok();
}
struct ShiftedEnv(u64);
impl rto_graph::Extractor for ShiftedEnv {
fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> rto_graph::FactSet {
FileNodeExtractor.extract(path, blob_id, bytes)
}
fn env_tag(&self) -> u64 {
self.0
}
}
#[test]
fn a_changed_extraction_identity_re_extracts_at_an_unchanged_tree() {
use rto_graph::Extractor as _;
let dir = fresh_dir("env-shift");
git(&dir, &["init", "-q"]);
write(&dir, "a.txt", "alpha\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "initial"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
let first = ShiftedEnv(1);
let r1 = sync(&mut store, &repo, &cache, &first).expect("cold sync");
assert!(!r1.no_op);
assert_eq!(r1.blobs_extracted, 1);
let r2 = sync(&mut store, &repo, &cache, &first).expect("resync");
assert!(r2.no_op, "an unchanged tree and identity is a no-op");
let second = ShiftedEnv(2);
assert_ne!(first.env_tag(), second.env_tag());
let r3 = sync(&mut store, &repo, &cache, &second).expect("shifted env");
assert!(
!r3.no_op,
"a changed extraction identity must re-extract, not report `up to date`",
);
assert_eq!(r1.tree, r3.tree, "the tree did not move");
assert_eq!(
r3.blobs_extracted, 1,
"the blob must be re-extracted under the new identity, not served from cache",
);
assert!(
sync(&mut store, &repo, &cache, &second)
.expect("resync")
.no_op
);
}
fn cache_entries(cache: &ObjectCache) -> Vec<(PathBuf, Vec<u8>)> {
fn walk(dir: &Path, root: &Path, out: &mut Vec<(PathBuf, Vec<u8>)>) {
let entries =
std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()));
for entry in entries {
let path = entry
.unwrap_or_else(|e| panic!("cache entry under {}: {e}", dir.display()))
.path();
if path.is_dir() {
walk(&path, root, out);
} else {
let rel = path
.strip_prefix(root)
.unwrap_or_else(|_| {
panic!(
"cache entry {} is not under the cache root {}",
path.display(),
root.display(),
)
})
.to_path_buf();
let bytes = std::fs::read(&path)
.unwrap_or_else(|e| panic!("read cache entry {}: {e}", path.display()));
out.push((rel, bytes));
}
}
}
let mut out = Vec::new();
walk(cache.root(), cache.root(), &mut out);
out.sort();
out
}
fn lesson(body: &str) -> MemoryWrite<'_> {
MemoryWrite {
scope: DEFAULT_MEMORY_SCOPE,
kind: MemoryKind::Lesson,
anchor: None,
body,
confidence: None,
supersedes: None,
}
}
#[test]
fn memory_writes_do_not_invalidate_the_fact_cache() {
let dir = fresh_dir("memory-cache");
git(&dir, &["init", "-q"]);
write(&dir, "a.txt", "alpha\n");
write(&dir, "src/c.rs", "fn main() {}\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "initial"]);
let repo = Repo::discover(&dir).expect("discover");
let cache = cache_for(&repo);
let mut store = Store::open_in_memory().expect("store");
let ex = FileNodeExtractor;
let cold = sync(&mut store, &repo, &cache, &ex).expect("cold sync");
assert!(!cold.no_op);
assert_eq!(cold.blobs_extracted, 2, "both blobs are extracted cold");
let facts_before = cache_entries(&cache);
assert!(
!facts_before.is_empty(),
"the cache must have something in it"
);
let identity_before = store.sync_env().expect("sync env");
let tree_before = store.sync_state().expect("sync state");
assert!(identity_before.is_some(), "a sync records an identity");
let anchored = store
.record_memory(&MemoryWrite {
anchor: Some("file:src/c.rs"),
kind: MemoryKind::Attempt,
confidence: Some(0.9),
..lesson("The retry loop double-counted partial batches.")
})
.expect("anchored write");
store
.record_memory(&MemoryWrite {
supersedes: Some(anchored),
..lesson("Superseded: the dedup key alone was not enough.")
})
.expect("superseding write");
let doomed = store
.record_memory(&lesson("A record that will be forgotten."))
.expect("write");
store.forget_memory(doomed).expect("forget");
assert_eq!(
store.memory_counts().expect("counts"),
(1, 1),
"the writes must actually have done work",
);
assert_eq!(
store.sync_env().expect("sync env"),
identity_before,
"a memory write must not perturb the recorded extraction identity",
);
assert_eq!(
store.sync_state().expect("sync state"),
tree_before,
"a memory write must not disturb the synced tree",
);
assert_eq!(
cache_entries(&cache),
facts_before,
"no cached fact set may be added, rewritten or retired by a memory write",
);
let after = sync(&mut store, &repo, &cache, &ex).expect("resync");
assert!(
after.no_op,
"a sync after a memory write must still be a no-op",
);
assert_eq!(
after.blobs_extracted, 0,
"memory must not force a single blob to be re-extracted",
);
std::fs::remove_dir_all(&dir).ok();
}