1use std::collections::{BTreeMap, BTreeSet};
12
13use crate::cache::{CacheError, ObjectCache};
14use crate::extract::Extractor;
15use crate::git::{GitError, Repo};
16use crate::store::StoreError;
17use crate::{Edge, EdgeKind, FactSet, NodeKind, Store};
18
19#[derive(Debug, thiserror::Error)]
21pub enum SyncError {
22 #[error(transparent)]
24 Store(#[from] StoreError),
25 #[error(transparent)]
27 Cache(#[from] CacheError),
28 #[error(transparent)]
30 Git(#[from] GitError),
31 #[error("worktree io error: {0}")]
33 Io(#[from] std::io::Error),
34}
35
36#[derive(Debug, Clone, Default, serde::Serialize)]
38pub struct SyncReport {
39 pub tree: String,
41 pub no_op: bool,
43 pub blobs_total: usize,
45 pub blobs_extracted: usize,
47 pub blobs_cached: usize,
49 pub blobs_dirty: usize,
52 pub nodes: u64,
54 pub edges: u64,
56}
57
58pub fn sync(
65 store: &mut Store,
66 repo: &Repo,
67 cache: &ObjectCache,
68 extractor: &dyn Extractor,
69) -> Result<SyncReport, SyncError> {
70 let tree = repo.head_tree_id()?;
71
72 if store.sync_state()?.as_deref() == Some(tree.as_str()) {
73 return Ok(SyncReport {
74 no_op: true,
75 nodes: store.node_count()?,
76 edges: store.edge_count()?,
77 tree,
78 ..SyncReport::default()
79 });
80 }
81
82 let committed = extract_committed(repo, cache, extractor)?;
83 let total = committed.by_path.len();
84 let mut assembled = flatten(committed.by_path);
85 resolve_calls(&mut assembled);
86 store.rebuild(&assembled, &tree)?;
87
88 Ok(SyncReport {
89 no_op: false,
90 blobs_total: total,
91 blobs_extracted: committed.extracted,
92 blobs_cached: committed.cached,
93 blobs_dirty: 0,
94 nodes: store.node_count()?,
95 edges: store.edge_count()?,
96 tree,
97 })
98}
99
100pub fn sync_worktree(
114 store: &mut Store,
115 repo: &Repo,
116 cache: &ObjectCache,
117 extractor: &dyn Extractor,
118) -> Result<SyncReport, SyncError> {
119 let tree = repo.head_tree_id()?;
120 let committed = extract_committed(repo, cache, extractor)?;
121 let total = committed.by_path.len();
122 let mut by_path = committed.by_path;
123
124 let mut dirty: BTreeSet<(String, String)> = BTreeSet::new();
128 if let Some(workdir) = repo.workdir() {
129 for blob in &committed.blobs {
130 match std::fs::read(workdir.join(&blob.path)) {
131 Ok(bytes) => {
132 let woid = repo.blob_oid(&bytes)?;
133 if woid != blob.oid {
134 by_path.insert(
135 blob.path.clone(),
136 extractor.extract(&blob.path, &woid, &bytes),
137 );
138 dirty.insert((blob.path.clone(), woid));
139 }
140 }
141 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
142 by_path.remove(&blob.path);
143 dirty.insert((blob.path.clone(), "\0deleted".to_owned()));
144 }
145 Err(e) => return Err(e.into()),
146 }
147 }
148 }
149
150 let state = if dirty.is_empty() {
153 tree.clone()
154 } else {
155 let mut buf = String::new();
156 for (path, marker) in &dirty {
157 buf.push_str(path);
158 buf.push('\0');
159 buf.push_str(marker);
160 buf.push('\n');
161 }
162 format!("{tree}:dirty:{:016x}", fnv1a64(buf.as_bytes()))
163 };
164 let dirty_count = dirty.len();
165
166 if store.sync_state()?.as_deref() == Some(state.as_str()) {
167 return Ok(SyncReport {
168 no_op: true,
169 blobs_total: total,
170 blobs_dirty: dirty_count,
171 nodes: store.node_count()?,
172 edges: store.edge_count()?,
173 tree,
174 ..SyncReport::default()
175 });
176 }
177
178 let mut assembled = flatten(by_path);
179 resolve_calls(&mut assembled);
180 store.rebuild(&assembled, &state)?;
181
182 Ok(SyncReport {
183 no_op: false,
184 blobs_total: total,
185 blobs_extracted: committed.extracted,
186 blobs_cached: committed.cached,
187 blobs_dirty: dirty_count,
188 nodes: store.node_count()?,
189 edges: store.edge_count()?,
190 tree,
191 })
192}
193
194struct Committed {
197 blobs: Vec<crate::BlobRef>,
198 by_path: BTreeMap<String, FactSet>,
199 extracted: usize,
200 cached: usize,
201}
202
203fn extract_committed(
205 repo: &Repo,
206 cache: &ObjectCache,
207 extractor: &dyn Extractor,
208) -> Result<Committed, SyncError> {
209 let blobs = repo.walk_blobs()?;
210 let mut by_path = BTreeMap::new();
211 let mut extracted = 0usize;
212 let mut cached = 0usize;
213
214 for blob in &blobs {
215 let key = cache_key(&blob.path, &blob.oid);
222 let facts = if let Some(facts) = cache.get(&key)? {
223 cached += 1;
224 facts
225 } else {
226 let bytes = repo.read_blob(&blob.oid)?;
227 let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
228 cache.put(&key, &facts)?;
229 extracted += 1;
230 facts
231 };
232 by_path.insert(blob.path.clone(), facts);
233 }
234
235 Ok(Committed {
236 blobs,
237 by_path,
238 extracted,
239 cached,
240 })
241}
242
243fn flatten(by_path: BTreeMap<String, FactSet>) -> FactSet {
245 let mut assembled = FactSet::new();
246 for facts in by_path.into_values() {
247 assembled.nodes.extend(facts.nodes);
248 assembled.edges.extend(facts.edges);
249 }
250 assembled
251}
252
253fn resolve_calls(facts: &mut FactSet) {
262 let mut by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
264 for n in &facts.nodes {
265 if n.kind == NodeKind::Fn {
266 by_name
267 .entry(n.name.as_str())
268 .or_default()
269 .push(n.key.as_str());
270 }
271 }
272
273 let mut resolved: BTreeSet<(String, String)> = BTreeSet::new();
275 for n in &facts.nodes {
276 if n.kind != NodeKind::Fn {
277 continue;
278 }
279 let Some(calls) = n.meta.get("calls").and_then(|v| v.as_array()) else {
280 continue;
281 };
282 for callee in calls.iter().filter_map(|v| v.as_str()) {
283 if let Some(targets) = by_name.get(callee)
284 && targets.len() == 1
285 {
286 resolved.insert((n.key.clone(), targets[0].to_owned()));
287 }
288 }
289 }
290
291 for (src, dst) in resolved {
292 facts.edges.push(Edge::derived(src, dst, EdgeKind::Calls));
293 }
294}
295
296fn cache_key(path: &str, oid: &str) -> String {
301 format!("{oid}-{:016x}", fnv1a64(path.as_bytes()))
302}
303
304fn fnv1a64(bytes: &[u8]) -> u64 {
307 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
308 for &b in bytes {
309 hash ^= u64::from(b);
310 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
311 }
312 hash
313}
314
315#[cfg(test)]
316mod tests {
317 use super::{cache_key, resolve_calls};
318 use crate::{EdgeKind, FactSet, Node, NodeKind};
319
320 fn fn_node(key: &str, name: &str, calls: &[&str]) -> Node {
321 let mut n = Node::new(key, NodeKind::Fn, name);
322 if !calls.is_empty() {
323 n.meta = serde_json::json!({ "calls": calls });
324 }
325 n
326 }
327
328 #[test]
329 fn resolve_calls_links_unique_names_only() {
330 let mut fs = FactSet::new()
331 .with_node(fn_node(
332 "sym:rust:a.rs#caller",
333 "caller",
334 &["target", "dup", "missing"],
335 ))
336 .with_node(fn_node("sym:rust:a.rs#target", "target", &[]))
337 .with_node(fn_node("sym:rust:a.rs#dup", "dup", &[]))
339 .with_node(fn_node("sym:rust:b.rs#dup", "dup", &[]));
340
341 resolve_calls(&mut fs);
342
343 let calls: Vec<_> = fs
344 .edges
345 .iter()
346 .filter(|e| e.kind == EdgeKind::Calls)
347 .collect();
348 assert_eq!(
349 calls.len(),
350 1,
351 "only the unambiguous, known callee is linked"
352 );
353 assert_eq!(calls[0].src, "sym:rust:a.rs#caller");
354 assert_eq!(calls[0].dst, "sym:rust:a.rs#target");
355 }
356
357 #[test]
358 fn cache_key_separates_paths_but_is_stable() {
359 let oid = "abc123";
360 assert_eq!(cache_key("src/a.rs", oid), cache_key("src/a.rs", oid));
362 assert_ne!(cache_key("src/a.rs", oid), cache_key("src/b.rs", oid));
364 assert_ne!(cache_key("src/a.rs", "aaa"), cache_key("src/a.rs", "bbb"));
366 assert!(cache_key("src/a.rs", oid).starts_with("abc123-"));
368 }
369}