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, Some(&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, Some(&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 let env = extractor.env_tag();
219
220 for blob in &blobs {
221 let key = cache_key(&blob.path, &blob.oid, env);
229 let facts = if let Some(facts) = cache.get(&key)? {
230 cached += 1;
231 facts
232 } else {
233 let bytes = repo.read_blob(&blob.oid)?;
234 let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
235 cache.put(&key, &facts)?;
236 extracted += 1;
237 facts
238 };
239 by_path.insert(blob.path.clone(), facts);
240 }
241
242 Ok(Committed {
243 blobs,
244 by_path,
245 extracted,
246 cached,
247 })
248}
249
250fn flatten(by_path: BTreeMap<String, FactSet>) -> FactSet {
252 let mut assembled = FactSet::new();
253 for facts in by_path.into_values() {
254 assembled.nodes.extend(facts.nodes);
255 assembled.edges.extend(facts.edges);
256 }
257 assembled
258}
259
260fn resolve_calls(facts: &mut FactSet) {
269 let mut by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
271 for n in &facts.nodes {
272 if n.kind == NodeKind::Fn {
273 by_name
274 .entry(n.name.as_str())
275 .or_default()
276 .push(n.key.as_str());
277 }
278 }
279
280 let mut resolved: BTreeSet<(String, String)> = BTreeSet::new();
282 for n in &facts.nodes {
283 if n.kind != NodeKind::Fn {
284 continue;
285 }
286 let Some(calls) = n.meta.get("calls").and_then(|v| v.as_array()) else {
287 continue;
288 };
289 for callee in calls.iter().filter_map(|v| v.as_str()) {
290 if let Some(targets) = by_name.get(callee)
291 && targets.len() == 1
292 {
293 resolved.insert((n.key.clone(), targets[0].to_owned()));
294 }
295 }
296 }
297
298 for (src, dst) in resolved {
299 facts.edges.push(Edge::derived(src, dst, EdgeKind::Calls));
300 }
301}
302
303fn cache_key(path: &str, oid: &str, env: u64) -> String {
313 format!(
314 "{oid}-{:016x}-v{}-e{env:016x}",
315 fnv1a64(path.as_bytes()),
316 crate::extract::EXTRACT_VERSION,
317 )
318}
319
320fn fnv1a64(bytes: &[u8]) -> u64 {
323 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
324 for &b in bytes {
325 hash ^= u64::from(b);
326 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
327 }
328 hash
329}
330
331#[cfg(test)]
332mod tests {
333 use super::{cache_key, resolve_calls};
334 use crate::{EdgeKind, FactSet, Node, NodeKind};
335
336 fn fn_node(key: &str, name: &str, calls: &[&str]) -> Node {
337 let mut n = Node::new(key, NodeKind::Fn, name);
338 if !calls.is_empty() {
339 n.meta = serde_json::json!({ "calls": calls });
340 }
341 n
342 }
343
344 #[test]
345 fn resolve_calls_links_unique_names_only() {
346 let mut fs = FactSet::new()
347 .with_node(fn_node(
348 "sym:rust:a.rs#caller",
349 "caller",
350 &["target", "dup", "missing"],
351 ))
352 .with_node(fn_node("sym:rust:a.rs#target", "target", &[]))
353 .with_node(fn_node("sym:rust:a.rs#dup", "dup", &[]))
355 .with_node(fn_node("sym:rust:b.rs#dup", "dup", &[]));
356
357 resolve_calls(&mut fs);
358
359 let calls: Vec<_> = fs
360 .edges
361 .iter()
362 .filter(|e| e.kind == EdgeKind::Calls)
363 .collect();
364 assert_eq!(
365 calls.len(),
366 1,
367 "only the unambiguous, known callee is linked"
368 );
369 assert_eq!(calls[0].src, "sym:rust:a.rs#caller");
370 assert_eq!(calls[0].dst, "sym:rust:a.rs#target");
371 }
372
373 #[test]
374 fn cache_key_separates_paths_but_is_stable() {
375 let oid = "abc123";
376 assert_eq!(cache_key("src/a.rs", oid, 0), cache_key("src/a.rs", oid, 0));
378 assert_ne!(cache_key("src/a.rs", oid, 0), cache_key("src/b.rs", oid, 0));
380 assert_ne!(
382 cache_key("src/a.rs", "aaa", 0),
383 cache_key("src/a.rs", "bbb", 0)
384 );
385 assert_ne!(
388 cache_key("src/a.rs", oid, 0),
389 cache_key("src/a.rs", oid, 42)
390 );
391 assert!(cache_key("src/a.rs", oid, 0).starts_with("abc123-"));
393 assert!(
395 cache_key("src/a.rs", oid, 0)
396 .contains(&format!("-v{}", crate::extract::EXTRACT_VERSION))
397 );
398 }
399}