Skip to main content

gonzalo_cli/
lib.rs

1//! Command implementations for the gonzalo admin CLI.
2
3use anyhow::Context;
4use anyhow::Result;
5use gonzalo_core::{
6    BlobStore, Body, ContentHash, Identity, KeyPrefix, Manifest, Meta, PutResult, Record,
7    RecordKey, RecordKind, Revision, Store,
8};
9use gonzalo_graph::{CodeGraph, EXTRACTION_VERSION, GraphStore, Language, build};
10use gonzalo_graph_sqlite::{SqliteGraphStore, view_db_path};
11use gonzalo_parse::{ParserPool, worker_extraction_version};
12use gonzalo_store_fs::FsStore;
13use gonzalo_ticket::IngestSummary;
14use gonzalo_ticket_config::{Config, Connection, parse_category};
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::time::{Duration, Instant};
18
19mod walk;
20pub use walk::{IgnoredCounts, IndexFilter, SourceFiles, UnindexedCounts};
21
22mod watch;
23pub use watch::{WatchConfig, watch};
24
25// ─── list ────────────────────────────────────────────────────────────────────
26
27/// Return all record keys in the store, optionally filtered by namespace /
28/// collection.
29pub async fn list(
30    root: &Path,
31    namespace: Option<String>,
32    collection: Option<String>,
33) -> Result<Vec<RecordKey>> {
34    let store = FsStore::new(root);
35    let prefix = KeyPrefix {
36        namespace,
37        collection,
38    };
39    let keys = store.list(&prefix).await?;
40    Ok(keys)
41}
42
43// ─── get ─────────────────────────────────────────────────────────────────────
44
45/// Fetch a single record, or `None` if it does not exist.
46pub async fn get(root: &Path, ns: &str, col: &str, id: &str) -> Result<Option<Record>> {
47    let store = FsStore::new(root);
48    let key = RecordKey::new(ns, col, id);
49    Ok(store.get(&key).await?)
50}
51
52// ─── status ──────────────────────────────────────────────────────────────────
53
54/// Count of records grouped by `"namespace/collection"`.
55pub async fn status(root: &Path) -> Result<BTreeMap<String, usize>> {
56    let keys = list(root, None, None).await?;
57    let mut map: BTreeMap<String, usize> = BTreeMap::new();
58    for k in keys {
59        *map.entry(format!("{}/{}", k.namespace, k.collection))
60            .or_insert(0) += 1;
61    }
62    Ok(map)
63}
64
65// ─── migrate ─────────────────────────────────────────────────────────────────
66
67/// Summary returned by [`migrate`].
68pub struct MigrateSummary {
69    pub imported: usize,
70    pub skipped: usize,
71}
72
73/// Recursively import every file under `src` as a record in the fs store at
74/// `root`. Idempotent: if the key already exists, skip it.
75pub async fn migrate(
76    root: &Path,
77    src: &Path,
78    namespace: &str,
79    collection: &str,
80    kind: RecordKind,
81) -> Result<MigrateSummary> {
82    let store = FsStore::new(root);
83    let mut imported = 0usize;
84    let mut skipped = 0usize;
85
86    // Collect all file paths recursively using std::fs (no walkdir dep).
87    let files = collect_files(src)?;
88
89    for abs_path in files {
90        // Build relative path string with `/` as separator.
91        let rel = abs_path
92            .strip_prefix(src)
93            .map_err(|e| anyhow::anyhow!("strip_prefix failed: {e}"))?;
94        let rel_str = rel
95            .components()
96            .map(|c| c.as_os_str().to_string_lossy().into_owned())
97            .collect::<Vec<_>>()
98            .join("/");
99
100        // Use the relative path verbatim as the record id. The store now
101        // encodes arbitrary key characters reversibly and injectively, so two
102        // distinct source files can never collide onto one record (the old
103        // `segment()` collapse silently dropped one of `docs/api.md` and
104        // `docs_api.md`). Ids stay human-readable (`docs/api.md`).
105        let key = RecordKey::new(namespace, collection, rel_str);
106
107        // Idempotency: skip if already present.
108        if store.get(&key).await?.is_some() {
109            skipped += 1;
110            continue;
111        }
112
113        let file_bytes = std::fs::read(&abs_path)?;
114        let body = Body::Inline(file_bytes);
115        let record = Record {
116            key,
117            kind,
118            revision: Revision::initial(body.bytes()),
119            parent: None,
120            body,
121            meta: Meta {
122                author: Identity::new(CLI_AUTHOR),
123                origin_system: "migrate".into(),
124                created: 0,
125                updated: 0,
126                labels: BTreeMap::new(),
127            },
128            links: vec![],
129            ancestors: Vec::new(),
130            deleted_at: None,
131            deleted_blob: None,
132            deleted_kind: None,
133        };
134
135        match store.put(record, None).await? {
136            PutResult::Committed(_) => imported += 1,
137            PutResult::Conflict(_) => skipped += 1,
138        }
139    }
140
141    Ok(MigrateSummary { imported, skipped })
142}
143
144/// Walk `dir` recursively and return a sorted list of all file paths.
145fn collect_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
146    let mut out = Vec::new();
147    collect_files_inner(dir, &mut out)?;
148    out.sort();
149    Ok(out)
150}
151
152fn collect_files_inner(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
153    // Name the directory: a bare "No such file or directory (os error 2)"
154    // leaves the user guessing which path was wrong (#299).
155    let entries = std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))?;
156    for entry in entries {
157        let entry = entry?;
158        let ft = entry.file_type()?;
159        if ft.is_dir() {
160            collect_files_inner(&entry.path(), out)?;
161        } else if ft.is_file() {
162            out.push(entry.path());
163        }
164    }
165    Ok(())
166}
167
168// ─── index ───────────────────────────────────────────────────────────────────
169
170/// Summary returned by [`index`].
171pub struct IndexSummary {
172    /// Source files parsed into slices.
173    pub files: usize,
174    /// Paths newly added to the view.
175    pub added: usize,
176    /// Paths whose slice content changed.
177    pub modified: usize,
178    /// Paths removed from the view since the last index.
179    pub deleted: usize,
180    /// Files skipped because an isolated parse worker crashed or hung on them
181    /// (only possible when parsing through the pool).
182    pub skipped: usize,
183    /// Which files those were. Counting them without naming them leaves no way
184    /// to find the offender but bisecting by directory (#270).
185    pub skipped_paths: Vec<String>,
186    /// Paths excluded from the view by an [`IndexFilter`] rule — vendored or
187    /// generated files, dependency/output directories, and gitignored trees
188    /// (#209). Distinct from `skipped`, which is a parse failure.
189    pub ignored: IgnoredCounts,
190    /// Files gonzalo has no grammar for. Distinct from both of the above:
191    /// recognized as a file, would have been indexed, but its extension names
192    /// no language gonzalo parses (#259).
193    pub unindexed: UnindexedCounts,
194    /// Whether this run used the git-diff-driven incremental driver (only the
195    /// changed set re-parsed) rather than the full tree walk.
196    pub incremental: bool,
197}
198
199/// The `gonzalo-parse-worker` file name for this platform.
200fn worker_file_name() -> &'static str {
201    if cfg!(windows) {
202        "gonzalo-parse-worker.exe"
203    } else {
204        "gonzalo-parse-worker"
205    }
206}
207
208/// Where a parse worker was found.
209///
210/// `#[non_exhaustive]`: this set is already known to be growing — #212's own
211/// proposal anticipates a `--parse-worker <path>` flag, which is a fourth
212/// source. Sealing it now costs nothing; adding a variant later to a sealed
213/// enum is not a breaking change, while adding one to an open enum is (#241).
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215#[non_exhaustive]
216pub enum WorkerSource {
217    /// The `GONZALO_PARSE_WORKER` environment override.
218    Env,
219    /// Beside the running executable (the installed / `cargo build` layout).
220    Sibling,
221    /// On `PATH` — the symlink and split-install layouts, where a sibling
222    /// check cannot succeed but the worker is plainly available.
223    Path,
224}
225
226impl WorkerSource {
227    fn describe(self) -> &'static str {
228        match self {
229            Self::Env => "GONZALO_PARSE_WORKER",
230            Self::Sibling => "beside the executable",
231            Self::Path => "on PATH",
232        }
233    }
234}
235
236/// How an index run will parse.
237///
238/// The two modes produce identical graphs, which is exactly why the fallback
239/// went unnoticed: the worker is a pure isolation wrapper, so its absence costs
240/// nothing until a grammar `abort()`s and takes the whole run with it instead of
241/// skipping one file (#212). Reporting the mode is what makes the guarantee
242/// checkable at the moment it matters.
243#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum ParseMode {
246    /// Crash-isolated: parses run in a worker subprocess pool.
247    Isolated { path: PathBuf, source: WorkerSource },
248    /// In-process: no worker was found, so a grammar crash aborts the run.
249    /// Carries every location that was searched, so the warning can say where
250    /// to put one.
251    InProcess { searched: Vec<PathBuf> },
252}
253
254impl ParseMode {
255    /// The worker binary to parse through, or `None` to parse in-process.
256    pub fn worker(&self) -> Option<&Path> {
257        match self {
258            Self::Isolated { path, .. } => Some(path),
259            Self::InProcess { .. } => None,
260        }
261    }
262
263    /// Whether parsing is crash-isolated.
264    pub fn is_isolated(&self) -> bool {
265        matches!(self, Self::Isolated { .. })
266    }
267
268    /// One line naming the active mode, for `gonzalo index` to print.
269    pub fn summary(&self) -> String {
270        match self {
271            Self::Isolated { path, source } => {
272                format!("isolated worker {} ({})", path.display(), source.describe())
273            }
274            Self::InProcess { .. } => "in-process (no worker found)".to_string(),
275        }
276    }
277
278    /// The warning to emit when isolation is inactive, naming everywhere that
279    /// was searched. `None` when parsing is isolated.
280    pub fn warning(&self) -> Option<String> {
281        let Self::InProcess { searched } = self else {
282            return None;
283        };
284        let where_looked = if searched.is_empty() {
285            "  (nowhere — no executable path and no PATH entries)".to_string()
286        } else {
287            searched
288                .iter()
289                .map(|p| format!("  {}", p.display()))
290                .collect::<Vec<_>>()
291                .join("\n")
292        };
293        Some(format!(
294            "gonzalo index: warning — parsing IN-PROCESS, not crash-isolated. A tree-sitter \
295             grammar that aborts will take down this run instead of skipping one file.\n\
296             Searched for {}:\n{where_looked}\n\
297             Install it (`cargo install gonzalo-parse`), put it on PATH or beside this \
298             executable, or point GONZALO_PARSE_WORKER at it. Use --require-parse-worker to \
299             make this a hard error.",
300            worker_file_name()
301        ))
302    }
303}
304
305/// Locate the `gonzalo-parse-worker` binary, given the inputs that decide it.
306///
307/// Split from the environment so it can be tested: setting a process
308/// environment variable is `unsafe` under edition 2024, which this workspace
309/// forbids outright.
310///
311/// Order is override, then adjacency, then `PATH` — most specific first.
312fn resolve_worker_from(
313    env_override: Option<PathBuf>,
314    exe: Option<PathBuf>,
315    search_path: &[PathBuf],
316) -> ParseMode {
317    let name = worker_file_name();
318    let mut searched = Vec::new();
319
320    if let Some(path) = env_override {
321        if path.is_file() {
322            return ParseMode::Isolated {
323                path,
324                source: WorkerSource::Env,
325            };
326        }
327        searched.push(path);
328    }
329
330    if let Some(exe) = exe {
331        let sibling = exe.with_file_name(name);
332        if sibling.is_file() {
333            return ParseMode::Isolated {
334                path: sibling,
335                source: WorkerSource::Sibling,
336            };
337        }
338        searched.push(sibling);
339    }
340
341    for dir in search_path {
342        let candidate = dir.join(name);
343        if candidate.is_file() {
344            return ParseMode::Isolated {
345                path: candidate,
346                source: WorkerSource::Path,
347            };
348        }
349        searched.push(candidate);
350    }
351
352    ParseMode::InProcess { searched }
353}
354
355/// Locate the `gonzalo-parse-worker` binary for crash-isolated parsing:
356/// the `GONZALO_PARSE_WORKER` override, else a sibling of the current
357/// executable, else `PATH`. Falls back to in-process parsing when none of those
358/// has one — reporting where it looked (#212).
359pub fn resolve_parse_worker() -> ParseMode {
360    let search_path: Vec<PathBuf> = std::env::var_os("PATH")
361        .map(|p| std::env::split_paths(&p).collect())
362        .unwrap_or_default();
363    resolve_worker_from(
364        std::env::var_os("GONZALO_PARSE_WORKER").map(PathBuf::from),
365        std::env::current_exe().ok(),
366        &search_path,
367    )
368}
369
370/// Parse one file's `content` as `language`, through the crash-isolated `pool`
371/// if one is available (a crash/hang yields `None` — skip the file), or
372/// in-process otherwise.
373async fn parse_file(
374    pool: Option<&ParserPool>,
375    language: Language,
376    content: &str,
377    rel: &str,
378) -> Option<CodeGraph> {
379    match pool {
380        Some(pool) => match pool.parse(language, content).await {
381            Ok(graph) => Some(graph),
382            Err(e) => {
383                eprintln!("{}", skip_message(rel, &e.to_string()));
384                None
385            }
386        },
387        None => Some(build(language, content)),
388    }
389}
390
391/// The line printed when a file is skipped, naming it.
392///
393/// A skipped file is silently absent from the view — `search` finds none of its
394/// symbols, `callers` misses every call it makes — so the one line saying that
395/// happened has to say *where*. Without the path the only way to find the
396/// offender is bisecting by directory, which is what landing #266 cost (#270).
397fn skip_message(rel: &str, error: &str) -> String {
398    format!("gonzalo index: skipping {rel} — parse worker error: {error}")
399}
400
401/// Skipped paths for the index summary, bounded, with a count of what was left
402/// out so a tree full of crashes does not become a wall of text.
403pub fn named_skips(paths: &[String]) -> String {
404    const MOST: usize = 5;
405    if paths.is_empty() {
406        return String::new();
407    }
408    let listed = paths
409        .iter()
410        .take(MOST)
411        .map(String::as_str)
412        .collect::<Vec<_>>()
413        .join(", ");
414    let more = paths.len().saturating_sub(MOST);
415    if more > 0 {
416        format!("{listed}, and {more} more")
417    } else {
418        listed
419    }
420}
421
422/// Index the source files under `src` into the `(repo, view)` code-graph view:
423/// parse each file into a content-addressed slice, then reconcile the view's
424/// manifest to the tree (ADR 0012). Re-running updates the view.
425///
426/// When `src` is the root of a git repo and a base commit was recorded on a
427/// prior run, the changed set is sourced directly from `git diff` (only the
428/// added/modified files are re-parsed and deleted files dropped) — the
429/// incremental driver of gonzalo#93. Otherwise the full tree is walked. Either
430/// way the manifest is reconciled as a set, so a full walk always converges the
431/// view even if an incremental run ever missed a change. Slices orphaned by
432/// deletions are left for a separate GC pass (which must see all live views).
433pub async fn index(root: &Path, src: &Path, repo: &str, view: &str) -> Result<IndexSummary> {
434    index_with(root, src, repo, view, &IndexFilter::default()).await
435}
436
437/// [`index`], with control over which paths enter the view.
438///
439/// `filter` carries any `--include` overrides; the built-in vendored/generated
440/// rules and `.gitignore` apply either way (#209).
441pub async fn index_with(
442    root: &Path,
443    src: &Path,
444    repo: &str,
445    view: &str,
446    filter: &IndexFilter,
447) -> Result<IndexSummary> {
448    let mode = resolve_parse_worker();
449    index_with_worker(root, src, repo, view, filter, mode.worker()).await
450}
451
452/// The warning shown when the parse path's extraction version is not this
453/// build's. `reported` is the worker's version, or `0` for "would not say".
454///
455/// Unknown and known-stale are **different states**, and saying so matters most
456/// on the upgrade everyone performs at least once: every worker released before
457/// `--extraction-version` (#228) answers nothing, while producing extraction
458/// that may well be current. Telling that user to "install a matching worker to
459/// get version N extraction" claims their data is wrong when it is not (#239).
460fn worker_version_warning(reported: u32) -> String {
461    if reported == 0 {
462        format!(
463            "gonzalo index: note — the parse worker did not report an extraction version, so \
464             it predates `--extraction-version` (gonzalo 0.5.0 or earlier). Its output may \
465             well match this build's version {EXTRACTION_VERSION}, but that cannot be \
466             confirmed, so the view is credited `unknown` and rebuilt in full. Upgrading \
467             gonzalo-parse-worker alongside gonzalo clears this and rebuilds once more."
468        )
469    } else {
470        format!(
471            "gonzalo index: WARNING — the parse worker reports extraction version {reported}, \
472             but this build records {EXTRACTION_VERSION}. The two disagree about what a parse \
473             records. Parsing anyway, rebuilding the view in full and crediting it to the \
474             worker; install a matching gonzalo-parse-worker to get {EXTRACTION_VERSION} \
475             extraction."
476        )
477    }
478}
479
480/// Print `msg` to stderr at most once per process.
481///
482/// `--watch` re-enters the index path on every debounced change, so a
483/// per-index warning becomes a paragraph per file save (#239). The condition
484/// cannot change mid-process anyway: the binaries on disk are what they are.
485fn warn_once(msg: &str) {
486    static ONCE: std::sync::Once = std::sync::Once::new();
487    ONCE.call_once(|| eprintln!("{msg}"));
488}
489
490/// How long to wait for a worker to answer [`worker_extraction_version`].
491/// Generous next to a process spawn; a worker that misses it reads as unknown
492/// rather than wedging the run.
493const WORKER_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
494
495/// [`index_with`], with the parse worker pinned rather than resolved.
496///
497/// `worker` is the `gonzalo-parse-worker` binary to parse through, or `None` to
498/// parse in-process. [`index_with`] passes whatever [`resolve_parse_worker`]
499/// finds; pinning it explicitly is what lets a test drive a known parse path.
500pub async fn index_with_worker(
501    root: &Path,
502    src: &Path,
503    repo: &str,
504    view: &str,
505    filter: &IndexFilter,
506    worker: Option<&Path>,
507) -> Result<IndexSummary> {
508    let store = FsStore::new(root);
509
510    // The extraction format the parse path will actually produce — which is not
511    // necessarily this build's. The worker is a separately installed binary, so
512    // ask it; `None` (a pre-#228 worker, a missing one, a hang) is unknown, and
513    // unknown is recorded as 0 so it can never be mistaken for agreement (#228).
514    // With no worker the CLI parses in-process, so its own constant is accurate.
515    let parse_version = match worker {
516        Some(bin) => worker_extraction_version(bin, WORKER_PROBE_TIMEOUT)
517            .await
518            .unwrap_or(0),
519        None => EXTRACTION_VERSION,
520    };
521    if parse_version != EXTRACTION_VERSION {
522        warn_once(&worker_version_warning(parse_version));
523    }
524
525    // Parse through a crash-isolated worker pool when a worker binary is
526    // available (so a grammar crash on one file skips that file instead of
527    // aborting the index); otherwise parse in-process.
528    let pool = worker.map(|bin| {
529        let workers = std::thread::available_parallelism()
530            .map(|n| n.get())
531            .unwrap_or(4)
532            .clamp(1, 8);
533        ParserPool::new(bin, workers, std::time::Duration::from_secs(30))
534    });
535
536    // The persistent per-view graph, queried by the daemon without re-assembly.
537    let db_path = view_db_path(&root.join("graphs"), repo, view);
538    let mut graph = SqliteGraphStore::open(&db_path)
539        .with_context(|| format!("opening graph db for {repo}/{view}"))?;
540
541    // Load the view's current manifest (empty if new).
542    let key = Manifest::key(repo, view);
543    let existing = store.get(&key).await?;
544    let current = match &existing {
545        Some(rec) => Manifest::from_body(&rec.body)?,
546        None => Manifest::new(),
547    };
548
549    // Choose the driver: incremental when `src` is a git repo root, a base was
550    // recorded, and the diff against it is readable; otherwise a full walk.
551    let base_path = db_path.with_extension("base");
552    // A view built by a parser that recorded different things must be rebuilt
553    // in full: the incremental driver carries unchanged slices forward, so
554    // without this an existing view keeps pre-upgrade extraction forever.
555    // Compared against the version the *parse path* produces, not this build's
556    // — otherwise a stale worker's output is carried forward as current (#228).
557    let version_path = db_path.with_extension("fmt");
558    let recorded_version: Option<u32> = std::fs::read_to_string(&version_path)
559        .ok()
560        .and_then(|s| s.trim().parse().ok());
561    let format_changed = recorded_version != Some(parse_version);
562
563    let recorded_base = std::fs::read_to_string(&base_path)
564        .ok()
565        .map(|s| s.trim().to_string())
566        .filter(|s| !s.is_empty())
567        .filter(|_| !format_changed);
568    let incremental_changed = recorded_base
569        .as_deref()
570        .and_then(|base| gonzalo_store_git::changed_paths(src, base).ok());
571
572    // Stage every persistent-graph mutation instead of applying it inline. The
573    // SqliteGraphStore is advanced only after the manifest that describes it
574    // commits, so a concurrent-writer Conflict (below) leaves the graph
575    // untouched rather than advanced ahead of a manifest that never landed (#153).
576    let mut staging = GraphStaging::default();
577    let DesiredView {
578        entries: desired,
579        files,
580        skipped_paths,
581        ignored,
582        unindexed,
583        incremental,
584    } = match incremental_changed {
585        Some(changed) => {
586            build_desired_incremental(
587                &store,
588                &mut staging,
589                pool.as_ref(),
590                src,
591                &current,
592                &changed,
593                filter,
594            )
595            .await?
596        }
597        None => build_desired_full(&store, &mut staging, pool.as_ref(), src, filter).await?,
598    };
599
600    // Reconcile against the current manifest and stage removed paths for the
601    // persistent graph (applied only after the manifest commit).
602    let recon = current.reconcile(&desired);
603    for path in &recon.deleted {
604        staging.removes.push(path.clone());
605    }
606
607    // Write the manifest record (create-or-update under OCC).
608    let body = recon.manifest.to_body();
609    let (revision, expected, parent) = match &existing {
610        Some(rec) => (
611            rec.revision.next(body.bytes()),
612            Some(rec.revision.clone()),
613            Some(rec.revision.clone()),
614        ),
615        None => (Revision::initial(body.bytes()), None, None),
616    };
617    let record = Record {
618        key,
619        kind: RecordKind::GraphManifest,
620        revision,
621        parent,
622        body,
623        meta: Meta {
624            author: Identity::new("gonzalo-index"),
625            origin_system: "gonzalo-index".into(),
626            created: 0,
627            updated: 0,
628            labels: BTreeMap::new(),
629        },
630        links: Vec::new(),
631        ancestors: Vec::new(),
632        deleted_at: None,
633        deleted_blob: None,
634        deleted_kind: None,
635    };
636    match store.put(record, expected).await? {
637        PutResult::Committed(_) => {}
638        PutResult::Conflict(_) => {
639            // The manifest moved under us: abandon the run WITHOUT touching the
640            // persistent graph, so the SqliteGraphStore never advances ahead of a
641            // committed manifest (#153). Orphaned slice blobs written above are
642            // reclaimed by a later gc pass.
643            anyhow::bail!("manifest for {repo}/{view} changed concurrently; retry the index")
644        }
645    }
646
647    // Only added/modified paths need checking: an unchanged path was referenced
648    // by both the old manifest and the new one, so no sweep ever saw it as
649    // garbage.
650    let newly_referenced: Vec<(String, ContentHash)> = recon
651        .added
652        .iter()
653        .chain(recon.modified.iter())
654        .filter_map(|p| recon.manifest.get(p).map(|h| (p.clone(), h.clone())))
655        .collect();
656    // An error here returns after the manifest committed but before
657    // `staging.apply`, so the persistent graph stays behind the manifest (#153's
658    // invariant holds) and the next run starts from a stale base; hence the
659    // message tells the operator to re-index.
660    let restored = ensure_slices_present(&store, &newly_referenced, &staging).await?;
661    if restored > 0 {
662        eprintln!("restored {restored} slice blob(s) swept during this index run");
663    }
664
665    // Manifest committed — now advance the persistent graph to match it.
666    staging.apply(&mut graph);
667
668    // Record the current HEAD as the base for the next run's incremental diff.
669    // Only succeeds when `src` is a git repo root with at least one commit.
670    if let Ok(sha) = gonzalo_store_git::head_commit(src) {
671        if let Some(parent) = base_path.parent() {
672            std::fs::create_dir_all(parent).ok();
673        }
674        std::fs::write(&base_path, sha).ok();
675    }
676    // Record the extraction format this view was built with, so the next run can
677    // tell whether an incremental pass is still valid. This is the version the
678    // parse path actually produced — never this build's constant when something
679    // else did the parsing, or the view would claim an upgrade it never got and
680    // no later re-index would revisit it (#228).
681    if let Some(parent) = version_path.parent() {
682        std::fs::create_dir_all(parent).ok();
683        std::fs::write(&version_path, parse_version.to_string()).ok();
684    }
685
686    Ok(IndexSummary {
687        files,
688        added: recon.added.len(),
689        modified: recon.modified.len(),
690        deleted: recon.deleted.len(),
691        skipped: skipped_paths.len(),
692        skipped_paths,
693        ignored,
694        unindexed,
695        incremental,
696    })
697}
698
699/// A set of persistent-graph mutations collected during a [`index`] run but not
700/// yet applied. Staging the writes lets [`index`] commit the view's manifest
701/// first and only then advance the [`SqliteGraphStore`] — so a manifest Conflict
702/// leaves the persistent graph untouched (#153).
703#[derive(Default)]
704struct GraphStaging {
705    /// `(relative path, parsed slice)` to (re)insert; insert replaces the path's
706    /// existing rows.
707    inserts: Vec<(String, CodeGraph)>,
708    /// Relative paths whose rows should be removed.
709    removes: Vec<String>,
710}
711
712impl GraphStaging {
713    /// Apply every staged mutation to the persistent graph. Called only after
714    /// the manifest commit succeeds.
715    fn apply(self, graph: &mut SqliteGraphStore) {
716        for (rel, slice) in self.inserts {
717            graph.insert(&rel, slice);
718        }
719        for rel in self.removes {
720            graph.remove_path(&rel);
721        }
722    }
723}
724
725/// Full-walk desired set: parse every supported source file under `src` that
726/// `filter` admits.
727/// What one indexing driver produced: the manifest entries the view should
728/// hold, plus the counts [`IndexSummary`] reports.
729///
730/// A named type rather than a tuple because the two drivers must agree on it
731/// exactly, and a six-element tuple says nothing about which `usize` is which.
732struct DesiredView {
733    entries: BTreeMap<String, ContentHash>,
734    /// Files parsed into the view this run.
735    files: usize,
736    /// Files a parse worker crashed or hung on, by path (#270).
737    skipped_paths: Vec<String>,
738    ignored: IgnoredCounts,
739    unindexed: UnindexedCounts,
740    /// Whether the git-diff-driven driver produced this, rather than a full walk.
741    incremental: bool,
742}
743
744/// Confirm the slice blobs a just-committed manifest references still exist,
745/// re-uploading any a sweep took.
746///
747/// A sweep can delete a blob between the upload and the manifest commit naming
748/// it. GC's age filter covers a freshly uploaded slice, but not an **old** blob
749/// this run newly references — content-addressed dedup makes that ordinary when
750/// a file reverts to earlier content. Returns how many blobs it restored. See
751/// ADR 0028.
752async fn ensure_slices_present(
753    store: &FsStore,
754    wanted: &[(String, ContentHash)],
755    staging: &GraphStaging,
756) -> anyhow::Result<usize> {
757    let mut restored = 0;
758    for (path, hash) in wanted {
759        if store.has_blob(hash).await? {
760            continue;
761        }
762        let Some((_, slice)) = staging.inserts.iter().find(|(p, _)| p == path) else {
763            anyhow::bail!(
764                "slice blob {} for {path} is missing and this run did not parse it, \
765                 so it cannot be restored; re-index the view",
766                hash.0
767            );
768        };
769        let put = store.put_blob(&slice.to_slice_bytes()).await?;
770        anyhow::ensure!(
771            &put == hash,
772            "restored slice for {path} hashes to {} rather than the committed {}",
773            put.0,
774            hash.0
775        );
776        restored += 1;
777    }
778    Ok(restored)
779}
780
781async fn build_desired_full(
782    store: &FsStore,
783    staging: &mut GraphStaging,
784    pool: Option<&ParserPool>,
785    src: &Path,
786    filter: &IndexFilter,
787) -> Result<DesiredView> {
788    let mut desired: BTreeMap<String, ContentHash> = BTreeMap::new();
789    let mut skipped_paths: Vec<String> = Vec::new();
790    let walk::SourceFiles {
791        files: sources,
792        ignored,
793        unindexed,
794    } = walk::source_files(src, filter)?;
795    for (path, language) in sources {
796        let content = std::fs::read_to_string(&path)
797            .with_context(|| format!("reading {}", path.display()))?;
798        let rel = path
799            .strip_prefix(src)
800            .unwrap_or(&path)
801            .to_string_lossy()
802            .replace('\\', "/");
803        let Some(slice) = parse_file(pool, language, &content, &rel).await else {
804            skipped_paths.push(rel.clone());
805            continue;
806        };
807        let hash = store.put_blob(&slice.to_slice_bytes()).await?;
808        staging.inserts.push((rel.clone(), slice)); // insert replaces this path's rows
809        desired.insert(rel, hash);
810    }
811    let files = desired.len();
812    Ok(DesiredView {
813        entries: desired,
814        files,
815        skipped_paths,
816        ignored,
817        unindexed,
818        incremental: false,
819    })
820}
821
822/// Incremental desired set: start from the current manifest and apply only the
823/// git-reported changes — re-parse added/modified source files, drop deleted
824/// ones, and carry every unchanged path forward untouched. `files` counts the
825/// files re-parsed this run.
826async fn build_desired_incremental(
827    store: &FsStore,
828    staging: &mut GraphStaging,
829    pool: Option<&ParserPool>,
830    src: &Path,
831    current: &Manifest,
832    changed: &gonzalo_store_git::ChangedPaths,
833    filter: &IndexFilter,
834) -> Result<DesiredView> {
835    let mut desired = current.entries.clone();
836    let mut files = 0usize;
837    let mut skipped_paths: Vec<String> = Vec::new();
838    // Gitignored paths never reach here — `git2`'s diff omits them — so only the
839    // path-only rules apply, and only files are ever counted.
840    let mut ignored = IgnoredCounts::default();
841    let mut unindexed = UnindexedCounts::default();
842
843    // Re-apply the filter to paths carried forward from the previous run, not
844    // just to changed ones. A view indexed under laxer rules keeps its vendored
845    // bundles forever otherwise: `mermaid.min.js` never changes, so it never
846    // appears in the diff, so an incremental run never reconsiders it — and once
847    // a base commit is recorded there is no full walk to clean it up. Making the
848    // carried-forward set self-healing is what lets an existing view benefit
849    // from #209 rather than only newly created ones.
850    let stale = walk::stale_entries(src, filter, desired.keys().map(String::as_str));
851    for rel in stale {
852        desired.remove(&rel);
853        staging.removes.push(rel);
854        ignored.files += 1;
855    }
856
857    for rel in changed.added.iter().chain(changed.modified.iter()) {
858        if !filter.is_indexable(rel) {
859            // A path that a previous, laxer walk admitted must also be dropped
860            // from the view, not merely skipped, or the two drivers disagree.
861            if desired.remove(rel).is_some() {
862                staging.removes.push(rel.clone());
863            }
864            ignored.files += 1;
865            continue;
866        }
867        let Some(language) = Path::new(rel)
868            .extension()
869            .and_then(|e| e.to_str())
870            .and_then(Language::from_extension)
871        else {
872            // Indexable, but gonzalo has no grammar for it. The filter check
873            // above already rejected anything deliberately excluded, so this
874            // counts exactly the files a user would expect to have been
875            // indexed and were not (#259).
876            unindexed.record(Path::new(rel).extension().and_then(|e| e.to_str()));
877            continue;
878        };
879        // A file git reports as changed but that we can no longer read (e.g.
880        // it vanished between diff and read) is treated as a removal.
881        let content = match std::fs::read_to_string(src.join(rel)) {
882            Ok(c) => c,
883            Err(_) => {
884                if desired.remove(rel).is_some() {
885                    staging.removes.push(rel.clone());
886                }
887                continue;
888            }
889        };
890        let Some(slice) = parse_file(pool, language, &content, rel).await else {
891            skipped_paths.push(rel.clone());
892            continue;
893        };
894        let hash = store.put_blob(&slice.to_slice_bytes()).await?;
895        staging.inserts.push((rel.clone(), slice));
896        desired.insert(rel.clone(), hash);
897        files += 1;
898    }
899
900    for rel in &changed.deleted {
901        if desired.remove(rel).is_some() {
902            staging.removes.push(rel.clone());
903        }
904    }
905
906    Ok(DesiredView {
907        entries: desired,
908        files,
909        skipped_paths,
910        ignored,
911        unindexed,
912        incremental: true,
913    })
914}
915
916// ─── gc ────────────────────────────────────────────────────────────────────
917
918/// Summary returned by [`gc`].
919pub struct GcSummary {
920    /// Records scanned to build the mark set, tombstones included.
921    pub scanned: usize,
922    /// Unreferenced blobs deleted.
923    pub freed: usize,
924    /// Blobs kept because some record still references them.
925    pub retained: usize,
926    /// Unreferenced blobs kept because they are younger than the sweep policy's
927    /// `min_age`. Without this an operator cannot tell "nothing to reclaim" from
928    /// "reclaiming held back by the age rule".
929    pub deferred: usize,
930}
931
932/// Sweep unreferenced blobs from the store at `root` (gonzalo#292).
933///
934/// Blobs are content-addressed and **shared** — identical content dedups across
935/// views and records alike — so GC marks against every record in the store at
936/// once. Four things keep a blob: a record whose body *is* the blob, a
937/// tombstone pinning the blob its record used to hold, every slice a
938/// `graph-manifest` names (ADR 0012), and every shard a `vector-manifest`
939/// names (ADR 0027). Marking any one alone would delete the other three's
940/// content, so this scans raw (tombstones included) and unions all four via
941/// [`gonzalo_core::live_blob_hashes`].
942///
943/// Deleting a record therefore does not reclaim its bytes: the tombstone pins
944/// them until `collect` removes it past the horizon. Reclaiming is `delete`,
945/// then `collect`, then this.
946pub async fn gc(root: &Path, min_age: Duration) -> Result<GcSummary> {
947    let store = FsStore::new(root);
948
949    // Every record, across all repos and collections (unset prefix = all), and
950    // raw so tombstones — the things that pin deleted records' blobs — are seen.
951    let keys = store.list_raw(&KeyPrefix::default()).await?;
952    let mut records = Vec::with_capacity(keys.len());
953    for key in &keys {
954        if let Some(record) = store.get_raw(key).await? {
955            records.push(record);
956        }
957    }
958
959    let live = gonzalo_core::live_blob_hashes(&records)?;
960    let policy = gonzalo_core::SweepPolicy {
961        min_age,
962        now: std::time::SystemTime::now(),
963    };
964    let report = gonzalo_core::sweep_blobs_with(&store, &live, policy).await?;
965    Ok(GcSummary {
966        scanned: records.len(),
967        freed: report.freed.len(),
968        retained: report.retained,
969        deferred: report.deferred,
970    })
971}
972
973/// The optional knobs of an indexing run (#331).
974///
975/// Collapsed into one struct so a new knob is a field rather than an argument
976/// respelled at every fan-out layer and call site.
977#[derive(Debug, Clone)]
978pub struct IndexOptions {
979    /// Sweep unreferenced blobs after a successful index.
980    pub gc_after: bool,
981    /// Blobs younger than this are never swept (ADR 0028).
982    pub min_age: Duration,
983    /// Which repo-relative paths are eligible for indexing (#209).
984    pub filter: IndexFilter,
985    /// Pin the parse worker rather than resolving one.
986    pub worker: Option<PathBuf>,
987}
988
989impl Default for IndexOptions {
990    /// Written out rather than derived: a derived `min_age` would be
991    /// `Duration::ZERO`, so a caller who sets `gc_after` and leaves the rest
992    /// alone would reinstate the age-unaware sweep ADR 0028 removed. The
993    /// default is the same floor `gc` uses.
994    fn default() -> Self {
995        Self {
996            gc_after: false,
997            min_age: gonzalo_core::DEFAULT_MIN_AGE,
998            filter: IndexFilter::default(),
999            worker: None,
1000        }
1001    }
1002}
1003
1004/// [`index`] the `(repo, view)` view, then — when `opts.gc_after` — sweep
1005/// unreferenced blobs. The opt-in post-index trigger of gonzalo#104: the sweep
1006/// runs only after a successful index and always goes through [`gc`], which
1007/// marks against *every* record in the store (never a per-view subset), so a
1008/// slice the just-indexed view dropped but another view still references is
1009/// preserved.
1010///
1011/// The optional knobs ride in [`IndexOptions`] rather than as positional
1012/// arguments, so adding one is a field rather than an edit at every layer
1013/// between here and the call sites (#331).
1014pub async fn index_with_options(
1015    root: &Path,
1016    src: &Path,
1017    repo: &str,
1018    view: &str,
1019    opts: &IndexOptions,
1020) -> Result<(IndexSummary, Option<GcSummary>)> {
1021    let resolved;
1022    let worker = match &opts.worker {
1023        Some(path) => Some(path.as_path()),
1024        None => {
1025            resolved = resolve_parse_worker();
1026            resolved.worker()
1027        }
1028    };
1029    let summary = index_with_worker(root, src, repo, view, &opts.filter, worker).await?;
1030    let swept = if opts.gc_after {
1031        Some(gc(root, opts.min_age).await?)
1032    } else {
1033        None
1034    };
1035    Ok((summary, swept))
1036}
1037
1038/// [`index_with_options`] for the common case: default filter, resolved parse
1039/// worker, and the sweep governed by `gc_after` / `min_age`.
1040pub async fn index_with_gc(
1041    root: &Path,
1042    src: &Path,
1043    repo: &str,
1044    view: &str,
1045    gc_after: bool,
1046    min_age: Duration,
1047) -> Result<(IndexSummary, Option<GcSummary>)> {
1048    index_with_options(
1049        root,
1050        src,
1051        repo,
1052        view,
1053        &IndexOptions {
1054            gc_after,
1055            min_age,
1056            ..IndexOptions::default()
1057        },
1058    )
1059    .await
1060}
1061
1062// ─── watch (debounce core) ──────────────────────────────────────────────────
1063
1064/// Coalesces a burst of filesystem change notifications so a rapid series of
1065/// edits triggers a single re-index rather than one per event. The clock is
1066/// injected (`now`), so the logic is deterministic and unit-testable without
1067/// real sleeps — the seam the watcher loop drives with `Instant::now()`.
1068#[derive(Debug)]
1069pub struct Debouncer {
1070    window: Duration,
1071    /// When the most recent unhandled change was observed.
1072    last_event: Option<Instant>,
1073}
1074
1075impl Debouncer {
1076    /// A debouncer that fires once the tree has been quiet for `window`.
1077    pub fn new(window: Duration) -> Self {
1078        Self {
1079            window,
1080            last_event: None,
1081        }
1082    }
1083
1084    /// Record that a change was observed at `now`.
1085    pub fn on_event(&mut self, now: Instant) {
1086        self.last_event = Some(now);
1087    }
1088
1089    /// Whether a change is waiting to be handled.
1090    pub fn is_pending(&self) -> bool {
1091        self.last_event.is_some()
1092    }
1093
1094    /// Whether a re-index is due at `now`: a change is pending and the tree has
1095    /// been quiet for at least `window` since the last event. False when nothing
1096    /// is pending.
1097    pub fn is_due(&self, now: Instant) -> bool {
1098        matches!(self.last_event, Some(t) if now.duration_since(t) >= self.window)
1099    }
1100
1101    /// Clear the pending change after a re-index has run.
1102    pub fn clear(&mut self) {
1103        self.last_event = None;
1104    }
1105}
1106
1107// ─── delete / reset / collect (tombstones, gonzalo#203) ─────────────────────
1108
1109/// Open the fs store at `root` with an explicit ancestor cap (spec §3.9).
1110/// A cap of 0 is rejected here, before any record is touched.
1111pub fn open_store(root: &Path, ancestor_cap: usize) -> Result<FsStore> {
1112    FsStore::new(root)
1113        .with_ancestor_cap(ancestor_cap)
1114        .with_context(|| format!("opening store at {}", root.display()))
1115}
1116
1117/// Outcome of [`delete`].
1118#[derive(Clone, Debug, PartialEq, Eq)]
1119pub enum DeleteOutcome {
1120    /// A tombstone was written, or the record was already absent or deleted.
1121    Deleted,
1122    /// `expected` did not match. Nothing was written. `current` is the live
1123    /// record's revision.
1124    Conflict { current: Revision },
1125}
1126
1127/// The identity the CLI stamps on records it writes. Matches `migrate` above
1128/// (which uses this constant) and the `ticket sync --author` default. The CLI
1129/// opens only `FsStore`, which honours a named deleter; over a daemon, spec
1130/// §3.6 applies instead and a non-admin token is stamped as itself.
1131pub const CLI_AUTHOR: &str = "gonzalo-cli";
1132
1133/// Delete one record by writing a tombstone attributed to [`CLI_AUTHOR`]
1134/// (spec §3.1–§3.2). Deleting an absent or already-deleted key is `Deleted`.
1135pub async fn delete(
1136    root: &Path,
1137    ancestor_cap: usize,
1138    namespace: &str,
1139    collection: &str,
1140    id: &str,
1141    expected: Option<Revision>,
1142) -> Result<DeleteOutcome> {
1143    let store = open_store(root, ancestor_cap)?;
1144    let key = RecordKey::new(namespace, collection, id);
1145    let author = Some(Identity::new(CLI_AUTHOR));
1146    Ok(match store.delete_as(&key, expected, author).await? {
1147        gonzalo_core::DeleteResult::Deleted => DeleteOutcome::Deleted,
1148        gonzalo_core::DeleteResult::Conflict(conflict) => DeleteOutcome::Conflict {
1149            current: conflict.current.revision,
1150        },
1151    })
1152}
1153
1154/// Restore the record at `namespace/collection/id` from its tombstone.
1155///
1156/// The restore is attributed to [`CLI_AUTHOR`], the operator restoring it,
1157/// rather than whoever deleted the record.
1158pub async fn undelete(
1159    root: &Path,
1160    ancestor_cap: usize,
1161    namespace: &str,
1162    collection: &str,
1163    id: &str,
1164) -> Result<Revision> {
1165    let store = open_store(root, ancestor_cap)?;
1166    let key = RecordKey::new(namespace, collection, id);
1167    let author = Identity::new(CLI_AUTHOR);
1168    Ok(gonzalo_core::undelete(&store, &key, gonzalo_core::now_ms(), Some(&author)).await?)
1169}
1170
1171/// Physically remove the tombstone at `namespace/collection/id`, discarding the
1172/// restore window it holds.
1173///
1174/// Refuses anything that is not a tombstone. [`gonzalo_core::plan_purge`] does
1175/// not check the record's kind: it removes whatever matches the revision, so
1176/// without this check an operator could physically delete a live record
1177/// leaving no tombstone, which a peer that had not synced since would
1178/// resurrect (ADR 0021). Core keeps its contract because `collect` depends on
1179/// it; the check belongs here, at the operator surface (ADR 0030).
1180///
1181/// Benign race: a concurrent `collect` that purges the same key first makes
1182/// the plan a no-op, which `Store::purge` reports as success, so this can
1183/// print `purged:` for a tombstone it did not itself remove. That is
1184/// `Store::purge`'s documented idempotency.
1185pub async fn purge(
1186    root: &Path,
1187    ancestor_cap: usize,
1188    namespace: &str,
1189    collection: &str,
1190    id: &str,
1191) -> Result<Revision> {
1192    let store = open_store(root, ancestor_cap)?;
1193    let key = RecordKey::new(namespace, collection, id);
1194    let Some(record) = store.get_raw(&key).await? else {
1195        anyhow::bail!("record not found: {key}");
1196    };
1197    if !record.is_tombstone() {
1198        anyhow::bail!(
1199            "{key} is live at revision {}; purge only discards an existing tombstone. \
1200             To remove the record use `gonzalo delete`: it keeps a restore window \
1201             for manifest kinds, and purging that tombstone would discard it.",
1202            record.revision.counter
1203        );
1204    }
1205    let revision = record.revision.clone();
1206    // Exits 1, not `EXIT_CONFLICT`: purge takes no `--expected`, so there is no
1207    // stale input for a caller to refresh. The revision moved between our read
1208    // and the purge (a concurrent `undelete`, or a create over a non-manifest
1209    // tombstone), and re-running re-reads it.
1210    if let gonzalo_core::DeleteResult::Conflict(_) = store.purge(&key, revision.clone()).await? {
1211        anyhow::bail!("{key} changed while purging; nothing was removed, retry");
1212    }
1213    Ok(revision)
1214}
1215
1216/// Tombstone every live record in `namespace` (optionally one `collection`)
1217/// via [`gonzalo_core::reset_as`], attributing each tombstone to
1218/// [`CLI_AUTHOR`].
1219pub async fn reset(
1220    root: &Path,
1221    ancestor_cap: usize,
1222    namespace: String,
1223    collection: Option<String>,
1224) -> Result<gonzalo_core::ResetReport> {
1225    let store = open_store(root, ancestor_cap)?;
1226    let prefix = KeyPrefix {
1227        namespace: Some(namespace),
1228        collection,
1229    };
1230    Ok(gonzalo_core::reset_as(&store, &prefix, Some(Identity::new(CLI_AUTHOR))).await?)
1231}
1232
1233/// Exit code for a write conflict (`delete` with a stale `--expected`, or
1234/// `reset` leaving any record live). Distinct from 1 (error, via anyhow) and
1235/// 2 (usage error, via clap), so automation can tell "retry" from "broken"
1236/// (spec §3.10).
1237pub const EXIT_CONFLICT: u8 = 3;
1238
1239/// The process exit code for a finished `reset`: 0 with no conflicts,
1240/// [`EXIT_CONFLICT`] otherwise (re-run to finish, spec §3.10).
1241pub fn reset_exit_code(report: &gonzalo_core::ResetReport) -> u8 {
1242    if report.conflicts.is_empty() {
1243        0
1244    } else {
1245        EXIT_CONFLICT
1246    }
1247}
1248
1249/// Purge tombstones at least `horizon` old via [`gonzalo_core::collect`].
1250/// `namespace == None` collects across the whole store.
1251pub async fn collect(
1252    root: &Path,
1253    ancestor_cap: usize,
1254    namespace: Option<String>,
1255    collection: Option<String>,
1256    horizon: Duration,
1257    now_ms: i64,
1258) -> Result<gonzalo_core::CollectReport> {
1259    let store = open_store(root, ancestor_cap)?;
1260    let prefix = KeyPrefix {
1261        namespace,
1262        collection,
1263    };
1264    Ok(gonzalo_core::collect(&store, &prefix, horizon, now_ms).await?)
1265}
1266
1267/// A collection horizon as the operator typed it, plus its parsed value, so
1268/// command output can echo the horizon it used (spec §3.10, §8.2).
1269#[derive(Clone, Debug, PartialEq, Eq)]
1270pub struct Horizon {
1271    pub raw: String,
1272    pub duration: Duration,
1273}
1274
1275/// Parse a duration of one positive whole number followed by exactly one unit:
1276/// `d`, `h`, `m` or `s` (e.g. `30d`, `12h`). Hand-rolled on purpose. The
1277/// workspace has no `humantime`, and a horizon never needs compound or
1278/// fractional forms.
1279pub fn parse_duration(raw: &str) -> std::result::Result<Duration, String> {
1280    let s = raw.trim();
1281    let split = s
1282        .find(|c: char| !c.is_ascii_digit())
1283        .ok_or_else(|| format!("{raw:?}: missing unit (use d, h, m or s, e.g. 30d)"))?;
1284    let (digits, unit) = s.split_at(split);
1285    if digits.is_empty() {
1286        return Err(format!(
1287            "{raw:?}: expected a number before the unit, e.g. 30d"
1288        ));
1289    }
1290    let per_unit: u64 = match unit {
1291        "d" => 86_400,
1292        "h" => 3_600,
1293        "m" => 60,
1294        "s" => 1,
1295        _ => return Err(format!("{raw:?}: unknown unit {unit:?} (use d, h, m or s)")),
1296    };
1297    let n: u64 = digits
1298        .parse()
1299        .map_err(|_| format!("{raw:?}: duration is too large"))?;
1300    if n == 0 {
1301        return Err(format!("{raw:?}: horizon must be greater than zero"));
1302    }
1303    let secs = n
1304        .checked_mul(per_unit)
1305        .ok_or_else(|| format!("{raw:?}: duration is too large"))?;
1306    Ok(Duration::from_secs(secs))
1307}
1308
1309/// clap value parser for `--older-than`.
1310pub fn parse_horizon(raw: &str) -> std::result::Result<Horizon, String> {
1311    Ok(Horizon {
1312        raw: raw.trim().to_string(),
1313        duration: parse_duration(raw)?,
1314    })
1315}
1316
1317/// clap value parser for `--expected`: a revision as JSON, exactly as
1318/// `gonzalo get` prints it, e.g. `{"counter":1,"hash":"…"}`.
1319pub fn parse_revision(raw: &str) -> std::result::Result<Revision, String> {
1320    serde_json::from_str(raw).map_err(|e| {
1321        format!(r#"expected a revision as JSON, e.g. {{"counter":1,"hash":"…"}} ({e})"#)
1322    })
1323}
1324
1325// ─── sync_stores ─────────────────────────────────────────────────────────────
1326
1327/// Summary returned by [`sync_stores`].
1328#[derive(Clone, Debug, Default, PartialEq, Eq)]
1329pub struct SyncSummary {
1330    pub copied_to_a: usize,
1331    pub copied_to_b: usize,
1332    /// Keys where one store was behind the other's revision chain and was
1333    /// overwritten (both directions, tombstones included).
1334    pub fast_forwarded: usize,
1335    pub merged: usize,
1336    /// The keys sync could not reconcile, so the caller can print them rather
1337    /// than only a count (gonzalo#299).
1338    pub conflicts: Vec<RecordKey>,
1339    /// Keys still racing when sync gave up after its pass limit. Non-empty means
1340    /// the stores may still disagree on them even with no conflicts, so the run
1341    /// is not proof of convergence (gonzalo#290).
1342    pub unconverged: Vec<RecordKey>,
1343}
1344
1345/// The process exit code for a finished `sync`: 0 when it converged with no
1346/// conflicts, [`EXIT_CONFLICT`] when it reports conflicts to resolve or gave up
1347/// before converging. Both are recoverable outcomes a script retries or acts on,
1348/// matching `delete`, `reset` and `collect` (#290).
1349pub fn sync_exit_code(summary: &SyncSummary) -> u8 {
1350    if summary.conflicts.is_empty() && summary.unconverged.is_empty() {
1351        0
1352    } else {
1353        EXIT_CONFLICT
1354    }
1355}
1356
1357/// Sync two filesystem stores via [`gonzalo_core::sync`], at the default
1358/// ancestor cap.
1359pub async fn sync_stores(a: &Path, b: &Path) -> Result<SyncSummary> {
1360    sync_stores_with_cap(a, b, gonzalo_core::DEFAULT_ANCESTOR_CAP).await
1361}
1362
1363/// [`sync_stores`] with an explicit ancestor cap for both stores (spec §3.9).
1364pub async fn sync_stores_with_cap(a: &Path, b: &Path, ancestor_cap: usize) -> Result<SyncSummary> {
1365    let store_a = open_store(a, ancestor_cap)?;
1366    let store_b = open_store(b, ancestor_cap)?;
1367    let report = gonzalo_core::sync(&store_a, &store_b).await?;
1368    Ok(SyncSummary {
1369        copied_to_a: report.copied_to_a.len(),
1370        copied_to_b: report.copied_to_b.len(),
1371        fast_forwarded: report.fast_forwarded_to_a.len() + report.fast_forwarded_to_b.len(),
1372        merged: report.merged.len(),
1373        conflicts: report.conflicts.into_iter().map(|c| c.key).collect(),
1374        unconverged: report.unconverged,
1375    })
1376}
1377
1378// ─── ticket sync ───────────────────────────────────────────────────────────
1379
1380/// Per-connection ingest result.
1381pub struct TicketSyncReport {
1382    pub connection: String,
1383    pub summary: IngestSummary,
1384}
1385
1386/// Load the ticket config, build each connection's source, and ingest its
1387/// tickets into the fs store at `root`.
1388pub async fn ticket_sync(
1389    config_path: &Path,
1390    root: &Path,
1391    author: &str,
1392) -> Result<Vec<TicketSyncReport>> {
1393    let config = Config::load(config_path).context("loading ticket config")?;
1394    let store = FsStore::new(root);
1395    let mut reports = Vec::new();
1396    for (name, source) in config.sources().context("building ticket sources")? {
1397        // Scope each record key by connection name so the same issue on two
1398        // boards yields two distinct records rather than colliding (#159).
1399        let summary = gonzalo_ticket::ingest(source.as_ref(), &store, author, Some(&name))
1400            .await
1401            .with_context(|| format!("syncing connection {name}"))?;
1402        reports.push(TicketSyncReport {
1403            connection: name,
1404            summary,
1405        });
1406    }
1407    Ok(reports)
1408}
1409
1410// ─── ticket move ─────────────────────────────────────────────────────────────
1411
1412/// Move a board card to the column for `category`. Selects the connection named
1413/// `connection`, or the sole connection if there is exactly one.
1414pub async fn ticket_move(
1415    config_path: &Path,
1416    connection: Option<&str>,
1417    uid: &str,
1418    category: &str,
1419) -> Result<()> {
1420    let cat = parse_category(category)
1421        .ok_or_else(|| anyhow::anyhow!("unknown state category {category:?}"))?;
1422    let config = Config::load(config_path).context("loading ticket config")?;
1423    let conn = select_connection(&config.connections, connection)?;
1424    let source = gonzalo_ticket_config::build_source(conn).context("building ticket source")?;
1425    source
1426        .set_state(uid, cat)
1427        .await
1428        .with_context(|| format!("moving {uid} to {category}"))?;
1429    Ok(())
1430}
1431
1432/// Pick the requested connection by name, or the only one if unambiguous.
1433fn select_connection<'a>(
1434    connections: &'a [Connection],
1435    name: Option<&str>,
1436) -> Result<&'a Connection> {
1437    match name {
1438        Some(n) => connections
1439            .iter()
1440            .find(|c| c.name == n)
1441            .ok_or_else(|| anyhow::anyhow!("no connection named {n:?}")),
1442        None => match connections {
1443            [one] => Ok(one),
1444            [] => Err(anyhow::anyhow!("no connections configured")),
1445            _ => Err(anyhow::anyhow!(
1446                "multiple connections configured; pass --connection <name>"
1447            )),
1448        },
1449    }
1450}
1451
1452// ─── Tests ────────────────────────────────────────────────────────────────────
1453
1454#[cfg(test)]
1455mod tests {
1456
1457    /// `IndexOptions::default()` must not mean "sweep everything immediately".
1458    ///
1459    /// `gc_after` is off by default, but a caller who turns it on and leaves
1460    /// `min_age` alone would otherwise reinstate exactly the age-unaware sweep
1461    /// #325 removed — blobs deleted out from under a writer about to reference
1462    /// them. The default is the same floor `gc` uses.
1463    #[test]
1464    fn default_index_options_keep_the_gc_grace_period() {
1465        let opts = IndexOptions::default();
1466        assert!(!opts.gc_after, "a sweep is opt-in");
1467        assert_eq!(
1468            opts.min_age,
1469            gonzalo_core::DEFAULT_MIN_AGE,
1470            "a defaulted min_age of zero sweeps blobs a writer is about to reference"
1471        );
1472    }
1473    use super::*;
1474    use gonzalo_graph::GraphStore;
1475    use tempfile::TempDir;
1476
1477    fn write_file(dir: &Path, name: &str, contents: &str) {
1478        std::fs::write(dir.join(name), contents).unwrap();
1479    }
1480
1481    // The real `index`/`index_with`/`index_with_gc` resolve a parse worker from
1482    // the environment, and since #212 that includes `PATH` — so on any machine
1483    // with `gonzalo-parse-worker` installed these tests would silently measure
1484    // that binary instead of this build. (Concretely: a released worker predates
1485    // `--extraction-version`, so #228 reads it as unknown and every run becomes a
1486    // full walk, and the incremental assertions below fail for reasons that have
1487    // nothing to do with what they test.)
1488    //
1489    // These shadow the real entry points with parsing pinned in-process, so a
1490    // test asserts about the code under test and nothing ambient. Worker
1491    // resolution itself is covered directly, against `resolve_worker_from`.
1492
1493    async fn index(root: &Path, src: &Path, repo: &str, view: &str) -> Result<IndexSummary> {
1494        index_with(root, src, repo, view, &IndexFilter::default()).await
1495    }
1496
1497    async fn index_with(
1498        root: &Path,
1499        src: &Path,
1500        repo: &str,
1501        view: &str,
1502        filter: &IndexFilter,
1503    ) -> Result<IndexSummary> {
1504        index_with_worker(root, src, repo, view, filter, None).await
1505    }
1506
1507    async fn index_with_gc(
1508        root: &Path,
1509        src: &Path,
1510        repo: &str,
1511        view: &str,
1512        gc_after: bool,
1513        min_age: Duration,
1514    ) -> Result<(IndexSummary, Option<GcSummary>)> {
1515        index_with_options(
1516            root,
1517            src,
1518            repo,
1519            view,
1520            &IndexOptions {
1521                gc_after,
1522                min_age,
1523                ..IndexOptions::default()
1524            },
1525        )
1526        .await
1527    }
1528
1529    // ── migrate: basic import ────────────────────────────────────────────────
1530
1531    #[tokio::test]
1532    async fn migrate_imports_two_files() {
1533        let root = TempDir::new().unwrap();
1534        let src = TempDir::new().unwrap();
1535
1536        write_file(src.path(), "alpha.md", "hello alpha");
1537        write_file(src.path(), "beta.md", "hello beta");
1538
1539        let summary = migrate(
1540            root.path(),
1541            src.path(),
1542            "testns",
1543            "testcol",
1544            RecordKind::Topic,
1545        )
1546        .await
1547        .unwrap();
1548
1549        assert_eq!(summary.imported, 2, "should have imported 2 files");
1550        assert_eq!(summary.skipped, 0, "nothing should be skipped yet");
1551    }
1552
1553    // ── list: shows the right keys after migrate ─────────────────────────────
1554
1555    #[tokio::test]
1556    async fn list_returns_migrated_keys() {
1557        let root = TempDir::new().unwrap();
1558        let src = TempDir::new().unwrap();
1559
1560        write_file(src.path(), "alpha.md", "hello alpha");
1561        write_file(src.path(), "beta.md", "hello beta");
1562
1563        migrate(
1564            root.path(),
1565            src.path(),
1566            "testns",
1567            "testcol",
1568            RecordKind::Topic,
1569        )
1570        .await
1571        .unwrap();
1572
1573        let keys = list(root.path(), None, None).await.unwrap();
1574        assert_eq!(keys.len(), 2);
1575    }
1576
1577    // ── migrate: idempotent on second run ────────────────────────────────────
1578
1579    #[tokio::test]
1580    async fn migrate_is_idempotent() {
1581        let root = TempDir::new().unwrap();
1582        let src = TempDir::new().unwrap();
1583
1584        write_file(src.path(), "alpha.md", "hello alpha");
1585        write_file(src.path(), "beta.md", "hello beta");
1586
1587        migrate(
1588            root.path(),
1589            src.path(),
1590            "testns",
1591            "testcol",
1592            RecordKind::Topic,
1593        )
1594        .await
1595        .unwrap();
1596
1597        let second = migrate(
1598            root.path(),
1599            src.path(),
1600            "testns",
1601            "testcol",
1602            RecordKind::Topic,
1603        )
1604        .await
1605        .unwrap();
1606
1607        assert_eq!(
1608            second.skipped, 2,
1609            "second run should skip both already-imported files"
1610        );
1611        assert_eq!(second.imported, 0, "second run should import nothing new");
1612    }
1613
1614    // ── get: round-trips body ────────────────────────────────────────────────
1615
1616    #[tokio::test]
1617    async fn get_returns_migrated_record_body() {
1618        let root = TempDir::new().unwrap();
1619        let src = TempDir::new().unwrap();
1620
1621        write_file(src.path(), "alpha.md", "hello alpha");
1622
1623        migrate(
1624            root.path(),
1625            src.path(),
1626            "testns",
1627            "testcol",
1628            RecordKind::Topic,
1629        )
1630        .await
1631        .unwrap();
1632
1633        // The id is the source-relative path verbatim.
1634        let record = get(root.path(), "testns", "testcol", "alpha.md")
1635            .await
1636            .unwrap();
1637
1638        assert!(record.is_some(), "record should be present");
1639        let body = record.unwrap().body;
1640        assert_eq!(body.bytes(), b"hello alpha");
1641    }
1642
1643    // ── status: correct namespace/collection count ───────────────────────────
1644
1645    #[tokio::test]
1646    async fn status_groups_by_ns_col() {
1647        let root = TempDir::new().unwrap();
1648        let src = TempDir::new().unwrap();
1649
1650        write_file(src.path(), "alpha.md", "hello alpha");
1651        write_file(src.path(), "beta.md", "hello beta");
1652
1653        migrate(
1654            root.path(),
1655            src.path(),
1656            "testns",
1657            "testcol",
1658            RecordKind::Topic,
1659        )
1660        .await
1661        .unwrap();
1662
1663        let map = status(root.path()).await.unwrap();
1664        assert_eq!(map.get("testns/testcol").copied(), Some(2));
1665    }
1666
1667    // ── sync_stores: propagates records ─────────────────────────────────────
1668
1669    #[tokio::test]
1670    async fn sync_stores_copies_to_b() {
1671        let store_a = TempDir::new().unwrap();
1672        let store_b = TempDir::new().unwrap();
1673        let src = TempDir::new().unwrap();
1674
1675        write_file(src.path(), "note.md", "synced content");
1676
1677        // Import only into store A.
1678        migrate(
1679            store_a.path(),
1680            src.path(),
1681            "testns",
1682            "testcol",
1683            RecordKind::Topic,
1684        )
1685        .await
1686        .unwrap();
1687
1688        let summary = sync_stores(store_a.path(), store_b.path()).await.unwrap();
1689        assert_eq!(summary.copied_to_b, 1);
1690
1691        // Store B should now have the key.
1692        let keys = list(store_b.path(), None, None).await.unwrap();
1693        assert_eq!(keys.len(), 1);
1694    }
1695
1696    #[tokio::test]
1697    async fn sync_stores_fast_forwards_a_peer_that_is_behind() {
1698        let store_a = TempDir::new().unwrap();
1699        let store_b = TempDir::new().unwrap();
1700        let src = TempDir::new().unwrap();
1701        write_file(src.path(), "note.md", "synced content");
1702        migrate(
1703            store_a.path(),
1704            src.path(),
1705            "testns",
1706            "testcol",
1707            RecordKind::Topic,
1708        )
1709        .await
1710        .unwrap();
1711        let _ = sync_stores(store_a.path(), store_b.path()).await.unwrap();
1712
1713        // A edits the record; B is now simply behind.
1714        let fs_a = FsStore::new(store_a.path());
1715        let key = fs_a.list(&KeyPrefix::default()).await.unwrap().remove(0);
1716        let cur = fs_a.get(&key).await.unwrap().unwrap();
1717        let mut next = cur.clone();
1718        next.body = Body::Inline(b"synced content\nmore\n".to_vec());
1719        next.revision = cur.revision.next(next.body.bytes());
1720        next.parent = Some(cur.revision.clone());
1721        next.ancestors = Vec::new();
1722        assert!(matches!(
1723            fs_a.put(next, Some(cur.revision)).await.unwrap(),
1724            PutResult::Committed(_)
1725        ));
1726
1727        let summary = sync_stores(store_a.path(), store_b.path()).await.unwrap();
1728        assert_eq!(summary.fast_forwarded, 1);
1729        assert_eq!(summary.merged, 0);
1730        assert!(summary.conflicts.is_empty());
1731        assert!(summary.unconverged.is_empty());
1732    }
1733
1734    // ── ticket_sync: empty config → no reports ───────────────────────────────
1735
1736    #[tokio::test]
1737    async fn ticket_sync_with_no_connections_returns_no_reports() {
1738        let root = TempDir::new().unwrap();
1739        let cfg = TempDir::new().unwrap();
1740        let cfg_path = cfg.path().join("tickets.toml");
1741        std::fs::write(&cfg_path, "").unwrap(); // empty config = zero connections
1742
1743        let reports = ticket_sync(&cfg_path, root.path(), "tester").await.unwrap();
1744        assert!(reports.is_empty());
1745    }
1746
1747    // ── ticket_move: unknown category errors before any network call ─────────
1748
1749    #[tokio::test]
1750    async fn ticket_move_unknown_category_errors() {
1751        let cfg = TempDir::new().unwrap();
1752        let cfg_path = cfg.path().join("tickets.toml");
1753        std::fs::write(
1754            &cfg_path,
1755            "[[connection]]\nname=\"b\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=1\ntoken_env=\"X\"\n",
1756        )
1757        .unwrap();
1758        // "frozen" is not a valid category → error before any network call.
1759        let err = ticket_move(&cfg_path, None, "o/r#1", "frozen")
1760            .await
1761            .unwrap_err();
1762        assert!(err.to_string().contains("category"), "got {err}");
1763    }
1764
1765    // ── ticket_move: ambiguous connection requires --connection ─────────────
1766
1767    #[tokio::test]
1768    async fn ticket_move_requires_connection_when_many() {
1769        let cfg = TempDir::new().unwrap();
1770        let cfg_path = cfg.path().join("tickets.toml");
1771        std::fs::write(
1772            &cfg_path,
1773            "[[connection]]\nname=\"a\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=1\ntoken_env=\"X\"\n\
1774             [[connection]]\nname=\"b\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=2\ntoken_env=\"Y\"\n",
1775        )
1776        .unwrap();
1777        let err = ticket_move(&cfg_path, None, "o/r#1", "done")
1778            .await
1779            .unwrap_err();
1780        assert!(err.to_string().contains("connection"), "got {err}");
1781    }
1782
1783    // ── index: build a queryable view from a source tree ─────────────────────
1784
1785    // Review Focus 3: the ergonomic regression this design accepts must be a
1786    // clean refusal with guidance, not a panic and not a half-written
1787    // manifest. This is the graph-indexer half of #333's hazard: `index`
1788    // derives `expected` from a consumer `get`, which hides the tombstone.
1789    #[tokio::test]
1790    async fn index_over_a_deleted_manifest_is_refused_with_guidance() {
1791        let root = TempDir::new().unwrap();
1792        let src = TempDir::new().unwrap();
1793        write_file(src.path(), "a.rs", "fn a() {}");
1794
1795        index(root.path(), src.path(), "r", "main").await.unwrap();
1796
1797        // Delete the view's manifest, as `gonzalo delete` or `reset` would.
1798        let store = open_store(root.path(), gonzalo_core::DEFAULT_ANCESTOR_CAP).unwrap();
1799        let key = Manifest::key("r", "main");
1800        assert_eq!(
1801            store.delete(&key, None).await.unwrap(),
1802            gonzalo_core::DeleteResult::Deleted
1803        );
1804        let tomb = store.get_raw(&key).await.unwrap().expect("tombstone");
1805        let retained = tomb.body.clone();
1806
1807        // Re-indexing must refuse rather than overwrite the restore window.
1808        let err = index(root.path(), src.path(), "r", "main")
1809            .await
1810            .err()
1811            .expect("indexing over a deleted manifest must be refused");
1812        let msg = format!("{err:#}");
1813        assert!(
1814            msg.contains("gonzalo undelete") && msg.contains("gonzalo purge"),
1815            "the refusal must tell the operator both ways out, got {msg}"
1816        );
1817
1818        // The manifest record is untouched, so the window is intact (orphan slice
1819        // blobs are gc'd).
1820        let after = store
1821            .get_raw(&key)
1822            .await
1823            .unwrap()
1824            .expect("tombstone survives");
1825        assert!(after.is_tombstone());
1826        assert_eq!(after.body, retained, "the retained body was not touched");
1827
1828        // Follow the refusal's own advice: purge the tombstone, then the
1829        // indexer is unblocked. Nothing else asserts this for a key reached
1830        // through `index`.
1831        purge(
1832            root.path(),
1833            gonzalo_core::DEFAULT_ANCESTOR_CAP,
1834            &key.namespace,
1835            &key.collection,
1836            &key.id,
1837        )
1838        .await
1839        .unwrap();
1840        index(root.path(), src.path(), "r", "main")
1841            .await
1842            .expect("indexing succeeds once the tombstone is purged");
1843        let live = store.get_raw(&key).await.unwrap().expect("manifest");
1844        assert!(!live.is_tombstone(), "the view is live again");
1845    }
1846
1847    #[tokio::test]
1848    async fn index_builds_a_queryable_view() {
1849        let root = TempDir::new().unwrap();
1850        let src = TempDir::new().unwrap();
1851        write_file(src.path(), "a.rs", "fn helper() {}");
1852        std::fs::create_dir(src.path().join("sub")).unwrap();
1853        write_file(src.path(), "sub/b.rs", "fn caller() { helper(); }");
1854
1855        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1856        assert_eq!(summary.files, 2);
1857        assert_eq!(summary.added, 2);
1858        assert_eq!(summary.modified, 0);
1859        assert_eq!(summary.deleted, 0);
1860
1861        // The indexed view assembles and answers real queries.
1862        let store = FsStore::new(root.path());
1863        let manifest = Manifest::from_body(
1864            &store
1865                .get(&Manifest::key("r", "main"))
1866                .await
1867                .unwrap()
1868                .unwrap()
1869                .body,
1870        )
1871        .unwrap();
1872        let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1873        let defs = graph.definitions("helper");
1874        assert_eq!(defs.len(), 1);
1875        assert_eq!(defs[0].path, "a.rs");
1876        assert_eq!(graph.callers_of("helper"), vec!["caller".to_string()]);
1877        assert!(
1878            graph
1879                .symbols_in_file("sub/b.rs")
1880                .iter()
1881                .any(|s| s.name == "caller")
1882        );
1883    }
1884
1885    #[tokio::test]
1886    async fn reindex_reports_modifications_and_deletions() {
1887        let root = TempDir::new().unwrap();
1888        let src = TempDir::new().unwrap();
1889        write_file(src.path(), "keep.rs", "fn keep() {}");
1890        write_file(src.path(), "gone.rs", "fn gone() {}");
1891        index(root.path(), src.path(), "r", "v").await.unwrap();
1892
1893        // Change one file, remove another.
1894        write_file(src.path(), "keep.rs", "fn keep() { extra(); }");
1895        std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1896
1897        let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1898        assert_eq!(summary.files, 1);
1899        assert_eq!(summary.added, 0);
1900        assert_eq!(summary.modified, 1);
1901        assert_eq!(summary.deleted, 1);
1902    }
1903
1904    #[tokio::test]
1905    async fn index_skips_non_rust_and_build_dirs() {
1906        let root = TempDir::new().unwrap();
1907        let src = TempDir::new().unwrap();
1908        write_file(src.path(), "real.rs", "fn real() {}");
1909        write_file(src.path(), "notes.txt", "not source");
1910        std::fs::create_dir(src.path().join("target")).unwrap();
1911        write_file(src.path(), "target/gen.rs", "fn generated() {}");
1912
1913        let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1914        assert_eq!(summary.files, 1, "only real.rs is indexed");
1915        assert_eq!(summary.added, 1);
1916    }
1917
1918    #[tokio::test]
1919    async fn index_handles_multiple_languages() {
1920        let root = TempDir::new().unwrap();
1921        let src = TempDir::new().unwrap();
1922        write_file(src.path(), "lib.rs", "fn rust_fn() {}");
1923        write_file(src.path(), "app.py", "def py_fn():\n    pass\n");
1924
1925        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1926        assert_eq!(summary.files, 2, "both the .rs and .py file are indexed");
1927
1928        // Both languages' symbols are queryable in the assembled view.
1929        let store = FsStore::new(root.path());
1930        let manifest = Manifest::from_body(
1931            &store
1932                .get(&Manifest::key("r", "main"))
1933                .await
1934                .unwrap()
1935                .unwrap()
1936                .body,
1937        )
1938        .unwrap();
1939        let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1940        assert_eq!(graph.definitions("rust_fn")[0].path, "lib.rs");
1941        assert_eq!(graph.definitions("py_fn")[0].path, "app.py");
1942    }
1943
1944    #[tokio::test]
1945    async fn index_writes_a_queryable_sqlite_graph() {
1946        let root = TempDir::new().unwrap();
1947        let src = TempDir::new().unwrap();
1948        write_file(
1949            src.path(),
1950            "lib.rs",
1951            "fn helper() {}\nfn main() { helper(); }",
1952        );
1953        index(root.path(), src.path(), "r", "main").await.unwrap();
1954
1955        // The persistent per-view graph exists under <root>/graphs and answers
1956        // queries without re-assembly.
1957        let db = view_db_path(&root.path().join("graphs"), "r", "main");
1958        let g = SqliteGraphStore::open(&db).unwrap();
1959        assert_eq!(g.definitions("helper")[0].path, "lib.rs");
1960        assert_eq!(g.callers_of("helper"), vec!["main".to_string()]);
1961    }
1962
1963    #[tokio::test]
1964    async fn reindex_removes_deleted_paths_from_the_sqlite_graph() {
1965        let root = TempDir::new().unwrap();
1966        let src = TempDir::new().unwrap();
1967        write_file(src.path(), "keep.rs", "fn keep() {}");
1968        write_file(src.path(), "gone.rs", "fn gone() {}");
1969        index(root.path(), src.path(), "r", "v").await.unwrap();
1970
1971        std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1972        index(root.path(), src.path(), "r", "v").await.unwrap();
1973
1974        let g =
1975            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "v")).unwrap();
1976        assert_eq!(g.definitions("keep").len(), 1);
1977        assert!(
1978            g.definitions("gone").is_empty(),
1979            "deleted file's symbols must be gone from the graph"
1980        );
1981    }
1982
1983    // ── index: manifest commit precedes the SQLite graph write (gonzalo#153) ──
1984
1985    #[tokio::test]
1986    async fn build_desired_stages_graph_writes_without_touching_the_store() {
1987        // The reordered control flow (#153): parsing/desired-set construction
1988        // must only *stage* persistent-graph mutations. The SqliteGraphStore is
1989        // advanced solely by GraphStaging::apply — which index() calls strictly
1990        // after the manifest commit — so a manifest Conflict leaves the graph
1991        // untouched instead of advanced ahead of an uncommitted manifest.
1992        let root = TempDir::new().unwrap();
1993        let src = TempDir::new().unwrap();
1994        write_file(src.path(), "a.rs", "fn a() {}");
1995
1996        let store = FsStore::new(root.path());
1997        let db_path = view_db_path(&root.path().join("graphs"), "r", "main");
1998        let mut graph = SqliteGraphStore::open(&db_path).unwrap();
1999
2000        let mut staging = GraphStaging::default();
2001        let DesiredView {
2002            entries: desired,
2003            files,
2004            incremental,
2005            ..
2006        } = build_desired_full(
2007            &store,
2008            &mut staging,
2009            None,
2010            src.path(),
2011            &IndexFilter::default(),
2012        )
2013        .await
2014        .unwrap();
2015        assert!(!incremental);
2016        assert_eq!(files, 1);
2017        assert_eq!(desired.len(), 1);
2018        assert_eq!(staging.inserts.len(), 1, "the write is staged, not applied");
2019
2020        // Nothing has reached the persistent graph yet — this is the state after
2021        // a manifest Conflict would `bail!`.
2022        assert!(
2023            graph.definitions("a").is_empty(),
2024            "SqliteGraphStore must be untouched until the manifest commits"
2025        );
2026
2027        // Applying the staged writes (index()'s post-commit step) advances it.
2028        staging.apply(&mut graph);
2029        assert_eq!(graph.definitions("a")[0].path, "a.rs");
2030    }
2031
2032    // ── index: git-driven incremental sync (gonzalo#93) ──────────────────────
2033
2034    /// Init a git repo at `dir` and commit every current file.
2035    fn git_init_commit(dir: &Path) {
2036        let repo = git2::Repository::init(dir).unwrap();
2037        let mut index = repo.index().unwrap();
2038        index
2039            .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
2040            .unwrap();
2041        index.write().unwrap();
2042        let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
2043        let sig = git2::Signature::now("t", "t@localhost").unwrap();
2044        repo.commit(Some("HEAD"), &sig, &sig, "c", &tree, &[])
2045            .unwrap();
2046    }
2047
2048    // ── the parse-version warning says the right thing (gonzalo#239) ─────────
2049
2050    #[test]
2051    fn an_unknown_worker_version_does_not_claim_the_extraction_is_wrong() {
2052        // Every worker released before #228 answers nothing while very likely
2053        // producing current extraction. Telling that user to upgrade "to get
2054        // version N extraction" asserts their data is wrong. It is not — it is
2055        // unconfirmed, which is a different thing and the whole point here.
2056        let msg = worker_version_warning(0);
2057        assert!(msg.contains("did not report"), "{msg}");
2058        assert!(msg.contains("cannot be confirmed"), "{msg}");
2059        assert!(
2060            !msg.contains("WARNING"),
2061            "unconfirmed is a note, not a warning: {msg}"
2062        );
2063        assert!(
2064            !msg.contains(&format!("to get {EXTRACTION_VERSION} extraction")),
2065            "must not claim the recorded extraction is wrong: {msg}"
2066        );
2067    }
2068
2069    #[test]
2070    fn a_known_mismatch_still_warns_plainly() {
2071        // A worker that reports a *different* version genuinely disagrees about
2072        // what a parse records, so the strong wording is accurate there.
2073        let msg = worker_version_warning(1);
2074        assert!(msg.contains("WARNING"), "{msg}");
2075        assert!(msg.contains("reports extraction version 1"), "{msg}");
2076        assert!(msg.contains(&EXTRACTION_VERSION.to_string()), "{msg}");
2077    }
2078
2079    // ── index: locating the parse worker (gonzalo#212) ───────────────────────
2080
2081    /// Create an empty file at `dir/name` and return its path.
2082    fn touch(dir: &Path, name: &str) -> PathBuf {
2083        let path = dir.join(name);
2084        std::fs::write(&path, "").unwrap();
2085        path
2086    }
2087
2088    #[test]
2089    fn the_env_override_wins_over_everything_else() {
2090        let over = TempDir::new().unwrap();
2091        let beside = TempDir::new().unwrap();
2092        let on_path = TempDir::new().unwrap();
2093        let chosen = touch(over.path(), "my-worker");
2094        touch(beside.path(), worker_file_name());
2095        touch(on_path.path(), worker_file_name());
2096
2097        let mode = resolve_worker_from(
2098            Some(chosen.clone()),
2099            Some(beside.path().join("gonzalo")),
2100            &[on_path.path().to_path_buf()],
2101        );
2102        assert_eq!(
2103            mode,
2104            ParseMode::Isolated {
2105                path: chosen,
2106                source: WorkerSource::Env
2107            }
2108        );
2109    }
2110
2111    #[test]
2112    fn a_sibling_of_the_executable_is_preferred_over_path() {
2113        let beside = TempDir::new().unwrap();
2114        let on_path = TempDir::new().unwrap();
2115        let sibling = touch(beside.path(), worker_file_name());
2116        touch(on_path.path(), worker_file_name());
2117
2118        let mode = resolve_worker_from(
2119            None,
2120            Some(beside.path().join("gonzalo")),
2121            &[on_path.path().to_path_buf()],
2122        );
2123        assert_eq!(
2124            mode,
2125            ParseMode::Isolated {
2126                path: sibling,
2127                source: WorkerSource::Sibling
2128            }
2129        );
2130    }
2131
2132    #[test]
2133    fn the_worker_is_found_on_path_when_it_is_not_beside_the_executable() {
2134        // The layouts a sibling check cannot reach: a symlinked `gonzalo` on
2135        // PATH, a container that copies one binary, `cargo run` from a
2136        // workspace target dir. The worker is plainly available in all three.
2137        let beside = TempDir::new().unwrap();
2138        let empty = TempDir::new().unwrap();
2139        let on_path = TempDir::new().unwrap();
2140        let found = touch(on_path.path(), worker_file_name());
2141
2142        let mode = resolve_worker_from(
2143            None,
2144            Some(beside.path().join("gonzalo")),
2145            &[empty.path().to_path_buf(), on_path.path().to_path_buf()],
2146        );
2147        assert_eq!(
2148            mode,
2149            ParseMode::Isolated {
2150                path: found,
2151                source: WorkerSource::Path
2152            }
2153        );
2154    }
2155
2156    #[test]
2157    fn no_worker_anywhere_falls_back_and_names_every_place_it_looked() {
2158        let missing = TempDir::new().unwrap();
2159        let beside = TempDir::new().unwrap();
2160        let on_path = TempDir::new().unwrap();
2161        let override_path = missing.path().join("not-here");
2162
2163        let mode = resolve_worker_from(
2164            Some(override_path.clone()),
2165            Some(beside.path().join("gonzalo")),
2166            &[on_path.path().to_path_buf()],
2167        );
2168
2169        let ParseMode::InProcess { searched } = &mode else {
2170            panic!("expected the in-process fallback, got {mode:?}");
2171        };
2172        // A bad override is a place it looked, not a reason to stop looking.
2173        assert_eq!(
2174            searched,
2175            &[
2176                override_path,
2177                beside.path().join(worker_file_name()),
2178                on_path.path().join(worker_file_name()),
2179            ]
2180        );
2181        assert!(!mode.is_isolated());
2182        assert!(mode.worker().is_none());
2183
2184        // The warning has to be actionable: it names the binary, every path
2185        // tried, and what a grammar crash now costs.
2186        let warning = mode.warning().expect("in-process must warn");
2187        assert!(warning.contains(worker_file_name()));
2188        assert!(warning.contains(&beside.path().join(worker_file_name()).display().to_string()));
2189        assert!(warning.contains("--require-parse-worker"));
2190        assert!(warning.contains("IN-PROCESS"));
2191    }
2192
2193    #[test]
2194    fn an_isolated_mode_names_its_worker_and_never_warns() {
2195        let dir = TempDir::new().unwrap();
2196        let worker = touch(dir.path(), worker_file_name());
2197        let mode = resolve_worker_from(None, Some(dir.path().join("gonzalo")), &[]);
2198
2199        assert_eq!(mode.worker(), Some(worker.as_path()));
2200        assert!(mode.warning().is_none(), "isolation is the quiet path");
2201        let summary = mode.summary();
2202        assert!(summary.contains("isolated"));
2203        assert!(summary.contains(&worker.display().to_string()));
2204        assert!(summary.contains("beside the executable"));
2205    }
2206
2207    #[test]
2208    fn a_directory_named_like_the_worker_is_not_mistaken_for_it() {
2209        // `is_file`, not `exists` — a directory of that name on PATH would
2210        // otherwise be selected and every parse would fail to spawn.
2211        let on_path = TempDir::new().unwrap();
2212        std::fs::create_dir(on_path.path().join(worker_file_name())).unwrap();
2213        let mode = resolve_worker_from(None, None, &[on_path.path().to_path_buf()]);
2214        assert!(!mode.is_isolated());
2215    }
2216
2217    // ── index: the parse path's extraction version (gonzalo#228) ─────────────
2218
2219    /// An executable stand-in for `gonzalo-parse-worker` that reports
2220    /// `reports` as its extraction version and parses everything to an empty
2221    /// slice. Enough to drive an index run without a real grammar.
2222    fn worker_reporting(dir: &Path, name: &str, reports: Option<u32>) -> PathBuf {
2223        use std::os::unix::fs::PermissionsExt;
2224        let answer = match reports {
2225            // A pre-#228 worker ignores argv and blocks on stdin; the probe
2226            // closes stdin, so it exits silently having answered nothing.
2227            None => String::new(),
2228            Some(v) => format!("[ \"$1\" = --extraction-version ] && {{ echo {v}; exit 0; }}\n"),
2229        };
2230        let path = dir.join(name);
2231        std::fs::write(
2232            &path,
2233            format!(
2234                "#!/bin/sh\n{answer}while IFS= read -r _; do \
2235                 printf '%s\\n' '{{\"symbols\":[],\"references\":[]}}'; done\n"
2236            ),
2237        )
2238        .unwrap();
2239        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
2240        path
2241    }
2242
2243    fn fmt_of(root: &Path) -> String {
2244        let p = view_db_path(&root.join("graphs"), "r", "main").with_extension("fmt");
2245        std::fs::read_to_string(p).unwrap()
2246    }
2247
2248    #[tokio::test]
2249    async fn a_stale_worker_forces_a_full_walk_and_is_never_credited() {
2250        // The hole #228 records: EXTRACTION_VERSION is compiled into the CLI,
2251        // but the *worker* is what parses. A new CLI driving an old worker used
2252        // to emit pre-upgrade extraction and then stamp `.fmt` with the new
2253        // version — leaving the view wrong AND marked current, so no later
2254        // re-index would ever repair it.
2255        let root = TempDir::new().unwrap();
2256        let src = TempDir::new().unwrap();
2257        write_file(src.path(), "a.rs", "fn a() {}");
2258        git_init_commit(src.path());
2259        let bins = TempDir::new().unwrap();
2260
2261        // A worker that agrees with this build behaves exactly as before.
2262        let current = worker_reporting(bins.path(), "current", Some(EXTRACTION_VERSION));
2263        let filter = IndexFilter::default();
2264        index_with_worker(
2265            root.path(),
2266            src.path(),
2267            "r",
2268            "main",
2269            &filter,
2270            Some(current.as_path()),
2271        )
2272        .await
2273        .unwrap();
2274        assert_eq!(fmt_of(root.path()), EXTRACTION_VERSION.to_string());
2275        assert!(
2276            index_with_worker(
2277                root.path(),
2278                src.path(),
2279                "r",
2280                "main",
2281                &filter,
2282                Some(current.as_path())
2283            )
2284            .await
2285            .unwrap()
2286            .incremental,
2287            "a worker at the current version still indexes incrementally"
2288        );
2289
2290        // Swap in a worker one version behind — the exact upgrade-order slip.
2291        let stale = worker_reporting(bins.path(), "stale", Some(EXTRACTION_VERSION - 1));
2292        let summary = index_with_worker(
2293            root.path(),
2294            src.path(),
2295            "r",
2296            "main",
2297            &filter,
2298            Some(stale.as_path()),
2299        )
2300        .await
2301        .unwrap();
2302        assert!(
2303            !summary.incremental,
2304            "a worker that disagrees with the CLI must rebuild in full"
2305        );
2306        assert_eq!(
2307            fmt_of(root.path()),
2308            (EXTRACTION_VERSION - 1).to_string(),
2309            "and the view must be credited to what actually parsed, not to the CLI"
2310        );
2311
2312        // Because the stamp is honest, fixing the worker repairs the view on the
2313        // next run instead of leaving it silently half-upgraded forever.
2314        let after = index_with_worker(
2315            root.path(),
2316            src.path(),
2317            "r",
2318            "main",
2319            &filter,
2320            Some(current.as_path()),
2321        )
2322        .await
2323        .unwrap();
2324        assert!(!after.incremental, "restoring the worker re-walks the view");
2325        assert_eq!(fmt_of(root.path()), EXTRACTION_VERSION.to_string());
2326    }
2327
2328    #[tokio::test]
2329    async fn a_worker_that_cannot_report_its_version_is_recorded_as_unknown() {
2330        // A pre-#228 worker answers nothing, so the view is credited to 0 —
2331        // "built by a parser that would not say". Two such runs may still go
2332        // incrementally: 0 == 0 claims only that the parse path is the same
2333        // unknown one, which is true, and forcing a full walk on every index
2334        // would punish every not-yet-upgraded user (badly so under `--watch`).
2335        //
2336        // What must not happen is 0 being mistaken for this build's version —
2337        // that is the #228 failure, and the transition below is where it shows.
2338        let root = TempDir::new().unwrap();
2339        let src = TempDir::new().unwrap();
2340        write_file(src.path(), "a.rs", "fn a() {}");
2341        git_init_commit(src.path());
2342        let bins = TempDir::new().unwrap();
2343        let silent = worker_reporting(bins.path(), "silent", None);
2344        let filter = IndexFilter::default();
2345
2346        index_with_worker(
2347            root.path(),
2348            src.path(),
2349            "r",
2350            "main",
2351            &filter,
2352            Some(silent.as_path()),
2353        )
2354        .await
2355        .unwrap();
2356        assert_eq!(fmt_of(root.path()), "0", "unknown is recorded, not assumed");
2357
2358        // Installing a worker that does report repairs the view rather than
2359        // inheriting whatever the silent one left behind.
2360        let current = worker_reporting(bins.path(), "current", Some(EXTRACTION_VERSION));
2361        let healed = index_with_worker(
2362            root.path(),
2363            src.path(),
2364            "r",
2365            "main",
2366            &filter,
2367            Some(current.as_path()),
2368        )
2369        .await
2370        .unwrap();
2371        assert!(
2372            !healed.incremental,
2373            "a view built by an unknown parser must be rebuilt once a known one appears"
2374        );
2375        assert_eq!(fmt_of(root.path()), EXTRACTION_VERSION.to_string());
2376    }
2377
2378    #[tokio::test]
2379    async fn in_process_parsing_is_credited_to_this_build() {
2380        // With no worker at all the CLI parses in-process, so its own constant
2381        // does describe what was recorded (#212's fallback path).
2382        let root = TempDir::new().unwrap();
2383        let src = TempDir::new().unwrap();
2384        write_file(src.path(), "a.rs", "fn a() {}");
2385        git_init_commit(src.path());
2386
2387        index_with_worker(
2388            root.path(),
2389            src.path(),
2390            "r",
2391            "main",
2392            &IndexFilter::default(),
2393            None,
2394        )
2395        .await
2396        .unwrap();
2397        assert_eq!(fmt_of(root.path()), EXTRACTION_VERSION.to_string());
2398    }
2399
2400    #[tokio::test]
2401    async fn a_format_change_forces_a_full_walk() {
2402        // Without this, an existing view keeps pre-upgrade extraction forever:
2403        // the incremental driver carries unchanged slices forward untouched, so
2404        // a parser improvement never reaches files that did not change (#223).
2405        let root = TempDir::new().unwrap();
2406        let src = TempDir::new().unwrap();
2407        write_file(src.path(), "a.rs", "fn a() {}");
2408        git_init_commit(src.path());
2409        index(root.path(), src.path(), "r", "main").await.unwrap();
2410
2411        // A second run would normally take the incremental path...
2412        let fmt = view_db_path(&root.path().join("graphs"), "r", "main").with_extension("fmt");
2413        assert_eq!(
2414            std::fs::read_to_string(&fmt).unwrap(),
2415            EXTRACTION_VERSION.to_string()
2416        );
2417        assert!(
2418            index(root.path(), src.path(), "r", "main")
2419                .await
2420                .unwrap()
2421                .incremental
2422        );
2423
2424        // ...but not when the view was built by an older extraction format.
2425        std::fs::write(&fmt, "1").unwrap();
2426        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2427        assert!(!summary.incremental, "a format change must rebuild in full");
2428        assert_eq!(
2429            std::fs::read_to_string(&fmt).unwrap(),
2430            EXTRACTION_VERSION.to_string(),
2431            "and must record the version it rebuilt with"
2432        );
2433    }
2434
2435    #[tokio::test]
2436    async fn first_index_of_git_repo_is_full_then_reindex_is_incremental() {
2437        let root = TempDir::new().unwrap();
2438        let src = TempDir::new().unwrap();
2439        write_file(src.path(), "a.rs", "fn a() {}");
2440        write_file(src.path(), "b.rs", "fn b() {}");
2441        git_init_commit(src.path());
2442
2443        // First run: no recorded base yet → full walk, records the base.
2444        let first = index(root.path(), src.path(), "r", "main").await.unwrap();
2445        assert!(!first.incremental, "first index is a full walk");
2446        assert_eq!(first.files, 2);
2447        assert_eq!(first.added, 2);
2448
2449        // Change the working tree: modify a.rs, add untracked c.rs, leave b.rs.
2450        write_file(src.path(), "a.rs", "fn a() { helper(); }");
2451        write_file(src.path(), "c.rs", "fn c() {}");
2452
2453        let second = index(root.path(), src.path(), "r", "main").await.unwrap();
2454        assert!(second.incremental, "second index uses the git diff driver");
2455        assert_eq!(second.files, 2, "only a.rs and c.rs are re-parsed");
2456        assert_eq!(second.added, 1, "c.rs added");
2457        assert_eq!(second.modified, 1, "a.rs modified");
2458        assert_eq!(second.deleted, 0);
2459
2460        // b.rs was carried forward unchanged and is still queryable.
2461        let g =
2462            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
2463        assert_eq!(g.definitions("b")[0].path, "b.rs");
2464        assert_eq!(g.definitions("c")[0].path, "c.rs");
2465    }
2466
2467    #[tokio::test]
2468    async fn incremental_index_drops_deleted_files() {
2469        let root = TempDir::new().unwrap();
2470        let src = TempDir::new().unwrap();
2471        write_file(src.path(), "keep.rs", "fn keep() {}");
2472        write_file(src.path(), "gone.rs", "fn gone() {}");
2473        git_init_commit(src.path());
2474        index(root.path(), src.path(), "r", "v").await.unwrap();
2475
2476        std::fs::remove_file(src.path().join("gone.rs")).unwrap();
2477        let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
2478        assert!(summary.incremental);
2479        assert_eq!(summary.deleted, 1);
2480
2481        let g =
2482            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "v")).unwrap();
2483        assert_eq!(g.definitions("keep").len(), 1);
2484        assert!(g.definitions("gone").is_empty(), "deleted file is gone");
2485    }
2486
2487    #[tokio::test]
2488    async fn non_git_src_stays_full_walk() {
2489        let root = TempDir::new().unwrap();
2490        let src = TempDir::new().unwrap();
2491        write_file(src.path(), "a.rs", "fn a() {}");
2492        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2493        assert!(!summary.incremental, "a non-git tree cannot go incremental");
2494    }
2495
2496    // ── gc: sweep blobs no record references (gonzalo#94, #292) ─────────────
2497
2498    #[tokio::test]
2499    async fn gc_on_empty_store_frees_nothing() {
2500        let root = TempDir::new().unwrap();
2501        let summary = gc(root.path(), Duration::ZERO).await.unwrap();
2502        assert_eq!(summary.scanned, 0);
2503        assert_eq!(summary.freed, 0);
2504        assert_eq!(summary.retained, 0);
2505    }
2506
2507    #[tokio::test]
2508    async fn gc_frees_slices_orphaned_by_a_reindex() {
2509        let root = TempDir::new().unwrap();
2510        let src = TempDir::new().unwrap();
2511        write_file(src.path(), "a.rs", "fn a() {}");
2512        index(root.path(), src.path(), "r", "main").await.unwrap();
2513
2514        // Reindex with different content: the original slice is now orphaned.
2515        write_file(src.path(), "a.rs", "fn a() { b(); }");
2516        index(root.path(), src.path(), "r", "main").await.unwrap();
2517
2518        let summary = gc(root.path(), Duration::ZERO).await.unwrap();
2519        assert_eq!(summary.scanned, 1, "the view's manifest record");
2520        assert_eq!(summary.freed, 1, "the pre-edit slice is unreferenced");
2521        assert_eq!(summary.retained, 1, "the current slice stays");
2522
2523        // GC did not corrupt the live view.
2524        let g =
2525            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
2526        assert_eq!(g.definitions("a")[0].path, "a.rs");
2527    }
2528
2529    // The flag's default is a string clap parses at startup. A typo here is a
2530    // runtime failure on every `gonzalo gc`, which no other test would catch.
2531    #[test]
2532    fn the_default_min_age_spelling_parses_to_an_hour() {
2533        assert_eq!(
2534            parse_duration("1h").unwrap(),
2535            std::time::Duration::from_secs(3600)
2536        );
2537        assert_eq!(parse_duration("1h").unwrap(), gonzalo_core::DEFAULT_MIN_AGE);
2538    }
2539
2540    #[tokio::test]
2541    async fn gc_defers_a_blob_younger_than_min_age_and_counts_it() {
2542        let dir = tempfile::tempdir().unwrap();
2543        let store = FsStore::new(dir.path());
2544        store.put_blob(b"brand new").await.unwrap();
2545
2546        // The default horizon: nothing written moments ago is collectable.
2547        let held = gc(dir.path(), gonzalo_core::DEFAULT_MIN_AGE).await.unwrap();
2548        assert_eq!(held.freed, 0);
2549        assert_eq!(held.deferred, 1);
2550
2551        // An operator asking for the old behaviour gets it.
2552        let swept = gc(dir.path(), std::time::Duration::ZERO).await.unwrap();
2553        assert_eq!(swept.freed, 1);
2554        assert_eq!(swept.deferred, 0);
2555    }
2556
2557    // ── index: view membership (#209) ────────────────────────────────────────
2558
2559    #[tokio::test]
2560    async fn index_excludes_vendored_bundles_from_the_view() {
2561        let root = TempDir::new().unwrap();
2562        let src = TempDir::new().unwrap();
2563        write_file(src.path(), "a.rs", "fn a() {}");
2564        write_file(src.path(), "mermaid.min.js", "var a=1,e=2,t=3;");
2565
2566        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2567        assert_eq!(summary.files, 1, "only the hand-written source");
2568        assert_eq!(summary.ignored.files, 1, "the minified bundle, reported");
2569
2570        let graph =
2571            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
2572        assert!(
2573            graph.all_symbols().iter().all(|s| s.path == "a.rs"),
2574            "no symbol may come from a vendored bundle"
2575        );
2576    }
2577
2578    #[tokio::test]
2579    async fn incremental_reindex_prunes_paths_a_laxer_run_admitted() {
2580        // The upgrade path: a view indexed before the filter existed still holds
2581        // vendored bundles. They never change, so they never appear in the git
2582        // diff, and once a base commit is recorded there is no full walk — so
2583        // without pruning the carried-forward set they would persist forever.
2584        let root = TempDir::new().unwrap();
2585        let src = TempDir::new().unwrap();
2586        write_file(src.path(), "a.rs", "fn a() {}");
2587        write_file(src.path(), "vendor.min.js", "var a=1,e=2;");
2588
2589        // Index once with a filter that admits the bundle, standing in for the
2590        // pre-#209 behaviour.
2591        let lax = IndexFilter::new(&["vendor.min.js".to_string()]);
2592        let first = index_with(root.path(), src.path(), "r", "main", &lax)
2593            .await
2594            .unwrap();
2595        assert_eq!(first.files, 2, "the bundle is in the view to begin with");
2596
2597        // Re-index with the default rules. The bundle is unchanged, so only the
2598        // carried-forward prune can remove it.
2599        let second = index_with(
2600            root.path(),
2601            src.path(),
2602            "r",
2603            "main",
2604            &IndexFilter::default(),
2605        )
2606        .await
2607        .unwrap();
2608        assert_eq!(second.deleted, 1, "the bundle is dropped from the view");
2609        assert_eq!(second.ignored.files, 1);
2610
2611        let graph =
2612            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
2613        assert!(
2614            graph.all_symbols().iter().all(|s| s.path == "a.rs"),
2615            "no vendored symbol may survive the re-index"
2616        );
2617    }
2618
2619    #[tokio::test]
2620    async fn incremental_reindex_keeps_paths_the_filter_still_admits() {
2621        // The prune must not eat ordinary carried-forward files.
2622        let root = TempDir::new().unwrap();
2623        let src = TempDir::new().unwrap();
2624        write_file(src.path(), "a.rs", "fn a() {}");
2625        write_file(src.path(), "b.rs", "fn b() {}");
2626        index(root.path(), src.path(), "r", "main").await.unwrap();
2627
2628        let second = index(root.path(), src.path(), "r", "main").await.unwrap();
2629        assert_eq!(second.deleted, 0, "nothing legitimate is pruned");
2630        assert_eq!(second.ignored.files, 0);
2631    }
2632
2633    #[tokio::test]
2634    async fn index_can_be_told_to_keep_a_vendored_path() {
2635        let root = TempDir::new().unwrap();
2636        let src = TempDir::new().unwrap();
2637        write_file(src.path(), "a.rs", "fn a() {}");
2638        write_file(src.path(), "keep.min.js", "function f(){}");
2639
2640        let filter = IndexFilter::new(&["keep.min.js".to_string()]);
2641        let summary = index_with(root.path(), src.path(), "r", "main", &filter)
2642            .await
2643            .unwrap();
2644        assert_eq!(summary.files, 2, "the override re-admits it");
2645        assert_eq!(summary.ignored.files, 0);
2646    }
2647
2648    #[tokio::test]
2649    async fn index_with_gc_sweeps_orphans_when_enabled() {
2650        let root = TempDir::new().unwrap();
2651        let src = TempDir::new().unwrap();
2652        write_file(src.path(), "a.rs", "fn a() {}");
2653        index(root.path(), src.path(), "r", "main").await.unwrap();
2654
2655        // Reindex changed content with the post-index sweep on: the pre-edit
2656        // slice is orphaned and should be freed in the same call.
2657        write_file(src.path(), "a.rs", "fn a() { b(); }");
2658        let (_summary, swept) =
2659            index_with_gc(root.path(), src.path(), "r", "main", true, Duration::ZERO)
2660                .await
2661                .unwrap();
2662        let swept = swept.expect("gc runs when enabled");
2663        assert_eq!(swept.freed, 1, "orphaned slice swept during the index");
2664
2665        // A follow-up gc finds nothing left to free.
2666        assert_eq!(gc(root.path(), Duration::ZERO).await.unwrap().freed, 0);
2667    }
2668
2669    #[tokio::test]
2670    async fn index_with_gc_skips_sweep_when_disabled() {
2671        let root = TempDir::new().unwrap();
2672        let src = TempDir::new().unwrap();
2673        write_file(src.path(), "a.rs", "fn a() {}");
2674        index(root.path(), src.path(), "r", "main").await.unwrap();
2675
2676        write_file(src.path(), "a.rs", "fn a() { b(); }");
2677        let (_summary, swept) =
2678            index_with_gc(root.path(), src.path(), "r", "main", false, Duration::ZERO)
2679                .await
2680                .unwrap();
2681        assert!(swept.is_none(), "no gc when disabled");
2682
2683        // The orphan survived: an explicit gc still has one to free.
2684        assert_eq!(gc(root.path(), Duration::ZERO).await.unwrap().freed, 1);
2685    }
2686
2687    // ── watch: debounce core (gonzalo#100) ──────────────────────────────────
2688
2689    #[test]
2690    fn debouncer_not_due_without_events() {
2691        let d = Debouncer::new(Duration::from_millis(500));
2692        assert!(!d.is_pending());
2693        assert!(!d.is_due(Instant::now()));
2694    }
2695
2696    #[test]
2697    fn debouncer_due_only_after_quiet_window() {
2698        let t0 = Instant::now();
2699        let mut d = Debouncer::new(Duration::from_millis(500));
2700        d.on_event(t0);
2701        assert!(d.is_pending());
2702        // Still inside the window → not due.
2703        assert!(!d.is_due(t0 + Duration::from_millis(499)));
2704        // Window elapsed → due.
2705        assert!(d.is_due(t0 + Duration::from_millis(500)));
2706    }
2707
2708    #[test]
2709    fn debouncer_coalesces_a_burst() {
2710        let t0 = Instant::now();
2711        let mut d = Debouncer::new(Duration::from_millis(500));
2712        d.on_event(t0);
2713        d.on_event(t0 + Duration::from_millis(200)); // second edit resets the clock
2714        // 500ms after the *first* event is not enough — the burst is still hot.
2715        assert!(!d.is_due(t0 + Duration::from_millis(500)));
2716        // 500ms after the *last* event → due, and only once for the whole burst.
2717        assert!(d.is_due(t0 + Duration::from_millis(700)));
2718    }
2719
2720    #[test]
2721    fn debouncer_clear_resets_pending() {
2722        let t0 = Instant::now();
2723        let mut d = Debouncer::new(Duration::from_millis(500));
2724        d.on_event(t0);
2725        d.clear();
2726        assert!(!d.is_pending());
2727        assert!(!d.is_due(t0 + Duration::from_secs(10)));
2728    }
2729
2730    #[tokio::test]
2731    async fn gc_retains_slice_still_referenced_by_another_view() {
2732        let root = TempDir::new().unwrap();
2733
2734        // Two views index the *same* file content → one shared, deduped slice.
2735        let src_a = TempDir::new().unwrap();
2736        write_file(src_a.path(), "shared.rs", "fn shared() {}");
2737        index(root.path(), src_a.path(), "r", "a").await.unwrap();
2738
2739        let src_b = TempDir::new().unwrap();
2740        write_file(src_b.path(), "shared.rs", "fn shared() {}");
2741        index(root.path(), src_b.path(), "r", "b").await.unwrap();
2742
2743        // Remove the file from view A only; view B still references the slice.
2744        std::fs::remove_file(src_a.path().join("shared.rs")).unwrap();
2745        index(root.path(), src_a.path(), "r", "a").await.unwrap();
2746
2747        let summary = gc(root.path(), Duration::ZERO).await.unwrap();
2748        assert_eq!(summary.scanned, 2, "one manifest record per view");
2749        assert_eq!(
2750            summary.freed, 0,
2751            "the shared slice is live via view B and must not be swept"
2752        );
2753
2754        // View B still assembles and answers with the shared symbol.
2755        let store = FsStore::new(root.path());
2756        let manifest = Manifest::from_body(
2757            &store
2758                .get(&Manifest::key("r", "b"))
2759                .await
2760                .unwrap()
2761                .unwrap()
2762                .body,
2763        )
2764        .unwrap();
2765        let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
2766        assert_eq!(graph.definitions("shared")[0].path, "shared.rs");
2767    }
2768
2769    // ---- files gonzalo cannot parse (#259) ---------------------------------
2770
2771    #[tokio::test]
2772    async fn a_tree_of_unparseable_files_says_so_instead_of_reporting_nothing() {
2773        // Indexing a Jupyter-notebook-only repository reported every count at
2774        // zero with no hint why, so an empty view read as "the repo is empty"
2775        // rather than "gonzalo does not parse this" (#259).
2776        let root = TempDir::new().unwrap();
2777        let src = TempDir::new().unwrap();
2778        write_file(src.path(), "analysis.ipynb", "{ \"cells\": [] }");
2779        write_file(src.path(), "notes.org", "* heading");
2780
2781        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2782        assert_eq!(summary.files, 0, "nothing was indexable");
2783        assert_eq!(summary.unindexed.files, 2, "but two files were seen");
2784        assert_eq!(
2785            summary.unindexed.extensions(),
2786            vec![("ipynb".to_string(), 1), ("org".to_string(), 1)],
2787            "and the message can name them"
2788        );
2789    }
2790
2791    #[tokio::test]
2792    async fn unparseable_files_are_counted_beside_ones_that_index() {
2793        let root = TempDir::new().unwrap();
2794        let src = TempDir::new().unwrap();
2795        write_file(src.path(), "a.rs", "fn helper() {}");
2796        write_file(src.path(), "README.md", "# hi");
2797
2798        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2799        assert_eq!(summary.files, 1);
2800        assert_eq!(summary.unindexed.files, 1);
2801        assert_eq!(summary.unindexed.extensions(), vec![("md".to_string(), 1)]);
2802    }
2803
2804    #[tokio::test]
2805    async fn extensions_are_reported_most_common_first() {
2806        let root = TempDir::new().unwrap();
2807        let src = TempDir::new().unwrap();
2808        for name in ["a.md", "b.md", "c.md", "d.txt"] {
2809            write_file(src.path(), name, "x");
2810        }
2811        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2812        assert_eq!(
2813            summary.unindexed.extensions(),
2814            vec![("md".to_string(), 3), ("txt".to_string(), 1)]
2815        );
2816    }
2817
2818    #[tokio::test]
2819    async fn an_extensionless_file_is_counted_without_an_extension() {
2820        let root = TempDir::new().unwrap();
2821        let src = TempDir::new().unwrap();
2822        write_file(src.path(), "LICENSE", "text");
2823        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2824        assert_eq!(summary.unindexed.files, 1);
2825        assert!(summary.unindexed.extensions().is_empty(), "nothing to name");
2826    }
2827
2828    #[tokio::test]
2829    async fn a_genuinely_empty_tree_reports_no_unindexed_files() {
2830        let root = TempDir::new().unwrap();
2831        let src = TempDir::new().unwrap();
2832        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2833        assert_eq!(summary.files, 0);
2834        assert_eq!(summary.unindexed.files, 0);
2835    }
2836
2837    // ---- naming a skipped file (#270) --------------------------------------
2838
2839    #[test]
2840    fn a_skip_message_names_the_file() {
2841        // Without the path there is no way to find the offender but bisecting
2842        // by directory, which is what landing #266 actually cost.
2843        let msg = skip_message("src/gpu/pipeline_state_cache.h", "parse worker died");
2844        assert!(msg.contains("src/gpu/pipeline_state_cache.h"), "{msg}");
2845        assert!(msg.contains("parse worker died"), "{msg}");
2846    }
2847
2848    #[tokio::test]
2849    async fn a_clean_index_reports_no_skipped_paths() {
2850        let root = TempDir::new().unwrap();
2851        let src = TempDir::new().unwrap();
2852        write_file(src.path(), "a.rs", "fn helper() {}");
2853
2854        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2855        assert_eq!(summary.skipped, 0);
2856        assert!(summary.skipped_paths.is_empty());
2857    }
2858
2859    #[test]
2860    fn the_skipped_listing_is_bounded_and_says_how_many_it_left_out() {
2861        let few: Vec<String> = vec!["a.rs".into(), "b.rs".into()];
2862        let listed = named_skips(&few);
2863        assert!(
2864            listed.contains("a.rs") && listed.contains("b.rs"),
2865            "{listed}"
2866        );
2867        assert!(!listed.contains("more"), "nothing was left out: {listed}");
2868
2869        let many: Vec<String> = (0..12).map(|i| format!("f{i}.rs")).collect();
2870        let listed = named_skips(&many);
2871        assert!(listed.contains("f0.rs"), "{listed}");
2872        assert!(
2873            listed.contains("7 more"),
2874            "must say what it left out: {listed}"
2875        );
2876    }
2877
2878    #[test]
2879    fn no_skips_lists_nothing() {
2880        assert_eq!(named_skips(&[]), String::new());
2881    }
2882}
2883
2884#[cfg(test)]
2885mod tombstone_cli_tests {
2886    use super::*;
2887    use tempfile::TempDir;
2888
2889    #[test]
2890    fn parse_duration_accepts_each_unit() {
2891        assert_eq!(parse_duration("30d"), Ok(Duration::from_secs(30 * 86_400)));
2892        assert_eq!(parse_duration("12h"), Ok(Duration::from_secs(12 * 3_600)));
2893        assert_eq!(parse_duration("90m"), Ok(Duration::from_secs(90 * 60)));
2894        assert_eq!(parse_duration("45s"), Ok(Duration::from_secs(45)));
2895        assert_eq!(parse_duration(" 7d "), Ok(Duration::from_secs(7 * 86_400)));
2896    }
2897
2898    #[test]
2899    fn parse_duration_rejects_malformed_input() {
2900        for (raw, needle) in [
2901            ("", "missing unit"),
2902            ("30", "missing unit"),
2903            ("d", "expected a number"),
2904            ("-5d", "expected a number"),
2905            ("30x", "unknown unit"),
2906            ("30dd", "unknown unit"),
2907            ("1d2h", "unknown unit"),
2908            ("0d", "greater than zero"),
2909            ("99999999999999999999d", "too large"),
2910            ("18446744073709551615d", "too large"),
2911        ] {
2912            let err = parse_duration(raw).expect_err(raw);
2913            assert!(
2914                err.contains(needle),
2915                "{raw:?}: {err:?} should mention {needle:?}"
2916            );
2917        }
2918    }
2919
2920    #[test]
2921    fn parse_horizon_keeps_the_operator_spelling() {
2922        let h = parse_horizon(" 30d").unwrap();
2923        assert_eq!(h.raw, "30d");
2924        assert_eq!(h.duration, Duration::from_secs(2_592_000));
2925    }
2926
2927    #[test]
2928    fn parse_revision_reads_the_json_that_get_prints() {
2929        let rev = parse_revision(r#"{"counter":3,"hash":"abc"}"#).unwrap();
2930        assert_eq!(
2931            rev,
2932            Revision {
2933                counter: 3,
2934                hash: ContentHash("abc".into())
2935            }
2936        );
2937        let err = parse_revision("3").unwrap_err();
2938        assert!(err.contains("expected a revision as JSON"), "{err}");
2939    }
2940
2941    #[test]
2942    fn open_store_rejects_a_zero_ancestor_cap() {
2943        let root = TempDir::new().unwrap();
2944        let err = open_store(root.path(), 0).err().expect("cap 0 rejected");
2945        assert!(
2946            format!("{err:#}").contains("ancestor cap must be at least 1"),
2947            "{err:#}"
2948        );
2949        assert!(open_store(root.path(), 1).is_ok());
2950    }
2951
2952    #[tokio::test]
2953    async fn delete_conflicts_on_a_stale_revision_and_attributes_the_tombstone() {
2954        let root = TempDir::new().unwrap();
2955        let store = FsStore::new(root.path());
2956        let body = Body::Inline(b"x".to_vec());
2957        let record = Record {
2958            key: RecordKey::new("ns", "col", "k"),
2959            kind: RecordKind::Topic,
2960            revision: Revision::initial(body.bytes()),
2961            parent: None,
2962            body,
2963            meta: Meta {
2964                author: Identity::new("t"),
2965                origin_system: "test".into(),
2966                created: 0,
2967                updated: 0,
2968                labels: BTreeMap::new(),
2969            },
2970            links: Vec::new(),
2971            ancestors: Vec::new(),
2972            deleted_at: None,
2973            deleted_blob: None,
2974            deleted_kind: None,
2975        };
2976        let live = record.revision.clone();
2977        assert!(matches!(
2978            store.put(record, None).await.unwrap(),
2979            PutResult::Committed(_)
2980        ));
2981        let stale = Revision {
2982            counter: 99,
2983            hash: ContentHash("deadbeef".into()),
2984        };
2985
2986        let outcome = delete(root.path(), 32, "ns", "col", "k", Some(stale))
2987            .await
2988            .unwrap();
2989        assert!(matches!(outcome, DeleteOutcome::Conflict { ref current } if *current == live));
2990
2991        let outcome = delete(root.path(), 32, "ns", "col", "k", Some(live))
2992            .await
2993            .unwrap();
2994        assert!(matches!(outcome, DeleteOutcome::Deleted));
2995        assert_eq!(get(root.path(), "ns", "col", "k").await.unwrap(), None);
2996
2997        // The seeded record was authored by "t"; the tombstone names the CLI.
2998        let tomb = store
2999            .get_raw(&RecordKey::new("ns", "col", "k"))
3000            .await
3001            .unwrap()
3002            .expect("tombstone stored");
3003        assert!(tomb.is_tombstone());
3004        assert_eq!(tomb.meta.author, Identity::new(CLI_AUTHOR));
3005    }
3006
3007    #[tokio::test]
3008    async fn undelete_stamps_the_cli_author_not_the_deleter() {
3009        let root = TempDir::new().unwrap();
3010        let store = FsStore::new(root.path());
3011        let body = gonzalo_core::Manifest::new().to_body();
3012        let key = RecordKey::new("ns", "col", "k");
3013        let record = Record {
3014            key: key.clone(),
3015            kind: RecordKind::GraphManifest,
3016            revision: Revision::initial(body.bytes()),
3017            parent: None,
3018            body,
3019            meta: Meta {
3020                author: Identity::new("not-the-cli"),
3021                origin_system: "test".into(),
3022                created: 0,
3023                updated: 0,
3024                labels: BTreeMap::new(),
3025            },
3026            links: Vec::new(),
3027            ancestors: Vec::new(),
3028            deleted_at: None,
3029            deleted_blob: None,
3030            deleted_kind: None,
3031        };
3032        assert!(matches!(
3033            store.put(record, None).await.unwrap(),
3034            PutResult::Committed(_)
3035        ));
3036        let deleted = store
3037            .delete_as(&key, None, Some(Identity::new("someone-else")))
3038            .await
3039            .unwrap();
3040        assert!(matches!(deleted, gonzalo_core::DeleteResult::Deleted));
3041
3042        undelete(
3043            root.path(),
3044            gonzalo_core::DEFAULT_ANCESTOR_CAP,
3045            "ns",
3046            "col",
3047            "k",
3048        )
3049        .await
3050        .unwrap();
3051
3052        let restored = store.get(&key).await.unwrap().expect("restored");
3053        assert_eq!(restored.meta.author, Identity::new(CLI_AUTHOR));
3054    }
3055
3056    #[tokio::test]
3057    async fn reset_stamps_the_cli_author() {
3058        let root = TempDir::new().unwrap();
3059        let store = FsStore::new(root.path());
3060        let body = Body::Inline(b"x".to_vec());
3061        let record = Record {
3062            key: RecordKey::new("ns", "col", "k"),
3063            kind: RecordKind::Topic,
3064            revision: Revision::initial(body.bytes()),
3065            parent: None,
3066            body,
3067            meta: Meta {
3068                author: Identity::new("not-the-cli"),
3069                origin_system: "test".into(),
3070                created: 0,
3071                updated: 0,
3072                labels: BTreeMap::new(),
3073            },
3074            links: Vec::new(),
3075            ancestors: Vec::new(),
3076            deleted_at: None,
3077            deleted_blob: None,
3078            deleted_kind: None,
3079        };
3080        assert!(matches!(
3081            store.put(record, None).await.unwrap(),
3082            PutResult::Committed(_)
3083        ));
3084
3085        let report = reset(
3086            root.path(),
3087            gonzalo_core::DEFAULT_ANCESTOR_CAP,
3088            "ns".to_string(),
3089            None,
3090        )
3091        .await
3092        .unwrap();
3093        assert_eq!(report.deleted, vec![RecordKey::new("ns", "col", "k")]);
3094
3095        let tomb = store
3096            .get_raw(&RecordKey::new("ns", "col", "k"))
3097            .await
3098            .unwrap()
3099            .expect("tombstone stored");
3100        assert!(tomb.is_tombstone());
3101        assert_eq!(tomb.meta.author, Identity::new(CLI_AUTHOR));
3102    }
3103
3104    #[test]
3105    fn reset_exit_code_is_three_only_with_conflicts() {
3106        let clean = gonzalo_core::ResetReport {
3107            deleted: vec![RecordKey::new("ns", "col", "a")],
3108            conflicts: Vec::new(),
3109        };
3110        assert_eq!(reset_exit_code(&clean), 0);
3111
3112        let conflicted = gonzalo_core::ResetReport {
3113            deleted: Vec::new(),
3114            conflicts: vec![RecordKey::new("ns", "col", "b")],
3115        };
3116        assert_eq!(reset_exit_code(&conflicted), EXIT_CONFLICT);
3117    }
3118
3119    #[test]
3120    fn sync_exit_code_is_three_for_conflicts_or_non_convergence() {
3121        let clean = SyncSummary {
3122            copied_to_a: 1,
3123            copied_to_b: 2,
3124            fast_forwarded: 3,
3125            merged: 4,
3126            conflicts: Vec::new(),
3127            unconverged: Vec::new(),
3128        };
3129        assert_eq!(sync_exit_code(&clean), 0);
3130
3131        let conflicted = SyncSummary {
3132            conflicts: vec![RecordKey::new("ns", "col", "a")],
3133            ..clean.clone()
3134        };
3135        assert_eq!(sync_exit_code(&conflicted), EXIT_CONFLICT);
3136
3137        // A run that gave up before converging is not "in sync", even though it
3138        // reports no conflicts (#290).
3139        let unconverged = SyncSummary {
3140            unconverged: vec![RecordKey::new("ns", "col", "b")],
3141            ..clean
3142        };
3143        assert_eq!(sync_exit_code(&unconverged), EXIT_CONFLICT);
3144    }
3145
3146    #[tokio::test]
3147    async fn ensure_slices_present_restores_a_blob_that_was_swept() {
3148        let dir = tempfile::tempdir().unwrap();
3149        let store = FsStore::new(dir.path());
3150
3151        // Parse one real file so staging holds a Slice whose bytes we can restore.
3152        let src = dir.path().join("src");
3153        std::fs::create_dir_all(&src).unwrap();
3154        std::fs::write(src.join("lib.rs"), "pub fn alpha() {}\n").unwrap();
3155
3156        let mut staging = GraphStaging::default();
3157        let desired = build_desired_full(&store, &mut staging, None, &src, &IndexFilter::default())
3158            .await
3159            .unwrap();
3160
3161        let wanted: Vec<(String, ContentHash)> = desired
3162            .entries
3163            .iter()
3164            .map(|(p, h)| (p.clone(), h.clone()))
3165            .collect();
3166        assert!(!wanted.is_empty(), "the parsed file produced a slice");
3167
3168        // A sweep takes it between the upload and the manifest commit.
3169        store.delete_blob(&wanted[0].1).await.unwrap();
3170        assert!(!store.has_blob(&wanted[0].1).await.unwrap());
3171
3172        let restored = ensure_slices_present(&store, &wanted, &staging)
3173            .await
3174            .unwrap();
3175        assert_eq!(restored, 1);
3176        assert!(store.has_blob(&wanted[0].1).await.unwrap());
3177    }
3178}