Skip to main content

reference_query/store/
mod.rs

1//! Storage — SQLite (WAL mode), schema, and queries.
2//!
3//! The background indexer writes here; search reads. WAL mode lets those
4//! happen concurrently. See `docs/ARCHITECTURE.md` for the schema.
5
6mod schema;
7
8use std::collections::HashMap;
9use std::path::Path;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use rusqlite::{Connection, OptionalExtension, params};
13
14use crate::core::{RepoIdentity, Symbol};
15
16pub type Result<T> = rusqlite::Result<T>;
17
18/// A symbol as returned by search candidate queries (joined with its file and
19/// repository for display and ranking).
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SymbolRow {
22    pub name: String,
23    pub kind: String,
24    pub language: String,
25    pub file: String,
26    pub line: i64,
27    /// 1-based last line of the definition body; `None` for rows indexed before
28    /// end-line tracking (they backfill on the next re-extract).
29    pub end_line: Option<i64>,
30    pub parent: Option<String>,
31    pub repository_id: i64,
32    pub repo_identity: String,
33    /// File mtime (unix *nanoseconds*) — a recency signal.
34    pub mtime: Option<i64>,
35    /// Last git commit time touching the file — the stronger recency signal.
36    pub git_ts: Option<i64>,
37    /// Access level (`public`/`crate`/`private`/`protected`) when the language
38    /// expresses one; `None` for unknown (or pre-v9 rows). A ranking hint.
39    pub visibility: Option<String>,
40}
41
42/// A learned selection signal for ranking: how often a `(file, name)` was
43/// chosen for a query, and when it was last chosen.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SelectionStat {
46    pub repository_id: i64,
47    pub file: String,
48    pub name: String,
49    pub selections: i64,
50    pub last_selected_at: i64,
51}
52
53/// Column projection shared by the candidate queries. Column order is consumed
54/// by [`row_to_candidate`].
55const CANDIDATE_COLS: &str = "s.id, s.name, s.kind, s.language, fi.path, s.line, \
56    s.end_line, s.parent, s.repository_id, r.identity, fi.mtime, fi.git_ts, s.visibility";
57const CANDIDATE_FROM: &str = "FROM symbols s \
58    JOIN files fi ON fi.id = s.file_id \
59    JOIN repositories r ON r.id = s.repository_id";
60
61/// A handle to the rq database.
62pub struct Store {
63    conn: Connection,
64}
65
66impl Drop for Store {
67    fn drop(&mut self) {
68        // SQLite's recommended pre-close hygiene: refreshes planner statistics
69        // for the query shapes this connection actually ran. Cheap, best-effort.
70        let _ = self.conn.execute_batch("PRAGMA optimize;");
71    }
72}
73
74/// A parsed file ready to persist — the unit the indexer produces (in parallel)
75/// and [`Store::replace_files`] writes in one batched transaction.
76#[derive(Debug, Clone)]
77pub struct FileSymbols {
78    pub path: String,
79    pub language: String,
80    pub mtime: Option<i64>,
81    pub content_hash: String,
82    pub symbols: Vec<Symbol>,
83}
84
85/// One row of `rq status` output — the current indexed totals for a repo (not
86/// any single run's incremental counts).
87#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
88pub struct CoverageRow {
89    /// Repository identity (`github.com/org/repo` or `local:/path`). Named `repo`
90    /// in JSON, matching the search result field.
91    #[serde(rename = "repo")]
92    pub identity: String,
93    pub status: String,
94    pub files: i64,
95    pub symbols: i64,
96}
97
98impl Store {
99    /// Open (creating if needed) the database at `path`, enabling WAL and
100    /// applying the schema.
101    pub fn open(path: &Path) -> Result<Store> {
102        let conn = Connection::open(path)?;
103        Self::init(conn)
104    }
105
106    /// Open an in-memory database — used by tests.
107    pub fn open_in_memory() -> Result<Store> {
108        let conn = Connection::open_in_memory()?;
109        Self::init(conn)
110    }
111
112    fn init(conn: Connection) -> Result<Store> {
113        // WAL lets one writer and many readers coexist; busy_timeout makes a
114        // second writer (e.g. two `rq` processes in two terminals, both warming)
115        // wait briefly instead of erroring with "database is locked".
116        conn.execute_batch(
117            "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=3000; \
118             PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY; PRAGMA cache_size=-16384;",
119        )?;
120        let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
121        if version == 0 {
122            // fresh database — SCHEMA is already at the current version
123            conn.execute_batch(schema::SCHEMA)?;
124            conn.execute_batch(schema::FTS_INSERT_TRIGGER)?;
125        } else {
126            // cumulative migrations for existing databases
127            for (v, sql) in schema::MIGRATIONS {
128                if version < v {
129                    conn.execute_batch(sql)?;
130                }
131            }
132        }
133        if version != schema::VERSION {
134            conn.pragma_update(None, "user_version", schema::VERSION)?;
135        }
136        Ok(Store { conn })
137    }
138
139    /// Insert or update a repository, returning its id.
140    pub fn upsert_repository(
141        &self,
142        identity: &RepoIdentity,
143        default_branch: Option<&str>,
144    ) -> Result<i64> {
145        let now = now_unix();
146        self.conn.query_row(
147            "INSERT INTO repositories (identity, default_branch, created_at, updated_at)
148             VALUES (?1, ?2, ?3, ?3)
149             ON CONFLICT(identity) DO UPDATE SET
150               default_branch = COALESCE(excluded.default_branch, repositories.default_branch),
151               updated_at = excluded.updated_at
152             RETURNING id",
153            params![identity.to_string(), default_branch, now],
154            |r| r.get(0),
155        )
156    }
157
158    /// Record (or update) a local checkout of a repository.
159    pub fn upsert_checkout(
160        &self,
161        repository_id: i64,
162        root_path: &str,
163        branch: Option<&str>,
164    ) -> Result<()> {
165        self.conn.execute(
166            "INSERT INTO checkouts (repository_id, root_path, current_branch)
167             VALUES (?1, ?2, ?3)
168             ON CONFLICT(root_path) DO UPDATE SET
169               repository_id = excluded.repository_id,
170               current_branch = excluded.current_branch",
171            params![repository_id, root_path, branch],
172        )?;
173        Ok(())
174    }
175
176    /// True if `path` is already indexed at this exact content hash — the
177    /// incremental-skip check.
178    pub fn file_unchanged(
179        &self,
180        repository_id: i64,
181        path: &str,
182        content_hash: &str,
183    ) -> Result<bool> {
184        let stored: Option<String> = self
185            .conn
186            .query_row(
187                "SELECT content_hash FROM files WHERE repository_id = ?1 AND path = ?2",
188                params![repository_id, path],
189                |r| r.get(0),
190            )
191            .optional()?;
192        Ok(stored.as_deref() == Some(content_hash))
193    }
194
195    /// Indexed path → stored mtime for a repository. The budgeted warm pass uses
196    /// this to skip unchanged files with a cheap `stat` (no read or re-hash).
197    pub fn file_mtimes(&self, repository_id: i64) -> Result<HashMap<String, Option<i64>>> {
198        let mut stmt = self
199            .conn
200            .prepare("SELECT path, mtime FROM files WHERE repository_id = ?1")?;
201        let rows = stmt.query_map(params![repository_id], |r| {
202            Ok((r.get::<_, String>(0)?, r.get::<_, Option<i64>>(1)?))
203        })?;
204        let mut map = HashMap::new();
205        for row in rows {
206            let (path, mtime) = row?;
207            map.insert(path, mtime);
208        }
209        Ok(map)
210    }
211
212    /// Replace all symbols for one file — the single-file form of
213    /// [`Store::replace_files`] (same upsert, hash-skip, and batching).
214    pub fn replace_file_symbols(
215        &mut self,
216        repository_id: i64,
217        path: &str,
218        language: &str,
219        mtime: Option<i64>,
220        content_hash: &str,
221        symbols: &[Symbol],
222    ) -> Result<()> {
223        self.replace_files(
224            repository_id,
225            &[FileSymbols {
226                path: path.to_string(),
227                language: language.to_string(),
228                mtime,
229                content_hash: content_hash.to_string(),
230                symbols: symbols.to_vec(),
231            }],
232        )?;
233        Ok(())
234    }
235
236    /// Write many parsed files, one transaction per chunk — a batched `fsync`
237    /// instead of one per file, while bounding how much a single transaction
238    /// holds (a cold index of a huge repo would otherwise be one enormous txn).
239    /// A file whose content hash already matches the index is skipped (not
240    /// rewritten). Returns `(files_written, symbols_written)`; skips don't count.
241    pub fn replace_files(
242        &mut self,
243        repository_id: i64,
244        files: &[FileSymbols],
245    ) -> Result<(usize, usize)> {
246        /// Files per transaction — bounds memory and WAL frame size on a big index.
247        const BATCH: usize = 512;
248
249        let now = now_unix();
250        let mut files_written = 0;
251        let mut symbols_written = 0;
252        for chunk in files.chunks(BATCH) {
253            let tx = self.conn.transaction()?;
254            {
255                let mut upsert = tx.prepare(
256                    "INSERT INTO files (repository_id, path, language, mtime, content_hash, indexed_at)
257                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)
258                     ON CONFLICT(repository_id, path) DO UPDATE SET
259                       language = excluded.language,
260                       mtime = excluded.mtime,
261                       content_hash = excluded.content_hash,
262                       indexed_at = excluded.indexed_at
263                     RETURNING id",
264                )?;
265                let mut current = tx.prepare(
266                    "SELECT content_hash FROM files WHERE repository_id = ?1 AND path = ?2",
267                )?;
268                let mut touch = tx.prepare(
269                    "UPDATE files SET mtime = ?3, indexed_at = ?4
270                     WHERE repository_id = ?1 AND path = ?2",
271                )?;
272                let mut clear = tx.prepare("DELETE FROM symbols WHERE file_id = ?1")?;
273                let mut insert = tx.prepare(
274                    "INSERT INTO symbols
275                       (repository_id, file_id, name, name_lower, kind, language, line, end_line,
276                        parent, visibility)
277                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
278                )?;
279                for f in chunk {
280                    // content unchanged (e.g. mtime moved but bytes didn't): skip
281                    // the rewrite, but refresh the stat columns — otherwise a
282                    // touched (or racily-indexed) file re-parses on every warm
283                    let stored: Option<String> = current
284                        .query_row(params![repository_id, f.path], |r| r.get(0))
285                        .optional()?;
286                    if stored.as_deref() == Some(f.content_hash.as_str()) {
287                        touch.execute(params![repository_id, f.path, f.mtime, now])?;
288                        continue;
289                    }
290                    let file_id: i64 = upsert.query_row(
291                        params![
292                            repository_id,
293                            f.path,
294                            f.language,
295                            f.mtime,
296                            f.content_hash,
297                            now
298                        ],
299                        |r| r.get(0),
300                    )?;
301                    clear.execute(params![file_id])?;
302                    for s in &f.symbols {
303                        insert.execute(params![
304                            repository_id,
305                            file_id,
306                            s.name,
307                            s.name.to_lowercase(),
308                            s.kind.as_str(),
309                            s.language,
310                            s.line,
311                            s.end_line,
312                            s.parent,
313                            s.visibility,
314                        ])?;
315                    }
316                    files_written += 1;
317                    symbols_written += f.symbols.len();
318                }
319            }
320            tx.commit()?;
321        }
322        Ok((files_written, symbols_written))
323    }
324
325    /// Suspend per-row FTS maintenance for a cold bulk index: drop the
326    /// `AFTER INSERT` trigger so symbol inserts skip the expensive per-row
327    /// trigram tokenization. Pair with [`rebuild_fts`](Self::rebuild_fts), which
328    /// rebuilds the index in one pass and restores the trigger. No-op safe to
329    /// call when the trigger is already gone.
330    pub fn defer_fts_insert(&self) -> Result<()> {
331        self.conn
332            .execute_batch("DROP TRIGGER IF EXISTS symbols_ai;")?;
333        Ok(())
334    }
335
336    /// Rebuild the trigram FTS index from the symbols table in one bulk pass —
337    /// far cheaper than the per-row trigger on a cold index — then recreate the
338    /// `AFTER INSERT` trigger so later incremental writes stay in sync. The
339    /// inverse of [`defer_fts_insert`](Self::defer_fts_insert). One transaction:
340    /// a concurrent writer either lands before the rebuild (and is captured by
341    /// it — the rebuild scans the whole symbols table) or after the trigger is
342    /// back, never in between.
343    pub fn rebuild_fts(&self) -> Result<()> {
344        let sql = format!(
345            "BEGIN IMMEDIATE;\nINSERT INTO symbols_fts(symbols_fts) VALUES('rebuild');\n{}\nCOMMIT;",
346            schema::FTS_INSERT_TRIGGER
347        );
348        self.conn.execute_batch(&sql)?;
349        Ok(())
350    }
351
352    /// Whether the `AFTER INSERT` FTS-sync trigger is currently absent — true
353    /// only mid-bulk-index (see [`defer_fts_insert`](Self::defer_fts_insert))
354    /// or after one crashed before its [`rebuild_fts`](Self::rebuild_fts).
355    pub fn fts_trigger_missing(&self) -> Result<bool> {
356        let n: i64 = self.conn.query_row(
357            "SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name='symbols_ai'",
358            [],
359            |r| r.get(0),
360        )?;
361        Ok(n == 0)
362    }
363
364    /// Record indexing coverage for a repository (scope `full`).
365    pub fn set_coverage(
366        &self,
367        repository_id: i64,
368        files_seen: i64,
369        files_indexed: i64,
370        status: &str,
371    ) -> Result<()> {
372        let now = now_unix();
373        self.conn.execute(
374            "INSERT INTO coverage
375               (repository_id, scope, files_seen, files_indexed, status, last_indexed_at)
376             VALUES (?1, 'full', ?2, ?3, ?4, ?5)
377             ON CONFLICT(repository_id, scope) DO UPDATE SET
378               files_seen = excluded.files_seen,
379               files_indexed = excluded.files_indexed,
380               status = excluded.status,
381               last_indexed_at = excluded.last_indexed_at",
382            params![repository_id, files_seen, files_indexed, status, now],
383        )?;
384        Ok(())
385    }
386
387    /// Set the last-commit time for files in a repository, from a path → unix-ts
388    /// map (git log). Files not in the map are left untouched.
389    pub fn set_file_git_ts(
390        &mut self,
391        repository_id: i64,
392        times: &HashMap<String, i64>,
393    ) -> Result<()> {
394        let tx = self.conn.transaction()?;
395        {
396            let mut stmt =
397                tx.prepare("UPDATE files SET git_ts = ?3 WHERE repository_id = ?1 AND path = ?2")?;
398            for (path, ts) in times {
399                stmt.execute(params![repository_id, path, ts])?;
400            }
401        }
402        tx.commit()
403    }
404
405    /// All known repositories with their coverage status and current totals.
406    pub fn coverage_overview(&self) -> Result<Vec<CoverageRow>> {
407        let mut stmt = self.conn.prepare(
408            "SELECT r.identity,
409                    COALESCE(c.status, 'never'),
410                    (SELECT COUNT(*) FROM files fi WHERE fi.repository_id = r.id),
411                    (SELECT COUNT(*) FROM symbols s WHERE s.repository_id = r.id)
412             FROM repositories r
413             LEFT JOIN coverage c ON c.repository_id = r.id AND c.scope = 'full'
414             ORDER BY r.identity",
415        )?;
416        let rows = stmt
417            .query_map([], |r| {
418                Ok(CoverageRow {
419                    identity: r.get(0)?,
420                    status: r.get(1)?,
421                    files: r.get(2)?,
422                    symbols: r.get(3)?,
423                })
424            })?
425            .collect::<Result<Vec<_>>>()?;
426        Ok(rows)
427    }
428
429    /// The normalized identity of a repository by one of its checkout roots, if
430    /// known — lets the hot path resolve identity from the cache instead of
431    /// forking `git remote`. `root` should be the canonical work-tree path.
432    pub fn identity_for_root(&self, root: &str) -> Result<Option<String>> {
433        self.conn
434            .query_row(
435                "SELECT r.identity FROM repositories r
436                 JOIN checkouts c ON c.repository_id = r.id
437                 WHERE c.root_path = ?1",
438                params![root],
439                |r| r.get(0),
440            )
441            .optional()
442    }
443
444    /// The id of a repository by its normalized identity, if known.
445    pub fn repository_id(&self, identity: &str) -> Result<Option<i64>> {
446        self.conn
447            .query_row(
448                "SELECT id FROM repositories WHERE identity = ?1",
449                params![identity],
450                |r| r.get(0),
451            )
452            .optional()
453    }
454
455    /// Coverage status for a repository's full scope (`never`/`warming`/
456    /// `complete`), or `None` if the repository is unknown.
457    pub fn coverage_status(&self, identity: &str) -> Result<Option<String>> {
458        self.conn
459            .query_row(
460                "SELECT c.status FROM coverage c
461                 JOIN repositories r ON r.id = c.repository_id
462                 WHERE r.identity = ?1 AND c.scope = 'full'",
463                params![identity],
464                |r| r.get(0),
465            )
466            .optional()
467    }
468
469    /// Current indexed totals for a repository: (files, symbols).
470    pub fn repo_totals(&self, repository_id: i64) -> Result<(i64, i64)> {
471        self.conn.query_row(
472            "SELECT (SELECT COUNT(*) FROM files WHERE repository_id = ?1),
473                    (SELECT COUNT(*) FROM symbols WHERE repository_id = ?1)",
474            params![repository_id],
475            |r| Ok((r.get(0)?, r.get(1)?)),
476        )
477    }
478
479    /// Every symbol defined in one file (repo-relative path), in line order — a
480    /// structural outline rather than a ranked search. Backed by `idx_symbols_file`.
481    pub fn symbols_in_file(&self, repository_id: i64, path: &str) -> Result<Vec<SymbolRow>> {
482        let sql = format!(
483            "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
484             WHERE s.repository_id = ?1 AND fi.path = ?2 \
485             ORDER BY s.line, s.name"
486        );
487        let mut stmt = self.conn.prepare(&sql)?;
488        let rows = stmt.query_map(params![repository_id, path], row_to_candidate)?;
489        let mut out = Vec::new();
490        for row in rows {
491            out.push(row?.1);
492        }
493        Ok(out)
494    }
495
496    /// The on-disk root of a repository's checkout, used to resolve relative
497    /// paths when validating staleness.
498    pub fn checkout_root(&self, repository_id: i64) -> Result<Option<String>> {
499        self.conn
500            .query_row(
501                "SELECT root_path FROM checkouts WHERE repository_id = ?1 ORDER BY id LIMIT 1",
502                params![repository_id],
503                |r| r.get(0),
504            )
505            .optional()
506    }
507
508    /// Every checkout root recorded for a repository, newest first. A repo can
509    /// have more than one (it was moved or cloned twice, both under the same
510    /// remote identity), and an old row may be stale — so callers that read files
511    /// try these in order (current checkout before a stale one).
512    pub fn checkout_roots(&self, repository_id: i64) -> Result<Vec<String>> {
513        let mut stmt = self
514            .conn
515            .prepare("SELECT root_path FROM checkouts WHERE repository_id = ?1 ORDER BY id DESC")?;
516        let rows = stmt.query_map(params![repository_id], |r| r.get(0))?;
517        let mut out = Vec::new();
518        for r in rows {
519            out.push(r?);
520        }
521        Ok(out)
522    }
523
524    /// Drop a checkout row — used to prune a stale binding (a repo moved away
525    /// from `root_path`). Symbols/coverage are keyed by repo identity, not this
526    /// row, so forgetting a checkout only forgets *where* the repo was on disk.
527    pub fn forget_checkout(&mut self, root_path: &str) -> Result<()> {
528        self.conn.execute(
529            "DELETE FROM checkouts WHERE root_path = ?1",
530            params![root_path],
531        )?;
532        Ok(())
533    }
534
535    /// Drop a file and its symbols — used when a file has been deleted on disk.
536    pub fn forget_file(&mut self, repository_id: i64, path: &str) -> Result<()> {
537        let tx = self.conn.transaction()?;
538        let file_id: Option<i64> = tx
539            .query_row(
540                "SELECT id FROM files WHERE repository_id = ?1 AND path = ?2",
541                params![repository_id, path],
542                |r| r.get(0),
543            )
544            .optional()?;
545        if let Some(fid) = file_id {
546            tx.execute("DELETE FROM symbols WHERE file_id = ?1", params![fid])?;
547            tx.execute("DELETE FROM files WHERE id = ?1", params![fid])?;
548        }
549        tx.commit()
550    }
551
552    /// Drop a repository entirely — the inverse of indexing it: its symbols (and
553    /// their FTS rows, via trigger), files, coverage, learned selections, events,
554    /// checkout, and the repository row. Deleted in FK-safe order in one
555    /// transaction.
556    pub fn drop_repository(&mut self, repository_id: i64) -> Result<()> {
557        let tx = self.conn.transaction()?;
558        for sql in [
559            "DELETE FROM symbols WHERE repository_id = ?1",
560            "DELETE FROM files WHERE repository_id = ?1",
561            "DELETE FROM coverage WHERE repository_id = ?1",
562            "DELETE FROM selection_stats WHERE repository_id = ?1",
563            "DELETE FROM events WHERE repository_id = ?1",
564            "DELETE FROM checkouts WHERE repository_id = ?1",
565            "DELETE FROM repositories WHERE id = ?1",
566        ] {
567            tx.execute(sql, params![repository_id])?;
568        }
569        tx.commit()
570    }
571
572    // ----- behavioral learning -----
573
574    /// Append a raw interaction event (the cheap write on the hot path; rollup
575    /// happens later in [`Store::aggregate_events`]).
576    pub fn record_event(
577        &self,
578        kind: &str,
579        query: Option<&str>,
580        repository_id: Option<i64>,
581        path: Option<&str>,
582        line: Option<i64>,
583        branch: Option<&str>,
584    ) -> Result<()> {
585        self.conn.execute(
586            "INSERT INTO events (type, query, repository_id, path, line, branch, ts)
587             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
588            params![kind, query, repository_id, path, line, branch, now_unix()],
589        )?;
590        Ok(())
591    }
592
593    /// Learned selections relevant to a query, read by ranking. Matches not just
594    /// the exact query but any *shorter* query the user has selected for — a pick
595    /// for `han` informs `handler` — so typing more keeps the benefit.
596    pub fn selections_for(&self, query_norm: &str) -> Result<Vec<SelectionStat>> {
597        let mut stmt = self.conn.prepare_cached(
598            "SELECT repository_id, file, name, selections, last_selected_at
599             FROM selection_stats WHERE ?1 LIKE query_norm || '%'",
600        )?;
601        let rows = stmt
602            .query_map(params![query_norm], |r| {
603                Ok(SelectionStat {
604                    repository_id: r.get(0)?,
605                    file: r.get(1)?,
606                    name: r.get(2)?,
607                    selections: r.get(3)?,
608                    last_selected_at: r.get::<_, Option<i64>>(4)?.unwrap_or(0),
609                })
610            })?
611            .collect::<Result<Vec<_>>>()?;
612        Ok(rows)
613    }
614
615    /// Roll up to `batch` new `open`/`select` events into `selection_stats`.
616    /// Returns how many events were processed. Resolves the chosen symbol from
617    /// `(repo, path, line)` at rollup time, turning a selection into a
618    /// `(query, file, name)` signal. This is the amortized post-processing run
619    /// after a user interaction.
620    pub fn aggregate_events(&mut self, batch: usize) -> Result<usize> {
621        let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
622
623        type Pending = (
624            i64,
625            Option<String>,
626            Option<i64>,
627            Option<String>,
628            Option<i64>,
629            i64,
630        );
631        let pending: Vec<Pending> = {
632            let mut stmt = self.conn.prepare(
633                "SELECT id, query, repository_id, path, line, ts FROM events
634                 WHERE id > ?1 AND type IN ('select', 'open')
635                 ORDER BY id LIMIT ?2",
636            )?;
637            stmt.query_map(params![hwm, batch as i64], |r| {
638                Ok((
639                    r.get(0)?,
640                    r.get(1)?,
641                    r.get(2)?,
642                    r.get(3)?,
643                    r.get(4)?,
644                    r.get(5)?,
645                ))
646            })?
647            .collect::<Result<Vec<_>>>()?
648        };
649
650        if pending.is_empty() {
651            // advance past trailing non-selection events so we don't rescan them
652            let max_id: Option<i64> =
653                self.conn
654                    .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
655            if let Some(m) = max_id.filter(|m| *m > hwm) {
656                self.meta_set_i64("events_hwm", m)?;
657            }
658            return Ok(0);
659        }
660
661        let drained = pending.len() < batch;
662        let max_pending_id = pending.iter().map(|p| p.0).max().unwrap_or(hwm);
663
664        let tx = self.conn.transaction()?;
665        let mut processed = 0;
666        for (_id, query, repo, path, line, ts) in &pending {
667            processed += 1;
668            let (Some(query), Some(repo), Some(path)) = (query, repo, path) else {
669                continue;
670            };
671            let name: Option<String> = match line {
672                Some(line) => tx
673                    .query_row(
674                        "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
675                         WHERE s.repository_id = ?1 AND fi.path = ?2 AND s.line <= ?3
676                         ORDER BY s.line DESC LIMIT 1",
677                        params![repo, path, line],
678                        |r| r.get(0),
679                    )
680                    .optional()?,
681                None => tx
682                    .query_row(
683                        "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
684                         WHERE s.repository_id = ?1 AND fi.path = ?2
685                           AND s.kind IN ('class', 'module')
686                         ORDER BY s.line ASC LIMIT 1",
687                        params![repo, path],
688                        |r| r.get(0),
689                    )
690                    .optional()?,
691            };
692            if let Some(name) = name {
693                tx.execute(
694                    "INSERT INTO selection_stats
695                       (repository_id, query_norm, file, name, selections, last_selected_at)
696                     VALUES (?1, ?2, ?3, ?4, 1, ?5)
697                     ON CONFLICT(repository_id, query_norm, file, name) DO UPDATE SET
698                       selections = selections + 1,
699                       last_selected_at = max(last_selected_at, excluded.last_selected_at)",
700                    params![repo, query, path, name, ts],
701                )?;
702            }
703        }
704
705        let new_hwm = if drained {
706            tx.query_row("SELECT MAX(id) FROM events", [], |r| {
707                r.get::<_, Option<i64>>(0)
708            })?
709            .unwrap_or(max_pending_id)
710        } else {
711            max_pending_id
712        };
713        tx.execute(
714            "INSERT INTO meta (key, value) VALUES ('events_hwm', ?1)
715             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
716            params![new_hwm.to_string()],
717        )?;
718        tx.commit()?;
719        Ok(processed)
720    }
721
722    /// Keep the raw `events` log bounded. Deletes only events that have already
723    /// been rolled up (id ≤ the aggregation high-water mark) and are not among
724    /// the most recent `keep_recent` rows (which `is_repeat_search` needs).
725    /// Returns the number deleted.
726    pub fn prune_events(&self, keep_recent: i64) -> Result<usize> {
727        let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
728        let max_id: Option<i64> = self
729            .conn
730            .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
731        let Some(max_id) = max_id else {
732            return Ok(0);
733        };
734        let cutoff = hwm.min(max_id - keep_recent);
735        if cutoff <= 0 {
736            return Ok(0);
737        }
738        let n = self
739            .conn
740            .execute("DELETE FROM events WHERE id <= ?1", params![cutoff])?;
741        Ok(n)
742    }
743
744    /// The git HEAD sha recorded at the last complete index of a repo, if any —
745    /// used to detect that the committed tree is unchanged since indexing.
746    pub fn indexed_head(&self, repository_id: i64) -> Result<Option<String>> {
747        self.meta_get(&format!("head:{repository_id}"))
748    }
749
750    /// Record the git HEAD sha at a complete index.
751    pub fn set_indexed_head(&self, repository_id: i64, head: &str) -> Result<()> {
752        self.meta_set(&format!("head:{repository_id}"), head)
753    }
754
755    /// The git HEAD sha at the last commit-times capture (recency signal), if
756    /// any — lets the next capture read only the commits since, or skip the
757    /// `git log` entirely when HEAD hasn't moved.
758    pub fn git_ts_head(&self, repository_id: i64) -> Result<Option<String>> {
759        self.meta_get(&format!("git_ts_head:{repository_id}"))
760    }
761
762    /// Record the git HEAD sha a commit-times capture ran at.
763    pub fn set_git_ts_head(&self, repository_id: i64, head: &str) -> Result<()> {
764        self.meta_set(&format!("git_ts_head:{repository_id}"), head)
765    }
766
767    /// The detached-warm single-flight lock for a repo: `(pid, stamped_at)` of
768    /// the process that claimed it, if any. Liveness/staleness policy is the
769    /// caller's (the store just holds the record).
770    pub fn warm_lock(&self, identity: &str) -> Result<Option<(u32, i64)>> {
771        Ok(self
772            .meta_get(&format!("warm_lock:{identity}"))?
773            .and_then(|v| {
774                let (pid, ts) = v.split_once(':')?;
775                Some((pid.parse().ok()?, ts.parse().ok()?))
776            }))
777    }
778
779    /// Claim the detached-warm lock for this process.
780    pub fn set_warm_lock(&self, identity: &str, pid: u32) -> Result<()> {
781        self.meta_set(
782            &format!("warm_lock:{identity}"),
783            &format!("{pid}:{}", now_unix()),
784        )
785    }
786
787    /// Release the detached-warm lock.
788    pub fn clear_warm_lock(&self, identity: &str) -> Result<()> {
789        self.conn.execute(
790            "DELETE FROM meta WHERE key = ?1",
791            params![format!("warm_lock:{identity}")],
792        )?;
793        Ok(())
794    }
795
796    /// The cached branch-changed file list for a repo: `(stamp, computed_at,
797    /// files)`. Stored rather than recomputed because the git diff behind it is
798    /// O(tracked files) and runs on the search path.
799    pub fn branch_files_get(&self, identity: &str) -> Result<Option<(String, i64, Vec<String>)>> {
800        let Some(raw) = self.meta_get(&format!("branch_files:{identity}"))? else {
801            return Ok(None);
802        };
803        let mut lines = raw.lines();
804        let (Some(stamp), Some(at)) = (lines.next(), lines.next()) else {
805            return Ok(None);
806        };
807        let Ok(at) = at.parse::<i64>() else {
808            return Ok(None);
809        };
810        Ok(Some((
811            stamp.to_string(),
812            at,
813            lines.map(str::to_string).collect(),
814        )))
815    }
816
817    pub fn branch_files_set(
818        &self,
819        identity: &str,
820        stamp: &str,
821        at: i64,
822        files: &[String],
823    ) -> Result<()> {
824        // Newline-delimited: git paths can't contain one, and it beats pulling
825        // in a serializer for three fields.
826        let mut value = format!("{stamp}\n{at}");
827        for f in files {
828            value.push('\n');
829            value.push_str(f);
830        }
831        self.meta_set(&format!("branch_files:{identity}"), &value)
832    }
833
834    fn meta_get(&self, key: &str) -> Result<Option<String>> {
835        self.conn
836            .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
837                r.get(0)
838            })
839            .optional()
840    }
841
842    fn meta_set(&self, key: &str, value: &str) -> Result<()> {
843        self.conn.execute(
844            "INSERT INTO meta (key, value) VALUES (?1, ?2)
845             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
846            params![key, value],
847        )?;
848        Ok(())
849    }
850
851    fn meta_get_i64(&self, key: &str) -> Result<Option<i64>> {
852        Ok(self.meta_get(key)?.and_then(|s| s.parse().ok()))
853    }
854
855    fn meta_set_i64(&self, key: &str, value: i64) -> Result<()> {
856        self.meta_set(key, &value.to_string())
857    }
858
859    /// Candidate symbols for a query, drawn from cheap layers and merged:
860    /// exact/prefix on `name_lower`, then broad fuzzy recall (first-char anchor,
861    /// trigram FTS, path). Ranking happens in `crate::search`; this only narrows
862    /// the field.
863    ///
864    /// When `force_fuzzy` is false and exact/prefix already matched, the broad
865    /// fuzzy layers are skipped: the relevance gate drops every fuzzy candidate
866    /// once a strong (exact/prefix) hit exists, so fetching and scoring them is
867    /// wasted. A wildcard query passes `force_fuzzy = true` — it isn't gated and
868    /// always needs the trigram recall.
869    pub fn search_candidates(
870        &self,
871        query: &str,
872        limit: usize,
873        force_fuzzy: bool,
874    ) -> Result<Vec<SymbolRow>> {
875        let q = query.to_ascii_lowercase();
876        let mut found: HashMap<i64, SymbolRow> = HashMap::new();
877
878        // exact name — always included, never subject to the cap. The
879        // match we most want must reach the scorer no matter how large the index
880        // is (a broad capped scan could otherwise truncate it away).
881        {
882            let sql = format!(
883                "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} WHERE s.name_lower = ?1 LIMIT ?2"
884            );
885            let mut stmt = self.conn.prepare_cached(&sql)?;
886            let rows = stmt.query_map(params![q, limit as i64], row_to_candidate)?;
887            for row in rows {
888                let (id, cand) = row?;
889                found.insert(id, cand);
890            }
891        }
892
893        // query as a prefix — selective, so prefix matches always
894        // surface even on a huge repo (unlike the broad first-char anchor below,
895        // which the cap can truncate).
896        {
897            let like = format!("{}%", escape_like(&q));
898            let sql = format!(
899                "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
900                 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
901            );
902            let mut stmt = self.conn.prepare_cached(&sql)?;
903            let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
904            for row in rows {
905                let (id, cand) = row?;
906                found.entry(id).or_insert(cand);
907            }
908        }
909
910        // Fast path: a strong (exact/prefix) match exists, so the relevance gate
911        // will discard everything the broad layers below would add. Skip them —
912        // identical results, no wasted fetch/score. (Wildcard queries force the
913        // fuzzy layers; they aren't gated.)
914        if !force_fuzzy && !found.is_empty() {
915            return Ok(found.into_values().collect());
916        }
917
918        // fuzzy recall (a): first-character anchor (index-backed scan) for short
919        // skip-abbreviations like `usr → user` that prefix matching can't reach;
920        // the scorer filters and ranks. Best-effort under the cap — exact and
921        // prefix are already guaranteed above.
922        if let Some(first) = q.chars().next() {
923            let like = format!("{}%", escape_like(&first.to_string()));
924            let sql = format!(
925                "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
926                 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
927            );
928            let mut stmt = self.conn.prepare_cached(&sql)?;
929            let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
930            for row in rows {
931                let (id, cand) = row?;
932                found.entry(id).or_insert(cand);
933            }
934        }
935
936        // fuzzy recall (b): trigram FTS (OR of the query's trigrams).
937        if let Some(match_expr) = trigram_or_query(&q) {
938            let sql = format!(
939                "SELECT {CANDIDATE_COLS} FROM symbols_fts f \
940                 JOIN symbols s ON s.id = f.rowid \
941                 JOIN files fi ON fi.id = s.file_id \
942                 JOIN repositories r ON r.id = s.repository_id \
943                 WHERE symbols_fts MATCH ?1 LIMIT ?2"
944            );
945            let mut stmt = self.conn.prepare_cached(&sql)?;
946            let rows = stmt.query_map(params![match_expr, limit as i64], row_to_candidate)?;
947            for row in rows {
948                let (id, cand) = row?;
949                found.entry(id).or_insert(cand);
950            }
951        }
952
953        // path recall: primary definitions in files whose path matches the query,
954        // so `billing` can surface the class defined in `billing.rb`.
955        let path_like = format!("%{}%", escape_like(&q));
956        let sql = format!(
957            "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
958             WHERE fi.path LIKE ?1 ESCAPE '\\' AND s.kind IN ('class', 'module') LIMIT ?2"
959        );
960        {
961            let mut stmt = self.conn.prepare_cached(&sql)?;
962            let rows = stmt.query_map(params![path_like, limit as i64], row_to_candidate)?;
963            for row in rows {
964                let (id, cand) = row?;
965                found.entry(id).or_insert(cand);
966            }
967        }
968
969        Ok(found.into_values().collect())
970    }
971}
972
973fn row_to_candidate(r: &rusqlite::Row) -> Result<(i64, SymbolRow)> {
974    Ok((
975        r.get(0)?,
976        SymbolRow {
977            name: r.get(1)?,
978            kind: r.get(2)?,
979            language: r.get(3)?,
980            file: r.get(4)?,
981            line: r.get(5)?,
982            end_line: r.get(6)?,
983            parent: r.get(7)?,
984            repository_id: r.get(8)?,
985            repo_identity: r.get(9)?,
986            mtime: r.get(10)?,
987            git_ts: r.get(11)?,
988            visibility: r.get(12)?,
989        },
990    ))
991}
992
993/// Escape LIKE wildcards so identifier characters (`_`) are matched literally.
994fn escape_like(s: &str) -> String {
995    s.replace('\\', "\\\\")
996        .replace('%', "\\%")
997        .replace('_', "\\_")
998}
999
1000/// Build an FTS5 `MATCH` expression that ORs the query's trigrams, giving broad
1001/// recall (any shared trigram makes a candidate). `None` if the query is too
1002/// short to form a trigram.
1003fn trigram_or_query(q: &str) -> Option<String> {
1004    let cleaned: Vec<char> = q
1005        .chars()
1006        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1007        .collect();
1008    if cleaned.len() < 3 {
1009        return None;
1010    }
1011    let mut grams: Vec<String> = Vec::new();
1012    for w in cleaned.windows(3) {
1013        let gram: String = w.iter().collect();
1014        let quoted = format!("\"{gram}\"");
1015        if !grams.contains(&quoted) {
1016            grams.push(quoted);
1017        }
1018    }
1019    Some(grams.join(" OR "))
1020}
1021
1022fn now_unix() -> i64 {
1023    SystemTime::now()
1024        .duration_since(UNIX_EPOCH)
1025        .map(|d| d.as_secs() as i64)
1026        .unwrap_or(0)
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031    use super::*;
1032    use crate::core::Kind;
1033
1034    #[test]
1035    fn branch_files_round_trip() {
1036        let store = Store::open_in_memory().unwrap();
1037        assert!(store.branch_files_get("repo").unwrap().is_none());
1038
1039        let files = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
1040        store
1041            .branch_files_set("repo", "123:456", 99, &files)
1042            .unwrap();
1043        let (stamp, at, got) = store.branch_files_get("repo").unwrap().unwrap();
1044        assert_eq!(stamp, "123:456");
1045        assert_eq!(at, 99);
1046        assert_eq!(got, files);
1047
1048        // a later write replaces the entry rather than accumulating
1049        store.branch_files_set("repo", "789:1", 100, &[]).unwrap();
1050        let (stamp, _, got) = store.branch_files_get("repo").unwrap().unwrap();
1051        assert_eq!(stamp, "789:1");
1052        assert!(got.is_empty(), "an empty list is a real answer, not a miss");
1053
1054        // repos don't share an entry
1055        assert!(store.branch_files_get("other").unwrap().is_none());
1056    }
1057
1058    fn sym(name: &str, kind: Kind, line: u32, parent: Option<&str>) -> Symbol {
1059        Symbol {
1060            name: name.into(),
1061            kind,
1062            language: "ruby".into(),
1063            file: "app/models/user.rb".into(),
1064            line,
1065            end_line: line,
1066            parent: parent.map(String::from),
1067            visibility: None,
1068        }
1069    }
1070
1071    #[test]
1072    fn migration_adds_repo_indexes_to_an_existing_db() {
1073        let path = std::env::temp_dir().join(format!("rq-migrate-{}.db", std::process::id()));
1074        let _ = std::fs::remove_file(&path);
1075        {
1076            // simulate a pre-v5 database: no repo-scoped indexes, and the
1077            // (since-dropped) display_name column still present
1078            let store = Store::open(&path).unwrap();
1079            store
1080                .conn
1081                .execute_batch(
1082                    "DROP INDEX idx_symbols_repo; DROP INDEX idx_events_repo; \
1083                     ALTER TABLE repositories ADD COLUMN display_name TEXT; \
1084                     ALTER TABLE symbols DROP COLUMN visibility; \
1085                     PRAGMA user_version=4;",
1086                )
1087                .unwrap();
1088        }
1089        let store = Store::open(&path).unwrap();
1090        let n: i64 = store
1091            .conn
1092            .query_row(
1093                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' \
1094                 AND name IN ('idx_symbols_repo','idx_events_repo')",
1095                [],
1096                |r| r.get(0),
1097            )
1098            .unwrap();
1099        assert_eq!(n, 2);
1100        drop(store);
1101        let _ = std::fs::remove_file(&path);
1102    }
1103
1104    #[test]
1105    fn checkout_roots_returns_all_paths_newest_first() {
1106        let store = Store::open_in_memory().unwrap();
1107        let repo = store
1108            .upsert_repository(&RepoIdentity::local("/x"), None)
1109            .unwrap();
1110        // a repo indexed at an old path, then moved to a new one (same identity)
1111        store.upsert_checkout(repo, "/old/path", None).unwrap();
1112        store.upsert_checkout(repo, "/new/path", None).unwrap();
1113        let roots = store.checkout_roots(repo).unwrap();
1114        // both are returned, newest (most-recently inserted) first so a reader
1115        // tries the current checkout before a stale one
1116        assert_eq!(roots, vec!["/new/path", "/old/path"]);
1117    }
1118
1119    #[test]
1120    fn forget_checkout_prunes_a_stale_binding() {
1121        let mut store = Store::open_in_memory().unwrap();
1122        let repo = store
1123            .upsert_repository(&RepoIdentity::local("/x"), None)
1124            .unwrap();
1125        store.upsert_checkout(repo, "/old/path", None).unwrap();
1126        store.upsert_checkout(repo, "/new/path", None).unwrap();
1127        store.forget_checkout("/old/path").unwrap();
1128        // only the live binding remains; the repo (and its symbols) is untouched
1129        assert_eq!(store.checkout_roots(repo).unwrap(), vec!["/new/path"]);
1130        assert_eq!(store.repository_id("local:/x").unwrap(), Some(repo));
1131    }
1132
1133    #[test]
1134    fn prune_events_drops_aggregated_but_keeps_recent() {
1135        let mut store = Store::open_in_memory().unwrap();
1136        let repo = store
1137            .upsert_repository(&RepoIdentity::local("/x"), None)
1138            .unwrap();
1139        store
1140            .replace_file_symbols(
1141                repo,
1142                "a.rb",
1143                "ruby",
1144                None,
1145                "h",
1146                &[sym("Foo", Kind::Class, 1, None)],
1147            )
1148            .unwrap();
1149
1150        // a select, then a run of searches (so the newest event is a search)
1151        store
1152            .record_event(
1153                "select",
1154                Some("foo"),
1155                Some(repo),
1156                Some("a.rb"),
1157                Some(1),
1158                None,
1159            )
1160            .unwrap();
1161        for _ in 0..10 {
1162            store
1163                .record_event("search", Some("foo"), Some(repo), None, None, None)
1164                .unwrap();
1165        }
1166        store.aggregate_events(100).unwrap(); // hwm advances to the last id (11)
1167
1168        // 11 events, all aggregated; keep the 3 newest → drop ids 1..=8
1169        assert_eq!(store.prune_events(3).unwrap(), 8);
1170        // idempotent: nothing left to prune
1171        assert_eq!(store.prune_events(3).unwrap(), 0);
1172    }
1173
1174    #[test]
1175    fn git_ts_is_stored_and_surfaced_on_candidates() {
1176        let mut store = Store::open_in_memory().unwrap();
1177        let repo = store
1178            .upsert_repository(&RepoIdentity::local("/x"), None)
1179            .unwrap();
1180        store
1181            .replace_file_symbols(
1182                repo,
1183                "a.rb",
1184                "ruby",
1185                None,
1186                "h",
1187                &[sym("Foo", Kind::Class, 1, None)],
1188            )
1189            .unwrap();
1190
1191        let times = HashMap::from([("a.rb".to_string(), 1_700_000_000_i64)]);
1192        store.set_file_git_ts(repo, &times).unwrap();
1193
1194        let cands = store.search_candidates("foo", 10, false).unwrap();
1195        assert_eq!(cands[0].git_ts, Some(1_700_000_000));
1196    }
1197
1198    #[test]
1199    fn aggregates_a_selection_and_decays_on_repeat() {
1200        let mut store = Store::open_in_memory().unwrap();
1201        let repo = store
1202            .upsert_repository(&RepoIdentity::local("/x"), None)
1203            .unwrap();
1204        store
1205            .replace_file_symbols(
1206                repo,
1207                "a.rb",
1208                "ruby",
1209                None,
1210                "h",
1211                &[sym("Foo", Kind::Class, 1, None)],
1212            )
1213            .unwrap();
1214
1215        // a selection for "foo" rolls up into one learned stat
1216        store
1217            .record_event(
1218                "select",
1219                Some("foo"),
1220                Some(repo),
1221                Some("a.rb"),
1222                Some(1),
1223                None,
1224            )
1225            .unwrap();
1226        assert_eq!(store.aggregate_events(10).unwrap(), 1);
1227        assert_eq!(store.selections_for("foo").unwrap().len(), 1);
1228        // ...and a longer query still benefits (prefix learning)
1229        assert_eq!(store.selections_for("foobar").unwrap().len(), 1);
1230    }
1231
1232    #[test]
1233    fn indexes_and_reports_coverage() {
1234        let mut store = Store::open_in_memory().unwrap();
1235        let id = RepoIdentity::Remote("github.com/dpep/rq".into());
1236        let repo = store.upsert_repository(&id, Some("main")).unwrap();
1237        store
1238            .upsert_checkout(repo, "/tmp/rq", Some("main"))
1239            .unwrap();
1240
1241        let symbols = vec![
1242            sym("User", Kind::Class, 1, None),
1243            sym("save", Kind::Method, 5, Some("User")),
1244        ];
1245        store
1246            .replace_file_symbols(
1247                repo,
1248                "app/models/user.rb",
1249                "ruby",
1250                Some(100),
1251                "h1",
1252                &symbols,
1253            )
1254            .unwrap();
1255        store.set_coverage(repo, 10, 1, "warming").unwrap();
1256
1257        let overview = store.coverage_overview().unwrap();
1258        assert_eq!(overview.len(), 1);
1259        assert_eq!(overview[0].identity, "github.com/dpep/rq");
1260        assert_eq!(overview[0].status, "warming");
1261        assert_eq!(overview[0].symbols, 2);
1262    }
1263
1264    #[test]
1265    fn reindexing_a_file_replaces_its_symbols() {
1266        let mut store = Store::open_in_memory().unwrap();
1267        let repo = store
1268            .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1269            .unwrap();
1270
1271        store
1272            .replace_file_symbols(
1273                repo,
1274                "a.rb",
1275                "ruby",
1276                None,
1277                "h1",
1278                &[sym("Old", Kind::Class, 1, None)],
1279            )
1280            .unwrap();
1281        store
1282            .replace_file_symbols(
1283                repo,
1284                "a.rb",
1285                "ruby",
1286                None,
1287                "h2",
1288                &[sym("New", Kind::Class, 1, None)],
1289            )
1290            .unwrap();
1291
1292        store.set_coverage(repo, 1, 1, "complete").unwrap();
1293        let overview = store.coverage_overview().unwrap();
1294        // old symbol gone, new one present → still exactly one symbol
1295        assert_eq!(overview[0].symbols, 1);
1296    }
1297
1298    #[test]
1299    fn file_unchanged_detects_matching_hash() {
1300        let mut store = Store::open_in_memory().unwrap();
1301        let repo = store
1302            .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1303            .unwrap();
1304        store
1305            .replace_file_symbols(repo, "a.rb", "ruby", None, "abc", &[])
1306            .unwrap();
1307
1308        assert!(store.file_unchanged(repo, "a.rb", "abc").unwrap());
1309        assert!(!store.file_unchanged(repo, "a.rb", "xyz").unwrap());
1310        assert!(!store.file_unchanged(repo, "missing.rb", "abc").unwrap());
1311    }
1312}