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