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::cell::Cell;
12use std::collections::{BTreeMap, BTreeSet, HashSet};
13
14use crate::cache::{CacheError, ObjectCache, ObjectSweep};
15use crate::extract::Extractor;
16use crate::git::{GitError, Repo};
17use crate::store::StoreError;
18use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance, Store};
19
20/// Errors raised while syncing.
21#[derive(Debug, thiserror::Error)]
22pub enum SyncError {
23    /// A store operation failed.
24    #[error(transparent)]
25    Store(#[from] StoreError),
26    /// A cache operation failed.
27    #[error(transparent)]
28    Cache(#[from] CacheError),
29    /// A git operation failed.
30    #[error(transparent)]
31    Git(#[from] GitError),
32    /// Reading a working-tree file failed (dirty overlay).
33    #[error("worktree io error: {0}")]
34    Io(#[from] std::io::Error),
35}
36
37/// A summary of the work a [`sync`] performed.
38#[derive(Debug, Clone, Default, serde::Serialize)]
39pub struct SyncReport {
40    /// Hex id of the synced `HEAD` tree.
41    pub tree: String,
42    /// Whether the tree was unchanged and nothing was done.
43    pub no_op: bool,
44    /// Source files reflected in the graph — one `File` node per extracted blob.
45    /// Derived from the assembled graph (not the raw tree walk) so full and
46    /// incremental syncs report the same total for the same tree.
47    pub blobs_total: usize,
48    /// Blobs that were extracted (cache misses).
49    pub blobs_extracted: usize,
50    /// Blobs served from the cache (cache hits).
51    pub blobs_cached: usize,
52    /// Working-tree files whose uncommitted content overrode the committed blob
53    /// (the dirty overlay); always zero for a committed-only [`sync`].
54    pub blobs_dirty: usize,
55    /// Nodes in the store after syncing.
56    pub nodes: u64,
57    /// Edges in the store after syncing.
58    pub edges: u64,
59    /// The working tree this graph previously described, when it was a
60    /// **different** one and the sync therefore rebuilt from scratch rather than
61    /// trusting the recorded state (issue #330).
62    ///
63    /// `None` on every ordinary sync. `Some(path)` is the loud half of the
64    /// guarantee: the answer was corrected rather than served, and the caller can
65    /// say *which* tree the store had been holding, so a stale store is never a
66    /// silent wrong answer nor an unexplained slow one.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub rebuilt_from_foreign_worktree: Option<String>,
69}
70
71/// A stable identity for the working tree a graph is assembled from: the
72/// working-tree root, or the git dir for a bare repository.
73///
74/// The *path* is used rather than an opaque id because its whole job is to appear
75/// in a message naming the tree the store actually holds — an id the reader
76/// cannot act on would defeat the point. Linked worktrees have distinct roots, so
77/// this separates them; a plain branch switch within one tree does not change it,
78/// which is correct (the tree is the same, its content moved).
79#[must_use]
80pub fn worktree_id(repo: &Repo) -> String {
81    repo.workdir()
82        .unwrap_or_else(|| repo.git_dir())
83        .to_string_lossy()
84        .into_owned()
85}
86
87/// Decide whether `store`'s recorded sync state may be trusted for *this* tree.
88///
89/// Returns `Some(previous)` when the store was last assembled from a **different**
90/// working tree: its tree id, dirty-set hash and extraction env all describe
91/// someone else's tree, so no fast path may consult them and the caller must
92/// rebuild in full. `None` when the store belongs here or has never been stamped
93/// (unknown is adopted, not rebuilt — see [`Store::synced_worktree`]).
94///
95/// Rebuilding is cheap relative to being wrong: the object cache is shared across
96/// worktrees and already warm, so the re-extraction mostly hits it.
97fn foreign_worktree(store: &Store, repo: &Repo) -> Result<Option<String>, SyncError> {
98    let here = worktree_id(repo);
99    Ok(store.synced_worktree()?.filter(|prior| *prior != here))
100}
101
102/// Sync `store` to the repository's `HEAD` tree, extracting changed blobs with
103/// `extractor` and caching results in `cache`.
104///
105/// # Errors
106/// Returns a [`SyncError`] if git access, extraction caching, or the store
107/// rebuild fails.
108pub fn sync(
109    store: &mut Store,
110    repo: &Repo,
111    cache: &ObjectCache,
112    extractor: &dyn Extractor,
113) -> Result<SyncReport, SyncError> {
114    let tree = repo.head_tree_id()?;
115
116    // The extraction *identity*: the extractor code version (`EXTRACT_VERSION`,
117    // bumped when extraction output changes) plus its environment (installed image
118    // models + ingestion toggles). Both change what an unchanged file extracts to,
119    // and both are folded into the content-cache key — so this mirrors that key.
120    // Recorded with the tree so the next sync can tell whether reusing the stored
121    // facts (the incremental path) is sound; a binary upgrade that bumps the
122    // version, or a model change, invalidates it and forces a full re-extraction.
123    let env = format!(
124        "v{}-e{:016x}",
125        crate::extract::EXTRACT_VERSION,
126        extractor.env_tag()
127    );
128
129    // Nothing to do only when **both** the tree and the extraction identity are
130    // unchanged.
131    //
132    // The identity half is load-bearing, and its absence was a real hole: an
133    // `EXTRACT_VERSION` bump is supposed to guarantee that no user is served the
134    // previous version's facts, but a store already synced at the current `HEAD`
135    // returned `no_op` here before the identity was ever computed — so the new
136    // binary's facts appeared only once `HEAD` next moved. Enabling a feature that
137    // changes extraction output (`audio-metadata`, `pdf-text`, `image-ocr`) on a
138    // quiet repository therefore looked like it had done nothing at all. Every
139    // *other* consumer of the identity — the content-cache key, the incremental
140    // path below — already agreed on it; this one had simply never been asked.
141    //
142    // A store with no recorded identity (`None`) does not match, which is the safe
143    // direction: it re-extracts once and records one.
144    // …and only when the recorded state describes *this* working tree. A store
145    // assembled from another tree has a tree id, dirty hash and env that are all
146    // someone else's, so neither the no-op below nor the incremental diff may
147    // consult them: that is how a stale store reports "up to date" while holding
148    // a graph nobody is looking at (issue #330).
149    let foreign = foreign_worktree(store, repo)?;
150
151    if foreign.is_none()
152        && store.sync_state()?.as_deref() == Some(tree.as_str())
153        && store.sync_env()?.as_deref() == Some(env.as_str())
154    {
155        return Ok(SyncReport {
156            no_op: true,
157            nodes: store.node_count()?,
158            edges: store.edge_count()?,
159            tree,
160            ..SyncReport::default()
161        });
162    }
163
164    // Fast path: if the last sync was a committed one at a known tree with the
165    // same extraction identity, update only the paths that changed. Falls back to
166    // a full re-extraction on any doubt (no prior tree, identity changed, an
167    // unavailable diff, or a tree that is not ours).
168    if foreign.is_none()
169        && let Some(report) = try_incremental(store, repo, cache, extractor, &tree, &env)?
170    {
171        return Ok(report);
172    }
173
174    let committed = extract_committed(repo, cache, extractor)?;
175    let mut assembled = flatten(committed.by_path);
176    resolve_calls(&mut assembled);
177    append_submodule_nodes(repo.submodules()?, &mut assembled);
178    let total = file_count(&assembled);
179    store.reconcile(&assembled, Some(&tree))?;
180    store.set_sync_env(&env)?;
181    store.set_synced_worktree(&worktree_id(repo))?;
182
183    Ok(SyncReport {
184        no_op: false,
185        blobs_total: total,
186        blobs_extracted: committed.extracted,
187        blobs_cached: committed.cached,
188        blobs_dirty: 0,
189        nodes: store.node_count()?,
190        edges: store.edge_count()?,
191        tree,
192        rebuilt_from_foreign_worktree: foreign,
193    })
194}
195
196/// Attempt an incremental committed sync from the last-synced tree to `head_tree`.
197/// Returns `Ok(Some(report))` when it ran, `Ok(None)` when the fast path is not
198/// eligible (the caller then does a full sync).
199///
200/// It is sound because it produces the exact same **derived-only** graph a full
201/// sync would: it reconstructs the derived subgraph from the store (identified by
202/// the `Derived` provenance tag — unchanged paths' facts are a deterministic
203/// function of their unchanged blob content, so they equal a fresh extraction),
204/// drops the changed/deleted paths, extracts only the changed blobs, re-resolves
205/// cross-file `calls` globally, and feeds the result to the same [`Store::reconcile`]
206/// the full path uses. `check`/`reapply_imports` re-layer the authored/import
207/// facts afterward exactly as before — this only accelerates the derived layer.
208fn try_incremental(
209    store: &mut Store,
210    repo: &Repo,
211    cache: &ObjectCache,
212    extractor: &dyn Extractor,
213    head_tree: &str,
214    env: &str,
215) -> Result<Option<SyncReport>, SyncError> {
216    // Eligibility: a prior committed tree (a plain oid — worktree/index states
217    // carry a `:`-delimited marker), extracted under the same environment.
218    let Some(prior_tree) = store.sync_state()? else {
219        return Ok(None);
220    };
221    if prior_tree.contains(':') || store.sync_env()?.as_deref() != Some(env) {
222        return Ok(None);
223    }
224    // The prior tree object may have been pruned (gc); on any diff failure, fall
225    // back to the full path rather than guessing.
226    let Ok(diff) = repo.diff_trees(&prior_tree, head_tree) else {
227        return Ok(None);
228    };
229
230    // Reconstruct the derived subgraph from the store: every derived node, and
231    // every derived edge except `calls` (globally re-derived below from the full
232    // function set, since a changed file can flip name-resolution elsewhere).
233    let mut nodes: Vec<Node> = store.nodes_by_provenance(Provenance::Derived)?;
234    let mut edges: Vec<Edge> = store
235        .edges_by_provenance(Provenance::Derived)?
236        .into_iter()
237        .filter(|e| e.kind != EdgeKind::Calls)
238        .collect();
239
240    // Drop the changed and deleted paths' derived facts (their nodes, and any edge
241    // incident to them — per-blob derived edges are intra-file, so this is exact).
242    let touched: BTreeSet<&str> = diff
243        .changed
244        .iter()
245        .map(|b| b.path.as_str())
246        .chain(diff.deleted.iter().map(String::as_str))
247        .collect();
248    let dropped: HashSet<String> = nodes
249        .iter()
250        .filter(|n| n.path.as_deref().is_some_and(|p| touched.contains(p)))
251        .map(|n| n.key.clone())
252        .collect();
253    nodes.retain(|n| !dropped.contains(&n.key));
254    edges.retain(|e| !dropped.contains(&e.src) && !dropped.contains(&e.dst));
255
256    // Extract the changed blobs (cache-aware) and add their derived facts.
257    let env_tag = extractor.env_tag();
258    let mut extracted = 0usize;
259    let mut cached = 0usize;
260    for blob in &diff.changed {
261        let key = cache_key(&blob.path, &blob.oid, env_tag);
262        let facts = if let Some(facts) = cache.get(&key)? {
263            cached += 1;
264            facts
265        } else {
266            let bytes = repo.read_blob(&blob.oid)?;
267            let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
268            cache.put(&key, &facts)?;
269            extracted += 1;
270            facts
271        };
272        nodes.extend(facts.nodes);
273        edges.extend(facts.edges);
274    }
275
276    // Prune orphaned import-target nodes — a path-less derived node (e.g.
277    // `import:rust:foo`) that no surviving edge references. A full sync emits it
278    // only while some file imports it, so dropping the now-unreferenced ones keeps
279    // the two paths identical.
280    let referenced: HashSet<&str> = edges
281        .iter()
282        .flat_map(|e| [e.src.as_str(), e.dst.as_str()])
283        .collect();
284    nodes.retain(|n| n.path.is_some() || referenced.contains(n.key.as_str()));
285
286    // Global call resolution over the full (reconstructed + changed) function set,
287    // then reconcile to derived-only — identical to what the full path produces.
288    let mut assembled = FactSet { nodes, edges };
289    resolve_calls(&mut assembled);
290    append_submodule_nodes(repo.submodules()?, &mut assembled);
291    let total = file_count(&assembled);
292    store.reconcile(&assembled, Some(head_tree))?;
293    store.set_sync_env(env)?;
294    // The caller only reaches here for a store that is ours, but it may predate
295    // the stamp — record it, or this path would leave it unstamped forever.
296    store.set_synced_worktree(&worktree_id(repo))?;
297
298    Ok(Some(SyncReport {
299        no_op: false,
300        blobs_total: total,
301        blobs_extracted: extracted,
302        blobs_cached: cached,
303        blobs_dirty: 0,
304        nodes: store.node_count()?,
305        edges: store.edge_count()?,
306        tree: head_tree.to_owned(),
307        // Unreachable with a foreign store: the caller skips this path entirely.
308        rebuilt_from_foreign_worktree: None,
309    }))
310}
311
312/// Sync `store` to the working tree: the committed `HEAD` state with uncommitted
313/// working-tree changes overlaid on top (a pre-commit preview).
314///
315/// Committed blobs come from the content-addressed cache as in [`sync`]; then
316/// each tracked file whose working copy differs from its committed blob is
317/// re-extracted in memory (never cached, since dirty content is not a git
318/// object), deleted files are dropped, and brand-new **untracked** files (found
319/// via a gitignore-aware dirwalk, [`Repo::untracked_files`]) are overlaid in.
320/// The recorded sync state encodes the dirty set, so a later committed [`sync`]
321/// correctly supersedes the overlay.
322///
323/// # Errors
324/// Returns a [`SyncError`] if git access, extraction caching, working-tree I/O,
325/// or the store rebuild fails.
326pub fn sync_worktree(
327    store: &mut Store,
328    repo: &Repo,
329    cache: &ObjectCache,
330    extractor: &dyn Extractor,
331) -> Result<SyncReport, SyncError> {
332    let tree = repo.head_tree_id()?;
333    let committed = extract_committed(repo, cache, extractor)?;
334    let mut by_path = committed.by_path;
335
336    // Overlay uncommitted edits to tracked files. A file is dirty when its
337    // working-copy content hashes to a different git blob id than the committed
338    // one; identical content hashes identically, so clean files are skipped.
339    let mut dirty: BTreeSet<(String, String)> = BTreeSet::new();
340    if let Some(workdir) = repo.workdir() {
341        for blob in &committed.blobs {
342            match std::fs::read(workdir.join(&blob.path)) {
343                Ok(bytes) => {
344                    let woid = repo.blob_oid(&bytes)?;
345                    if woid != blob.oid {
346                        by_path.insert(
347                            blob.path.clone(),
348                            extractor.extract(&blob.path, &woid, &bytes),
349                        );
350                        dirty.insert((blob.path.clone(), woid));
351                    }
352                }
353                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
354                    by_path.remove(&blob.path);
355                    dirty.insert((blob.path.clone(), "\0deleted".to_owned()));
356                }
357                Err(e) => return Err(e.into()),
358            }
359        }
360
361        // Overlay brand-new files: those `HEAD` does not have and git would
362        // nonetheless carry — untracked-but-not-ignored, plus anything staged.
363        //
364        // Two sources, and it needs both (#636). `untracked_files` classifies the
365        // working tree **against the index**, so it stops reporting a file the
366        // moment it is `git add`-ed; the committed blob list above comes from the
367        // `HEAD` tree, where a new file does not exist either. A **staged
368        // addition** is therefore in neither, and used to fall straight through
369        // this overlay — so `git add`, an action that moves a file *closer* to
370        // committed, deleted its node from the graph and dropped the
371        // `+N uncommitted` marker at the exact moment the tree differed most from
372        // `HEAD`. Adding the index entries that `HEAD` lacks closes the gap.
373        //
374        // Content still comes from **disk**, not from the staged blob: this is the
375        // worktree source, and a file edited after being staged must be read as it
376        // now stands.
377        //
378        // `.gitignore` is still honoured, and the union states *how*: an ignored
379        // file is absent from the dirwalk, so it enters only by being in the
380        // index — which takes a deliberate `git add -f`. That is the right
381        // outcome rather than a leak, because force-adding overrides the ignore
382        // and the file will be committed; the graph would see it a moment later
383        // anyway.
384        //
385        // They count as dirty (so the preview re-runs when they change) and add to
386        // the blob total (they are genuinely new blobs, not edits of existing ones).
387        let head_paths: BTreeSet<&str> = committed.blobs.iter().map(|b| b.path.as_str()).collect();
388        let mut new_paths: BTreeSet<String> = repo.untracked_files()?.into_iter().collect();
389        for entry in repo.index_files()? {
390            if !head_paths.contains(entry.path.as_str()) {
391                new_paths.insert(entry.path);
392            }
393        }
394        for path in new_paths {
395            match std::fs::read(workdir.join(&path)) {
396                Ok(bytes) => {
397                    let woid = repo.blob_oid(&bytes)?;
398                    by_path.insert(path.clone(), extractor.extract(&path, &woid, &bytes));
399                    dirty.insert((path, woid));
400                }
401                // Raced away between the walk and the read — nothing to add.
402                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
403                Err(e) => return Err(e.into()),
404            }
405        }
406    }
407
408    // The blob total is the file count of the *overlaid* graph — committed files,
409    // minus working-tree deletions, plus untracked additions — not the committed
410    // baseline, so it stays consistent whether files were added or removed.
411    let total = by_path.len();
412
413    // Encode the dirty set into the sync state so repeated identical previews
414    // no-op, but any committed change (which alters the plain tree id) does not.
415    let state = if dirty.is_empty() {
416        tree.clone()
417    } else {
418        let mut buf = String::new();
419        for (path, marker) in &dirty {
420            buf.push_str(path);
421            buf.push('\0');
422            buf.push_str(marker);
423            buf.push('\n');
424        }
425        format!("{tree}:dirty:{:016x}", fnv1a64(buf.as_bytes()))
426    };
427    let dirty_count = dirty.len();
428
429    // A dirty-set hash computed for another tree says nothing about this one, so
430    // a foreign store may never no-op here (issue #330).
431    let foreign = foreign_worktree(store, repo)?;
432    if foreign.is_none() && store.sync_state()?.as_deref() == Some(state.as_str()) {
433        return Ok(SyncReport {
434            no_op: true,
435            blobs_total: total,
436            blobs_dirty: dirty_count,
437            nodes: store.node_count()?,
438            edges: store.edge_count()?,
439            tree,
440            ..SyncReport::default()
441        });
442    }
443
444    let mut assembled = flatten(by_path);
445    resolve_calls(&mut assembled);
446    append_submodule_nodes(repo.submodules()?, &mut assembled);
447    store.reconcile(&assembled, Some(&state))?;
448    store.set_synced_worktree(&worktree_id(repo))?;
449
450    Ok(SyncReport {
451        no_op: false,
452        blobs_total: total,
453        blobs_extracted: committed.extracted,
454        blobs_cached: committed.cached,
455        blobs_dirty: dirty_count,
456        nodes: store.node_count()?,
457        edges: store.edge_count()?,
458        tree,
459        rebuilt_from_foreign_worktree: foreign,
460    })
461}
462
463/// Sync `store` to the **git index** — the staged tree that a commit would
464/// record. Unlike [`sync_worktree`] (files on disk) this reads each staged blob
465/// by its index object id, so it validates *exactly what is about to be
466/// committed* (partially-staged changes and all). New staged files are included;
467/// unstaged working-tree edits are not. Backs the index-aware pre-commit gate.
468///
469/// # Errors
470/// Returns a [`SyncError`] if git access, extraction caching, or the store
471/// reconcile fails.
472pub fn sync_index(
473    store: &mut Store,
474    repo: &Repo,
475    cache: &ObjectCache,
476    extractor: &dyn Extractor,
477) -> Result<SyncReport, SyncError> {
478    let staged = repo.index_files()?;
479    // A stable state id over the staged (path, oid) set, in its own `index:`
480    // namespace so it never collides with a committed tree id or a worktree dirty
481    // marker — repeated identical index syncs then no-op, while any staged change
482    // does not.
483    let mut buf = String::new();
484    for blob in &staged {
485        buf.push_str(&blob.path);
486        buf.push('\0');
487        buf.push_str(&blob.oid);
488        buf.push('\n');
489    }
490    let state = format!("index:{:016x}", fnv1a64(buf.as_bytes()));
491
492    // An index hash from another tree describes another index (issue #330).
493    let foreign = foreign_worktree(store, repo)?;
494    if foreign.is_none() && store.sync_state()?.as_deref() == Some(state.as_str()) {
495        return Ok(SyncReport {
496            no_op: true,
497            blobs_total: staged.len(),
498            nodes: store.node_count()?,
499            edges: store.edge_count()?,
500            tree: state,
501            ..SyncReport::default()
502        });
503    }
504
505    let extracted = extract_blobs(repo, cache, extractor, staged)?;
506    let total = extracted.by_path.len();
507    let mut assembled = flatten(extracted.by_path);
508    resolve_calls(&mut assembled);
509    // Index mode is "exactly what a commit would record", so submodule pins come
510    // from the *staged* gitlinks, not `HEAD` — a staged bump is reflected.
511    append_submodule_nodes(repo.index_submodules()?, &mut assembled);
512    store.reconcile(&assembled, Some(&state))?;
513    store.set_synced_worktree(&worktree_id(repo))?;
514
515    Ok(SyncReport {
516        no_op: false,
517        blobs_total: total,
518        blobs_extracted: extracted.extracted,
519        blobs_cached: extracted.cached,
520        blobs_dirty: 0,
521        nodes: store.node_count()?,
522        edges: store.edge_count()?,
523        tree: state,
524        rebuilt_from_foreign_worktree: foreign,
525    })
526}
527
528/// Extract a repo's **derived graph at an arbitrary commit/tree `rev`** into
529/// `store`, replacing its contents — the same content-addressed extraction as
530/// [`sync`], but for a historical point rather than `HEAD`. Because extraction is
531/// keyed by `(path, blob oid, env)`, every blob unchanged versus another synced
532/// point is a cache hit, so resolving an older version only re-does what differs.
533///
534/// This backs **version-pin resolution** (ADR-0009 step 8): to resolve a spoke's
535/// cross-repo reference against the hub *version it deploys* (a submodule sha,
536/// an image tag → commit), extract the hub at that `rev` into an ephemeral store
537/// and resolve there. It populates the derived layer only (config keys, symbols,
538/// calls); authored/import layers are not re-applied, since this is a read-only
539/// resolution snapshot. No sync-state is recorded (`tree` carries `rev` for the
540/// report only).
541///
542/// # Errors
543/// Returns [`SyncError`] on git access, extraction caching, or store failure.
544pub fn sync_tree(
545    store: &mut Store,
546    repo: &Repo,
547    cache: &ObjectCache,
548    extractor: &dyn Extractor,
549    rev: &str,
550) -> Result<SyncReport, SyncError> {
551    let extracted = extract_blobs(repo, cache, extractor, repo.blobs_at(rev)?)?;
552    let mut assembled = flatten(extracted.by_path);
553    resolve_calls(&mut assembled);
554    append_submodule_nodes(repo.submodules_at(rev)?, &mut assembled);
555    let total = file_count(&assembled);
556    store.rebuild(&assembled, None)?;
557    Ok(SyncReport {
558        no_op: false,
559        blobs_total: total,
560        blobs_extracted: extracted.extracted,
561        blobs_cached: extracted.cached,
562        blobs_dirty: 0,
563        nodes: store.node_count()?,
564        edges: store.edge_count()?,
565        tree: rev.to_owned(),
566        // A historical-rev store deliberately records no synced state at all
567        // (`rebuild(.., None)` clears the row), so it is stamped with no tree
568        // either — it is a scratch view of a commit, not of a working tree.
569        rebuilt_from_foreign_worktree: None,
570    })
571}
572
573/// The committed fact sets for the `HEAD` tree, one per path, plus the blob list
574/// (for overlay comparison) and cache-hit/miss counts.
575struct Committed {
576    blobs: Vec<crate::BlobRef>,
577    by_path: BTreeMap<String, FactSet>,
578    extracted: usize,
579    cached: usize,
580}
581
582/// Extract (or load from cache) the fact set for every blob in the `HEAD` tree.
583fn extract_committed(
584    repo: &Repo,
585    cache: &ObjectCache,
586    extractor: &dyn Extractor,
587) -> Result<Committed, SyncError> {
588    extract_blobs(repo, cache, extractor, repo.walk_blobs()?)
589}
590
591/// Extract (or load from cache) the fact set for each blob in `blobs` — the
592/// shared core of [`extract_committed`] and [`sync_index`], differing only in
593/// which tree the blob list comes from (`HEAD` vs the git index).
594fn extract_blobs(
595    repo: &Repo,
596    cache: &ObjectCache,
597    extractor: &dyn Extractor,
598    blobs: Vec<crate::BlobRef>,
599) -> Result<Committed, SyncError> {
600    let mut by_path = BTreeMap::new();
601    let mut extracted = 0usize;
602    let mut cached = 0usize;
603
604    // Extraction output depends on runtime state beyond (path, bytes): which
605    // image models are installed, and the extractor's ingestion toggles. The
606    // extractor folds both into a single tag for the cache key. Computed once
607    // per sync.
608    let env = extractor.env_tag();
609
610    for blob in &blobs {
611        // Extraction is a function of (path, blob bytes) and — with `image-ocr`
612        // — the OCR model environment (`env`), never blob id alone: node keys are
613        // path-scoped (e.g. `file:<path>`), so the same blob content at two
614        // different paths yields different facts. Key the cache by (path, oid,
615        // env) so duplicate-content files (e.g. empty files, which git dedupes to
616        // one oid) never collide, the same path+oid in another branch/worktree
617        // still hits, and installing/upgrading OCR models re-extracts images.
618        let key = cache_key(&blob.path, &blob.oid, env);
619        let facts = if let Some(facts) = cache.get(&key)? {
620            cached += 1;
621            facts
622        } else {
623            let bytes = repo.read_blob(&blob.oid)?;
624            let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
625            cache.put(&key, &facts)?;
626            extracted += 1;
627            facts
628        };
629        by_path.insert(blob.path.clone(), facts);
630    }
631
632    Ok(Committed {
633        blobs,
634        by_path,
635        extracted,
636        cached,
637    })
638}
639
640/// Concatenate per-path fact sets into one assembled fact set.
641fn flatten(by_path: BTreeMap<String, FactSet>) -> FactSet {
642    let mut assembled = FactSet::new();
643    for facts in by_path.into_values() {
644        assembled.nodes.extend(facts.nodes);
645        assembled.edges.extend(facts.edges);
646    }
647    assembled
648}
649
650/// The `NodeKind::Other` token for a submodule-pin node (`submodule:<path>`).
651pub(crate) const SUBMODULE_KIND: &str = "submodule";
652
653/// Append the given submodule-pin nodes to `assembled`, replacing any already
654/// present. `subs` is the caller's source-appropriate list — `repo.submodules()`
655/// (the `HEAD` tree) for committed/worktree syncs, `repo.index_submodules()` (the
656/// staged gitlinks) for the index-aware pre-commit gate. A submodule pin is a
657/// **tree-level** derived fact (a gitlink + its `.gitmodules` URL, ADR-0009), not
658/// a per-blob one, so it is recomputed on every sync rather than cached. Removing
659/// any existing submodule nodes first makes the
660/// incremental path — which reconstructs derived nodes from the store — produce
661/// exactly the full sync's result: an unchanged pin re-adds identically, a bumped
662/// pin's new sha wins, and a removed submodule leaves none behind. The nodes carry
663/// `path = .gitmodules` (so a `.gitmodules` deletion drops them) and stand alone
664/// (no edges — nothing in the graph is their guaranteed endpoint).
665fn append_submodule_nodes(subs: Vec<crate::Submodule>, assembled: &mut FactSet) {
666    let kind = NodeKind::Other(SUBMODULE_KIND.to_owned());
667    assembled.nodes.retain(|n| n.kind != kind);
668    for sm in subs {
669        let key = format!("submodule:{}", sm.path);
670        let mut node = Node::new(key, kind.clone(), sm.path.clone());
671        node.path = Some(".gitmodules".to_owned());
672        node.provenance = Provenance::Derived;
673        node.meta = serde_json::json!({ "path": sm.path, "url": sm.url, "sha": sm.sha });
674        assembled.nodes.push(node);
675    }
676}
677
678/// The number of source files reflected in an assembled fact set (one `File`
679/// node per extracted blob). Both the full and incremental sync paths derive
680/// `SyncReport::blobs_total` from the *assembled graph* this way — not from the
681/// raw blob list — so the two paths report the same total for the same tree (the
682/// graphs are identical; see the equivalence test).
683fn file_count(facts: &FactSet) -> usize {
684    facts
685        .nodes
686        .iter()
687        .filter(|n| n.kind == NodeKind::File)
688        .count()
689}
690
691/// Resolve the per-function call records (`meta.calls`) accumulated during
692/// extraction into `calls` edges, now that every file's symbols are present.
693///
694/// Resolution is deliberately conservative — it links a call only when the target
695/// is **unambiguous** — but scope-aware: a callee descriptor may carry the
696/// immediate qualifier the call site provided (`b::foo`, `Type::assoc`,
697/// `Self::method`; see [`crate::extract`]). A call resolves when either
698///
699/// 1. its simple name is unique across the whole tree (the base case), or
700/// 2. its name is ambiguous but a qualifier picks out **exactly one** matching
701///    function — the one whose immediate scope segment equals that qualifier
702///    (with `Self` bound to the caller's own impl type).
703///
704/// This never links a name it could not before (it is a strict superset), and it
705/// still refuses to guess when a qualifier leaves more than one candidate. Runs at
706/// assembly time — not per blob — since a single blob cannot see other files.
707fn resolve_calls(facts: &mut FactSet) {
708    // Simple function name → the keys of functions with that name.
709    let mut by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
710    for n in &facts.nodes {
711        if n.kind == NodeKind::Fn {
712            by_name
713                .entry(n.name.as_str())
714                .or_default()
715                .push(n.key.as_str());
716        }
717    }
718
719    // Collect (caller, callee) pairs; BTreeSet dedupes and orders them.
720    let mut resolved: BTreeSet<(String, String)> = BTreeSet::new();
721    for n in &facts.nodes {
722        if n.kind != NodeKind::Fn {
723            continue;
724        }
725        let Some(calls) = n.meta.get("calls").and_then(|v| v.as_array()) else {
726            continue;
727        };
728        // The caller's own type (for binding `Self::` calls) is the scope segment
729        // immediately before its name in its key, if it is a method.
730        let caller_self = self_type_of(&n.key);
731        for descriptor in calls.iter().filter_map(|v| v.as_str()) {
732            let (qualifier, name) = split_callee(descriptor);
733            let Some(candidates) = by_name.get(name) else {
734                continue;
735            };
736            let target = if candidates.len() == 1 {
737                // Unambiguous by simple name — the base case (unchanged behaviour).
738                Some(candidates[0])
739            } else if let Some(q) = qualifier {
740                // Ambiguous name; try the qualifier. `Self` binds to the caller's
741                // impl type — a free function has none, so such a call stays open.
742                let want = if q == "Self" { caller_self } else { Some(q) };
743                want.and_then(|want| unique_in_scope(candidates, want, name))
744            } else {
745                None
746            };
747            if let Some(dst) = target {
748                resolved.insert((n.key.clone(), dst.to_owned()));
749            }
750        }
751    }
752
753    for (src, dst) in resolved {
754        facts.edges.push(Edge::derived(src, dst, EdgeKind::Calls));
755    }
756}
757
758/// The qualified suffix of a symbol key (`sym:<lang>:<path>#<qualified>` →
759/// `<qualified>`), i.e. the scope-segment path within its file.
760fn qualified_suffix(key: &str) -> &str {
761    key.rsplit_once('#').map_or(key, |(_, q)| q)
762}
763
764/// The caller's own type for binding a `Self::` call: the scope segment
765/// immediately before the function's name in its key (`Type::method` → `Type`),
766/// or `None` for a free function (no enclosing type).
767fn self_type_of(key: &str) -> Option<&str> {
768    let mut segs = qualified_suffix(key).rsplit("::");
769    segs.next()?; // the function's own name
770    segs.next() // the enclosing scope segment, if any
771}
772
773/// The single candidate whose immediate scope segment is `want` (so its key ends
774/// with the `want::name` segment pair), or `None` when zero or several match —
775/// segment-aware so `T::m` matches `a::T::m` but never `XT::m`.
776fn unique_in_scope<'a>(candidates: &[&'a str], want: &str, name: &str) -> Option<&'a str> {
777    let mut hit = None;
778    for &key in candidates {
779        let mut segs = qualified_suffix(key).rsplit("::");
780        if segs.next() == Some(name) && segs.next() == Some(want) {
781            if hit.is_some() {
782                return None; // more than one match at this scope — refuse to guess
783            }
784            hit = Some(key);
785        }
786    }
787    hit
788}
789
790/// Split a `meta.calls` descriptor into its immediate qualifier and simple name:
791/// `b::foo` → `(Some("b"), "foo")`, `foo` → `(None, "foo")`.
792fn split_callee(descriptor: &str) -> (Option<&str>, &str) {
793    match descriptor.rsplit_once("::") {
794        Some((qualifier, name)) => (Some(qualifier), name),
795        None => (None, descriptor),
796    }
797}
798
799/// Content-addressed cache key for a blob at a given path: the blob oid (kept
800/// as the leading, well-distributed shard) suffixed with a stable 64-bit hash of
801/// the path, the [`crate::extract::EXTRACT_VERSION`], and the extractor
802/// environment tag `env` (the installed media-model — OCR + vision + audio —
803/// identity; `0` when no media model is active — see
804/// [`crate::extract::media_env_tag`]). Sharing across branches/worktrees is
805/// preserved (same path+oid+version+env → same key) while duplicate content at
806/// distinct paths stays distinct; bumping the extractor version *or* changing the
807/// installed media models retires old entries so a re-extraction is forced.
808fn cache_key(path: &str, oid: &str, env: u64) -> String {
809    format!(
810        "{oid}-{:016x}-v{}-e{env:016x}",
811        fnv1a64(path.as_bytes()),
812        crate::extract::EXTRACT_VERSION,
813    )
814}
815
816/// How many superseded extractor generations [`sweep_superseded`] keeps behind
817/// the current one by default: **one**.
818///
819/// Not clutter, and not free — it is a trade against the one workflow this
820/// project actually has. Roteiro is developed *inside* the repository it indexes,
821/// so a branch that bumps [`crate::extract::EXTRACT_VERSION`] and the `main` it
822/// will merge into share one `.git/roteiro` (the cache is under the **common**
823/// git dir). With no retention, one maintenance pass on the branch deletes
824/// `main`'s whole live set, and every switch back pays a full cold extraction;
825/// keeping the previous generation makes that switch free. Rolling a release back
826/// one version gets the same protection as a side effect.
827///
828/// It is bounded, which is the part that matters: the complaint being answered
829/// (#387) is *unbounded* accumulation — four generations resident and counting —
830/// and the steady state here is two, whatever happens next.
831pub const DEFAULT_KEEP_GENERATIONS: u32 = 1;
832
833/// Delete the object-cache entries left behind by **superseded** extractor
834/// generations, keeping the current one and `keep_generations` behind it.
835///
836/// # Why a sweep and not a byte budget
837///
838/// Because a proof is available here and nowhere else. [`cache_key`] writes the
839/// extractor generation into every key, and that generation only ever moves
840/// forward, so an entry tagged with an older one *cannot be asked for* by any
841/// binary at or beyond the current generation — no bookkeeping, no recency, no
842/// guessing. A byte budget (the Stage 25 / `rto-llama` `ModelCache` precedent,
843/// ported to disk by [`crate::Store::sweep_agent_cache`]) would have had to
844/// invent an ordering over live entries and would then evict *reachable* ones by
845/// design: on a cache shared by every worktree that means one worktree silently
846/// paying for another's working set, and it would need a last-used column this
847/// store has no clock to fill (ADR-0013 §3). It buys a bound this does not give —
848/// the live set itself is unbounded, and a repository large enough for that to
849/// hurt still needs one. That is a second policy on top of this one, not an
850/// alternative to it, and nothing has yet measured a need for it.
851///
852/// # What "superseded" is allowed to mean
853///
854/// **Only the generation**, i.e. [`crate::extract::EXTRACT_BASE_VERSION`]. The
855/// other two things folded into a key are deliberately *not* eligible:
856///
857/// - The **feature namespace** ([`crate::extract::FEATURE_NAMESPACE_STRIDE`] and
858///   above). A default build and an `--all-features` build write different
859///   `EXTRACT_VERSION`s at the *same* generation, and both are live at once —
860///   `cargo test --workspace` and `cargo test --all-features` on one repository
861///   are exactly that. Sweeping on the whole version number would have each build
862///   delete the other's cache on sight, and the two would take turns
863///   re-extracting for ever. So the namespace is masked off, and every namespace
864///   at a kept generation is kept.
865/// - The **environment tag** (`-e…`: the installed media-model and ingestion
866///   identity). It is a hash — unordered, so no tag can be shown to supersede
867///   another, and several are legitimately live at once (a build without
868///   `image-ocr` tags `0`; a build with it and a model installed does not).
869///   Reclaiming those would need the ordering the paragraph above rejected. They
870///   are left alone, and the cost of that is stated rather than hidden: env churn
871///   *within* one generation is not reclaimed by this pass.
872///
873/// # Why this is safe while other worktrees are live
874///
875/// The rule reads only the key, never the repository — so it does not need to
876/// know what any other worktree has checked out, and cannot be wrong about it. A
877/// reachability rule phrased over *blob ids* would need exactly that knowledge,
878/// and would be the dangerous version of this function: an oid unreachable from
879/// one worktree's `HEAD` is routinely live in another's. This one never asks.
880///
881/// Its only cross-worktree effect is on a worktree running an **older** binary,
882/// which it can cost a re-extraction and nothing else — the cache is derived, so
883/// a miss is slow, never wrong. The asymmetry runs one way: an entry from a
884/// *newer* generation than the sweeper's is retained, because `generation >=
885/// oldest_kept` holds for anything ahead. Two binaries of different ages can
886/// therefore never take turns deleting each other's work.
887///
888/// # Errors
889/// Returns [`CacheError`] if the cache cannot be listed. See
890/// [`ObjectCache::sweep`] for what a failure to delete an individual entry does
891/// (it is counted, not raised).
892pub fn sweep_superseded(
893    cache: &ObjectCache,
894    keep_generations: u32,
895) -> Result<ReclaimReport, CacheError> {
896    let current = crate::extract::EXTRACT_BASE_VERSION;
897    let oldest_kept = current.saturating_sub(keep_generations);
898
899    // The predicate is the only thing that ever classifies an entry, and it runs
900    // exactly once per scanned entry — so tallying here is the one place the
901    // reason for a retention is known, and it costs nothing extra. Counting it
902    // afterwards would mean a second walk, and reconstructing it in the caller
903    // would mean a second copy of this rule.
904    let current_kept = Cell::new(0);
905    let recent_kept = Cell::new(0);
906    let ahead_kept = Cell::new(0);
907    let unrecognised_kept = Cell::new(0);
908    let tally = |counter: &Cell<usize>| counter.set(counter.get() + 1);
909
910    let sweep = cache.sweep(&|key| match key_generation(key) {
911        // Not a key this module writes — a foreign or future format. Unreadable
912        // is not the same as unreachable, and only one of the two may be deleted.
913        None => {
914            tally(&unrecognised_kept);
915            true
916        }
917        Some(generation) if generation > current => {
918            tally(&ahead_kept);
919            true
920        }
921        Some(generation) if generation == current => {
922            tally(&current_kept);
923            true
924        }
925        Some(generation) if generation >= oldest_kept => {
926            tally(&recent_kept);
927            true
928        }
929        Some(_) => false,
930    })?;
931
932    let report = ReclaimReport {
933        kept_current: current_kept.get(),
934        kept_recent: recent_kept.get(),
935        kept_ahead: ahead_kept.get(),
936        kept_unrecognised: unrecognised_kept.get(),
937        sweep,
938    };
939    debug_assert_eq!(
940        report.kept_total(),
941        report.sweep.retained,
942        "every retained entry is retained for exactly one of the four reasons",
943    );
944    Ok(report)
945}
946
947/// What one [`sweep_superseded`] pass did — and, for everything it kept, **why**.
948///
949/// The four `kept_*` counts exist because the retention rule keeps more than the
950/// obvious class, and a summary that named only the obvious one would describe an
951/// irreversible operation inaccurately. They partition [`ObjectSweep::retained`]:
952/// each retained entry falls into exactly one, and their sum is that total.
953#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
954pub struct ReclaimReport {
955    /// The underlying pass: what was scanned, freed, and left on disk.
956    pub sweep: ObjectSweep,
957    /// Kept at **this build's own generation** — the live set, the thing a sweep
958    /// exists to not touch.
959    pub kept_current: usize,
960    /// Kept at an **older** generation still inside the `keep_generations`
961    /// window. Unreachable by this build; deliberate insurance for the binary a
962    /// generation behind that shares this cache (see
963    /// [`DEFAULT_KEEP_GENERATIONS`]).
964    pub kept_recent: usize,
965    /// Kept because it belongs to a generation **ahead** of this build — another
966    /// worktree, or a colleague, running a newer binary against the same shared
967    /// cache. Never swept, which is what stops two binaries of different ages
968    /// taking turns deleting each other's work.
969    pub kept_ahead: usize,
970    /// Kept because `key_generation` could not read a generation out of the key
971    /// at all. Doubt retains, always — but a non-zero count here is worth
972    /// investigating rather than absorbing into a total, because it is either a
973    /// format this build no longer writes or a bug in the parser, and both are
974    /// things a reader would want to know their cache is holding.
975    pub kept_unrecognised: usize,
976}
977
978impl ReclaimReport {
979    /// The four `kept_*` counts summed — equal to [`ObjectSweep::retained`].
980    #[must_use]
981    pub fn kept_total(&self) -> usize {
982        self.kept_current + self.kept_recent + self.kept_ahead + self.kept_unrecognised
983    }
984}
985
986/// The extractor **generation** encoded in a [`cache_key`] key, or `None` if the
987/// key does not carry one in the exact shape `cache_key` writes.
988///
989/// The parse is strict on purpose: this is the predicate a delete hangs off, so
990/// every doubt has to resolve to `None`, which retains. It therefore requires the
991/// whole `-v<digits>-e<16 hex digits>` tail, rejects a sign that `u32::from_str`
992/// would otherwise accept (`+12`), and rejects an environment tag of the wrong
993/// width — anything merely *shaped like* a key is left alone.
994fn key_generation(key: &str) -> Option<u32> {
995    let (head, env) = key.rsplit_once("-e")?;
996    if env.len() != 16 || !env.bytes().all(|b| b.is_ascii_hexdigit()) {
997        return None;
998    }
999    let (_, version) = head.rsplit_once("-v")?;
1000    if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) {
1001        return None;
1002    }
1003    // Mask off the feature namespace; what remains is the generation. Sound while
1004    // the base stays below the stride, which `extract.rs` asserts at compile time.
1005    Some(version.parse::<u32>().ok()? % crate::extract::FEATURE_NAMESPACE_STRIDE)
1006}
1007
1008/// FNV-1a (64-bit). Dependency-free and deterministic; used only to derive
1009/// cache filenames, so it needs no cryptographic properties.
1010fn fnv1a64(bytes: &[u8]) -> u64 {
1011    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1012    for &b in bytes {
1013        hash ^= u64::from(b);
1014        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1015    }
1016    hash
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::{ObjectCache, cache_key, key_generation, resolve_calls};
1022    use crate::{EdgeKind, FactSet, Node, NodeKind};
1023
1024    fn fn_node(key: &str, name: &str, calls: &[&str]) -> Node {
1025        let mut n = Node::new(key, NodeKind::Fn, name);
1026        if !calls.is_empty() {
1027            n.meta = serde_json::json!({ "calls": calls });
1028        }
1029        n
1030    }
1031
1032    #[test]
1033    fn resolve_calls_links_unique_names_only() {
1034        let mut fs = FactSet::new()
1035            .with_node(fn_node(
1036                "sym:rust:a.rs#caller",
1037                "caller",
1038                &["target", "dup", "missing"],
1039            ))
1040            .with_node(fn_node("sym:rust:a.rs#target", "target", &[]))
1041            // Two functions named `dup` → ambiguous, must not be linked.
1042            .with_node(fn_node("sym:rust:a.rs#dup", "dup", &[]))
1043            .with_node(fn_node("sym:rust:b.rs#dup", "dup", &[]));
1044
1045        resolve_calls(&mut fs);
1046
1047        let calls: Vec<_> = fs
1048            .edges
1049            .iter()
1050            .filter(|e| e.kind == EdgeKind::Calls)
1051            .collect();
1052        assert_eq!(
1053            calls.len(),
1054            1,
1055            "only the unambiguous, known callee is linked"
1056        );
1057        assert_eq!(calls[0].src, "sym:rust:a.rs#caller");
1058        assert_eq!(calls[0].dst, "sym:rust:a.rs#target");
1059    }
1060
1061    #[test]
1062    fn cache_key_separates_paths_but_is_stable() {
1063        let oid = "abc123";
1064        // Same path + oid + env is stable across calls.
1065        assert_eq!(cache_key("src/a.rs", oid, 0), cache_key("src/a.rs", oid, 0));
1066        // Same blob content (oid) at two different paths must not collide.
1067        assert_ne!(cache_key("src/a.rs", oid, 0), cache_key("src/b.rs", oid, 0));
1068        // Different content at the same path differs too.
1069        assert_ne!(
1070            cache_key("src/a.rs", "aaa", 0),
1071            cache_key("src/a.rs", "bbb", 0)
1072        );
1073        // A different extractor environment (e.g. OCR models installed) differs,
1074        // so image facts are re-extracted when the models change.
1075        assert_ne!(
1076            cache_key("src/a.rs", oid, 0),
1077            cache_key("src/a.rs", oid, 42)
1078        );
1079        // Key stays sharded on the oid so the cache's 2-char shard is well spread.
1080        assert!(cache_key("src/a.rs", oid, 0).starts_with("abc123-"));
1081        // The extractor version is folded in, so a bump retires old entries.
1082        assert!(
1083            cache_key("src/a.rs", oid, 0)
1084                .contains(&format!("-v{}", crate::extract::EXTRACT_VERSION))
1085        );
1086    }
1087
1088    /// The sweep predicate's one input. The round trip is what makes the sweep
1089    /// safe: a key this module just wrote must decode to *this* generation, or a
1090    /// pass at the current version would delete its own live entries.
1091    #[test]
1092    fn key_generation_round_trips_the_key_this_module_writes() {
1093        let key = cache_key("src/a.rs", "abc123", 0);
1094        assert_eq!(
1095            key_generation(&key),
1096            Some(crate::extract::EXTRACT_BASE_VERSION),
1097            "a key written now decodes to the current generation: {key}",
1098        );
1099        // …and so does the same generation in another feature build's namespace,
1100        // which is the whole reason the namespace is masked off rather than
1101        // compared. Both are live at once on a machine that runs the default and
1102        // `--all-features` test suites over one repository.
1103        let base = crate::extract::EXTRACT_BASE_VERSION;
1104        for namespace in [100, 200, 300, 400, 500, 600, 700] {
1105            let other = format!(
1106                "abc123-0000000000000000-v{}-e0000000000000000",
1107                base + namespace
1108            );
1109            assert_eq!(
1110                key_generation(&other),
1111                Some(base),
1112                "namespace {namespace} is not a different generation",
1113            );
1114        }
1115    }
1116
1117    /// Every doubt resolves to `None`, and `None` retains. These are the strings
1118    /// that must *not* be read as a generation — each one would otherwise put a
1119    /// file nobody can identify in reach of a delete.
1120    #[test]
1121    fn key_generation_refuses_anything_it_did_not_write() {
1122        for not_a_key in [
1123            "",
1124            "abc123",                                                 // no tail at all
1125            "abc123-0000000000000000-v12",                            // no env tag
1126            "abc123-0000000000000000-e0000000000000000",              // no version tag
1127            "abc123-0000000000000000-v12-e00000000000000",            // env too short
1128            "abc123-0000000000000000-v12-e00000000000000000",         // env too long
1129            "abc123-0000000000000000-v12-egggggggggggggggg",          // env not hex
1130            "abc123-0000000000000000-v+12-e0000000000000000",         // `+12` parses as 12
1131            "abc123-0000000000000000-v-e0000000000000000",            // empty version
1132            "abc123-0000000000000000-v1 2-e0000000000000000",         // not all digits
1133            "abc123-0000000000000000-v99999999999-e0000000000000000", // overflows u32
1134        ] {
1135            assert_eq!(
1136                key_generation(not_a_key),
1137                None,
1138                "`{not_a_key}` must not be read as a generation",
1139            );
1140        }
1141    }
1142
1143    /// The sweep's contract, on a cache holding one entry per generation and
1144    /// namespace: the current generation survives in **every** namespace, the
1145    /// retained generations survive, older ones go, and a *newer* one — written
1146    /// by a binary ahead of this one sharing the same common git dir — is never
1147    /// touched, whatever the retention.
1148    #[test]
1149    fn sweep_superseded_keeps_current_future_and_kept_generations() {
1150        let base = crate::extract::EXTRACT_BASE_VERSION;
1151        let dir = std::env::temp_dir().join(format!("roteiro-gc-{}", std::process::id()));
1152        std::fs::remove_dir_all(&dir).ok();
1153        let cache = ObjectCache::open(&dir).expect("open");
1154
1155        let key = |version: u32| format!("abc123-0000000000000000-v{version}-e0000000000000000");
1156        let ancient = key(base - 2);
1157        let previous = key(base - 1);
1158        let current = key(base);
1159        let current_all_features = key(base + 700);
1160        let future = key(base + 1);
1161        let foreign = "not-a-roteiro-cache-key".to_owned();
1162        for k in [
1163            &ancient,
1164            &previous,
1165            &current,
1166            &current_all_features,
1167            &future,
1168            &foreign,
1169        ] {
1170            cache.put(k, &FactSet::new()).expect("put");
1171        }
1172
1173        // Keeping one generation back: only `base - 2` is unreachable.
1174        let swept =
1175            super::sweep_superseded(&cache, super::DEFAULT_KEEP_GENERATIONS).expect("sweep");
1176        assert_eq!(swept.sweep.removed, 1, "{swept:?}");
1177        // Retention is not one class, and the report says which. Two entries sit
1178        // at this generation (the two namespaces), one behind it, one ahead of
1179        // it, and one key that does not parse — each counted under its own
1180        // reason, because a summary that folded them together would describe an
1181        // irreversible operation inaccurately.
1182        assert_eq!(
1183            (
1184                swept.kept_current,
1185                swept.kept_recent,
1186                swept.kept_ahead,
1187                swept.kept_unrecognised,
1188            ),
1189            (2, 1, 1, 1),
1190            "{swept:?}",
1191        );
1192        assert_eq!(
1193            swept.kept_total(),
1194            swept.sweep.retained,
1195            "the four reasons must partition the retained total: {swept:?}",
1196        );
1197        assert!(!cache.contains(&ancient));
1198        for k in [
1199            &previous,
1200            &current,
1201            &current_all_features,
1202            &future,
1203            &foreign,
1204        ] {
1205            assert!(cache.contains(k), "`{k}` must survive a keep-1 sweep");
1206        }
1207
1208        // Keeping none: the previous generation goes too, and nothing else does.
1209        let swept = super::sweep_superseded(&cache, 0).expect("sweep");
1210        assert_eq!(swept.sweep.removed, 1, "{swept:?}");
1211        assert!(!cache.contains(&previous));
1212        for k in [&current, &current_all_features, &future, &foreign] {
1213            assert!(cache.contains(k), "`{k}` must survive a keep-0 sweep");
1214        }
1215
1216        // A repeat pass is a no-op: nothing reachable is ever swept "eventually".
1217        let swept = super::sweep_superseded(&cache, 0).expect("sweep");
1218        assert_eq!(swept.sweep.removed, 0, "{swept:?}");
1219        assert_eq!(swept.sweep.retained, 4, "{swept:?}");
1220
1221        std::fs::remove_dir_all(&dir).expect("cleanup");
1222    }
1223}