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),
}
#[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 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 blobs = repo.walk_blobs()?;
let mut assembled = FactSet::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
};
let FactSet { nodes, edges } = facts;
assembled.nodes.extend(nodes);
assembled.edges.extend(edges);
}
resolve_calls(&mut assembled);
store.rebuild(&assembled, &tree)?;
Ok(SyncReport {
no_op: false,
blobs_total: blobs.len(),
blobs_extracted: extracted,
blobs_cached: cached,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
})
}
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-"));
}
}