Skip to main content

rto_graph/
sync.rs

1//! The incremental, content-addressed sync engine.
2//!
3//! `sync` brings a [`Store`] into agreement with the repository's `HEAD` tree.
4//! Extraction is the expensive part and is content-addressed by blob id, so only
5//! blobs whose content changed are re-extracted; the rest load from the
6//! [`ObjectCache`]. If the tree id is unchanged since the last sync, it is a
7//! no-op. The graph itself is reassembled from the (cached) per-blob fact sets
8//! and rebuilt in a single transaction — a deliberately simple DB-write model
9//! for this stage; incremental DB updates can come later.
10
11use 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/// Errors raised while syncing.
20#[derive(Debug, thiserror::Error)]
21pub enum SyncError {
22    /// A store operation failed.
23    #[error(transparent)]
24    Store(#[from] StoreError),
25    /// A cache operation failed.
26    #[error(transparent)]
27    Cache(#[from] CacheError),
28    /// A git operation failed.
29    #[error(transparent)]
30    Git(#[from] GitError),
31    /// Reading a working-tree file failed (dirty overlay).
32    #[error("worktree io error: {0}")]
33    Io(#[from] std::io::Error),
34}
35
36/// A summary of the work a [`sync`] performed.
37#[derive(Debug, Clone, Default, serde::Serialize)]
38pub struct SyncReport {
39    /// Hex id of the synced `HEAD` tree.
40    pub tree: String,
41    /// Whether the tree was unchanged and nothing was done.
42    pub no_op: bool,
43    /// Total blobs in the tree.
44    pub blobs_total: usize,
45    /// Blobs that were extracted (cache misses).
46    pub blobs_extracted: usize,
47    /// Blobs served from the cache (cache hits).
48    pub blobs_cached: usize,
49    /// Working-tree files whose uncommitted content overrode the committed blob
50    /// (the dirty overlay); always zero for a committed-only [`sync`].
51    pub blobs_dirty: usize,
52    /// Nodes in the store after syncing.
53    pub nodes: u64,
54    /// Edges in the store after syncing.
55    pub edges: u64,
56}
57
58/// Sync `store` to the repository's `HEAD` tree, extracting changed blobs with
59/// `extractor` and caching results in `cache`.
60///
61/// # Errors
62/// Returns a [`SyncError`] if git access, extraction caching, or the store
63/// rebuild fails.
64pub 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
100/// Sync `store` to the working tree: the committed `HEAD` state with uncommitted
101/// edits to **tracked** files overlaid on top (a pre-commit preview).
102///
103/// Committed blobs come from the content-addressed cache as in [`sync`]; then
104/// each tracked file whose working copy differs from its committed blob is
105/// re-extracted in memory (never cached, since dirty content is not a git
106/// object), and deleted files are dropped. New *untracked* files are not yet
107/// included. The recorded sync state encodes the dirty set, so a later
108/// committed [`sync`] correctly supersedes the overlay.
109///
110/// # Errors
111/// Returns a [`SyncError`] if git access, extraction caching, working-tree I/O,
112/// or the store rebuild fails.
113pub 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    // Overlay uncommitted edits to tracked files. A file is dirty when its
125    // working-copy content hashes to a different git blob id than the committed
126    // one; identical content hashes identically, so clean files are skipped.
127    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    // Encode the dirty set into the sync state so repeated identical previews
151    // no-op, but any committed change (which alters the plain tree id) does not.
152    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
194/// The committed fact sets for the `HEAD` tree, one per path, plus the blob list
195/// (for overlay comparison) and cache-hit/miss counts.
196struct Committed {
197    blobs: Vec<crate::BlobRef>,
198    by_path: BTreeMap<String, FactSet>,
199    extracted: usize,
200    cached: usize,
201}
202
203/// Extract (or load from cache) the fact set for every blob in the `HEAD` tree.
204fn 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        // Extraction is a pure function of (path, blob bytes), not blob id
216        // alone: node keys are path-scoped (e.g. `file:<path>`), so the same
217        // blob content at two different paths yields different facts. Key the
218        // cache by (path, oid) so duplicate-content files (e.g. empty files,
219        // which git dedupes to one oid) never collide, while the same path+oid
220        // in another branch/worktree still hits.
221        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
243/// Concatenate per-path fact sets into one assembled fact set.
244fn 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
253/// Resolve the per-function call records (`meta.calls`) accumulated during
254/// extraction into `calls` edges, now that every file's symbols are present.
255///
256/// A callee simple-name is linked only when it resolves to **exactly one**
257/// function in the whole tree; ambiguous names (multiple `fn foo`) and unknown
258/// names (external/std calls) are left unresolved rather than guessed. This runs
259/// at assembly time — not per blob — because a single blob cannot see the
260/// definitions in other files.
261fn resolve_calls(facts: &mut FactSet) {
262    // Simple function name → the keys of functions with that name.
263    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    // Collect (caller, callee) pairs; BTreeSet dedupes and orders them.
274    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
296/// Content-addressed cache key for a blob at a given path: the blob oid (kept
297/// as the leading, well-distributed shard) suffixed with a stable 64-bit hash
298/// of the path. Sharing across branches/worktrees is preserved (same path+oid →
299/// same key) while duplicate content at distinct paths stays distinct.
300fn cache_key(path: &str, oid: &str) -> String {
301    format!("{oid}-{:016x}", fnv1a64(path.as_bytes()))
302}
303
304/// FNV-1a (64-bit). Dependency-free and deterministic; used only to derive
305/// cache filenames, so it needs no cryptographic properties.
306fn 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            // Two functions named `dup` → ambiguous, must not be linked.
338            .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        // Same path + oid is stable across calls.
361        assert_eq!(cache_key("src/a.rs", oid), cache_key("src/a.rs", oid));
362        // Same blob content (oid) at two different paths must not collide.
363        assert_ne!(cache_key("src/a.rs", oid), cache_key("src/b.rs", oid));
364        // Different content at the same path differs too.
365        assert_ne!(cache_key("src/a.rs", "aaa"), cache_key("src/a.rs", "bbb"));
366        // Key stays sharded on the oid so the cache's 2-char shard is well spread.
367        assert!(cache_key("src/a.rs", oid).starts_with("abc123-"));
368    }
369}