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