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;
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};
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("gonzalo-cli"),
123                origin_system: "migrate".into(),
124                created: 0,
125                updated: 0,
126                labels: BTreeMap::new(),
127            },
128            links: vec![],
129        };
130
131        match store.put(record, None).await? {
132            PutResult::Committed(_) => imported += 1,
133            PutResult::Conflict(_) => skipped += 1,
134        }
135    }
136
137    Ok(MigrateSummary { imported, skipped })
138}
139
140/// Walk `dir` recursively and return a sorted list of all file paths.
141fn collect_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
142    let mut out = Vec::new();
143    collect_files_inner(dir, &mut out)?;
144    out.sort();
145    Ok(out)
146}
147
148fn collect_files_inner(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
149    for entry in std::fs::read_dir(dir)? {
150        let entry = entry?;
151        let ft = entry.file_type()?;
152        if ft.is_dir() {
153            collect_files_inner(&entry.path(), out)?;
154        } else if ft.is_file() {
155            out.push(entry.path());
156        }
157    }
158    Ok(())
159}
160
161// ─── index ───────────────────────────────────────────────────────────────────
162
163/// Summary returned by [`index`].
164pub struct IndexSummary {
165    /// Source files parsed into slices.
166    pub files: usize,
167    /// Paths newly added to the view.
168    pub added: usize,
169    /// Paths whose slice content changed.
170    pub modified: usize,
171    /// Paths removed from the view since the last index.
172    pub deleted: usize,
173    /// Files skipped because an isolated parse worker crashed or hung on them
174    /// (only possible when parsing through the pool).
175    pub skipped: usize,
176    /// Paths excluded from the view by an [`IndexFilter`] rule — vendored or
177    /// generated files, dependency/output directories, and gitignored trees
178    /// (#209). Distinct from `skipped`, which is a parse failure.
179    pub ignored: IgnoredCounts,
180    /// Whether this run used the git-diff-driven incremental driver (only the
181    /// changed set re-parsed) rather than the full tree walk.
182    pub incremental: bool,
183}
184
185/// Locate the `gonzalo-parse-worker` binary for crash-isolated parsing:
186/// `GONZALO_PARSE_WORKER` env override, else a sibling of the current
187/// executable (installed/`cargo build` layout). Returns `None` when no worker is
188/// available, in which case indexing parses in-process.
189fn resolve_parse_worker() -> Option<PathBuf> {
190    if let Some(p) = std::env::var_os("GONZALO_PARSE_WORKER") {
191        let p = PathBuf::from(p);
192        if p.exists() {
193            return Some(p);
194        }
195    }
196    let sibling = std::env::current_exe()
197        .ok()?
198        .with_file_name(if cfg!(windows) {
199            "gonzalo-parse-worker.exe"
200        } else {
201            "gonzalo-parse-worker"
202        });
203    sibling.exists().then_some(sibling)
204}
205
206/// Parse one file's `content` as `language`, through the crash-isolated `pool`
207/// if one is available (a crash/hang yields `None` — skip the file), or
208/// in-process otherwise.
209async fn parse_file(
210    pool: Option<&ParserPool>,
211    language: Language,
212    content: &str,
213) -> Option<CodeGraph> {
214    match pool {
215        Some(pool) => match pool.parse(language, content).await {
216            Ok(graph) => Some(graph),
217            Err(e) => {
218                eprintln!("gonzalo index: skipping a file — parse worker error: {e}");
219                None
220            }
221        },
222        None => Some(build(language, content)),
223    }
224}
225
226/// Index the source files under `src` into the `(repo, view)` code-graph view:
227/// parse each file into a content-addressed slice, then reconcile the view's
228/// manifest to the tree (ADR 0012). Re-running updates the view.
229///
230/// When `src` is the root of a git repo and a base commit was recorded on a
231/// prior run, the changed set is sourced directly from `git diff` (only the
232/// added/modified files are re-parsed and deleted files dropped) — the
233/// incremental driver of gonzalo#93. Otherwise the full tree is walked. Either
234/// way the manifest is reconciled as a set, so a full walk always converges the
235/// view even if an incremental run ever missed a change. Slices orphaned by
236/// deletions are left for a separate GC pass (which must see all live views).
237pub async fn index(root: &Path, src: &Path, repo: &str, view: &str) -> Result<IndexSummary> {
238    index_with(root, src, repo, view, &IndexFilter::default()).await
239}
240
241/// [`index`], with control over which paths enter the view.
242///
243/// `filter` carries any `--include` overrides; the built-in vendored/generated
244/// rules and `.gitignore` apply either way (#209).
245pub async fn index_with(
246    root: &Path,
247    src: &Path,
248    repo: &str,
249    view: &str,
250    filter: &IndexFilter,
251) -> Result<IndexSummary> {
252    let store = FsStore::new(root);
253
254    // Parse through a crash-isolated worker pool when a worker binary is
255    // available (so a grammar crash on one file skips that file instead of
256    // aborting the index); otherwise parse in-process.
257    let pool = resolve_parse_worker().map(|bin| {
258        let workers = std::thread::available_parallelism()
259            .map(|n| n.get())
260            .unwrap_or(4)
261            .clamp(1, 8);
262        ParserPool::new(bin, workers, std::time::Duration::from_secs(30))
263    });
264
265    // The persistent per-view graph, queried by the daemon without re-assembly.
266    let db_path = view_db_path(&root.join("graphs"), repo, view);
267    let mut graph = SqliteGraphStore::open(&db_path)
268        .with_context(|| format!("opening graph db for {repo}/{view}"))?;
269
270    // Load the view's current manifest (empty if new).
271    let key = Manifest::key(repo, view);
272    let existing = store.get(&key).await?;
273    let current = match &existing {
274        Some(rec) => Manifest::from_body(&rec.body)?,
275        None => Manifest::new(),
276    };
277
278    // Choose the driver: incremental when `src` is a git repo root, a base was
279    // recorded, and the diff against it is readable; otherwise a full walk.
280    let base_path = db_path.with_extension("base");
281    // A view built by a parser that recorded different things must be rebuilt
282    // in full: the incremental driver carries unchanged slices forward, so
283    // without this an existing view keeps pre-upgrade extraction forever.
284    let version_path = db_path.with_extension("fmt");
285    let recorded_version: Option<u32> = std::fs::read_to_string(&version_path)
286        .ok()
287        .and_then(|s| s.trim().parse().ok());
288    let format_changed = recorded_version != Some(EXTRACTION_VERSION);
289
290    let recorded_base = std::fs::read_to_string(&base_path)
291        .ok()
292        .map(|s| s.trim().to_string())
293        .filter(|s| !s.is_empty())
294        .filter(|_| !format_changed);
295    let incremental_changed = recorded_base
296        .as_deref()
297        .and_then(|base| gonzalo_store_git::changed_paths(src, base).ok());
298
299    // Stage every persistent-graph mutation instead of applying it inline. The
300    // SqliteGraphStore is advanced only after the manifest that describes it
301    // commits, so a concurrent-writer Conflict (below) leaves the graph
302    // untouched rather than advanced ahead of a manifest that never landed (#153).
303    let mut staging = GraphStaging::default();
304    let (desired, files, skipped, ignored, incremental) = match incremental_changed {
305        Some(changed) => {
306            build_desired_incremental(
307                &store,
308                &mut staging,
309                pool.as_ref(),
310                src,
311                &current,
312                &changed,
313                filter,
314            )
315            .await?
316        }
317        None => build_desired_full(&store, &mut staging, pool.as_ref(), src, filter).await?,
318    };
319
320    // Reconcile against the current manifest and stage removed paths for the
321    // persistent graph (applied only after the manifest commit).
322    let recon = current.reconcile(&desired);
323    for path in &recon.deleted {
324        staging.removes.push(path.clone());
325    }
326
327    // Write the manifest record (create-or-update under OCC).
328    let body = recon.manifest.to_body();
329    let (revision, expected, parent) = match &existing {
330        Some(rec) => (
331            rec.revision.next(body.bytes()),
332            Some(rec.revision.clone()),
333            Some(rec.revision.clone()),
334        ),
335        None => (Revision::initial(body.bytes()), None, None),
336    };
337    let record = Record {
338        key,
339        kind: RecordKind::GraphManifest,
340        revision,
341        parent,
342        body,
343        meta: Meta {
344            author: Identity::new("gonzalo-index"),
345            origin_system: "gonzalo-index".into(),
346            created: 0,
347            updated: 0,
348            labels: BTreeMap::new(),
349        },
350        links: Vec::new(),
351    };
352    match store.put(record, expected).await? {
353        PutResult::Committed(_) => {}
354        PutResult::Conflict(_) => {
355            // The manifest moved under us: abandon the run WITHOUT touching the
356            // persistent graph, so the SqliteGraphStore never advances ahead of a
357            // committed manifest (#153). Orphaned slice blobs written above are
358            // reclaimed by a later gc pass.
359            anyhow::bail!("manifest for {repo}/{view} changed concurrently; retry the index")
360        }
361    }
362
363    // Manifest committed — now advance the persistent graph to match it.
364    staging.apply(&mut graph);
365
366    // Record the current HEAD as the base for the next run's incremental diff.
367    // Only succeeds when `src` is a git repo root with at least one commit.
368    if let Ok(sha) = gonzalo_store_git::head_commit(src) {
369        if let Some(parent) = base_path.parent() {
370            std::fs::create_dir_all(parent).ok();
371        }
372        std::fs::write(&base_path, sha).ok();
373    }
374    // Record the extraction format this view was built with, so the next run can
375    // tell whether an incremental pass is still valid.
376    if let Some(parent) = version_path.parent() {
377        std::fs::create_dir_all(parent).ok();
378        std::fs::write(&version_path, EXTRACTION_VERSION.to_string()).ok();
379    }
380
381    Ok(IndexSummary {
382        files,
383        added: recon.added.len(),
384        modified: recon.modified.len(),
385        deleted: recon.deleted.len(),
386        skipped,
387        ignored,
388        incremental,
389    })
390}
391
392/// A set of persistent-graph mutations collected during a [`index`] run but not
393/// yet applied. Staging the writes lets [`index`] commit the view's manifest
394/// first and only then advance the [`SqliteGraphStore`] — so a manifest Conflict
395/// leaves the persistent graph untouched (#153).
396#[derive(Default)]
397struct GraphStaging {
398    /// `(relative path, parsed slice)` to (re)insert; insert replaces the path's
399    /// existing rows.
400    inserts: Vec<(String, CodeGraph)>,
401    /// Relative paths whose rows should be removed.
402    removes: Vec<String>,
403}
404
405impl GraphStaging {
406    /// Apply every staged mutation to the persistent graph. Called only after
407    /// the manifest commit succeeds.
408    fn apply(self, graph: &mut SqliteGraphStore) {
409        for (rel, slice) in self.inserts {
410            graph.insert(&rel, slice);
411        }
412        for rel in self.removes {
413            graph.remove_path(&rel);
414        }
415    }
416}
417
418/// Full-walk desired set: parse every supported source file under `src` that
419/// `filter` admits.
420async fn build_desired_full(
421    store: &FsStore,
422    staging: &mut GraphStaging,
423    pool: Option<&ParserPool>,
424    src: &Path,
425    filter: &IndexFilter,
426) -> Result<(
427    BTreeMap<String, ContentHash>,
428    usize,
429    usize,
430    IgnoredCounts,
431    bool,
432)> {
433    let mut desired: BTreeMap<String, ContentHash> = BTreeMap::new();
434    let mut skipped = 0usize;
435    let (sources, ignored) = walk::source_files(src, filter)?;
436    for (path, language) in sources {
437        let content = std::fs::read_to_string(&path)
438            .with_context(|| format!("reading {}", path.display()))?;
439        let rel = path
440            .strip_prefix(src)
441            .unwrap_or(&path)
442            .to_string_lossy()
443            .replace('\\', "/");
444        let Some(slice) = parse_file(pool, language, &content).await else {
445            skipped += 1;
446            continue;
447        };
448        let hash = store.put_blob(&slice.to_slice_bytes()).await?;
449        staging.inserts.push((rel.clone(), slice)); // insert replaces this path's rows
450        desired.insert(rel, hash);
451    }
452    let files = desired.len();
453    Ok((desired, files, skipped, ignored, false))
454}
455
456/// Incremental desired set: start from the current manifest and apply only the
457/// git-reported changes — re-parse added/modified source files, drop deleted
458/// ones, and carry every unchanged path forward untouched. `files` counts the
459/// files re-parsed this run.
460async fn build_desired_incremental(
461    store: &FsStore,
462    staging: &mut GraphStaging,
463    pool: Option<&ParserPool>,
464    src: &Path,
465    current: &Manifest,
466    changed: &gonzalo_store_git::ChangedPaths,
467    filter: &IndexFilter,
468) -> Result<(
469    BTreeMap<String, ContentHash>,
470    usize,
471    usize,
472    IgnoredCounts,
473    bool,
474)> {
475    let mut desired = current.entries.clone();
476    let mut files = 0usize;
477    let mut skipped = 0usize;
478    // Gitignored paths never reach here — `git2`'s diff omits them — so only the
479    // path-only rules apply, and only files are ever counted.
480    let mut ignored = IgnoredCounts::default();
481
482    // Re-apply the filter to paths carried forward from the previous run, not
483    // just to changed ones. A view indexed under laxer rules keeps its vendored
484    // bundles forever otherwise: `mermaid.min.js` never changes, so it never
485    // appears in the diff, so an incremental run never reconsiders it — and once
486    // a base commit is recorded there is no full walk to clean it up. Making the
487    // carried-forward set self-healing is what lets an existing view benefit
488    // from #209 rather than only newly created ones.
489    let stale = walk::stale_entries(src, filter, desired.keys().map(String::as_str));
490    for rel in stale {
491        desired.remove(&rel);
492        staging.removes.push(rel);
493        ignored.files += 1;
494    }
495
496    for rel in changed.added.iter().chain(changed.modified.iter()) {
497        if !filter.is_indexable(rel) {
498            // A path that a previous, laxer walk admitted must also be dropped
499            // from the view, not merely skipped, or the two drivers disagree.
500            if desired.remove(rel).is_some() {
501                staging.removes.push(rel.clone());
502            }
503            ignored.files += 1;
504            continue;
505        }
506        let Some(language) = Path::new(rel)
507            .extension()
508            .and_then(|e| e.to_str())
509            .and_then(Language::from_extension)
510        else {
511            continue; // not a source file
512        };
513        // A file git reports as changed but that we can no longer read (e.g.
514        // it vanished between diff and read) is treated as a removal.
515        let content = match std::fs::read_to_string(src.join(rel)) {
516            Ok(c) => c,
517            Err(_) => {
518                if desired.remove(rel).is_some() {
519                    staging.removes.push(rel.clone());
520                }
521                continue;
522            }
523        };
524        let Some(slice) = parse_file(pool, language, &content).await else {
525            skipped += 1;
526            continue;
527        };
528        let hash = store.put_blob(&slice.to_slice_bytes()).await?;
529        staging.inserts.push((rel.clone(), slice));
530        desired.insert(rel.clone(), hash);
531        files += 1;
532    }
533
534    for rel in &changed.deleted {
535        if desired.remove(rel).is_some() {
536            staging.removes.push(rel.clone());
537        }
538    }
539
540    Ok((desired, files, skipped, ignored, true))
541}
542
543// ─── gc ────────────────────────────────────────────────────────────────────
544
545/// Summary returned by [`gc`].
546pub struct GcSummary {
547    /// Live manifests scanned to build the mark set.
548    pub manifests: usize,
549    /// Orphaned slice blobs deleted.
550    pub freed: usize,
551    /// Slice blobs kept because some live view still references them.
552    pub retained: usize,
553}
554
555/// Sweep orphaned code-graph slices from the store at `root`.
556///
557/// Slices are content-addressed and **shared across views** (identical content
558/// dedups), so GC must mark against *every* live view's manifest — deleting a
559/// slice still referenced by another view would corrupt it. This enumerates all
560/// `graph-manifest` records across every repo/view, unions their referenced
561/// hashes, and mark-sweeps the blob store via [`gonzalo_core::gc_blobs`] (A6).
562pub async fn gc(root: &Path) -> Result<GcSummary> {
563    let store = FsStore::new(root);
564
565    // Every view's manifest, across all repos (namespace unset = all repos).
566    let prefix = KeyPrefix {
567        namespace: None,
568        collection: Some(Manifest::collection().to_string()),
569    };
570    let keys = store.list(&prefix).await?;
571    let mut manifests = Vec::with_capacity(keys.len());
572    for key in &keys {
573        if let Some(rec) = store.get(key).await? {
574            manifests.push(Manifest::from_body(&rec.body)?);
575        }
576    }
577
578    let report = gonzalo_core::gc_blobs(&store, &manifests).await?;
579    Ok(GcSummary {
580        manifests: manifests.len(),
581        freed: report.freed.len(),
582        retained: report.retained,
583    })
584}
585
586/// [`index`] the `(repo, view)` view, then — when `gc_after` — sweep orphaned
587/// slices. The opt-in post-index trigger of gonzalo#104: the sweep runs only
588/// after a successful index and always goes through [`gc`], which marks against
589/// *every* live view's manifest (never a per-view subset), so a slice the just-
590/// indexed view dropped but another view still references is preserved.
591pub async fn index_with_gc(
592    root: &Path,
593    src: &Path,
594    repo: &str,
595    view: &str,
596    gc_after: bool,
597) -> Result<(IndexSummary, Option<GcSummary>)> {
598    index_with_gc_filtered(root, src, repo, view, gc_after, &IndexFilter::default()).await
599}
600
601/// [`index_with_gc`], with control over which paths enter the view (#209).
602pub async fn index_with_gc_filtered(
603    root: &Path,
604    src: &Path,
605    repo: &str,
606    view: &str,
607    gc_after: bool,
608    filter: &IndexFilter,
609) -> Result<(IndexSummary, Option<GcSummary>)> {
610    let summary = index_with(root, src, repo, view, filter).await?;
611    let swept = if gc_after {
612        Some(gc(root).await?)
613    } else {
614        None
615    };
616    Ok((summary, swept))
617}
618
619// ─── watch (debounce core) ──────────────────────────────────────────────────
620
621/// Coalesces a burst of filesystem change notifications so a rapid series of
622/// edits triggers a single re-index rather than one per event. The clock is
623/// injected (`now`), so the logic is deterministic and unit-testable without
624/// real sleeps — the seam the watcher loop drives with `Instant::now()`.
625#[derive(Debug)]
626pub struct Debouncer {
627    window: Duration,
628    /// When the most recent unhandled change was observed.
629    last_event: Option<Instant>,
630}
631
632impl Debouncer {
633    /// A debouncer that fires once the tree has been quiet for `window`.
634    pub fn new(window: Duration) -> Self {
635        Self {
636            window,
637            last_event: None,
638        }
639    }
640
641    /// Record that a change was observed at `now`.
642    pub fn on_event(&mut self, now: Instant) {
643        self.last_event = Some(now);
644    }
645
646    /// Whether a change is waiting to be handled.
647    pub fn is_pending(&self) -> bool {
648        self.last_event.is_some()
649    }
650
651    /// Whether a re-index is due at `now`: a change is pending and the tree has
652    /// been quiet for at least `window` since the last event. False when nothing
653    /// is pending.
654    pub fn is_due(&self, now: Instant) -> bool {
655        matches!(self.last_event, Some(t) if now.duration_since(t) >= self.window)
656    }
657
658    /// Clear the pending change after a re-index has run.
659    pub fn clear(&mut self) {
660        self.last_event = None;
661    }
662}
663
664// ─── sync_stores ─────────────────────────────────────────────────────────────
665
666/// Summary returned by [`sync_stores`].
667pub struct SyncSummary {
668    pub copied_to_a: usize,
669    pub copied_to_b: usize,
670    pub merged: usize,
671    pub conflicts: usize,
672}
673
674/// Sync two filesystem stores via [`gonzalo_core::sync`].
675pub async fn sync_stores(a: &Path, b: &Path) -> Result<SyncSummary> {
676    let store_a = FsStore::new(a);
677    let store_b = FsStore::new(b);
678    let report = gonzalo_core::sync(&store_a, &store_b).await?;
679    Ok(SyncSummary {
680        copied_to_a: report.copied_to_a.len(),
681        copied_to_b: report.copied_to_b.len(),
682        merged: report.merged.len(),
683        conflicts: report.conflicts.len(),
684    })
685}
686
687// ─── ticket sync ───────────────────────────────────────────────────────────
688
689/// Per-connection ingest result.
690pub struct TicketSyncReport {
691    pub connection: String,
692    pub summary: IngestSummary,
693}
694
695/// Load the ticket config, build each connection's source, and ingest its
696/// tickets into the fs store at `root`.
697pub async fn ticket_sync(
698    config_path: &Path,
699    root: &Path,
700    author: &str,
701) -> Result<Vec<TicketSyncReport>> {
702    let config = Config::load(config_path).context("loading ticket config")?;
703    let store = FsStore::new(root);
704    let mut reports = Vec::new();
705    for (name, source) in config.sources().context("building ticket sources")? {
706        // Scope each record key by connection name so the same issue on two
707        // boards yields two distinct records rather than colliding (#159).
708        let summary = gonzalo_ticket::ingest(source.as_ref(), &store, author, Some(&name))
709            .await
710            .with_context(|| format!("syncing connection {name}"))?;
711        reports.push(TicketSyncReport {
712            connection: name,
713            summary,
714        });
715    }
716    Ok(reports)
717}
718
719// ─── ticket move ─────────────────────────────────────────────────────────────
720
721/// Move a board card to the column for `category`. Selects the connection named
722/// `connection`, or the sole connection if there is exactly one.
723pub async fn ticket_move(
724    config_path: &Path,
725    connection: Option<&str>,
726    uid: &str,
727    category: &str,
728) -> Result<()> {
729    let cat = parse_category(category)
730        .ok_or_else(|| anyhow::anyhow!("unknown state category {category:?}"))?;
731    let config = Config::load(config_path).context("loading ticket config")?;
732    let conn = select_connection(&config.connections, connection)?;
733    let source = gonzalo_ticket_config::build_source(conn).context("building ticket source")?;
734    source
735        .set_state(uid, cat)
736        .await
737        .with_context(|| format!("moving {uid} to {category}"))?;
738    Ok(())
739}
740
741/// Pick the requested connection by name, or the only one if unambiguous.
742fn select_connection<'a>(
743    connections: &'a [Connection],
744    name: Option<&str>,
745) -> Result<&'a Connection> {
746    match name {
747        Some(n) => connections
748            .iter()
749            .find(|c| c.name == n)
750            .ok_or_else(|| anyhow::anyhow!("no connection named {n:?}")),
751        None => match connections {
752            [one] => Ok(one),
753            [] => Err(anyhow::anyhow!("no connections configured")),
754            _ => Err(anyhow::anyhow!(
755                "multiple connections configured; pass --connection <name>"
756            )),
757        },
758    }
759}
760
761// ─── Tests ────────────────────────────────────────────────────────────────────
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use gonzalo_graph::GraphStore;
767    use tempfile::TempDir;
768
769    fn write_file(dir: &Path, name: &str, contents: &str) {
770        std::fs::write(dir.join(name), contents).unwrap();
771    }
772
773    // ── migrate: basic import ────────────────────────────────────────────────
774
775    #[tokio::test]
776    async fn migrate_imports_two_files() {
777        let root = TempDir::new().unwrap();
778        let src = TempDir::new().unwrap();
779
780        write_file(src.path(), "alpha.md", "hello alpha");
781        write_file(src.path(), "beta.md", "hello beta");
782
783        let summary = migrate(
784            root.path(),
785            src.path(),
786            "testns",
787            "testcol",
788            RecordKind::Topic,
789        )
790        .await
791        .unwrap();
792
793        assert_eq!(summary.imported, 2, "should have imported 2 files");
794        assert_eq!(summary.skipped, 0, "nothing should be skipped yet");
795    }
796
797    // ── list: shows the right keys after migrate ─────────────────────────────
798
799    #[tokio::test]
800    async fn list_returns_migrated_keys() {
801        let root = TempDir::new().unwrap();
802        let src = TempDir::new().unwrap();
803
804        write_file(src.path(), "alpha.md", "hello alpha");
805        write_file(src.path(), "beta.md", "hello beta");
806
807        migrate(
808            root.path(),
809            src.path(),
810            "testns",
811            "testcol",
812            RecordKind::Topic,
813        )
814        .await
815        .unwrap();
816
817        let keys = list(root.path(), None, None).await.unwrap();
818        assert_eq!(keys.len(), 2);
819    }
820
821    // ── migrate: idempotent on second run ────────────────────────────────────
822
823    #[tokio::test]
824    async fn migrate_is_idempotent() {
825        let root = TempDir::new().unwrap();
826        let src = TempDir::new().unwrap();
827
828        write_file(src.path(), "alpha.md", "hello alpha");
829        write_file(src.path(), "beta.md", "hello beta");
830
831        migrate(
832            root.path(),
833            src.path(),
834            "testns",
835            "testcol",
836            RecordKind::Topic,
837        )
838        .await
839        .unwrap();
840
841        let second = migrate(
842            root.path(),
843            src.path(),
844            "testns",
845            "testcol",
846            RecordKind::Topic,
847        )
848        .await
849        .unwrap();
850
851        assert_eq!(
852            second.skipped, 2,
853            "second run should skip both already-imported files"
854        );
855        assert_eq!(second.imported, 0, "second run should import nothing new");
856    }
857
858    // ── get: round-trips body ────────────────────────────────────────────────
859
860    #[tokio::test]
861    async fn get_returns_migrated_record_body() {
862        let root = TempDir::new().unwrap();
863        let src = TempDir::new().unwrap();
864
865        write_file(src.path(), "alpha.md", "hello alpha");
866
867        migrate(
868            root.path(),
869            src.path(),
870            "testns",
871            "testcol",
872            RecordKind::Topic,
873        )
874        .await
875        .unwrap();
876
877        // The id is the source-relative path verbatim.
878        let record = get(root.path(), "testns", "testcol", "alpha.md")
879            .await
880            .unwrap();
881
882        assert!(record.is_some(), "record should be present");
883        let body = record.unwrap().body;
884        assert_eq!(body.bytes(), b"hello alpha");
885    }
886
887    // ── status: correct namespace/collection count ───────────────────────────
888
889    #[tokio::test]
890    async fn status_groups_by_ns_col() {
891        let root = TempDir::new().unwrap();
892        let src = TempDir::new().unwrap();
893
894        write_file(src.path(), "alpha.md", "hello alpha");
895        write_file(src.path(), "beta.md", "hello beta");
896
897        migrate(
898            root.path(),
899            src.path(),
900            "testns",
901            "testcol",
902            RecordKind::Topic,
903        )
904        .await
905        .unwrap();
906
907        let map = status(root.path()).await.unwrap();
908        assert_eq!(map.get("testns/testcol").copied(), Some(2));
909    }
910
911    // ── sync_stores: propagates records ─────────────────────────────────────
912
913    #[tokio::test]
914    async fn sync_stores_copies_to_b() {
915        let store_a = TempDir::new().unwrap();
916        let store_b = TempDir::new().unwrap();
917        let src = TempDir::new().unwrap();
918
919        write_file(src.path(), "note.md", "synced content");
920
921        // Import only into store A.
922        migrate(
923            store_a.path(),
924            src.path(),
925            "testns",
926            "testcol",
927            RecordKind::Topic,
928        )
929        .await
930        .unwrap();
931
932        let summary = sync_stores(store_a.path(), store_b.path()).await.unwrap();
933        assert_eq!(summary.copied_to_b, 1);
934
935        // Store B should now have the key.
936        let keys = list(store_b.path(), None, None).await.unwrap();
937        assert_eq!(keys.len(), 1);
938    }
939
940    // ── ticket_sync: empty config → no reports ───────────────────────────────
941
942    #[tokio::test]
943    async fn ticket_sync_with_no_connections_returns_no_reports() {
944        let root = TempDir::new().unwrap();
945        let cfg = TempDir::new().unwrap();
946        let cfg_path = cfg.path().join("tickets.toml");
947        std::fs::write(&cfg_path, "").unwrap(); // empty config = zero connections
948
949        let reports = ticket_sync(&cfg_path, root.path(), "tester").await.unwrap();
950        assert!(reports.is_empty());
951    }
952
953    // ── ticket_move: unknown category errors before any network call ─────────
954
955    #[tokio::test]
956    async fn ticket_move_unknown_category_errors() {
957        let cfg = TempDir::new().unwrap();
958        let cfg_path = cfg.path().join("tickets.toml");
959        std::fs::write(
960            &cfg_path,
961            "[[connection]]\nname=\"b\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=1\ntoken_env=\"X\"\n",
962        )
963        .unwrap();
964        // "frozen" is not a valid category → error before any network call.
965        let err = ticket_move(&cfg_path, None, "o/r#1", "frozen")
966            .await
967            .unwrap_err();
968        assert!(err.to_string().contains("category"), "got {err}");
969    }
970
971    // ── ticket_move: ambiguous connection requires --connection ─────────────
972
973    #[tokio::test]
974    async fn ticket_move_requires_connection_when_many() {
975        let cfg = TempDir::new().unwrap();
976        let cfg_path = cfg.path().join("tickets.toml");
977        std::fs::write(
978            &cfg_path,
979            "[[connection]]\nname=\"a\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=1\ntoken_env=\"X\"\n\
980             [[connection]]\nname=\"b\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=2\ntoken_env=\"Y\"\n",
981        )
982        .unwrap();
983        let err = ticket_move(&cfg_path, None, "o/r#1", "done")
984            .await
985            .unwrap_err();
986        assert!(err.to_string().contains("connection"), "got {err}");
987    }
988
989    // ── index: build a queryable view from a source tree ─────────────────────
990
991    #[tokio::test]
992    async fn index_builds_a_queryable_view() {
993        let root = TempDir::new().unwrap();
994        let src = TempDir::new().unwrap();
995        write_file(src.path(), "a.rs", "fn helper() {}");
996        std::fs::create_dir(src.path().join("sub")).unwrap();
997        write_file(src.path(), "sub/b.rs", "fn caller() { helper(); }");
998
999        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1000        assert_eq!(summary.files, 2);
1001        assert_eq!(summary.added, 2);
1002        assert_eq!(summary.modified, 0);
1003        assert_eq!(summary.deleted, 0);
1004
1005        // The indexed view assembles and answers real queries.
1006        let store = FsStore::new(root.path());
1007        let manifest = Manifest::from_body(
1008            &store
1009                .get(&Manifest::key("r", "main"))
1010                .await
1011                .unwrap()
1012                .unwrap()
1013                .body,
1014        )
1015        .unwrap();
1016        let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1017        let defs = graph.definitions("helper");
1018        assert_eq!(defs.len(), 1);
1019        assert_eq!(defs[0].path, "a.rs");
1020        assert_eq!(graph.callers_of("helper"), vec!["caller".to_string()]);
1021        assert!(
1022            graph
1023                .symbols_in_file("sub/b.rs")
1024                .iter()
1025                .any(|s| s.name == "caller")
1026        );
1027    }
1028
1029    #[tokio::test]
1030    async fn reindex_reports_modifications_and_deletions() {
1031        let root = TempDir::new().unwrap();
1032        let src = TempDir::new().unwrap();
1033        write_file(src.path(), "keep.rs", "fn keep() {}");
1034        write_file(src.path(), "gone.rs", "fn gone() {}");
1035        index(root.path(), src.path(), "r", "v").await.unwrap();
1036
1037        // Change one file, remove another.
1038        write_file(src.path(), "keep.rs", "fn keep() { extra(); }");
1039        std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1040
1041        let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1042        assert_eq!(summary.files, 1);
1043        assert_eq!(summary.added, 0);
1044        assert_eq!(summary.modified, 1);
1045        assert_eq!(summary.deleted, 1);
1046    }
1047
1048    #[tokio::test]
1049    async fn index_skips_non_rust_and_build_dirs() {
1050        let root = TempDir::new().unwrap();
1051        let src = TempDir::new().unwrap();
1052        write_file(src.path(), "real.rs", "fn real() {}");
1053        write_file(src.path(), "notes.txt", "not source");
1054        std::fs::create_dir(src.path().join("target")).unwrap();
1055        write_file(src.path(), "target/gen.rs", "fn generated() {}");
1056
1057        let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1058        assert_eq!(summary.files, 1, "only real.rs is indexed");
1059        assert_eq!(summary.added, 1);
1060    }
1061
1062    #[tokio::test]
1063    async fn index_handles_multiple_languages() {
1064        let root = TempDir::new().unwrap();
1065        let src = TempDir::new().unwrap();
1066        write_file(src.path(), "lib.rs", "fn rust_fn() {}");
1067        write_file(src.path(), "app.py", "def py_fn():\n    pass\n");
1068
1069        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1070        assert_eq!(summary.files, 2, "both the .rs and .py file are indexed");
1071
1072        // Both languages' symbols are queryable in the assembled view.
1073        let store = FsStore::new(root.path());
1074        let manifest = Manifest::from_body(
1075            &store
1076                .get(&Manifest::key("r", "main"))
1077                .await
1078                .unwrap()
1079                .unwrap()
1080                .body,
1081        )
1082        .unwrap();
1083        let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1084        assert_eq!(graph.definitions("rust_fn")[0].path, "lib.rs");
1085        assert_eq!(graph.definitions("py_fn")[0].path, "app.py");
1086    }
1087
1088    #[tokio::test]
1089    async fn index_writes_a_queryable_sqlite_graph() {
1090        let root = TempDir::new().unwrap();
1091        let src = TempDir::new().unwrap();
1092        write_file(
1093            src.path(),
1094            "lib.rs",
1095            "fn helper() {}\nfn main() { helper(); }",
1096        );
1097        index(root.path(), src.path(), "r", "main").await.unwrap();
1098
1099        // The persistent per-view graph exists under <root>/graphs and answers
1100        // queries without re-assembly.
1101        let db = view_db_path(&root.path().join("graphs"), "r", "main");
1102        let g = SqliteGraphStore::open(&db).unwrap();
1103        assert_eq!(g.definitions("helper")[0].path, "lib.rs");
1104        assert_eq!(g.callers_of("helper"), vec!["main".to_string()]);
1105    }
1106
1107    #[tokio::test]
1108    async fn reindex_removes_deleted_paths_from_the_sqlite_graph() {
1109        let root = TempDir::new().unwrap();
1110        let src = TempDir::new().unwrap();
1111        write_file(src.path(), "keep.rs", "fn keep() {}");
1112        write_file(src.path(), "gone.rs", "fn gone() {}");
1113        index(root.path(), src.path(), "r", "v").await.unwrap();
1114
1115        std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1116        index(root.path(), src.path(), "r", "v").await.unwrap();
1117
1118        let g =
1119            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "v")).unwrap();
1120        assert_eq!(g.definitions("keep").len(), 1);
1121        assert!(
1122            g.definitions("gone").is_empty(),
1123            "deleted file's symbols must be gone from the graph"
1124        );
1125    }
1126
1127    // ── index: manifest commit precedes the SQLite graph write (gonzalo#153) ──
1128
1129    #[tokio::test]
1130    async fn build_desired_stages_graph_writes_without_touching_the_store() {
1131        // The reordered control flow (#153): parsing/desired-set construction
1132        // must only *stage* persistent-graph mutations. The SqliteGraphStore is
1133        // advanced solely by GraphStaging::apply — which index() calls strictly
1134        // after the manifest commit — so a manifest Conflict leaves the graph
1135        // untouched instead of advanced ahead of an uncommitted manifest.
1136        let root = TempDir::new().unwrap();
1137        let src = TempDir::new().unwrap();
1138        write_file(src.path(), "a.rs", "fn a() {}");
1139
1140        let store = FsStore::new(root.path());
1141        let db_path = view_db_path(&root.path().join("graphs"), "r", "main");
1142        let mut graph = SqliteGraphStore::open(&db_path).unwrap();
1143
1144        let mut staging = GraphStaging::default();
1145        let (desired, files, _skipped, _ignored, incremental) = build_desired_full(
1146            &store,
1147            &mut staging,
1148            None,
1149            src.path(),
1150            &IndexFilter::default(),
1151        )
1152        .await
1153        .unwrap();
1154        assert!(!incremental);
1155        assert_eq!(files, 1);
1156        assert_eq!(desired.len(), 1);
1157        assert_eq!(staging.inserts.len(), 1, "the write is staged, not applied");
1158
1159        // Nothing has reached the persistent graph yet — this is the state after
1160        // a manifest Conflict would `bail!`.
1161        assert!(
1162            graph.definitions("a").is_empty(),
1163            "SqliteGraphStore must be untouched until the manifest commits"
1164        );
1165
1166        // Applying the staged writes (index()'s post-commit step) advances it.
1167        staging.apply(&mut graph);
1168        assert_eq!(graph.definitions("a")[0].path, "a.rs");
1169    }
1170
1171    // ── index: git-driven incremental sync (gonzalo#93) ──────────────────────
1172
1173    /// Init a git repo at `dir` and commit every current file.
1174    fn git_init_commit(dir: &Path) {
1175        let repo = git2::Repository::init(dir).unwrap();
1176        let mut index = repo.index().unwrap();
1177        index
1178            .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
1179            .unwrap();
1180        index.write().unwrap();
1181        let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
1182        let sig = git2::Signature::now("t", "t@localhost").unwrap();
1183        repo.commit(Some("HEAD"), &sig, &sig, "c", &tree, &[])
1184            .unwrap();
1185    }
1186
1187    #[tokio::test]
1188    async fn a_format_change_forces_a_full_walk() {
1189        // Without this, an existing view keeps pre-upgrade extraction forever:
1190        // the incremental driver carries unchanged slices forward untouched, so
1191        // a parser improvement never reaches files that did not change (#223).
1192        let root = TempDir::new().unwrap();
1193        let src = TempDir::new().unwrap();
1194        write_file(src.path(), "a.rs", "fn a() {}");
1195        git_init_commit(src.path());
1196        index(root.path(), src.path(), "r", "main").await.unwrap();
1197
1198        // A second run would normally take the incremental path...
1199        let fmt = view_db_path(&root.path().join("graphs"), "r", "main").with_extension("fmt");
1200        assert_eq!(
1201            std::fs::read_to_string(&fmt).unwrap(),
1202            EXTRACTION_VERSION.to_string()
1203        );
1204        assert!(
1205            index(root.path(), src.path(), "r", "main")
1206                .await
1207                .unwrap()
1208                .incremental
1209        );
1210
1211        // ...but not when the view was built by an older extraction format.
1212        std::fs::write(&fmt, "1").unwrap();
1213        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1214        assert!(!summary.incremental, "a format change must rebuild in full");
1215        assert_eq!(
1216            std::fs::read_to_string(&fmt).unwrap(),
1217            EXTRACTION_VERSION.to_string(),
1218            "and must record the version it rebuilt with"
1219        );
1220    }
1221
1222    #[tokio::test]
1223    async fn first_index_of_git_repo_is_full_then_reindex_is_incremental() {
1224        let root = TempDir::new().unwrap();
1225        let src = TempDir::new().unwrap();
1226        write_file(src.path(), "a.rs", "fn a() {}");
1227        write_file(src.path(), "b.rs", "fn b() {}");
1228        git_init_commit(src.path());
1229
1230        // First run: no recorded base yet → full walk, records the base.
1231        let first = index(root.path(), src.path(), "r", "main").await.unwrap();
1232        assert!(!first.incremental, "first index is a full walk");
1233        assert_eq!(first.files, 2);
1234        assert_eq!(first.added, 2);
1235
1236        // Change the working tree: modify a.rs, add untracked c.rs, leave b.rs.
1237        write_file(src.path(), "a.rs", "fn a() { helper(); }");
1238        write_file(src.path(), "c.rs", "fn c() {}");
1239
1240        let second = index(root.path(), src.path(), "r", "main").await.unwrap();
1241        assert!(second.incremental, "second index uses the git diff driver");
1242        assert_eq!(second.files, 2, "only a.rs and c.rs are re-parsed");
1243        assert_eq!(second.added, 1, "c.rs added");
1244        assert_eq!(second.modified, 1, "a.rs modified");
1245        assert_eq!(second.deleted, 0);
1246
1247        // b.rs was carried forward unchanged and is still queryable.
1248        let g =
1249            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
1250        assert_eq!(g.definitions("b")[0].path, "b.rs");
1251        assert_eq!(g.definitions("c")[0].path, "c.rs");
1252    }
1253
1254    #[tokio::test]
1255    async fn incremental_index_drops_deleted_files() {
1256        let root = TempDir::new().unwrap();
1257        let src = TempDir::new().unwrap();
1258        write_file(src.path(), "keep.rs", "fn keep() {}");
1259        write_file(src.path(), "gone.rs", "fn gone() {}");
1260        git_init_commit(src.path());
1261        index(root.path(), src.path(), "r", "v").await.unwrap();
1262
1263        std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1264        let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1265        assert!(summary.incremental);
1266        assert_eq!(summary.deleted, 1);
1267
1268        let g =
1269            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "v")).unwrap();
1270        assert_eq!(g.definitions("keep").len(), 1);
1271        assert!(g.definitions("gone").is_empty(), "deleted file is gone");
1272    }
1273
1274    #[tokio::test]
1275    async fn non_git_src_stays_full_walk() {
1276        let root = TempDir::new().unwrap();
1277        let src = TempDir::new().unwrap();
1278        write_file(src.path(), "a.rs", "fn a() {}");
1279        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1280        assert!(!summary.incremental, "a non-git tree cannot go incremental");
1281    }
1282
1283    // ── gc: sweep orphaned slices across all live views (gonzalo#94) ─────────
1284
1285    #[tokio::test]
1286    async fn gc_on_empty_store_frees_nothing() {
1287        let root = TempDir::new().unwrap();
1288        let summary = gc(root.path()).await.unwrap();
1289        assert_eq!(summary.manifests, 0);
1290        assert_eq!(summary.freed, 0);
1291        assert_eq!(summary.retained, 0);
1292    }
1293
1294    #[tokio::test]
1295    async fn gc_frees_slices_orphaned_by_a_reindex() {
1296        let root = TempDir::new().unwrap();
1297        let src = TempDir::new().unwrap();
1298        write_file(src.path(), "a.rs", "fn a() {}");
1299        index(root.path(), src.path(), "r", "main").await.unwrap();
1300
1301        // Reindex with different content: the original slice is now orphaned.
1302        write_file(src.path(), "a.rs", "fn a() { b(); }");
1303        index(root.path(), src.path(), "r", "main").await.unwrap();
1304
1305        let summary = gc(root.path()).await.unwrap();
1306        assert_eq!(summary.manifests, 1);
1307        assert_eq!(summary.freed, 1, "the pre-edit slice is unreferenced");
1308        assert_eq!(summary.retained, 1, "the current slice stays");
1309
1310        // GC did not corrupt the live view.
1311        let g =
1312            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
1313        assert_eq!(g.definitions("a")[0].path, "a.rs");
1314    }
1315
1316    // ── index: view membership (#209) ────────────────────────────────────────
1317
1318    #[tokio::test]
1319    async fn index_excludes_vendored_bundles_from_the_view() {
1320        let root = TempDir::new().unwrap();
1321        let src = TempDir::new().unwrap();
1322        write_file(src.path(), "a.rs", "fn a() {}");
1323        write_file(src.path(), "mermaid.min.js", "var a=1,e=2,t=3;");
1324
1325        let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1326        assert_eq!(summary.files, 1, "only the hand-written source");
1327        assert_eq!(summary.ignored.files, 1, "the minified bundle, reported");
1328
1329        let graph =
1330            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
1331        assert!(
1332            graph.all_symbols().iter().all(|s| s.path == "a.rs"),
1333            "no symbol may come from a vendored bundle"
1334        );
1335    }
1336
1337    #[tokio::test]
1338    async fn incremental_reindex_prunes_paths_a_laxer_run_admitted() {
1339        // The upgrade path: a view indexed before the filter existed still holds
1340        // vendored bundles. They never change, so they never appear in the git
1341        // diff, and once a base commit is recorded there is no full walk — so
1342        // without pruning the carried-forward set they would persist forever.
1343        let root = TempDir::new().unwrap();
1344        let src = TempDir::new().unwrap();
1345        write_file(src.path(), "a.rs", "fn a() {}");
1346        write_file(src.path(), "vendor.min.js", "var a=1,e=2;");
1347
1348        // Index once with a filter that admits the bundle, standing in for the
1349        // pre-#209 behaviour.
1350        let lax = IndexFilter::new(&["vendor.min.js".to_string()]);
1351        let first = index_with(root.path(), src.path(), "r", "main", &lax)
1352            .await
1353            .unwrap();
1354        assert_eq!(first.files, 2, "the bundle is in the view to begin with");
1355
1356        // Re-index with the default rules. The bundle is unchanged, so only the
1357        // carried-forward prune can remove it.
1358        let second = index_with(
1359            root.path(),
1360            src.path(),
1361            "r",
1362            "main",
1363            &IndexFilter::default(),
1364        )
1365        .await
1366        .unwrap();
1367        assert_eq!(second.deleted, 1, "the bundle is dropped from the view");
1368        assert_eq!(second.ignored.files, 1);
1369
1370        let graph =
1371            SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
1372        assert!(
1373            graph.all_symbols().iter().all(|s| s.path == "a.rs"),
1374            "no vendored symbol may survive the re-index"
1375        );
1376    }
1377
1378    #[tokio::test]
1379    async fn incremental_reindex_keeps_paths_the_filter_still_admits() {
1380        // The prune must not eat ordinary carried-forward files.
1381        let root = TempDir::new().unwrap();
1382        let src = TempDir::new().unwrap();
1383        write_file(src.path(), "a.rs", "fn a() {}");
1384        write_file(src.path(), "b.rs", "fn b() {}");
1385        index(root.path(), src.path(), "r", "main").await.unwrap();
1386
1387        let second = index(root.path(), src.path(), "r", "main").await.unwrap();
1388        assert_eq!(second.deleted, 0, "nothing legitimate is pruned");
1389        assert_eq!(second.ignored.files, 0);
1390    }
1391
1392    #[tokio::test]
1393    async fn index_can_be_told_to_keep_a_vendored_path() {
1394        let root = TempDir::new().unwrap();
1395        let src = TempDir::new().unwrap();
1396        write_file(src.path(), "a.rs", "fn a() {}");
1397        write_file(src.path(), "keep.min.js", "function f(){}");
1398
1399        let filter = IndexFilter::new(&["keep.min.js".to_string()]);
1400        let summary = index_with(root.path(), src.path(), "r", "main", &filter)
1401            .await
1402            .unwrap();
1403        assert_eq!(summary.files, 2, "the override re-admits it");
1404        assert_eq!(summary.ignored.files, 0);
1405    }
1406
1407    #[tokio::test]
1408    async fn index_with_gc_sweeps_orphans_when_enabled() {
1409        let root = TempDir::new().unwrap();
1410        let src = TempDir::new().unwrap();
1411        write_file(src.path(), "a.rs", "fn a() {}");
1412        index(root.path(), src.path(), "r", "main").await.unwrap();
1413
1414        // Reindex changed content with the post-index sweep on: the pre-edit
1415        // slice is orphaned and should be freed in the same call.
1416        write_file(src.path(), "a.rs", "fn a() { b(); }");
1417        let (_summary, swept) = index_with_gc(root.path(), src.path(), "r", "main", true)
1418            .await
1419            .unwrap();
1420        let swept = swept.expect("gc runs when enabled");
1421        assert_eq!(swept.freed, 1, "orphaned slice swept during the index");
1422
1423        // A follow-up gc finds nothing left to free.
1424        assert_eq!(gc(root.path()).await.unwrap().freed, 0);
1425    }
1426
1427    #[tokio::test]
1428    async fn index_with_gc_skips_sweep_when_disabled() {
1429        let root = TempDir::new().unwrap();
1430        let src = TempDir::new().unwrap();
1431        write_file(src.path(), "a.rs", "fn a() {}");
1432        index(root.path(), src.path(), "r", "main").await.unwrap();
1433
1434        write_file(src.path(), "a.rs", "fn a() { b(); }");
1435        let (_summary, swept) = index_with_gc(root.path(), src.path(), "r", "main", false)
1436            .await
1437            .unwrap();
1438        assert!(swept.is_none(), "no gc when disabled");
1439
1440        // The orphan survived: an explicit gc still has one to free.
1441        assert_eq!(gc(root.path()).await.unwrap().freed, 1);
1442    }
1443
1444    // ── watch: debounce core (gonzalo#100) ──────────────────────────────────
1445
1446    #[test]
1447    fn debouncer_not_due_without_events() {
1448        let d = Debouncer::new(Duration::from_millis(500));
1449        assert!(!d.is_pending());
1450        assert!(!d.is_due(Instant::now()));
1451    }
1452
1453    #[test]
1454    fn debouncer_due_only_after_quiet_window() {
1455        let t0 = Instant::now();
1456        let mut d = Debouncer::new(Duration::from_millis(500));
1457        d.on_event(t0);
1458        assert!(d.is_pending());
1459        // Still inside the window → not due.
1460        assert!(!d.is_due(t0 + Duration::from_millis(499)));
1461        // Window elapsed → due.
1462        assert!(d.is_due(t0 + Duration::from_millis(500)));
1463    }
1464
1465    #[test]
1466    fn debouncer_coalesces_a_burst() {
1467        let t0 = Instant::now();
1468        let mut d = Debouncer::new(Duration::from_millis(500));
1469        d.on_event(t0);
1470        d.on_event(t0 + Duration::from_millis(200)); // second edit resets the clock
1471        // 500ms after the *first* event is not enough — the burst is still hot.
1472        assert!(!d.is_due(t0 + Duration::from_millis(500)));
1473        // 500ms after the *last* event → due, and only once for the whole burst.
1474        assert!(d.is_due(t0 + Duration::from_millis(700)));
1475    }
1476
1477    #[test]
1478    fn debouncer_clear_resets_pending() {
1479        let t0 = Instant::now();
1480        let mut d = Debouncer::new(Duration::from_millis(500));
1481        d.on_event(t0);
1482        d.clear();
1483        assert!(!d.is_pending());
1484        assert!(!d.is_due(t0 + Duration::from_secs(10)));
1485    }
1486
1487    #[tokio::test]
1488    async fn gc_retains_slice_still_referenced_by_another_view() {
1489        let root = TempDir::new().unwrap();
1490
1491        // Two views index the *same* file content → one shared, deduped slice.
1492        let src_a = TempDir::new().unwrap();
1493        write_file(src_a.path(), "shared.rs", "fn shared() {}");
1494        index(root.path(), src_a.path(), "r", "a").await.unwrap();
1495
1496        let src_b = TempDir::new().unwrap();
1497        write_file(src_b.path(), "shared.rs", "fn shared() {}");
1498        index(root.path(), src_b.path(), "r", "b").await.unwrap();
1499
1500        // Remove the file from view A only; view B still references the slice.
1501        std::fs::remove_file(src_a.path().join("shared.rs")).unwrap();
1502        index(root.path(), src_a.path(), "r", "a").await.unwrap();
1503
1504        let summary = gc(root.path()).await.unwrap();
1505        assert_eq!(summary.manifests, 2);
1506        assert_eq!(
1507            summary.freed, 0,
1508            "the shared slice is live via view B and must not be swept"
1509        );
1510
1511        // View B still assembles and answers with the shared symbol.
1512        let store = FsStore::new(root.path());
1513        let manifest = Manifest::from_body(
1514            &store
1515                .get(&Manifest::key("r", "b"))
1516                .await
1517                .unwrap()
1518                .unwrap()
1519                .body,
1520        )
1521        .unwrap();
1522        let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1523        assert_eq!(graph.definitions("shared")[0].path, "shared.rs");
1524    }
1525}