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, 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
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, 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
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    // Extraction output depends on runtime state beyond (path, bytes): which
215    // image models are installed, and the extractor's ingestion toggles. The
216    // extractor folds both into a single tag for the cache key. Computed once
217    // per sync.
218    let env = extractor.env_tag();
219
220    for blob in &blobs {
221        // Extraction is a function of (path, blob bytes) and — with `image-ocr`
222        // — the OCR model environment (`env`), never blob id alone: node keys are
223        // path-scoped (e.g. `file:<path>`), so the same blob content at two
224        // different paths yields different facts. Key the cache by (path, oid,
225        // env) so duplicate-content files (e.g. empty files, which git dedupes to
226        // one oid) never collide, the same path+oid in another branch/worktree
227        // still hits, and installing/upgrading OCR models re-extracts images.
228        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
250/// Concatenate per-path fact sets into one assembled fact set.
251fn 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
260/// Resolve the per-function call records (`meta.calls`) accumulated during
261/// extraction into `calls` edges, now that every file's symbols are present.
262///
263/// A callee simple-name is linked only when it resolves to **exactly one**
264/// function in the whole tree; ambiguous names (multiple `fn foo`) and unknown
265/// names (external/std calls) are left unresolved rather than guessed. This runs
266/// at assembly time — not per blob — because a single blob cannot see the
267/// definitions in other files.
268fn resolve_calls(facts: &mut FactSet) {
269    // Simple function name → the keys of functions with that name.
270    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    // Collect (caller, callee) pairs; BTreeSet dedupes and orders them.
281    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
303/// Content-addressed cache key for a blob at a given path: the blob oid (kept
304/// as the leading, well-distributed shard) suffixed with a stable 64-bit hash of
305/// the path, the [`crate::extract::EXTRACT_VERSION`], and the extractor
306/// environment tag `env` (the installed image-model — OCR + vision — identity;
307/// `0` when no image model is active — see [`crate::extract::image_env_tag`]).
308/// Sharing across branches/worktrees is preserved (same path+oid+version+env →
309/// same key) while duplicate content at distinct paths stays distinct; bumping
310/// the extractor version *or* changing the installed image models retires old
311/// entries so a re-extraction is forced.
312fn 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
320/// FNV-1a (64-bit). Dependency-free and deterministic; used only to derive
321/// cache filenames, so it needs no cryptographic properties.
322fn 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            // Two functions named `dup` → ambiguous, must not be linked.
354            .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        // Same path + oid + env is stable across calls.
377        assert_eq!(cache_key("src/a.rs", oid, 0), cache_key("src/a.rs", oid, 0));
378        // Same blob content (oid) at two different paths must not collide.
379        assert_ne!(cache_key("src/a.rs", oid, 0), cache_key("src/b.rs", oid, 0));
380        // Different content at the same path differs too.
381        assert_ne!(
382            cache_key("src/a.rs", "aaa", 0),
383            cache_key("src/a.rs", "bbb", 0)
384        );
385        // A different extractor environment (e.g. OCR models installed) differs,
386        // so image facts are re-extracted when the models change.
387        assert_ne!(
388            cache_key("src/a.rs", oid, 0),
389            cache_key("src/a.rs", oid, 42)
390        );
391        // Key stays sharded on the oid so the cache's 2-char shard is well spread.
392        assert!(cache_key("src/a.rs", oid, 0).starts_with("abc123-"));
393        // The extractor version is folded in, so a bump retires old entries.
394        assert!(
395            cache_key("src/a.rs", oid, 0)
396                .contains(&format!("-v{}", crate::extract::EXTRACT_VERSION))
397        );
398    }
399}