1mod 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#[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 end_line: Option<i64>,
30 pub parent: Option<String>,
31 pub repository_id: i64,
32 pub repo_identity: String,
33 pub mtime: Option<i64>,
35 pub git_ts: Option<i64>,
37 pub visibility: Option<String>,
40}
41
42#[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
53const 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
61pub struct Store {
63 conn: Connection,
64}
65
66impl Drop for Store {
67 fn drop(&mut self) {
68 let _ = self.conn.execute_batch("PRAGMA optimize;");
71 }
72}
73
74#[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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
88pub struct CoverageRow {
89 #[serde(rename = "repo")]
92 pub identity: String,
93 pub status: String,
94 pub files: i64,
95 pub symbols: i64,
96}
97
98impl Store {
99 pub fn open(path: &Path) -> Result<Store> {
102 let conn = Connection::open(path)?;
103 Self::init(conn)
104 }
105
106 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 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 conn.execute_batch(schema::SCHEMA)?;
124 conn.execute_batch(schema::FTS_INSERT_TRIGGER)?;
125 } else {
126 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 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 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 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 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 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 pub fn replace_files(
242 &mut self,
243 repository_id: i64,
244 files: &[FileSymbols],
245 ) -> Result<(usize, usize)> {
246 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 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 pub fn defer_fts_insert(&self) -> Result<()> {
331 self.conn
332 .execute_batch("DROP TRIGGER IF EXISTS symbols_ai;")?;
333 Ok(())
334 }
335
336 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn is_repeat_search(&self, repository_id: i64, query_norm: &str) -> Result<bool> {
619 let last: Option<(String, Option<String>)> = self
620 .conn
621 .query_row(
622 "SELECT type, query FROM events WHERE repository_id = ?1 ORDER BY id DESC LIMIT 1",
623 params![repository_id],
624 |r| Ok((r.get(0)?, r.get(1)?)),
625 )
626 .optional()?;
627 Ok(matches!(last, Some((kind, Some(q))) if kind == "search" && q == query_norm))
628 }
629
630 pub fn decay_selections(&self, repository_id: i64, query_norm: &str) -> Result<()> {
633 self.conn.execute(
634 "UPDATE selection_stats SET selections = selections - 1
635 WHERE repository_id = ?1 AND query_norm = ?2",
636 params![repository_id, query_norm],
637 )?;
638 self.conn.execute(
639 "DELETE FROM selection_stats
640 WHERE repository_id = ?1 AND query_norm = ?2 AND selections <= 0",
641 params![repository_id, query_norm],
642 )?;
643 Ok(())
644 }
645
646 pub fn aggregate_events(&mut self, batch: usize) -> Result<usize> {
652 let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
653
654 type Pending = (
655 i64,
656 Option<String>,
657 Option<i64>,
658 Option<String>,
659 Option<i64>,
660 i64,
661 );
662 let pending: Vec<Pending> = {
663 let mut stmt = self.conn.prepare(
664 "SELECT id, query, repository_id, path, line, ts FROM events
665 WHERE id > ?1 AND type IN ('select', 'open')
666 ORDER BY id LIMIT ?2",
667 )?;
668 stmt.query_map(params![hwm, batch as i64], |r| {
669 Ok((
670 r.get(0)?,
671 r.get(1)?,
672 r.get(2)?,
673 r.get(3)?,
674 r.get(4)?,
675 r.get(5)?,
676 ))
677 })?
678 .collect::<Result<Vec<_>>>()?
679 };
680
681 if pending.is_empty() {
682 let max_id: Option<i64> =
684 self.conn
685 .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
686 if let Some(m) = max_id.filter(|m| *m > hwm) {
687 self.meta_set_i64("events_hwm", m)?;
688 }
689 return Ok(0);
690 }
691
692 let drained = pending.len() < batch;
693 let max_pending_id = pending.iter().map(|p| p.0).max().unwrap_or(hwm);
694
695 let tx = self.conn.transaction()?;
696 let mut processed = 0;
697 for (_id, query, repo, path, line, ts) in &pending {
698 processed += 1;
699 let (Some(query), Some(repo), Some(path)) = (query, repo, path) else {
700 continue;
701 };
702 let name: Option<String> = match line {
703 Some(line) => tx
704 .query_row(
705 "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
706 WHERE s.repository_id = ?1 AND fi.path = ?2 AND s.line <= ?3
707 ORDER BY s.line DESC LIMIT 1",
708 params![repo, path, line],
709 |r| r.get(0),
710 )
711 .optional()?,
712 None => tx
713 .query_row(
714 "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
715 WHERE s.repository_id = ?1 AND fi.path = ?2
716 AND s.kind IN ('class', 'module')
717 ORDER BY s.line ASC LIMIT 1",
718 params![repo, path],
719 |r| r.get(0),
720 )
721 .optional()?,
722 };
723 if let Some(name) = name {
724 tx.execute(
725 "INSERT INTO selection_stats
726 (repository_id, query_norm, file, name, selections, last_selected_at)
727 VALUES (?1, ?2, ?3, ?4, 1, ?5)
728 ON CONFLICT(repository_id, query_norm, file, name) DO UPDATE SET
729 selections = selections + 1,
730 last_selected_at = max(last_selected_at, excluded.last_selected_at)",
731 params![repo, query, path, name, ts],
732 )?;
733 }
734 }
735
736 let new_hwm = if drained {
737 tx.query_row("SELECT MAX(id) FROM events", [], |r| {
738 r.get::<_, Option<i64>>(0)
739 })?
740 .unwrap_or(max_pending_id)
741 } else {
742 max_pending_id
743 };
744 tx.execute(
745 "INSERT INTO meta (key, value) VALUES ('events_hwm', ?1)
746 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
747 params![new_hwm.to_string()],
748 )?;
749 tx.commit()?;
750 Ok(processed)
751 }
752
753 pub fn prune_events(&self, keep_recent: i64) -> Result<usize> {
758 let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
759 let max_id: Option<i64> = self
760 .conn
761 .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
762 let Some(max_id) = max_id else {
763 return Ok(0);
764 };
765 let cutoff = hwm.min(max_id - keep_recent);
766 if cutoff <= 0 {
767 return Ok(0);
768 }
769 let n = self
770 .conn
771 .execute("DELETE FROM events WHERE id <= ?1", params![cutoff])?;
772 Ok(n)
773 }
774
775 pub fn indexed_head(&self, repository_id: i64) -> Result<Option<String>> {
778 self.meta_get(&format!("head:{repository_id}"))
779 }
780
781 pub fn set_indexed_head(&self, repository_id: i64, head: &str) -> Result<()> {
783 self.meta_set(&format!("head:{repository_id}"), head)
784 }
785
786 pub fn git_ts_head(&self, repository_id: i64) -> Result<Option<String>> {
790 self.meta_get(&format!("git_ts_head:{repository_id}"))
791 }
792
793 pub fn set_git_ts_head(&self, repository_id: i64, head: &str) -> Result<()> {
795 self.meta_set(&format!("git_ts_head:{repository_id}"), head)
796 }
797
798 pub fn warm_lock(&self, identity: &str) -> Result<Option<(u32, i64)>> {
802 Ok(self
803 .meta_get(&format!("warm_lock:{identity}"))?
804 .and_then(|v| {
805 let (pid, ts) = v.split_once(':')?;
806 Some((pid.parse().ok()?, ts.parse().ok()?))
807 }))
808 }
809
810 pub fn set_warm_lock(&self, identity: &str, pid: u32) -> Result<()> {
812 self.meta_set(
813 &format!("warm_lock:{identity}"),
814 &format!("{pid}:{}", now_unix()),
815 )
816 }
817
818 pub fn clear_warm_lock(&self, identity: &str) -> Result<()> {
820 self.conn.execute(
821 "DELETE FROM meta WHERE key = ?1",
822 params![format!("warm_lock:{identity}")],
823 )?;
824 Ok(())
825 }
826
827 fn meta_get(&self, key: &str) -> Result<Option<String>> {
828 self.conn
829 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
830 r.get(0)
831 })
832 .optional()
833 }
834
835 fn meta_set(&self, key: &str, value: &str) -> Result<()> {
836 self.conn.execute(
837 "INSERT INTO meta (key, value) VALUES (?1, ?2)
838 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
839 params![key, value],
840 )?;
841 Ok(())
842 }
843
844 fn meta_get_i64(&self, key: &str) -> Result<Option<i64>> {
845 Ok(self.meta_get(key)?.and_then(|s| s.parse().ok()))
846 }
847
848 fn meta_set_i64(&self, key: &str, value: i64) -> Result<()> {
849 self.meta_set(key, &value.to_string())
850 }
851
852 pub fn search_candidates(
863 &self,
864 query: &str,
865 limit: usize,
866 force_fuzzy: bool,
867 ) -> Result<Vec<SymbolRow>> {
868 let q = query.to_ascii_lowercase();
869 let mut found: HashMap<i64, SymbolRow> = HashMap::new();
870
871 {
875 let sql = format!(
876 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} WHERE s.name_lower = ?1 LIMIT ?2"
877 );
878 let mut stmt = self.conn.prepare_cached(&sql)?;
879 let rows = stmt.query_map(params![q, limit as i64], row_to_candidate)?;
880 for row in rows {
881 let (id, cand) = row?;
882 found.insert(id, cand);
883 }
884 }
885
886 {
890 let like = format!("{}%", escape_like(&q));
891 let sql = format!(
892 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
893 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
894 );
895 let mut stmt = self.conn.prepare_cached(&sql)?;
896 let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
897 for row in rows {
898 let (id, cand) = row?;
899 found.entry(id).or_insert(cand);
900 }
901 }
902
903 if !force_fuzzy && !found.is_empty() {
908 return Ok(found.into_values().collect());
909 }
910
911 if let Some(first) = q.chars().next() {
916 let like = format!("{}%", escape_like(&first.to_string()));
917 let sql = format!(
918 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
919 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
920 );
921 let mut stmt = self.conn.prepare_cached(&sql)?;
922 let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
923 for row in rows {
924 let (id, cand) = row?;
925 found.entry(id).or_insert(cand);
926 }
927 }
928
929 if let Some(match_expr) = trigram_or_query(&q) {
931 let sql = format!(
932 "SELECT {CANDIDATE_COLS} FROM symbols_fts f \
933 JOIN symbols s ON s.id = f.rowid \
934 JOIN files fi ON fi.id = s.file_id \
935 JOIN repositories r ON r.id = s.repository_id \
936 WHERE symbols_fts MATCH ?1 LIMIT ?2"
937 );
938 let mut stmt = self.conn.prepare_cached(&sql)?;
939 let rows = stmt.query_map(params![match_expr, limit as i64], row_to_candidate)?;
940 for row in rows {
941 let (id, cand) = row?;
942 found.entry(id).or_insert(cand);
943 }
944 }
945
946 let path_like = format!("%{}%", escape_like(&q));
949 let sql = format!(
950 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
951 WHERE fi.path LIKE ?1 ESCAPE '\\' AND s.kind IN ('class', 'module') LIMIT ?2"
952 );
953 {
954 let mut stmt = self.conn.prepare_cached(&sql)?;
955 let rows = stmt.query_map(params![path_like, limit as i64], row_to_candidate)?;
956 for row in rows {
957 let (id, cand) = row?;
958 found.entry(id).or_insert(cand);
959 }
960 }
961
962 Ok(found.into_values().collect())
963 }
964}
965
966fn row_to_candidate(r: &rusqlite::Row) -> Result<(i64, SymbolRow)> {
967 Ok((
968 r.get(0)?,
969 SymbolRow {
970 name: r.get(1)?,
971 kind: r.get(2)?,
972 language: r.get(3)?,
973 file: r.get(4)?,
974 line: r.get(5)?,
975 end_line: r.get(6)?,
976 parent: r.get(7)?,
977 repository_id: r.get(8)?,
978 repo_identity: r.get(9)?,
979 mtime: r.get(10)?,
980 git_ts: r.get(11)?,
981 visibility: r.get(12)?,
982 },
983 ))
984}
985
986fn escape_like(s: &str) -> String {
988 s.replace('\\', "\\\\")
989 .replace('%', "\\%")
990 .replace('_', "\\_")
991}
992
993fn trigram_or_query(q: &str) -> Option<String> {
997 let cleaned: Vec<char> = q
998 .chars()
999 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1000 .collect();
1001 if cleaned.len() < 3 {
1002 return None;
1003 }
1004 let mut grams: Vec<String> = Vec::new();
1005 for w in cleaned.windows(3) {
1006 let gram: String = w.iter().collect();
1007 let quoted = format!("\"{gram}\"");
1008 if !grams.contains("ed) {
1009 grams.push(quoted);
1010 }
1011 }
1012 Some(grams.join(" OR "))
1013}
1014
1015fn now_unix() -> i64 {
1016 SystemTime::now()
1017 .duration_since(UNIX_EPOCH)
1018 .map(|d| d.as_secs() as i64)
1019 .unwrap_or(0)
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025 use crate::core::Kind;
1026
1027 fn sym(name: &str, kind: Kind, line: u32, parent: Option<&str>) -> Symbol {
1028 Symbol {
1029 name: name.into(),
1030 kind,
1031 language: "ruby".into(),
1032 file: "app/models/user.rb".into(),
1033 line,
1034 end_line: line,
1035 parent: parent.map(String::from),
1036 visibility: None,
1037 }
1038 }
1039
1040 #[test]
1041 fn migration_adds_repo_indexes_to_an_existing_db() {
1042 let path = std::env::temp_dir().join(format!("rq-migrate-{}.db", std::process::id()));
1043 let _ = std::fs::remove_file(&path);
1044 {
1045 let store = Store::open(&path).unwrap();
1048 store
1049 .conn
1050 .execute_batch(
1051 "DROP INDEX idx_symbols_repo; DROP INDEX idx_events_repo; \
1052 ALTER TABLE repositories ADD COLUMN display_name TEXT; \
1053 ALTER TABLE symbols DROP COLUMN visibility; \
1054 PRAGMA user_version=4;",
1055 )
1056 .unwrap();
1057 }
1058 let store = Store::open(&path).unwrap();
1059 let n: i64 = store
1060 .conn
1061 .query_row(
1062 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' \
1063 AND name IN ('idx_symbols_repo','idx_events_repo')",
1064 [],
1065 |r| r.get(0),
1066 )
1067 .unwrap();
1068 assert_eq!(n, 2);
1069 drop(store);
1070 let _ = std::fs::remove_file(&path);
1071 }
1072
1073 #[test]
1074 fn checkout_roots_returns_all_paths_newest_first() {
1075 let store = Store::open_in_memory().unwrap();
1076 let repo = store
1077 .upsert_repository(&RepoIdentity::local("/x"), None)
1078 .unwrap();
1079 store.upsert_checkout(repo, "/old/path", None).unwrap();
1081 store.upsert_checkout(repo, "/new/path", None).unwrap();
1082 let roots = store.checkout_roots(repo).unwrap();
1083 assert_eq!(roots, vec!["/new/path", "/old/path"]);
1086 }
1087
1088 #[test]
1089 fn forget_checkout_prunes_a_stale_binding() {
1090 let mut store = Store::open_in_memory().unwrap();
1091 let repo = store
1092 .upsert_repository(&RepoIdentity::local("/x"), None)
1093 .unwrap();
1094 store.upsert_checkout(repo, "/old/path", None).unwrap();
1095 store.upsert_checkout(repo, "/new/path", None).unwrap();
1096 store.forget_checkout("/old/path").unwrap();
1097 assert_eq!(store.checkout_roots(repo).unwrap(), vec!["/new/path"]);
1099 assert_eq!(store.repository_id("local:/x").unwrap(), Some(repo));
1100 }
1101
1102 #[test]
1103 fn prune_events_drops_aggregated_but_keeps_recent() {
1104 let mut store = Store::open_in_memory().unwrap();
1105 let repo = store
1106 .upsert_repository(&RepoIdentity::local("/x"), None)
1107 .unwrap();
1108 store
1109 .replace_file_symbols(
1110 repo,
1111 "a.rb",
1112 "ruby",
1113 None,
1114 "h",
1115 &[sym("Foo", Kind::Class, 1, None)],
1116 )
1117 .unwrap();
1118
1119 store
1121 .record_event(
1122 "select",
1123 Some("foo"),
1124 Some(repo),
1125 Some("a.rb"),
1126 Some(1),
1127 None,
1128 )
1129 .unwrap();
1130 for _ in 0..10 {
1131 store
1132 .record_event("search", Some("foo"), Some(repo), None, None, None)
1133 .unwrap();
1134 }
1135 store.aggregate_events(100).unwrap(); assert_eq!(store.prune_events(3).unwrap(), 8);
1139 assert_eq!(store.prune_events(3).unwrap(), 0);
1141 assert!(store.is_repeat_search(repo, "foo").unwrap());
1143 }
1144
1145 #[test]
1146 fn git_ts_is_stored_and_surfaced_on_candidates() {
1147 let mut store = Store::open_in_memory().unwrap();
1148 let repo = store
1149 .upsert_repository(&RepoIdentity::local("/x"), None)
1150 .unwrap();
1151 store
1152 .replace_file_symbols(
1153 repo,
1154 "a.rb",
1155 "ruby",
1156 None,
1157 "h",
1158 &[sym("Foo", Kind::Class, 1, None)],
1159 )
1160 .unwrap();
1161
1162 let times = HashMap::from([("a.rb".to_string(), 1_700_000_000_i64)]);
1163 store.set_file_git_ts(repo, ×).unwrap();
1164
1165 let cands = store.search_candidates("foo", 10, false).unwrap();
1166 assert_eq!(cands[0].git_ts, Some(1_700_000_000));
1167 }
1168
1169 #[test]
1170 fn aggregates_a_selection_and_decays_on_repeat() {
1171 let mut store = Store::open_in_memory().unwrap();
1172 let repo = store
1173 .upsert_repository(&RepoIdentity::local("/x"), None)
1174 .unwrap();
1175 store
1176 .replace_file_symbols(
1177 repo,
1178 "a.rb",
1179 "ruby",
1180 None,
1181 "h",
1182 &[sym("Foo", Kind::Class, 1, None)],
1183 )
1184 .unwrap();
1185
1186 store
1188 .record_event(
1189 "select",
1190 Some("foo"),
1191 Some(repo),
1192 Some("a.rb"),
1193 Some(1),
1194 None,
1195 )
1196 .unwrap();
1197 assert_eq!(store.aggregate_events(10).unwrap(), 1);
1198 assert_eq!(store.selections_for("foo").unwrap().len(), 1);
1199 assert_eq!(store.selections_for("foobar").unwrap().len(), 1);
1201
1202 assert!(!store.is_repeat_search(repo, "foo").unwrap());
1204 store
1206 .record_event("search", Some("foo"), Some(repo), None, None, None)
1207 .unwrap();
1208 assert!(store.is_repeat_search(repo, "foo").unwrap());
1209
1210 store.decay_selections(repo, "foo").unwrap();
1212 assert!(store.selections_for("foo").unwrap().is_empty());
1213 }
1214
1215 #[test]
1216 fn indexes_and_reports_coverage() {
1217 let mut store = Store::open_in_memory().unwrap();
1218 let id = RepoIdentity::Remote("github.com/dpep/rq".into());
1219 let repo = store.upsert_repository(&id, Some("main")).unwrap();
1220 store
1221 .upsert_checkout(repo, "/tmp/rq", Some("main"))
1222 .unwrap();
1223
1224 let symbols = vec![
1225 sym("User", Kind::Class, 1, None),
1226 sym("save", Kind::Method, 5, Some("User")),
1227 ];
1228 store
1229 .replace_file_symbols(
1230 repo,
1231 "app/models/user.rb",
1232 "ruby",
1233 Some(100),
1234 "h1",
1235 &symbols,
1236 )
1237 .unwrap();
1238 store.set_coverage(repo, 10, 1, "warming").unwrap();
1239
1240 let overview = store.coverage_overview().unwrap();
1241 assert_eq!(overview.len(), 1);
1242 assert_eq!(overview[0].identity, "github.com/dpep/rq");
1243 assert_eq!(overview[0].status, "warming");
1244 assert_eq!(overview[0].symbols, 2);
1245 }
1246
1247 #[test]
1248 fn reindexing_a_file_replaces_its_symbols() {
1249 let mut store = Store::open_in_memory().unwrap();
1250 let repo = store
1251 .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1252 .unwrap();
1253
1254 store
1255 .replace_file_symbols(
1256 repo,
1257 "a.rb",
1258 "ruby",
1259 None,
1260 "h1",
1261 &[sym("Old", Kind::Class, 1, None)],
1262 )
1263 .unwrap();
1264 store
1265 .replace_file_symbols(
1266 repo,
1267 "a.rb",
1268 "ruby",
1269 None,
1270 "h2",
1271 &[sym("New", Kind::Class, 1, None)],
1272 )
1273 .unwrap();
1274
1275 store.set_coverage(repo, 1, 1, "complete").unwrap();
1276 let overview = store.coverage_overview().unwrap();
1277 assert_eq!(overview[0].symbols, 1);
1279 }
1280
1281 #[test]
1282 fn file_unchanged_detects_matching_hash() {
1283 let mut store = Store::open_in_memory().unwrap();
1284 let repo = store
1285 .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1286 .unwrap();
1287 store
1288 .replace_file_symbols(repo, "a.rb", "ruby", None, "abc", &[])
1289 .unwrap();
1290
1291 assert!(store.file_unchanged(repo, "a.rb", "abc").unwrap());
1292 assert!(!store.file_unchanged(repo, "a.rb", "xyz").unwrap());
1293 assert!(!store.file_unchanged(repo, "missing.rb", "abc").unwrap());
1294 }
1295}