use std::collections::{BTreeMap, BTreeSet};
use crate::cache::{CacheError, ObjectCache};
use crate::extract::Extractor;
use crate::git::{GitError, Repo};
use crate::store::StoreError;
use crate::{Edge, EdgeKind, FactSet, NodeKind, Store};
#[derive(Debug, thiserror::Error)]
pub enum SyncError {
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Cache(#[from] CacheError),
#[error(transparent)]
Git(#[from] GitError),
#[error("worktree io error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct SyncReport {
pub tree: String,
pub no_op: bool,
pub blobs_total: usize,
pub blobs_extracted: usize,
pub blobs_cached: usize,
pub blobs_dirty: usize,
pub nodes: u64,
pub edges: u64,
}
pub fn sync(
store: &mut Store,
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
) -> Result<SyncReport, SyncError> {
let tree = repo.head_tree_id()?;
if store.sync_state()?.as_deref() == Some(tree.as_str()) {
return Ok(SyncReport {
no_op: true,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
..SyncReport::default()
});
}
let committed = extract_committed(repo, cache, extractor)?;
let total = committed.by_path.len();
let mut assembled = flatten(committed.by_path);
resolve_calls(&mut assembled);
store.rebuild(&assembled, &tree)?;
Ok(SyncReport {
no_op: false,
blobs_total: total,
blobs_extracted: committed.extracted,
blobs_cached: committed.cached,
blobs_dirty: 0,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
})
}
pub fn sync_worktree(
store: &mut Store,
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
) -> Result<SyncReport, SyncError> {
let tree = repo.head_tree_id()?;
let committed = extract_committed(repo, cache, extractor)?;
let total = committed.by_path.len();
let mut by_path = committed.by_path;
let mut dirty: BTreeSet<(String, String)> = BTreeSet::new();
if let Some(workdir) = repo.workdir() {
for blob in &committed.blobs {
match std::fs::read(workdir.join(&blob.path)) {
Ok(bytes) => {
let woid = repo.blob_oid(&bytes)?;
if woid != blob.oid {
by_path.insert(
blob.path.clone(),
extractor.extract(&blob.path, &woid, &bytes),
);
dirty.insert((blob.path.clone(), woid));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
by_path.remove(&blob.path);
dirty.insert((blob.path.clone(), "\0deleted".to_owned()));
}
Err(e) => return Err(e.into()),
}
}
}
let state = if dirty.is_empty() {
tree.clone()
} else {
let mut buf = String::new();
for (path, marker) in &dirty {
buf.push_str(path);
buf.push('\0');
buf.push_str(marker);
buf.push('\n');
}
format!("{tree}:dirty:{:016x}", fnv1a64(buf.as_bytes()))
};
let dirty_count = dirty.len();
if store.sync_state()?.as_deref() == Some(state.as_str()) {
return Ok(SyncReport {
no_op: true,
blobs_total: total,
blobs_dirty: dirty_count,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
..SyncReport::default()
});
}
let mut assembled = flatten(by_path);
resolve_calls(&mut assembled);
store.rebuild(&assembled, &state)?;
Ok(SyncReport {
no_op: false,
blobs_total: total,
blobs_extracted: committed.extracted,
blobs_cached: committed.cached,
blobs_dirty: dirty_count,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
})
}
struct Committed {
blobs: Vec<crate::BlobRef>,
by_path: BTreeMap<String, FactSet>,
extracted: usize,
cached: usize,
}
fn extract_committed(
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
) -> Result<Committed, SyncError> {
let blobs = repo.walk_blobs()?;
let mut by_path = BTreeMap::new();
let mut extracted = 0usize;
let mut cached = 0usize;
for blob in &blobs {
let key = cache_key(&blob.path, &blob.oid);
let facts = if let Some(facts) = cache.get(&key)? {
cached += 1;
facts
} else {
let bytes = repo.read_blob(&blob.oid)?;
let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
cache.put(&key, &facts)?;
extracted += 1;
facts
};
by_path.insert(blob.path.clone(), facts);
}
Ok(Committed {
blobs,
by_path,
extracted,
cached,
})
}
fn flatten(by_path: BTreeMap<String, FactSet>) -> FactSet {
let mut assembled = FactSet::new();
for facts in by_path.into_values() {
assembled.nodes.extend(facts.nodes);
assembled.edges.extend(facts.edges);
}
assembled
}
fn resolve_calls(facts: &mut FactSet) {
let mut by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for n in &facts.nodes {
if n.kind == NodeKind::Fn {
by_name
.entry(n.name.as_str())
.or_default()
.push(n.key.as_str());
}
}
let mut resolved: BTreeSet<(String, String)> = BTreeSet::new();
for n in &facts.nodes {
if n.kind != NodeKind::Fn {
continue;
}
let Some(calls) = n.meta.get("calls").and_then(|v| v.as_array()) else {
continue;
};
for callee in calls.iter().filter_map(|v| v.as_str()) {
if let Some(targets) = by_name.get(callee)
&& targets.len() == 1
{
resolved.insert((n.key.clone(), targets[0].to_owned()));
}
}
}
for (src, dst) in resolved {
facts.edges.push(Edge::derived(src, dst, EdgeKind::Calls));
}
}
fn cache_key(path: &str, oid: &str) -> String {
format!("{oid}-{:016x}", fnv1a64(path.as_bytes()))
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::{cache_key, resolve_calls};
use crate::{EdgeKind, FactSet, Node, NodeKind};
fn fn_node(key: &str, name: &str, calls: &[&str]) -> Node {
let mut n = Node::new(key, NodeKind::Fn, name);
if !calls.is_empty() {
n.meta = serde_json::json!({ "calls": calls });
}
n
}
#[test]
fn resolve_calls_links_unique_names_only() {
let mut fs = FactSet::new()
.with_node(fn_node(
"sym:rust:a.rs#caller",
"caller",
&["target", "dup", "missing"],
))
.with_node(fn_node("sym:rust:a.rs#target", "target", &[]))
.with_node(fn_node("sym:rust:a.rs#dup", "dup", &[]))
.with_node(fn_node("sym:rust:b.rs#dup", "dup", &[]));
resolve_calls(&mut fs);
let calls: Vec<_> = fs
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.collect();
assert_eq!(
calls.len(),
1,
"only the unambiguous, known callee is linked"
);
assert_eq!(calls[0].src, "sym:rust:a.rs#caller");
assert_eq!(calls[0].dst, "sym:rust:a.rs#target");
}
#[test]
fn cache_key_separates_paths_but_is_stable() {
let oid = "abc123";
assert_eq!(cache_key("src/a.rs", oid), cache_key("src/a.rs", oid));
assert_ne!(cache_key("src/a.rs", oid), cache_key("src/b.rs", oid));
assert_ne!(cache_key("src/a.rs", "aaa"), cache_key("src/a.rs", "bbb"));
assert!(cache_key("src/a.rs", oid).starts_with("abc123-"));
}
}