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    /// Whether the most recent event for this repo is a `search` for the same
616    /// query — i.e. the query was repeated with no selection in between, a
617    /// signal that the last results missed.
618    pub fn is_repeat_search(&self, repository_id: i64, query_norm: &str) -> Result<bool> {
619        let last: Option<(String, Option<String>)> = self
620            .conn
621            .query_row(
622                "SELECT type, query FROM events WHERE repository_id = ?1 ORDER BY id DESC LIMIT 1",
623                params![repository_id],
624                |r| Ok((r.get(0)?, r.get(1)?)),
625            )
626            .optional()?;
627        Ok(matches!(last, Some((kind, Some(q))) if kind == "search" && q == query_norm))
628    }
629
630    /// Decay the learned boost for a query — a repeated search signals the
631    /// learned pick didn't satisfy. Rows that reach zero are dropped.
632    pub fn decay_selections(&self, repository_id: i64, query_norm: &str) -> Result<()> {
633        self.conn.execute(
634            "UPDATE selection_stats SET selections = selections - 1
635             WHERE repository_id = ?1 AND query_norm = ?2",
636            params![repository_id, query_norm],
637        )?;
638        self.conn.execute(
639            "DELETE FROM selection_stats
640             WHERE repository_id = ?1 AND query_norm = ?2 AND selections <= 0",
641            params![repository_id, query_norm],
642        )?;
643        Ok(())
644    }
645
646    /// Roll up to `batch` new `open`/`select` events into `selection_stats`.
647    /// Returns how many events were processed. Resolves the chosen symbol from
648    /// `(repo, path, line)` at rollup time, turning a selection into a
649    /// `(query, file, name)` signal. This is the amortized post-processing run
650    /// after a user interaction.
651    pub fn aggregate_events(&mut self, batch: usize) -> Result<usize> {
652        let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
653
654        type Pending = (
655            i64,
656            Option<String>,
657            Option<i64>,
658            Option<String>,
659            Option<i64>,
660            i64,
661        );
662        let pending: Vec<Pending> = {
663            let mut stmt = self.conn.prepare(
664                "SELECT id, query, repository_id, path, line, ts FROM events
665                 WHERE id > ?1 AND type IN ('select', 'open')
666                 ORDER BY id LIMIT ?2",
667            )?;
668            stmt.query_map(params![hwm, batch as i64], |r| {
669                Ok((
670                    r.get(0)?,
671                    r.get(1)?,
672                    r.get(2)?,
673                    r.get(3)?,
674                    r.get(4)?,
675                    r.get(5)?,
676                ))
677            })?
678            .collect::<Result<Vec<_>>>()?
679        };
680
681        if pending.is_empty() {
682            // advance past trailing non-selection events so we don't rescan them
683            let max_id: Option<i64> =
684                self.conn
685                    .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
686            if let Some(m) = max_id.filter(|m| *m > hwm) {
687                self.meta_set_i64("events_hwm", m)?;
688            }
689            return Ok(0);
690        }
691
692        let drained = pending.len() < batch;
693        let max_pending_id = pending.iter().map(|p| p.0).max().unwrap_or(hwm);
694
695        let tx = self.conn.transaction()?;
696        let mut processed = 0;
697        for (_id, query, repo, path, line, ts) in &pending {
698            processed += 1;
699            let (Some(query), Some(repo), Some(path)) = (query, repo, path) else {
700                continue;
701            };
702            let name: Option<String> = match line {
703                Some(line) => tx
704                    .query_row(
705                        "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
706                         WHERE s.repository_id = ?1 AND fi.path = ?2 AND s.line <= ?3
707                         ORDER BY s.line DESC LIMIT 1",
708                        params![repo, path, line],
709                        |r| r.get(0),
710                    )
711                    .optional()?,
712                None => tx
713                    .query_row(
714                        "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
715                         WHERE s.repository_id = ?1 AND fi.path = ?2
716                           AND s.kind IN ('class', 'module')
717                         ORDER BY s.line ASC LIMIT 1",
718                        params![repo, path],
719                        |r| r.get(0),
720                    )
721                    .optional()?,
722            };
723            if let Some(name) = name {
724                tx.execute(
725                    "INSERT INTO selection_stats
726                       (repository_id, query_norm, file, name, selections, last_selected_at)
727                     VALUES (?1, ?2, ?3, ?4, 1, ?5)
728                     ON CONFLICT(repository_id, query_norm, file, name) DO UPDATE SET
729                       selections = selections + 1,
730                       last_selected_at = max(last_selected_at, excluded.last_selected_at)",
731                    params![repo, query, path, name, ts],
732                )?;
733            }
734        }
735
736        let new_hwm = if drained {
737            tx.query_row("SELECT MAX(id) FROM events", [], |r| {
738                r.get::<_, Option<i64>>(0)
739            })?
740            .unwrap_or(max_pending_id)
741        } else {
742            max_pending_id
743        };
744        tx.execute(
745            "INSERT INTO meta (key, value) VALUES ('events_hwm', ?1)
746             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
747            params![new_hwm.to_string()],
748        )?;
749        tx.commit()?;
750        Ok(processed)
751    }
752
753    /// Keep the raw `events` log bounded. Deletes only events that have already
754    /// been rolled up (id ≤ the aggregation high-water mark) and are not among
755    /// the most recent `keep_recent` rows (which `is_repeat_search` needs).
756    /// Returns the number deleted.
757    pub fn prune_events(&self, keep_recent: i64) -> Result<usize> {
758        let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
759        let max_id: Option<i64> = self
760            .conn
761            .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
762        let Some(max_id) = max_id else {
763            return Ok(0);
764        };
765        let cutoff = hwm.min(max_id - keep_recent);
766        if cutoff <= 0 {
767            return Ok(0);
768        }
769        let n = self
770            .conn
771            .execute("DELETE FROM events WHERE id <= ?1", params![cutoff])?;
772        Ok(n)
773    }
774
775    /// The git HEAD sha recorded at the last complete index of a repo, if any —
776    /// used to detect that the committed tree is unchanged since indexing.
777    pub fn indexed_head(&self, repository_id: i64) -> Result<Option<String>> {
778        self.meta_get(&format!("head:{repository_id}"))
779    }
780
781    /// Record the git HEAD sha at a complete index.
782    pub fn set_indexed_head(&self, repository_id: i64, head: &str) -> Result<()> {
783        self.meta_set(&format!("head:{repository_id}"), head)
784    }
785
786    /// The git HEAD sha at the last commit-times capture (recency signal), if
787    /// any — lets the next capture read only the commits since, or skip the
788    /// `git log` entirely when HEAD hasn't moved.
789    pub fn git_ts_head(&self, repository_id: i64) -> Result<Option<String>> {
790        self.meta_get(&format!("git_ts_head:{repository_id}"))
791    }
792
793    /// Record the git HEAD sha a commit-times capture ran at.
794    pub fn set_git_ts_head(&self, repository_id: i64, head: &str) -> Result<()> {
795        self.meta_set(&format!("git_ts_head:{repository_id}"), head)
796    }
797
798    /// The detached-warm single-flight lock for a repo: `(pid, stamped_at)` of
799    /// the process that claimed it, if any. Liveness/staleness policy is the
800    /// caller's (the store just holds the record).
801    pub fn warm_lock(&self, identity: &str) -> Result<Option<(u32, i64)>> {
802        Ok(self
803            .meta_get(&format!("warm_lock:{identity}"))?
804            .and_then(|v| {
805                let (pid, ts) = v.split_once(':')?;
806                Some((pid.parse().ok()?, ts.parse().ok()?))
807            }))
808    }
809
810    /// Claim the detached-warm lock for this process.
811    pub fn set_warm_lock(&self, identity: &str, pid: u32) -> Result<()> {
812        self.meta_set(
813            &format!("warm_lock:{identity}"),
814            &format!("{pid}:{}", now_unix()),
815        )
816    }
817
818    /// Release the detached-warm lock.
819    pub fn clear_warm_lock(&self, identity: &str) -> Result<()> {
820        self.conn.execute(
821            "DELETE FROM meta WHERE key = ?1",
822            params![format!("warm_lock:{identity}")],
823        )?;
824        Ok(())
825    }
826
827    fn meta_get(&self, key: &str) -> Result<Option<String>> {
828        self.conn
829            .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
830                r.get(0)
831            })
832            .optional()
833    }
834
835    fn meta_set(&self, key: &str, value: &str) -> Result<()> {
836        self.conn.execute(
837            "INSERT INTO meta (key, value) VALUES (?1, ?2)
838             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
839            params![key, value],
840        )?;
841        Ok(())
842    }
843
844    fn meta_get_i64(&self, key: &str) -> Result<Option<i64>> {
845        Ok(self.meta_get(key)?.and_then(|s| s.parse().ok()))
846    }
847
848    fn meta_set_i64(&self, key: &str, value: i64) -> Result<()> {
849        self.meta_set(key, &value.to_string())
850    }
851
852    /// Candidate symbols for a query, drawn from cheap layers and merged:
853    /// exact/prefix on `name_lower`, then broad fuzzy recall (first-char anchor,
854    /// trigram FTS, path). Ranking happens in `crate::search`; this only narrows
855    /// the field.
856    ///
857    /// When `force_fuzzy` is false and exact/prefix already matched, the broad
858    /// fuzzy layers are skipped: the relevance gate drops every fuzzy candidate
859    /// once a strong (exact/prefix) hit exists, so fetching and scoring them is
860    /// wasted. A wildcard query passes `force_fuzzy = true` — it isn't gated and
861    /// always needs the trigram recall.
862    pub fn search_candidates(
863        &self,
864        query: &str,
865        limit: usize,
866        force_fuzzy: bool,
867    ) -> Result<Vec<SymbolRow>> {
868        let q = query.to_ascii_lowercase();
869        let mut found: HashMap<i64, SymbolRow> = HashMap::new();
870
871        // exact name — always included, never subject to the cap. The
872        // match we most want must reach the scorer no matter how large the index
873        // is (a broad capped scan could otherwise truncate it away).
874        {
875            let sql = format!(
876                "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} WHERE s.name_lower = ?1 LIMIT ?2"
877            );
878            let mut stmt = self.conn.prepare_cached(&sql)?;
879            let rows = stmt.query_map(params![q, limit as i64], row_to_candidate)?;
880            for row in rows {
881                let (id, cand) = row?;
882                found.insert(id, cand);
883            }
884        }
885
886        // query as a prefix — selective, so prefix matches always
887        // surface even on a huge repo (unlike the broad first-char anchor below,
888        // which the cap can truncate).
889        {
890            let like = format!("{}%", escape_like(&q));
891            let sql = format!(
892                "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
893                 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
894            );
895            let mut stmt = self.conn.prepare_cached(&sql)?;
896            let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
897            for row in rows {
898                let (id, cand) = row?;
899                found.entry(id).or_insert(cand);
900            }
901        }
902
903        // Fast path: a strong (exact/prefix) match exists, so the relevance gate
904        // will discard everything the broad layers below would add. Skip them —
905        // identical results, no wasted fetch/score. (Wildcard queries force the
906        // fuzzy layers; they aren't gated.)
907        if !force_fuzzy && !found.is_empty() {
908            return Ok(found.into_values().collect());
909        }
910
911        // fuzzy recall (a): first-character anchor (index-backed scan) for short
912        // skip-abbreviations like `usr → user` that prefix matching can't reach;
913        // the scorer filters and ranks. Best-effort under the cap — exact and
914        // prefix are already guaranteed above.
915        if let Some(first) = q.chars().next() {
916            let like = format!("{}%", escape_like(&first.to_string()));
917            let sql = format!(
918                "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
919                 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
920            );
921            let mut stmt = self.conn.prepare_cached(&sql)?;
922            let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
923            for row in rows {
924                let (id, cand) = row?;
925                found.entry(id).or_insert(cand);
926            }
927        }
928
929        // fuzzy recall (b): trigram FTS (OR of the query's trigrams).
930        if let Some(match_expr) = trigram_or_query(&q) {
931            let sql = format!(
932                "SELECT {CANDIDATE_COLS} FROM symbols_fts f \
933                 JOIN symbols s ON s.id = f.rowid \
934                 JOIN files fi ON fi.id = s.file_id \
935                 JOIN repositories r ON r.id = s.repository_id \
936                 WHERE symbols_fts MATCH ?1 LIMIT ?2"
937            );
938            let mut stmt = self.conn.prepare_cached(&sql)?;
939            let rows = stmt.query_map(params![match_expr, limit as i64], row_to_candidate)?;
940            for row in rows {
941                let (id, cand) = row?;
942                found.entry(id).or_insert(cand);
943            }
944        }
945
946        // path recall: primary definitions in files whose path matches the query,
947        // so `billing` can surface the class defined in `billing.rb`.
948        let path_like = format!("%{}%", escape_like(&q));
949        let sql = format!(
950            "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
951             WHERE fi.path LIKE ?1 ESCAPE '\\' AND s.kind IN ('class', 'module') LIMIT ?2"
952        );
953        {
954            let mut stmt = self.conn.prepare_cached(&sql)?;
955            let rows = stmt.query_map(params![path_like, limit as i64], row_to_candidate)?;
956            for row in rows {
957                let (id, cand) = row?;
958                found.entry(id).or_insert(cand);
959            }
960        }
961
962        Ok(found.into_values().collect())
963    }
964}
965
966fn row_to_candidate(r: &rusqlite::Row) -> Result<(i64, SymbolRow)> {
967    Ok((
968        r.get(0)?,
969        SymbolRow {
970            name: r.get(1)?,
971            kind: r.get(2)?,
972            language: r.get(3)?,
973            file: r.get(4)?,
974            line: r.get(5)?,
975            end_line: r.get(6)?,
976            parent: r.get(7)?,
977            repository_id: r.get(8)?,
978            repo_identity: r.get(9)?,
979            mtime: r.get(10)?,
980            git_ts: r.get(11)?,
981            visibility: r.get(12)?,
982        },
983    ))
984}
985
986/// Escape LIKE wildcards so identifier characters (`_`) are matched literally.
987fn escape_like(s: &str) -> String {
988    s.replace('\\', "\\\\")
989        .replace('%', "\\%")
990        .replace('_', "\\_")
991}
992
993/// Build an FTS5 `MATCH` expression that ORs the query's trigrams, giving broad
994/// recall (any shared trigram makes a candidate). `None` if the query is too
995/// short to form a trigram.
996fn trigram_or_query(q: &str) -> Option<String> {
997    let cleaned: Vec<char> = q
998        .chars()
999        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1000        .collect();
1001    if cleaned.len() < 3 {
1002        return None;
1003    }
1004    let mut grams: Vec<String> = Vec::new();
1005    for w in cleaned.windows(3) {
1006        let gram: String = w.iter().collect();
1007        let quoted = format!("\"{gram}\"");
1008        if !grams.contains(&quoted) {
1009            grams.push(quoted);
1010        }
1011    }
1012    Some(grams.join(" OR "))
1013}
1014
1015fn now_unix() -> i64 {
1016    SystemTime::now()
1017        .duration_since(UNIX_EPOCH)
1018        .map(|d| d.as_secs() as i64)
1019        .unwrap_or(0)
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025    use crate::core::Kind;
1026
1027    fn sym(name: &str, kind: Kind, line: u32, parent: Option<&str>) -> Symbol {
1028        Symbol {
1029            name: name.into(),
1030            kind,
1031            language: "ruby".into(),
1032            file: "app/models/user.rb".into(),
1033            line,
1034            end_line: line,
1035            parent: parent.map(String::from),
1036            visibility: None,
1037        }
1038    }
1039
1040    #[test]
1041    fn migration_adds_repo_indexes_to_an_existing_db() {
1042        let path = std::env::temp_dir().join(format!("rq-migrate-{}.db", std::process::id()));
1043        let _ = std::fs::remove_file(&path);
1044        {
1045            // simulate a pre-v5 database: no repo-scoped indexes, and the
1046            // (since-dropped) display_name column still present
1047            let store = Store::open(&path).unwrap();
1048            store
1049                .conn
1050                .execute_batch(
1051                    "DROP INDEX idx_symbols_repo; DROP INDEX idx_events_repo; \
1052                     ALTER TABLE repositories ADD COLUMN display_name TEXT; \
1053                     ALTER TABLE symbols DROP COLUMN visibility; \
1054                     PRAGMA user_version=4;",
1055                )
1056                .unwrap();
1057        }
1058        let store = Store::open(&path).unwrap();
1059        let n: i64 = store
1060            .conn
1061            .query_row(
1062                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' \
1063                 AND name IN ('idx_symbols_repo','idx_events_repo')",
1064                [],
1065                |r| r.get(0),
1066            )
1067            .unwrap();
1068        assert_eq!(n, 2);
1069        drop(store);
1070        let _ = std::fs::remove_file(&path);
1071    }
1072
1073    #[test]
1074    fn checkout_roots_returns_all_paths_newest_first() {
1075        let store = Store::open_in_memory().unwrap();
1076        let repo = store
1077            .upsert_repository(&RepoIdentity::local("/x"), None)
1078            .unwrap();
1079        // a repo indexed at an old path, then moved to a new one (same identity)
1080        store.upsert_checkout(repo, "/old/path", None).unwrap();
1081        store.upsert_checkout(repo, "/new/path", None).unwrap();
1082        let roots = store.checkout_roots(repo).unwrap();
1083        // both are returned, newest (most-recently inserted) first so a reader
1084        // tries the current checkout before a stale one
1085        assert_eq!(roots, vec!["/new/path", "/old/path"]);
1086    }
1087
1088    #[test]
1089    fn forget_checkout_prunes_a_stale_binding() {
1090        let mut store = Store::open_in_memory().unwrap();
1091        let repo = store
1092            .upsert_repository(&RepoIdentity::local("/x"), None)
1093            .unwrap();
1094        store.upsert_checkout(repo, "/old/path", None).unwrap();
1095        store.upsert_checkout(repo, "/new/path", None).unwrap();
1096        store.forget_checkout("/old/path").unwrap();
1097        // only the live binding remains; the repo (and its symbols) is untouched
1098        assert_eq!(store.checkout_roots(repo).unwrap(), vec!["/new/path"]);
1099        assert_eq!(store.repository_id("local:/x").unwrap(), Some(repo));
1100    }
1101
1102    #[test]
1103    fn prune_events_drops_aggregated_but_keeps_recent() {
1104        let mut store = Store::open_in_memory().unwrap();
1105        let repo = store
1106            .upsert_repository(&RepoIdentity::local("/x"), None)
1107            .unwrap();
1108        store
1109            .replace_file_symbols(
1110                repo,
1111                "a.rb",
1112                "ruby",
1113                None,
1114                "h",
1115                &[sym("Foo", Kind::Class, 1, None)],
1116            )
1117            .unwrap();
1118
1119        // a select, then a run of searches (so the newest event is a search)
1120        store
1121            .record_event(
1122                "select",
1123                Some("foo"),
1124                Some(repo),
1125                Some("a.rb"),
1126                Some(1),
1127                None,
1128            )
1129            .unwrap();
1130        for _ in 0..10 {
1131            store
1132                .record_event("search", Some("foo"), Some(repo), None, None, None)
1133                .unwrap();
1134        }
1135        store.aggregate_events(100).unwrap(); // hwm advances to the last id (11)
1136
1137        // 11 events, all aggregated; keep the 3 newest → drop ids 1..=8
1138        assert_eq!(store.prune_events(3).unwrap(), 8);
1139        // idempotent: nothing left to prune
1140        assert_eq!(store.prune_events(3).unwrap(), 0);
1141        // the most recent event still drives repeat detection
1142        assert!(store.is_repeat_search(repo, "foo").unwrap());
1143    }
1144
1145    #[test]
1146    fn git_ts_is_stored_and_surfaced_on_candidates() {
1147        let mut store = Store::open_in_memory().unwrap();
1148        let repo = store
1149            .upsert_repository(&RepoIdentity::local("/x"), None)
1150            .unwrap();
1151        store
1152            .replace_file_symbols(
1153                repo,
1154                "a.rb",
1155                "ruby",
1156                None,
1157                "h",
1158                &[sym("Foo", Kind::Class, 1, None)],
1159            )
1160            .unwrap();
1161
1162        let times = HashMap::from([("a.rb".to_string(), 1_700_000_000_i64)]);
1163        store.set_file_git_ts(repo, &times).unwrap();
1164
1165        let cands = store.search_candidates("foo", 10, false).unwrap();
1166        assert_eq!(cands[0].git_ts, Some(1_700_000_000));
1167    }
1168
1169    #[test]
1170    fn aggregates_a_selection_and_decays_on_repeat() {
1171        let mut store = Store::open_in_memory().unwrap();
1172        let repo = store
1173            .upsert_repository(&RepoIdentity::local("/x"), None)
1174            .unwrap();
1175        store
1176            .replace_file_symbols(
1177                repo,
1178                "a.rb",
1179                "ruby",
1180                None,
1181                "h",
1182                &[sym("Foo", Kind::Class, 1, None)],
1183            )
1184            .unwrap();
1185
1186        // a selection for "foo" rolls up into one learned stat
1187        store
1188            .record_event(
1189                "select",
1190                Some("foo"),
1191                Some(repo),
1192                Some("a.rb"),
1193                Some(1),
1194                None,
1195            )
1196            .unwrap();
1197        assert_eq!(store.aggregate_events(10).unwrap(), 1);
1198        assert_eq!(store.selections_for("foo").unwrap().len(), 1);
1199        // ...and a longer query still benefits (prefix learning)
1200        assert_eq!(store.selections_for("foobar").unwrap().len(), 1);
1201
1202        // last event is the select → not a repeat
1203        assert!(!store.is_repeat_search(repo, "foo").unwrap());
1204        // re-search "foo" without opening anything → repeat
1205        store
1206            .record_event("search", Some("foo"), Some(repo), None, None, None)
1207            .unwrap();
1208        assert!(store.is_repeat_search(repo, "foo").unwrap());
1209
1210        // decaying the lone selection drops it
1211        store.decay_selections(repo, "foo").unwrap();
1212        assert!(store.selections_for("foo").unwrap().is_empty());
1213    }
1214
1215    #[test]
1216    fn indexes_and_reports_coverage() {
1217        let mut store = Store::open_in_memory().unwrap();
1218        let id = RepoIdentity::Remote("github.com/dpep/rq".into());
1219        let repo = store.upsert_repository(&id, Some("main")).unwrap();
1220        store
1221            .upsert_checkout(repo, "/tmp/rq", Some("main"))
1222            .unwrap();
1223
1224        let symbols = vec![
1225            sym("User", Kind::Class, 1, None),
1226            sym("save", Kind::Method, 5, Some("User")),
1227        ];
1228        store
1229            .replace_file_symbols(
1230                repo,
1231                "app/models/user.rb",
1232                "ruby",
1233                Some(100),
1234                "h1",
1235                &symbols,
1236            )
1237            .unwrap();
1238        store.set_coverage(repo, 10, 1, "warming").unwrap();
1239
1240        let overview = store.coverage_overview().unwrap();
1241        assert_eq!(overview.len(), 1);
1242        assert_eq!(overview[0].identity, "github.com/dpep/rq");
1243        assert_eq!(overview[0].status, "warming");
1244        assert_eq!(overview[0].symbols, 2);
1245    }
1246
1247    #[test]
1248    fn reindexing_a_file_replaces_its_symbols() {
1249        let mut store = Store::open_in_memory().unwrap();
1250        let repo = store
1251            .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1252            .unwrap();
1253
1254        store
1255            .replace_file_symbols(
1256                repo,
1257                "a.rb",
1258                "ruby",
1259                None,
1260                "h1",
1261                &[sym("Old", Kind::Class, 1, None)],
1262            )
1263            .unwrap();
1264        store
1265            .replace_file_symbols(
1266                repo,
1267                "a.rb",
1268                "ruby",
1269                None,
1270                "h2",
1271                &[sym("New", Kind::Class, 1, None)],
1272            )
1273            .unwrap();
1274
1275        store.set_coverage(repo, 1, 1, "complete").unwrap();
1276        let overview = store.coverage_overview().unwrap();
1277        // old symbol gone, new one present → still exactly one symbol
1278        assert_eq!(overview[0].symbols, 1);
1279    }
1280
1281    #[test]
1282    fn file_unchanged_detects_matching_hash() {
1283        let mut store = Store::open_in_memory().unwrap();
1284        let repo = store
1285            .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1286            .unwrap();
1287        store
1288            .replace_file_symbols(repo, "a.rb", "ruby", None, "abc", &[])
1289            .unwrap();
1290
1291        assert!(store.file_unchanged(repo, "a.rb", "abc").unwrap());
1292        assert!(!store.file_unchanged(repo, "a.rb", "xyz").unwrap());
1293        assert!(!store.file_unchanged(repo, "missing.rb", "abc").unwrap());
1294    }
1295}