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, HashSet};
12
13use crate::cache::{CacheError, ObjectCache};
14use crate::extract::Extractor;
15use crate::git::{GitError, Repo};
16use crate::store::StoreError;
17use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance, 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    /// Source files reflected in the graph — one `File` node per extracted blob.
44    /// Derived from the assembled graph (not the raw tree walk) so full and
45    /// incremental syncs report the same total for the same tree.
46    pub blobs_total: usize,
47    /// Blobs that were extracted (cache misses).
48    pub blobs_extracted: usize,
49    /// Blobs served from the cache (cache hits).
50    pub blobs_cached: usize,
51    /// Working-tree files whose uncommitted content overrode the committed blob
52    /// (the dirty overlay); always zero for a committed-only [`sync`].
53    pub blobs_dirty: usize,
54    /// Nodes in the store after syncing.
55    pub nodes: u64,
56    /// Edges in the store after syncing.
57    pub edges: u64,
58}
59
60/// Sync `store` to the repository's `HEAD` tree, extracting changed blobs with
61/// `extractor` and caching results in `cache`.
62///
63/// # Errors
64/// Returns a [`SyncError`] if git access, extraction caching, or the store
65/// rebuild fails.
66pub fn sync(
67    store: &mut Store,
68    repo: &Repo,
69    cache: &ObjectCache,
70    extractor: &dyn Extractor,
71) -> Result<SyncReport, SyncError> {
72    let tree = repo.head_tree_id()?;
73
74    if store.sync_state()?.as_deref() == Some(tree.as_str()) {
75        return Ok(SyncReport {
76            no_op: true,
77            nodes: store.node_count()?,
78            edges: store.edge_count()?,
79            tree,
80            ..SyncReport::default()
81        });
82    }
83
84    // The extraction *identity*: the extractor code version (`EXTRACT_VERSION`,
85    // bumped when extraction output changes) plus its environment (installed image
86    // models + ingestion toggles). Both change what an unchanged file extracts to,
87    // and both are folded into the content-cache key — so this mirrors that key.
88    // Recorded with the tree so the next sync can tell whether reusing the stored
89    // facts (the incremental path) is sound; a binary upgrade that bumps the
90    // version, or a model change, invalidates it and forces a full re-extraction.
91    let env = format!(
92        "v{}-e{:016x}",
93        crate::extract::EXTRACT_VERSION,
94        extractor.env_tag()
95    );
96
97    // Fast path: if the last sync was a committed one at a known tree with the
98    // same extraction identity, update only the paths that changed. Falls back to
99    // a full re-extraction on any doubt (no prior tree, identity changed, or an
100    // unavailable diff).
101    if let Some(report) = try_incremental(store, repo, cache, extractor, &tree, &env)? {
102        return Ok(report);
103    }
104
105    let committed = extract_committed(repo, cache, extractor)?;
106    let mut assembled = flatten(committed.by_path);
107    resolve_calls(&mut assembled);
108    append_submodule_nodes(repo.submodules()?, &mut assembled);
109    let total = file_count(&assembled);
110    store.reconcile(&assembled, Some(&tree))?;
111    store.set_sync_env(&env)?;
112
113    Ok(SyncReport {
114        no_op: false,
115        blobs_total: total,
116        blobs_extracted: committed.extracted,
117        blobs_cached: committed.cached,
118        blobs_dirty: 0,
119        nodes: store.node_count()?,
120        edges: store.edge_count()?,
121        tree,
122    })
123}
124
125/// Attempt an incremental committed sync from the last-synced tree to `head_tree`.
126/// Returns `Ok(Some(report))` when it ran, `Ok(None)` when the fast path is not
127/// eligible (the caller then does a full sync).
128///
129/// It is sound because it produces the exact same **derived-only** graph a full
130/// sync would: it reconstructs the derived subgraph from the store (identified by
131/// the `Derived` provenance tag — unchanged paths' facts are a deterministic
132/// function of their unchanged blob content, so they equal a fresh extraction),
133/// drops the changed/deleted paths, extracts only the changed blobs, re-resolves
134/// cross-file `calls` globally, and feeds the result to the same [`Store::reconcile`]
135/// the full path uses. `check`/`reapply_imports` re-layer the authored/import
136/// facts afterward exactly as before — this only accelerates the derived layer.
137fn try_incremental(
138    store: &mut Store,
139    repo: &Repo,
140    cache: &ObjectCache,
141    extractor: &dyn Extractor,
142    head_tree: &str,
143    env: &str,
144) -> Result<Option<SyncReport>, SyncError> {
145    // Eligibility: a prior committed tree (a plain oid — worktree/index states
146    // carry a `:`-delimited marker), extracted under the same environment.
147    let Some(prior_tree) = store.sync_state()? else {
148        return Ok(None);
149    };
150    if prior_tree.contains(':') || store.sync_env()?.as_deref() != Some(env) {
151        return Ok(None);
152    }
153    // The prior tree object may have been pruned (gc); on any diff failure, fall
154    // back to the full path rather than guessing.
155    let Ok(diff) = repo.diff_trees(&prior_tree, head_tree) else {
156        return Ok(None);
157    };
158
159    // Reconstruct the derived subgraph from the store: every derived node, and
160    // every derived edge except `calls` (globally re-derived below from the full
161    // function set, since a changed file can flip name-resolution elsewhere).
162    let mut nodes: Vec<Node> = store.nodes_by_provenance(Provenance::Derived)?;
163    let mut edges: Vec<Edge> = store
164        .edges_by_provenance(Provenance::Derived)?
165        .into_iter()
166        .filter(|e| e.kind != EdgeKind::Calls)
167        .collect();
168
169    // Drop the changed and deleted paths' derived facts (their nodes, and any edge
170    // incident to them — per-blob derived edges are intra-file, so this is exact).
171    let touched: BTreeSet<&str> = diff
172        .changed
173        .iter()
174        .map(|b| b.path.as_str())
175        .chain(diff.deleted.iter().map(String::as_str))
176        .collect();
177    let dropped: HashSet<String> = nodes
178        .iter()
179        .filter(|n| n.path.as_deref().is_some_and(|p| touched.contains(p)))
180        .map(|n| n.key.clone())
181        .collect();
182    nodes.retain(|n| !dropped.contains(&n.key));
183    edges.retain(|e| !dropped.contains(&e.src) && !dropped.contains(&e.dst));
184
185    // Extract the changed blobs (cache-aware) and add their derived facts.
186    let env_tag = extractor.env_tag();
187    let mut extracted = 0usize;
188    let mut cached = 0usize;
189    for blob in &diff.changed {
190        let key = cache_key(&blob.path, &blob.oid, env_tag);
191        let facts = if let Some(facts) = cache.get(&key)? {
192            cached += 1;
193            facts
194        } else {
195            let bytes = repo.read_blob(&blob.oid)?;
196            let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
197            cache.put(&key, &facts)?;
198            extracted += 1;
199            facts
200        };
201        nodes.extend(facts.nodes);
202        edges.extend(facts.edges);
203    }
204
205    // Prune orphaned import-target nodes — a path-less derived node (e.g.
206    // `import:rust:foo`) that no surviving edge references. A full sync emits it
207    // only while some file imports it, so dropping the now-unreferenced ones keeps
208    // the two paths identical.
209    let referenced: HashSet<&str> = edges
210        .iter()
211        .flat_map(|e| [e.src.as_str(), e.dst.as_str()])
212        .collect();
213    nodes.retain(|n| n.path.is_some() || referenced.contains(n.key.as_str()));
214
215    // Global call resolution over the full (reconstructed + changed) function set,
216    // then reconcile to derived-only — identical to what the full path produces.
217    let mut assembled = FactSet { nodes, edges };
218    resolve_calls(&mut assembled);
219    append_submodule_nodes(repo.submodules()?, &mut assembled);
220    let total = file_count(&assembled);
221    store.reconcile(&assembled, Some(head_tree))?;
222    store.set_sync_env(env)?;
223
224    Ok(Some(SyncReport {
225        no_op: false,
226        blobs_total: total,
227        blobs_extracted: extracted,
228        blobs_cached: cached,
229        blobs_dirty: 0,
230        nodes: store.node_count()?,
231        edges: store.edge_count()?,
232        tree: head_tree.to_owned(),
233    }))
234}
235
236/// Sync `store` to the working tree: the committed `HEAD` state with uncommitted
237/// working-tree changes overlaid on top (a pre-commit preview).
238///
239/// Committed blobs come from the content-addressed cache as in [`sync`]; then
240/// each tracked file whose working copy differs from its committed blob is
241/// re-extracted in memory (never cached, since dirty content is not a git
242/// object), deleted files are dropped, and brand-new **untracked** files (found
243/// via a gitignore-aware dirwalk, [`Repo::untracked_files`]) are overlaid in.
244/// The recorded sync state encodes the dirty set, so a later committed [`sync`]
245/// correctly supersedes the overlay.
246///
247/// # Errors
248/// Returns a [`SyncError`] if git access, extraction caching, working-tree I/O,
249/// or the store rebuild fails.
250pub fn sync_worktree(
251    store: &mut Store,
252    repo: &Repo,
253    cache: &ObjectCache,
254    extractor: &dyn Extractor,
255) -> Result<SyncReport, SyncError> {
256    let tree = repo.head_tree_id()?;
257    let committed = extract_committed(repo, cache, extractor)?;
258    let mut by_path = committed.by_path;
259
260    // Overlay uncommitted edits to tracked files. A file is dirty when its
261    // working-copy content hashes to a different git blob id than the committed
262    // one; identical content hashes identically, so clean files are skipped.
263    let mut dirty: BTreeSet<(String, String)> = BTreeSet::new();
264    if let Some(workdir) = repo.workdir() {
265        for blob in &committed.blobs {
266            match std::fs::read(workdir.join(&blob.path)) {
267                Ok(bytes) => {
268                    let woid = repo.blob_oid(&bytes)?;
269                    if woid != blob.oid {
270                        by_path.insert(
271                            blob.path.clone(),
272                            extractor.extract(&blob.path, &woid, &bytes),
273                        );
274                        dirty.insert((blob.path.clone(), woid));
275                    }
276                }
277                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
278                    by_path.remove(&blob.path);
279                    dirty.insert((blob.path.clone(), "\0deleted".to_owned()));
280                }
281                Err(e) => return Err(e.into()),
282            }
283        }
284
285        // Overlay brand-new untracked files: not in `HEAD`, so absent from
286        // `committed.blobs` above. A gitignore-aware walk finds them so the
287        // working-tree `sync`/`check`/`review` see new work that isn't staged yet.
288        // They count as dirty (so the preview re-runs when they change) and add to
289        // the blob total (they are genuinely new blobs, not edits of existing ones).
290        for path in repo.untracked_files()? {
291            match std::fs::read(workdir.join(&path)) {
292                Ok(bytes) => {
293                    let woid = repo.blob_oid(&bytes)?;
294                    by_path.insert(path.clone(), extractor.extract(&path, &woid, &bytes));
295                    dirty.insert((path, woid));
296                }
297                // Raced away between the walk and the read — nothing to add.
298                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
299                Err(e) => return Err(e.into()),
300            }
301        }
302    }
303
304    // The blob total is the file count of the *overlaid* graph — committed files,
305    // minus working-tree deletions, plus untracked additions — not the committed
306    // baseline, so it stays consistent whether files were added or removed.
307    let total = by_path.len();
308
309    // Encode the dirty set into the sync state so repeated identical previews
310    // no-op, but any committed change (which alters the plain tree id) does not.
311    let state = if dirty.is_empty() {
312        tree.clone()
313    } else {
314        let mut buf = String::new();
315        for (path, marker) in &dirty {
316            buf.push_str(path);
317            buf.push('\0');
318            buf.push_str(marker);
319            buf.push('\n');
320        }
321        format!("{tree}:dirty:{:016x}", fnv1a64(buf.as_bytes()))
322    };
323    let dirty_count = dirty.len();
324
325    if store.sync_state()?.as_deref() == Some(state.as_str()) {
326        return Ok(SyncReport {
327            no_op: true,
328            blobs_total: total,
329            blobs_dirty: dirty_count,
330            nodes: store.node_count()?,
331            edges: store.edge_count()?,
332            tree,
333            ..SyncReport::default()
334        });
335    }
336
337    let mut assembled = flatten(by_path);
338    resolve_calls(&mut assembled);
339    append_submodule_nodes(repo.submodules()?, &mut assembled);
340    store.reconcile(&assembled, Some(&state))?;
341
342    Ok(SyncReport {
343        no_op: false,
344        blobs_total: total,
345        blobs_extracted: committed.extracted,
346        blobs_cached: committed.cached,
347        blobs_dirty: dirty_count,
348        nodes: store.node_count()?,
349        edges: store.edge_count()?,
350        tree,
351    })
352}
353
354/// Sync `store` to the **git index** — the staged tree that a commit would
355/// record. Unlike [`sync_worktree`] (files on disk) this reads each staged blob
356/// by its index object id, so it validates *exactly what is about to be
357/// committed* (partially-staged changes and all). New staged files are included;
358/// unstaged working-tree edits are not. Backs the index-aware pre-commit gate.
359///
360/// # Errors
361/// Returns a [`SyncError`] if git access, extraction caching, or the store
362/// reconcile fails.
363pub fn sync_index(
364    store: &mut Store,
365    repo: &Repo,
366    cache: &ObjectCache,
367    extractor: &dyn Extractor,
368) -> Result<SyncReport, SyncError> {
369    let staged = repo.index_files()?;
370    // A stable state id over the staged (path, oid) set, in its own `index:`
371    // namespace so it never collides with a committed tree id or a worktree dirty
372    // marker — repeated identical index syncs then no-op, while any staged change
373    // does not.
374    let mut buf = String::new();
375    for blob in &staged {
376        buf.push_str(&blob.path);
377        buf.push('\0');
378        buf.push_str(&blob.oid);
379        buf.push('\n');
380    }
381    let state = format!("index:{:016x}", fnv1a64(buf.as_bytes()));
382
383    if store.sync_state()?.as_deref() == Some(state.as_str()) {
384        return Ok(SyncReport {
385            no_op: true,
386            blobs_total: staged.len(),
387            nodes: store.node_count()?,
388            edges: store.edge_count()?,
389            tree: state,
390            ..SyncReport::default()
391        });
392    }
393
394    let extracted = extract_blobs(repo, cache, extractor, staged)?;
395    let total = extracted.by_path.len();
396    let mut assembled = flatten(extracted.by_path);
397    resolve_calls(&mut assembled);
398    // Index mode is "exactly what a commit would record", so submodule pins come
399    // from the *staged* gitlinks, not `HEAD` — a staged bump is reflected.
400    append_submodule_nodes(repo.index_submodules()?, &mut assembled);
401    store.reconcile(&assembled, Some(&state))?;
402
403    Ok(SyncReport {
404        no_op: false,
405        blobs_total: total,
406        blobs_extracted: extracted.extracted,
407        blobs_cached: extracted.cached,
408        blobs_dirty: 0,
409        nodes: store.node_count()?,
410        edges: store.edge_count()?,
411        tree: state,
412    })
413}
414
415/// Extract a repo's **derived graph at an arbitrary commit/tree `rev`** into
416/// `store`, replacing its contents — the same content-addressed extraction as
417/// [`sync`], but for a historical point rather than `HEAD`. Because extraction is
418/// keyed by `(path, blob oid, env)`, every blob unchanged versus another synced
419/// point is a cache hit, so resolving an older version only re-does what differs.
420///
421/// This backs **version-pin resolution** (ADR-0009 step 8): to resolve a spoke's
422/// cross-repo reference against the hub *version it deploys* (a submodule sha,
423/// an image tag → commit), extract the hub at that `rev` into an ephemeral store
424/// and resolve there. It populates the derived layer only (config keys, symbols,
425/// calls); authored/import layers are not re-applied, since this is a read-only
426/// resolution snapshot. No sync-state is recorded (`tree` carries `rev` for the
427/// report only).
428///
429/// # Errors
430/// Returns [`SyncError`] on git access, extraction caching, or store failure.
431pub fn sync_tree(
432    store: &mut Store,
433    repo: &Repo,
434    cache: &ObjectCache,
435    extractor: &dyn Extractor,
436    rev: &str,
437) -> Result<SyncReport, SyncError> {
438    let extracted = extract_blobs(repo, cache, extractor, repo.blobs_at(rev)?)?;
439    let mut assembled = flatten(extracted.by_path);
440    resolve_calls(&mut assembled);
441    append_submodule_nodes(repo.submodules_at(rev)?, &mut assembled);
442    let total = file_count(&assembled);
443    store.rebuild(&assembled, None)?;
444    Ok(SyncReport {
445        no_op: false,
446        blobs_total: total,
447        blobs_extracted: extracted.extracted,
448        blobs_cached: extracted.cached,
449        blobs_dirty: 0,
450        nodes: store.node_count()?,
451        edges: store.edge_count()?,
452        tree: rev.to_owned(),
453    })
454}
455
456/// The committed fact sets for the `HEAD` tree, one per path, plus the blob list
457/// (for overlay comparison) and cache-hit/miss counts.
458struct Committed {
459    blobs: Vec<crate::BlobRef>,
460    by_path: BTreeMap<String, FactSet>,
461    extracted: usize,
462    cached: usize,
463}
464
465/// Extract (or load from cache) the fact set for every blob in the `HEAD` tree.
466fn extract_committed(
467    repo: &Repo,
468    cache: &ObjectCache,
469    extractor: &dyn Extractor,
470) -> Result<Committed, SyncError> {
471    extract_blobs(repo, cache, extractor, repo.walk_blobs()?)
472}
473
474/// Extract (or load from cache) the fact set for each blob in `blobs` — the
475/// shared core of [`extract_committed`] and [`sync_index`], differing only in
476/// which tree the blob list comes from (`HEAD` vs the git index).
477fn extract_blobs(
478    repo: &Repo,
479    cache: &ObjectCache,
480    extractor: &dyn Extractor,
481    blobs: Vec<crate::BlobRef>,
482) -> Result<Committed, SyncError> {
483    let mut by_path = BTreeMap::new();
484    let mut extracted = 0usize;
485    let mut cached = 0usize;
486
487    // Extraction output depends on runtime state beyond (path, bytes): which
488    // image models are installed, and the extractor's ingestion toggles. The
489    // extractor folds both into a single tag for the cache key. Computed once
490    // per sync.
491    let env = extractor.env_tag();
492
493    for blob in &blobs {
494        // Extraction is a function of (path, blob bytes) and — with `image-ocr`
495        // — the OCR model environment (`env`), never blob id alone: node keys are
496        // path-scoped (e.g. `file:<path>`), so the same blob content at two
497        // different paths yields different facts. Key the cache by (path, oid,
498        // env) so duplicate-content files (e.g. empty files, which git dedupes to
499        // one oid) never collide, the same path+oid in another branch/worktree
500        // still hits, and installing/upgrading OCR models re-extracts images.
501        let key = cache_key(&blob.path, &blob.oid, env);
502        let facts = if let Some(facts) = cache.get(&key)? {
503            cached += 1;
504            facts
505        } else {
506            let bytes = repo.read_blob(&blob.oid)?;
507            let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
508            cache.put(&key, &facts)?;
509            extracted += 1;
510            facts
511        };
512        by_path.insert(blob.path.clone(), facts);
513    }
514
515    Ok(Committed {
516        blobs,
517        by_path,
518        extracted,
519        cached,
520    })
521}
522
523/// Concatenate per-path fact sets into one assembled fact set.
524fn flatten(by_path: BTreeMap<String, FactSet>) -> FactSet {
525    let mut assembled = FactSet::new();
526    for facts in by_path.into_values() {
527        assembled.nodes.extend(facts.nodes);
528        assembled.edges.extend(facts.edges);
529    }
530    assembled
531}
532
533/// The `NodeKind::Other` token for a submodule-pin node (`submodule:<path>`).
534pub(crate) const SUBMODULE_KIND: &str = "submodule";
535
536/// Append the given submodule-pin nodes to `assembled`, replacing any already
537/// present. `subs` is the caller's source-appropriate list — `repo.submodules()`
538/// (the `HEAD` tree) for committed/worktree syncs, `repo.index_submodules()` (the
539/// staged gitlinks) for the index-aware pre-commit gate. A submodule pin is a
540/// **tree-level** derived fact (a gitlink + its `.gitmodules` URL, ADR-0009), not
541/// a per-blob one, so it is recomputed on every sync rather than cached. Removing
542/// any existing submodule nodes first makes the
543/// incremental path — which reconstructs derived nodes from the store — produce
544/// exactly the full sync's result: an unchanged pin re-adds identically, a bumped
545/// pin's new sha wins, and a removed submodule leaves none behind. The nodes carry
546/// `path = .gitmodules` (so a `.gitmodules` deletion drops them) and stand alone
547/// (no edges — nothing in the graph is their guaranteed endpoint).
548fn append_submodule_nodes(subs: Vec<crate::Submodule>, assembled: &mut FactSet) {
549    let kind = NodeKind::Other(SUBMODULE_KIND.to_owned());
550    assembled.nodes.retain(|n| n.kind != kind);
551    for sm in subs {
552        let key = format!("submodule:{}", sm.path);
553        let mut node = Node::new(key, kind.clone(), sm.path.clone());
554        node.path = Some(".gitmodules".to_owned());
555        node.provenance = Provenance::Derived;
556        node.meta = serde_json::json!({ "path": sm.path, "url": sm.url, "sha": sm.sha });
557        assembled.nodes.push(node);
558    }
559}
560
561/// The number of source files reflected in an assembled fact set (one `File`
562/// node per extracted blob). Both the full and incremental sync paths derive
563/// `SyncReport::blobs_total` from the *assembled graph* this way — not from the
564/// raw blob list — so the two paths report the same total for the same tree (the
565/// graphs are identical; see the equivalence test).
566fn file_count(facts: &FactSet) -> usize {
567    facts
568        .nodes
569        .iter()
570        .filter(|n| n.kind == NodeKind::File)
571        .count()
572}
573
574/// Resolve the per-function call records (`meta.calls`) accumulated during
575/// extraction into `calls` edges, now that every file's symbols are present.
576///
577/// Resolution is deliberately conservative — it links a call only when the target
578/// is **unambiguous** — but scope-aware: a callee descriptor may carry the
579/// immediate qualifier the call site provided (`b::foo`, `Type::assoc`,
580/// `Self::method`; see [`crate::extract`]). A call resolves when either
581///
582/// 1. its simple name is unique across the whole tree (the base case), or
583/// 2. its name is ambiguous but a qualifier picks out **exactly one** matching
584///    function — the one whose immediate scope segment equals that qualifier
585///    (with `Self` bound to the caller's own impl type).
586///
587/// This never links a name it could not before (it is a strict superset), and it
588/// still refuses to guess when a qualifier leaves more than one candidate. Runs at
589/// assembly time — not per blob — since a single blob cannot see other files.
590fn resolve_calls(facts: &mut FactSet) {
591    // Simple function name → the keys of functions with that name.
592    let mut by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
593    for n in &facts.nodes {
594        if n.kind == NodeKind::Fn {
595            by_name
596                .entry(n.name.as_str())
597                .or_default()
598                .push(n.key.as_str());
599        }
600    }
601
602    // Collect (caller, callee) pairs; BTreeSet dedupes and orders them.
603    let mut resolved: BTreeSet<(String, String)> = BTreeSet::new();
604    for n in &facts.nodes {
605        if n.kind != NodeKind::Fn {
606            continue;
607        }
608        let Some(calls) = n.meta.get("calls").and_then(|v| v.as_array()) else {
609            continue;
610        };
611        // The caller's own type (for binding `Self::` calls) is the scope segment
612        // immediately before its name in its key, if it is a method.
613        let caller_self = self_type_of(&n.key);
614        for descriptor in calls.iter().filter_map(|v| v.as_str()) {
615            let (qualifier, name) = split_callee(descriptor);
616            let Some(candidates) = by_name.get(name) else {
617                continue;
618            };
619            let target = if candidates.len() == 1 {
620                // Unambiguous by simple name — the base case (unchanged behaviour).
621                Some(candidates[0])
622            } else if let Some(q) = qualifier {
623                // Ambiguous name; try the qualifier. `Self` binds to the caller's
624                // impl type — a free function has none, so such a call stays open.
625                let want = if q == "Self" { caller_self } else { Some(q) };
626                want.and_then(|want| unique_in_scope(candidates, want, name))
627            } else {
628                None
629            };
630            if let Some(dst) = target {
631                resolved.insert((n.key.clone(), dst.to_owned()));
632            }
633        }
634    }
635
636    for (src, dst) in resolved {
637        facts.edges.push(Edge::derived(src, dst, EdgeKind::Calls));
638    }
639}
640
641/// The qualified suffix of a symbol key (`sym:<lang>:<path>#<qualified>` →
642/// `<qualified>`), i.e. the scope-segment path within its file.
643fn qualified_suffix(key: &str) -> &str {
644    key.rsplit_once('#').map_or(key, |(_, q)| q)
645}
646
647/// The caller's own type for binding a `Self::` call: the scope segment
648/// immediately before the function's name in its key (`Type::method` → `Type`),
649/// or `None` for a free function (no enclosing type).
650fn self_type_of(key: &str) -> Option<&str> {
651    let mut segs = qualified_suffix(key).rsplit("::");
652    segs.next()?; // the function's own name
653    segs.next() // the enclosing scope segment, if any
654}
655
656/// The single candidate whose immediate scope segment is `want` (so its key ends
657/// with the `want::name` segment pair), or `None` when zero or several match —
658/// segment-aware so `T::m` matches `a::T::m` but never `XT::m`.
659fn unique_in_scope<'a>(candidates: &[&'a str], want: &str, name: &str) -> Option<&'a str> {
660    let mut hit = None;
661    for &key in candidates {
662        let mut segs = qualified_suffix(key).rsplit("::");
663        if segs.next() == Some(name) && segs.next() == Some(want) {
664            if hit.is_some() {
665                return None; // more than one match at this scope — refuse to guess
666            }
667            hit = Some(key);
668        }
669    }
670    hit
671}
672
673/// Split a `meta.calls` descriptor into its immediate qualifier and simple name:
674/// `b::foo` → `(Some("b"), "foo")`, `foo` → `(None, "foo")`.
675fn split_callee(descriptor: &str) -> (Option<&str>, &str) {
676    match descriptor.rsplit_once("::") {
677        Some((qualifier, name)) => (Some(qualifier), name),
678        None => (None, descriptor),
679    }
680}
681
682/// Content-addressed cache key for a blob at a given path: the blob oid (kept
683/// as the leading, well-distributed shard) suffixed with a stable 64-bit hash of
684/// the path, the [`crate::extract::EXTRACT_VERSION`], and the extractor
685/// environment tag `env` (the installed media-model — OCR + vision + audio —
686/// identity; `0` when no media model is active — see
687/// [`crate::extract::media_env_tag`]). Sharing across branches/worktrees is
688/// preserved (same path+oid+version+env → same key) while duplicate content at
689/// distinct paths stays distinct; bumping the extractor version *or* changing the
690/// installed media models retires old entries so a re-extraction is forced.
691fn cache_key(path: &str, oid: &str, env: u64) -> String {
692    format!(
693        "{oid}-{:016x}-v{}-e{env:016x}",
694        fnv1a64(path.as_bytes()),
695        crate::extract::EXTRACT_VERSION,
696    )
697}
698
699/// FNV-1a (64-bit). Dependency-free and deterministic; used only to derive
700/// cache filenames, so it needs no cryptographic properties.
701fn fnv1a64(bytes: &[u8]) -> u64 {
702    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
703    for &b in bytes {
704        hash ^= u64::from(b);
705        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
706    }
707    hash
708}
709
710#[cfg(test)]
711mod tests {
712    use super::{cache_key, resolve_calls};
713    use crate::{EdgeKind, FactSet, Node, NodeKind};
714
715    fn fn_node(key: &str, name: &str, calls: &[&str]) -> Node {
716        let mut n = Node::new(key, NodeKind::Fn, name);
717        if !calls.is_empty() {
718            n.meta = serde_json::json!({ "calls": calls });
719        }
720        n
721    }
722
723    #[test]
724    fn resolve_calls_links_unique_names_only() {
725        let mut fs = FactSet::new()
726            .with_node(fn_node(
727                "sym:rust:a.rs#caller",
728                "caller",
729                &["target", "dup", "missing"],
730            ))
731            .with_node(fn_node("sym:rust:a.rs#target", "target", &[]))
732            // Two functions named `dup` → ambiguous, must not be linked.
733            .with_node(fn_node("sym:rust:a.rs#dup", "dup", &[]))
734            .with_node(fn_node("sym:rust:b.rs#dup", "dup", &[]));
735
736        resolve_calls(&mut fs);
737
738        let calls: Vec<_> = fs
739            .edges
740            .iter()
741            .filter(|e| e.kind == EdgeKind::Calls)
742            .collect();
743        assert_eq!(
744            calls.len(),
745            1,
746            "only the unambiguous, known callee is linked"
747        );
748        assert_eq!(calls[0].src, "sym:rust:a.rs#caller");
749        assert_eq!(calls[0].dst, "sym:rust:a.rs#target");
750    }
751
752    #[test]
753    fn cache_key_separates_paths_but_is_stable() {
754        let oid = "abc123";
755        // Same path + oid + env is stable across calls.
756        assert_eq!(cache_key("src/a.rs", oid, 0), cache_key("src/a.rs", oid, 0));
757        // Same blob content (oid) at two different paths must not collide.
758        assert_ne!(cache_key("src/a.rs", oid, 0), cache_key("src/b.rs", oid, 0));
759        // Different content at the same path differs too.
760        assert_ne!(
761            cache_key("src/a.rs", "aaa", 0),
762            cache_key("src/a.rs", "bbb", 0)
763        );
764        // A different extractor environment (e.g. OCR models installed) differs,
765        // so image facts are re-extracted when the models change.
766        assert_ne!(
767            cache_key("src/a.rs", oid, 0),
768            cache_key("src/a.rs", oid, 42)
769        );
770        // Key stays sharded on the oid so the cache's 2-char shard is well spread.
771        assert!(cache_key("src/a.rs", oid, 0).starts_with("abc123-"));
772        // The extractor version is folded in, so a bump retires old entries.
773        assert!(
774            cache_key("src/a.rs", oid, 0)
775                .contains(&format!("-v{}", crate::extract::EXTRACT_VERSION))
776        );
777    }
778}