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 pub fn branch_files_get(&self, identity: &str) -> Result<Option<(String, i64, Vec<String>)>> {
831 let Some(raw) = self.meta_get(&format!("branch_files:{identity}"))? else {
832 return Ok(None);
833 };
834 let mut lines = raw.lines();
835 let (Some(stamp), Some(at)) = (lines.next(), lines.next()) else {
836 return Ok(None);
837 };
838 let Ok(at) = at.parse::<i64>() else {
839 return Ok(None);
840 };
841 Ok(Some((
842 stamp.to_string(),
843 at,
844 lines.map(str::to_string).collect(),
845 )))
846 }
847
848 pub fn branch_files_set(
849 &self,
850 identity: &str,
851 stamp: &str,
852 at: i64,
853 files: &[String],
854 ) -> Result<()> {
855 let mut value = format!("{stamp}\n{at}");
858 for f in files {
859 value.push('\n');
860 value.push_str(f);
861 }
862 self.meta_set(&format!("branch_files:{identity}"), &value)
863 }
864
865 fn meta_get(&self, key: &str) -> Result<Option<String>> {
866 self.conn
867 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
868 r.get(0)
869 })
870 .optional()
871 }
872
873 fn meta_set(&self, key: &str, value: &str) -> Result<()> {
874 self.conn.execute(
875 "INSERT INTO meta (key, value) VALUES (?1, ?2)
876 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
877 params![key, value],
878 )?;
879 Ok(())
880 }
881
882 fn meta_get_i64(&self, key: &str) -> Result<Option<i64>> {
883 Ok(self.meta_get(key)?.and_then(|s| s.parse().ok()))
884 }
885
886 fn meta_set_i64(&self, key: &str, value: i64) -> Result<()> {
887 self.meta_set(key, &value.to_string())
888 }
889
890 pub fn search_candidates(
901 &self,
902 query: &str,
903 limit: usize,
904 force_fuzzy: bool,
905 ) -> Result<Vec<SymbolRow>> {
906 let q = query.to_ascii_lowercase();
907 let mut found: HashMap<i64, SymbolRow> = HashMap::new();
908
909 {
913 let sql = format!(
914 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} WHERE s.name_lower = ?1 LIMIT ?2"
915 );
916 let mut stmt = self.conn.prepare_cached(&sql)?;
917 let rows = stmt.query_map(params![q, limit as i64], row_to_candidate)?;
918 for row in rows {
919 let (id, cand) = row?;
920 found.insert(id, cand);
921 }
922 }
923
924 {
928 let like = format!("{}%", escape_like(&q));
929 let sql = format!(
930 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
931 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
932 );
933 let mut stmt = self.conn.prepare_cached(&sql)?;
934 let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
935 for row in rows {
936 let (id, cand) = row?;
937 found.entry(id).or_insert(cand);
938 }
939 }
940
941 if !force_fuzzy && !found.is_empty() {
946 return Ok(found.into_values().collect());
947 }
948
949 if let Some(first) = q.chars().next() {
954 let like = format!("{}%", escape_like(&first.to_string()));
955 let sql = format!(
956 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
957 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
958 );
959 let mut stmt = self.conn.prepare_cached(&sql)?;
960 let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
961 for row in rows {
962 let (id, cand) = row?;
963 found.entry(id).or_insert(cand);
964 }
965 }
966
967 if let Some(match_expr) = trigram_or_query(&q) {
969 let sql = format!(
970 "SELECT {CANDIDATE_COLS} FROM symbols_fts f \
971 JOIN symbols s ON s.id = f.rowid \
972 JOIN files fi ON fi.id = s.file_id \
973 JOIN repositories r ON r.id = s.repository_id \
974 WHERE symbols_fts MATCH ?1 LIMIT ?2"
975 );
976 let mut stmt = self.conn.prepare_cached(&sql)?;
977 let rows = stmt.query_map(params![match_expr, limit as i64], row_to_candidate)?;
978 for row in rows {
979 let (id, cand) = row?;
980 found.entry(id).or_insert(cand);
981 }
982 }
983
984 let path_like = format!("%{}%", escape_like(&q));
987 let sql = format!(
988 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
989 WHERE fi.path LIKE ?1 ESCAPE '\\' AND s.kind IN ('class', 'module') LIMIT ?2"
990 );
991 {
992 let mut stmt = self.conn.prepare_cached(&sql)?;
993 let rows = stmt.query_map(params![path_like, limit as i64], row_to_candidate)?;
994 for row in rows {
995 let (id, cand) = row?;
996 found.entry(id).or_insert(cand);
997 }
998 }
999
1000 Ok(found.into_values().collect())
1001 }
1002}
1003
1004fn row_to_candidate(r: &rusqlite::Row) -> Result<(i64, SymbolRow)> {
1005 Ok((
1006 r.get(0)?,
1007 SymbolRow {
1008 name: r.get(1)?,
1009 kind: r.get(2)?,
1010 language: r.get(3)?,
1011 file: r.get(4)?,
1012 line: r.get(5)?,
1013 end_line: r.get(6)?,
1014 parent: r.get(7)?,
1015 repository_id: r.get(8)?,
1016 repo_identity: r.get(9)?,
1017 mtime: r.get(10)?,
1018 git_ts: r.get(11)?,
1019 visibility: r.get(12)?,
1020 },
1021 ))
1022}
1023
1024fn escape_like(s: &str) -> String {
1026 s.replace('\\', "\\\\")
1027 .replace('%', "\\%")
1028 .replace('_', "\\_")
1029}
1030
1031fn trigram_or_query(q: &str) -> Option<String> {
1035 let cleaned: Vec<char> = q
1036 .chars()
1037 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1038 .collect();
1039 if cleaned.len() < 3 {
1040 return None;
1041 }
1042 let mut grams: Vec<String> = Vec::new();
1043 for w in cleaned.windows(3) {
1044 let gram: String = w.iter().collect();
1045 let quoted = format!("\"{gram}\"");
1046 if !grams.contains("ed) {
1047 grams.push(quoted);
1048 }
1049 }
1050 Some(grams.join(" OR "))
1051}
1052
1053fn now_unix() -> i64 {
1054 SystemTime::now()
1055 .duration_since(UNIX_EPOCH)
1056 .map(|d| d.as_secs() as i64)
1057 .unwrap_or(0)
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063 use crate::core::Kind;
1064
1065 #[test]
1066 fn branch_files_round_trip() {
1067 let store = Store::open_in_memory().unwrap();
1068 assert!(store.branch_files_get("repo").unwrap().is_none());
1069
1070 let files = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
1071 store
1072 .branch_files_set("repo", "123:456", 99, &files)
1073 .unwrap();
1074 let (stamp, at, got) = store.branch_files_get("repo").unwrap().unwrap();
1075 assert_eq!(stamp, "123:456");
1076 assert_eq!(at, 99);
1077 assert_eq!(got, files);
1078
1079 store.branch_files_set("repo", "789:1", 100, &[]).unwrap();
1081 let (stamp, _, got) = store.branch_files_get("repo").unwrap().unwrap();
1082 assert_eq!(stamp, "789:1");
1083 assert!(got.is_empty(), "an empty list is a real answer, not a miss");
1084
1085 assert!(store.branch_files_get("other").unwrap().is_none());
1087 }
1088
1089 fn sym(name: &str, kind: Kind, line: u32, parent: Option<&str>) -> Symbol {
1090 Symbol {
1091 name: name.into(),
1092 kind,
1093 language: "ruby".into(),
1094 file: "app/models/user.rb".into(),
1095 line,
1096 end_line: line,
1097 parent: parent.map(String::from),
1098 visibility: None,
1099 }
1100 }
1101
1102 #[test]
1103 fn migration_adds_repo_indexes_to_an_existing_db() {
1104 let path = std::env::temp_dir().join(format!("rq-migrate-{}.db", std::process::id()));
1105 let _ = std::fs::remove_file(&path);
1106 {
1107 let store = Store::open(&path).unwrap();
1110 store
1111 .conn
1112 .execute_batch(
1113 "DROP INDEX idx_symbols_repo; DROP INDEX idx_events_repo; \
1114 ALTER TABLE repositories ADD COLUMN display_name TEXT; \
1115 ALTER TABLE symbols DROP COLUMN visibility; \
1116 PRAGMA user_version=4;",
1117 )
1118 .unwrap();
1119 }
1120 let store = Store::open(&path).unwrap();
1121 let n: i64 = store
1122 .conn
1123 .query_row(
1124 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' \
1125 AND name IN ('idx_symbols_repo','idx_events_repo')",
1126 [],
1127 |r| r.get(0),
1128 )
1129 .unwrap();
1130 assert_eq!(n, 2);
1131 drop(store);
1132 let _ = std::fs::remove_file(&path);
1133 }
1134
1135 #[test]
1136 fn checkout_roots_returns_all_paths_newest_first() {
1137 let store = Store::open_in_memory().unwrap();
1138 let repo = store
1139 .upsert_repository(&RepoIdentity::local("/x"), None)
1140 .unwrap();
1141 store.upsert_checkout(repo, "/old/path", None).unwrap();
1143 store.upsert_checkout(repo, "/new/path", None).unwrap();
1144 let roots = store.checkout_roots(repo).unwrap();
1145 assert_eq!(roots, vec!["/new/path", "/old/path"]);
1148 }
1149
1150 #[test]
1151 fn forget_checkout_prunes_a_stale_binding() {
1152 let mut store = Store::open_in_memory().unwrap();
1153 let repo = store
1154 .upsert_repository(&RepoIdentity::local("/x"), None)
1155 .unwrap();
1156 store.upsert_checkout(repo, "/old/path", None).unwrap();
1157 store.upsert_checkout(repo, "/new/path", None).unwrap();
1158 store.forget_checkout("/old/path").unwrap();
1159 assert_eq!(store.checkout_roots(repo).unwrap(), vec!["/new/path"]);
1161 assert_eq!(store.repository_id("local:/x").unwrap(), Some(repo));
1162 }
1163
1164 #[test]
1165 fn prune_events_drops_aggregated_but_keeps_recent() {
1166 let mut store = Store::open_in_memory().unwrap();
1167 let repo = store
1168 .upsert_repository(&RepoIdentity::local("/x"), None)
1169 .unwrap();
1170 store
1171 .replace_file_symbols(
1172 repo,
1173 "a.rb",
1174 "ruby",
1175 None,
1176 "h",
1177 &[sym("Foo", Kind::Class, 1, None)],
1178 )
1179 .unwrap();
1180
1181 store
1183 .record_event(
1184 "select",
1185 Some("foo"),
1186 Some(repo),
1187 Some("a.rb"),
1188 Some(1),
1189 None,
1190 )
1191 .unwrap();
1192 for _ in 0..10 {
1193 store
1194 .record_event("search", Some("foo"), Some(repo), None, None, None)
1195 .unwrap();
1196 }
1197 store.aggregate_events(100).unwrap(); assert_eq!(store.prune_events(3).unwrap(), 8);
1201 assert_eq!(store.prune_events(3).unwrap(), 0);
1203 assert!(store.is_repeat_search(repo, "foo").unwrap());
1205 }
1206
1207 #[test]
1208 fn git_ts_is_stored_and_surfaced_on_candidates() {
1209 let mut store = Store::open_in_memory().unwrap();
1210 let repo = store
1211 .upsert_repository(&RepoIdentity::local("/x"), None)
1212 .unwrap();
1213 store
1214 .replace_file_symbols(
1215 repo,
1216 "a.rb",
1217 "ruby",
1218 None,
1219 "h",
1220 &[sym("Foo", Kind::Class, 1, None)],
1221 )
1222 .unwrap();
1223
1224 let times = HashMap::from([("a.rb".to_string(), 1_700_000_000_i64)]);
1225 store.set_file_git_ts(repo, ×).unwrap();
1226
1227 let cands = store.search_candidates("foo", 10, false).unwrap();
1228 assert_eq!(cands[0].git_ts, Some(1_700_000_000));
1229 }
1230
1231 #[test]
1232 fn aggregates_a_selection_and_decays_on_repeat() {
1233 let mut store = Store::open_in_memory().unwrap();
1234 let repo = store
1235 .upsert_repository(&RepoIdentity::local("/x"), None)
1236 .unwrap();
1237 store
1238 .replace_file_symbols(
1239 repo,
1240 "a.rb",
1241 "ruby",
1242 None,
1243 "h",
1244 &[sym("Foo", Kind::Class, 1, None)],
1245 )
1246 .unwrap();
1247
1248 store
1250 .record_event(
1251 "select",
1252 Some("foo"),
1253 Some(repo),
1254 Some("a.rb"),
1255 Some(1),
1256 None,
1257 )
1258 .unwrap();
1259 assert_eq!(store.aggregate_events(10).unwrap(), 1);
1260 assert_eq!(store.selections_for("foo").unwrap().len(), 1);
1261 assert_eq!(store.selections_for("foobar").unwrap().len(), 1);
1263
1264 assert!(!store.is_repeat_search(repo, "foo").unwrap());
1266 store
1268 .record_event("search", Some("foo"), Some(repo), None, None, None)
1269 .unwrap();
1270 assert!(store.is_repeat_search(repo, "foo").unwrap());
1271
1272 store.decay_selections(repo, "foo").unwrap();
1274 assert!(store.selections_for("foo").unwrap().is_empty());
1275 }
1276
1277 #[test]
1278 fn indexes_and_reports_coverage() {
1279 let mut store = Store::open_in_memory().unwrap();
1280 let id = RepoIdentity::Remote("github.com/dpep/rq".into());
1281 let repo = store.upsert_repository(&id, Some("main")).unwrap();
1282 store
1283 .upsert_checkout(repo, "/tmp/rq", Some("main"))
1284 .unwrap();
1285
1286 let symbols = vec![
1287 sym("User", Kind::Class, 1, None),
1288 sym("save", Kind::Method, 5, Some("User")),
1289 ];
1290 store
1291 .replace_file_symbols(
1292 repo,
1293 "app/models/user.rb",
1294 "ruby",
1295 Some(100),
1296 "h1",
1297 &symbols,
1298 )
1299 .unwrap();
1300 store.set_coverage(repo, 10, 1, "warming").unwrap();
1301
1302 let overview = store.coverage_overview().unwrap();
1303 assert_eq!(overview.len(), 1);
1304 assert_eq!(overview[0].identity, "github.com/dpep/rq");
1305 assert_eq!(overview[0].status, "warming");
1306 assert_eq!(overview[0].symbols, 2);
1307 }
1308
1309 #[test]
1310 fn reindexing_a_file_replaces_its_symbols() {
1311 let mut store = Store::open_in_memory().unwrap();
1312 let repo = store
1313 .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1314 .unwrap();
1315
1316 store
1317 .replace_file_symbols(
1318 repo,
1319 "a.rb",
1320 "ruby",
1321 None,
1322 "h1",
1323 &[sym("Old", Kind::Class, 1, None)],
1324 )
1325 .unwrap();
1326 store
1327 .replace_file_symbols(
1328 repo,
1329 "a.rb",
1330 "ruby",
1331 None,
1332 "h2",
1333 &[sym("New", Kind::Class, 1, None)],
1334 )
1335 .unwrap();
1336
1337 store.set_coverage(repo, 1, 1, "complete").unwrap();
1338 let overview = store.coverage_overview().unwrap();
1339 assert_eq!(overview[0].symbols, 1);
1341 }
1342
1343 #[test]
1344 fn file_unchanged_detects_matching_hash() {
1345 let mut store = Store::open_in_memory().unwrap();
1346 let repo = store
1347 .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1348 .unwrap();
1349 store
1350 .replace_file_symbols(repo, "a.rb", "ruby", None, "abc", &[])
1351 .unwrap();
1352
1353 assert!(store.file_unchanged(repo, "a.rb", "abc").unwrap());
1354 assert!(!store.file_unchanged(repo, "a.rb", "xyz").unwrap());
1355 assert!(!store.file_unchanged(repo, "missing.rb", "abc").unwrap());
1356 }
1357}