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 aggregate_events(&mut self, batch: usize) -> Result<usize> {
621 let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
622
623 type Pending = (
624 i64,
625 Option<String>,
626 Option<i64>,
627 Option<String>,
628 Option<i64>,
629 i64,
630 );
631 let pending: Vec<Pending> = {
632 let mut stmt = self.conn.prepare(
633 "SELECT id, query, repository_id, path, line, ts FROM events
634 WHERE id > ?1 AND type IN ('select', 'open')
635 ORDER BY id LIMIT ?2",
636 )?;
637 stmt.query_map(params![hwm, batch as i64], |r| {
638 Ok((
639 r.get(0)?,
640 r.get(1)?,
641 r.get(2)?,
642 r.get(3)?,
643 r.get(4)?,
644 r.get(5)?,
645 ))
646 })?
647 .collect::<Result<Vec<_>>>()?
648 };
649
650 if pending.is_empty() {
651 let max_id: Option<i64> =
653 self.conn
654 .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
655 if let Some(m) = max_id.filter(|m| *m > hwm) {
656 self.meta_set_i64("events_hwm", m)?;
657 }
658 return Ok(0);
659 }
660
661 let drained = pending.len() < batch;
662 let max_pending_id = pending.iter().map(|p| p.0).max().unwrap_or(hwm);
663
664 let tx = self.conn.transaction()?;
665 let mut processed = 0;
666 for (_id, query, repo, path, line, ts) in &pending {
667 processed += 1;
668 let (Some(query), Some(repo), Some(path)) = (query, repo, path) else {
669 continue;
670 };
671 let name: Option<String> = match line {
672 Some(line) => tx
673 .query_row(
674 "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
675 WHERE s.repository_id = ?1 AND fi.path = ?2 AND s.line <= ?3
676 ORDER BY s.line DESC LIMIT 1",
677 params![repo, path, line],
678 |r| r.get(0),
679 )
680 .optional()?,
681 None => tx
682 .query_row(
683 "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
684 WHERE s.repository_id = ?1 AND fi.path = ?2
685 AND s.kind IN ('class', 'module')
686 ORDER BY s.line ASC LIMIT 1",
687 params![repo, path],
688 |r| r.get(0),
689 )
690 .optional()?,
691 };
692 if let Some(name) = name {
693 tx.execute(
694 "INSERT INTO selection_stats
695 (repository_id, query_norm, file, name, selections, last_selected_at)
696 VALUES (?1, ?2, ?3, ?4, 1, ?5)
697 ON CONFLICT(repository_id, query_norm, file, name) DO UPDATE SET
698 selections = selections + 1,
699 last_selected_at = max(last_selected_at, excluded.last_selected_at)",
700 params![repo, query, path, name, ts],
701 )?;
702 }
703 }
704
705 let new_hwm = if drained {
706 tx.query_row("SELECT MAX(id) FROM events", [], |r| {
707 r.get::<_, Option<i64>>(0)
708 })?
709 .unwrap_or(max_pending_id)
710 } else {
711 max_pending_id
712 };
713 tx.execute(
714 "INSERT INTO meta (key, value) VALUES ('events_hwm', ?1)
715 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
716 params![new_hwm.to_string()],
717 )?;
718 tx.commit()?;
719 Ok(processed)
720 }
721
722 pub fn prune_events(&self, keep_recent: i64) -> Result<usize> {
727 let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
728 let max_id: Option<i64> = self
729 .conn
730 .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
731 let Some(max_id) = max_id else {
732 return Ok(0);
733 };
734 let cutoff = hwm.min(max_id - keep_recent);
735 if cutoff <= 0 {
736 return Ok(0);
737 }
738 let n = self
739 .conn
740 .execute("DELETE FROM events WHERE id <= ?1", params![cutoff])?;
741 Ok(n)
742 }
743
744 pub fn indexed_head(&self, repository_id: i64) -> Result<Option<String>> {
747 self.meta_get(&format!("head:{repository_id}"))
748 }
749
750 pub fn set_indexed_head(&self, repository_id: i64, head: &str) -> Result<()> {
752 self.meta_set(&format!("head:{repository_id}"), head)
753 }
754
755 pub fn git_ts_head(&self, repository_id: i64) -> Result<Option<String>> {
759 self.meta_get(&format!("git_ts_head:{repository_id}"))
760 }
761
762 pub fn set_git_ts_head(&self, repository_id: i64, head: &str) -> Result<()> {
764 self.meta_set(&format!("git_ts_head:{repository_id}"), head)
765 }
766
767 pub fn warm_lock(&self, identity: &str) -> Result<Option<(u32, i64)>> {
771 Ok(self
772 .meta_get(&format!("warm_lock:{identity}"))?
773 .and_then(|v| {
774 let (pid, ts) = v.split_once(':')?;
775 Some((pid.parse().ok()?, ts.parse().ok()?))
776 }))
777 }
778
779 pub fn set_warm_lock(&self, identity: &str, pid: u32) -> Result<()> {
781 self.meta_set(
782 &format!("warm_lock:{identity}"),
783 &format!("{pid}:{}", now_unix()),
784 )
785 }
786
787 pub fn clear_warm_lock(&self, identity: &str) -> Result<()> {
789 self.conn.execute(
790 "DELETE FROM meta WHERE key = ?1",
791 params![format!("warm_lock:{identity}")],
792 )?;
793 Ok(())
794 }
795
796 pub fn branch_files_get(&self, identity: &str) -> Result<Option<(String, i64, Vec<String>)>> {
800 let Some(raw) = self.meta_get(&format!("branch_files:{identity}"))? else {
801 return Ok(None);
802 };
803 let mut lines = raw.lines();
804 let (Some(stamp), Some(at)) = (lines.next(), lines.next()) else {
805 return Ok(None);
806 };
807 let Ok(at) = at.parse::<i64>() else {
808 return Ok(None);
809 };
810 Ok(Some((
811 stamp.to_string(),
812 at,
813 lines.map(str::to_string).collect(),
814 )))
815 }
816
817 pub fn branch_files_set(
818 &self,
819 identity: &str,
820 stamp: &str,
821 at: i64,
822 files: &[String],
823 ) -> Result<()> {
824 let mut value = format!("{stamp}\n{at}");
827 for f in files {
828 value.push('\n');
829 value.push_str(f);
830 }
831 self.meta_set(&format!("branch_files:{identity}"), &value)
832 }
833
834 fn meta_get(&self, key: &str) -> Result<Option<String>> {
835 self.conn
836 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
837 r.get(0)
838 })
839 .optional()
840 }
841
842 fn meta_set(&self, key: &str, value: &str) -> Result<()> {
843 self.conn.execute(
844 "INSERT INTO meta (key, value) VALUES (?1, ?2)
845 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
846 params![key, value],
847 )?;
848 Ok(())
849 }
850
851 fn meta_get_i64(&self, key: &str) -> Result<Option<i64>> {
852 Ok(self.meta_get(key)?.and_then(|s| s.parse().ok()))
853 }
854
855 fn meta_set_i64(&self, key: &str, value: i64) -> Result<()> {
856 self.meta_set(key, &value.to_string())
857 }
858
859 pub fn search_candidates(
870 &self,
871 query: &str,
872 limit: usize,
873 force_fuzzy: bool,
874 ) -> Result<Vec<SymbolRow>> {
875 let q = query.to_ascii_lowercase();
876 let mut found: HashMap<i64, SymbolRow> = HashMap::new();
877
878 {
882 let sql = format!(
883 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} WHERE s.name_lower = ?1 LIMIT ?2"
884 );
885 let mut stmt = self.conn.prepare_cached(&sql)?;
886 let rows = stmt.query_map(params![q, limit as i64], row_to_candidate)?;
887 for row in rows {
888 let (id, cand) = row?;
889 found.insert(id, cand);
890 }
891 }
892
893 {
897 let like = format!("{}%", escape_like(&q));
898 let sql = format!(
899 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
900 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
901 );
902 let mut stmt = self.conn.prepare_cached(&sql)?;
903 let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
904 for row in rows {
905 let (id, cand) = row?;
906 found.entry(id).or_insert(cand);
907 }
908 }
909
910 if !force_fuzzy && !found.is_empty() {
915 return Ok(found.into_values().collect());
916 }
917
918 if let Some(first) = q.chars().next() {
923 let like = format!("{}%", escape_like(&first.to_string()));
924 let sql = format!(
925 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
926 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
927 );
928 let mut stmt = self.conn.prepare_cached(&sql)?;
929 let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
930 for row in rows {
931 let (id, cand) = row?;
932 found.entry(id).or_insert(cand);
933 }
934 }
935
936 if let Some(match_expr) = trigram_or_query(&q) {
938 let sql = format!(
939 "SELECT {CANDIDATE_COLS} FROM symbols_fts f \
940 JOIN symbols s ON s.id = f.rowid \
941 JOIN files fi ON fi.id = s.file_id \
942 JOIN repositories r ON r.id = s.repository_id \
943 WHERE symbols_fts MATCH ?1 LIMIT ?2"
944 );
945 let mut stmt = self.conn.prepare_cached(&sql)?;
946 let rows = stmt.query_map(params![match_expr, limit as i64], row_to_candidate)?;
947 for row in rows {
948 let (id, cand) = row?;
949 found.entry(id).or_insert(cand);
950 }
951 }
952
953 let path_like = format!("%{}%", escape_like(&q));
956 let sql = format!(
957 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
958 WHERE fi.path LIKE ?1 ESCAPE '\\' AND s.kind IN ('class', 'module') LIMIT ?2"
959 );
960 {
961 let mut stmt = self.conn.prepare_cached(&sql)?;
962 let rows = stmt.query_map(params![path_like, limit as i64], row_to_candidate)?;
963 for row in rows {
964 let (id, cand) = row?;
965 found.entry(id).or_insert(cand);
966 }
967 }
968
969 Ok(found.into_values().collect())
970 }
971}
972
973fn row_to_candidate(r: &rusqlite::Row) -> Result<(i64, SymbolRow)> {
974 Ok((
975 r.get(0)?,
976 SymbolRow {
977 name: r.get(1)?,
978 kind: r.get(2)?,
979 language: r.get(3)?,
980 file: r.get(4)?,
981 line: r.get(5)?,
982 end_line: r.get(6)?,
983 parent: r.get(7)?,
984 repository_id: r.get(8)?,
985 repo_identity: r.get(9)?,
986 mtime: r.get(10)?,
987 git_ts: r.get(11)?,
988 visibility: r.get(12)?,
989 },
990 ))
991}
992
993fn escape_like(s: &str) -> String {
995 s.replace('\\', "\\\\")
996 .replace('%', "\\%")
997 .replace('_', "\\_")
998}
999
1000fn trigram_or_query(q: &str) -> Option<String> {
1004 let cleaned: Vec<char> = q
1005 .chars()
1006 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1007 .collect();
1008 if cleaned.len() < 3 {
1009 return None;
1010 }
1011 let mut grams: Vec<String> = Vec::new();
1012 for w in cleaned.windows(3) {
1013 let gram: String = w.iter().collect();
1014 let quoted = format!("\"{gram}\"");
1015 if !grams.contains("ed) {
1016 grams.push(quoted);
1017 }
1018 }
1019 Some(grams.join(" OR "))
1020}
1021
1022fn now_unix() -> i64 {
1023 SystemTime::now()
1024 .duration_since(UNIX_EPOCH)
1025 .map(|d| d.as_secs() as i64)
1026 .unwrap_or(0)
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032 use crate::core::Kind;
1033
1034 #[test]
1035 fn branch_files_round_trip() {
1036 let store = Store::open_in_memory().unwrap();
1037 assert!(store.branch_files_get("repo").unwrap().is_none());
1038
1039 let files = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
1040 store
1041 .branch_files_set("repo", "123:456", 99, &files)
1042 .unwrap();
1043 let (stamp, at, got) = store.branch_files_get("repo").unwrap().unwrap();
1044 assert_eq!(stamp, "123:456");
1045 assert_eq!(at, 99);
1046 assert_eq!(got, files);
1047
1048 store.branch_files_set("repo", "789:1", 100, &[]).unwrap();
1050 let (stamp, _, got) = store.branch_files_get("repo").unwrap().unwrap();
1051 assert_eq!(stamp, "789:1");
1052 assert!(got.is_empty(), "an empty list is a real answer, not a miss");
1053
1054 assert!(store.branch_files_get("other").unwrap().is_none());
1056 }
1057
1058 fn sym(name: &str, kind: Kind, line: u32, parent: Option<&str>) -> Symbol {
1059 Symbol {
1060 name: name.into(),
1061 kind,
1062 language: "ruby".into(),
1063 file: "app/models/user.rb".into(),
1064 line,
1065 end_line: line,
1066 parent: parent.map(String::from),
1067 visibility: None,
1068 }
1069 }
1070
1071 #[test]
1072 fn migration_adds_repo_indexes_to_an_existing_db() {
1073 let path = std::env::temp_dir().join(format!("rq-migrate-{}.db", std::process::id()));
1074 let _ = std::fs::remove_file(&path);
1075 {
1076 let store = Store::open(&path).unwrap();
1079 store
1080 .conn
1081 .execute_batch(
1082 "DROP INDEX idx_symbols_repo; DROP INDEX idx_events_repo; \
1083 ALTER TABLE repositories ADD COLUMN display_name TEXT; \
1084 ALTER TABLE symbols DROP COLUMN visibility; \
1085 PRAGMA user_version=4;",
1086 )
1087 .unwrap();
1088 }
1089 let store = Store::open(&path).unwrap();
1090 let n: i64 = store
1091 .conn
1092 .query_row(
1093 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' \
1094 AND name IN ('idx_symbols_repo','idx_events_repo')",
1095 [],
1096 |r| r.get(0),
1097 )
1098 .unwrap();
1099 assert_eq!(n, 2);
1100 drop(store);
1101 let _ = std::fs::remove_file(&path);
1102 }
1103
1104 #[test]
1105 fn checkout_roots_returns_all_paths_newest_first() {
1106 let store = Store::open_in_memory().unwrap();
1107 let repo = store
1108 .upsert_repository(&RepoIdentity::local("/x"), None)
1109 .unwrap();
1110 store.upsert_checkout(repo, "/old/path", None).unwrap();
1112 store.upsert_checkout(repo, "/new/path", None).unwrap();
1113 let roots = store.checkout_roots(repo).unwrap();
1114 assert_eq!(roots, vec!["/new/path", "/old/path"]);
1117 }
1118
1119 #[test]
1120 fn forget_checkout_prunes_a_stale_binding() {
1121 let mut store = Store::open_in_memory().unwrap();
1122 let repo = store
1123 .upsert_repository(&RepoIdentity::local("/x"), None)
1124 .unwrap();
1125 store.upsert_checkout(repo, "/old/path", None).unwrap();
1126 store.upsert_checkout(repo, "/new/path", None).unwrap();
1127 store.forget_checkout("/old/path").unwrap();
1128 assert_eq!(store.checkout_roots(repo).unwrap(), vec!["/new/path"]);
1130 assert_eq!(store.repository_id("local:/x").unwrap(), Some(repo));
1131 }
1132
1133 #[test]
1134 fn prune_events_drops_aggregated_but_keeps_recent() {
1135 let mut store = Store::open_in_memory().unwrap();
1136 let repo = store
1137 .upsert_repository(&RepoIdentity::local("/x"), None)
1138 .unwrap();
1139 store
1140 .replace_file_symbols(
1141 repo,
1142 "a.rb",
1143 "ruby",
1144 None,
1145 "h",
1146 &[sym("Foo", Kind::Class, 1, None)],
1147 )
1148 .unwrap();
1149
1150 store
1152 .record_event(
1153 "select",
1154 Some("foo"),
1155 Some(repo),
1156 Some("a.rb"),
1157 Some(1),
1158 None,
1159 )
1160 .unwrap();
1161 for _ in 0..10 {
1162 store
1163 .record_event("search", Some("foo"), Some(repo), None, None, None)
1164 .unwrap();
1165 }
1166 store.aggregate_events(100).unwrap(); assert_eq!(store.prune_events(3).unwrap(), 8);
1170 assert_eq!(store.prune_events(3).unwrap(), 0);
1172 }
1173
1174 #[test]
1175 fn git_ts_is_stored_and_surfaced_on_candidates() {
1176 let mut store = Store::open_in_memory().unwrap();
1177 let repo = store
1178 .upsert_repository(&RepoIdentity::local("/x"), None)
1179 .unwrap();
1180 store
1181 .replace_file_symbols(
1182 repo,
1183 "a.rb",
1184 "ruby",
1185 None,
1186 "h",
1187 &[sym("Foo", Kind::Class, 1, None)],
1188 )
1189 .unwrap();
1190
1191 let times = HashMap::from([("a.rb".to_string(), 1_700_000_000_i64)]);
1192 store.set_file_git_ts(repo, ×).unwrap();
1193
1194 let cands = store.search_candidates("foo", 10, false).unwrap();
1195 assert_eq!(cands[0].git_ts, Some(1_700_000_000));
1196 }
1197
1198 #[test]
1199 fn aggregates_a_selection_and_decays_on_repeat() {
1200 let mut store = Store::open_in_memory().unwrap();
1201 let repo = store
1202 .upsert_repository(&RepoIdentity::local("/x"), None)
1203 .unwrap();
1204 store
1205 .replace_file_symbols(
1206 repo,
1207 "a.rb",
1208 "ruby",
1209 None,
1210 "h",
1211 &[sym("Foo", Kind::Class, 1, None)],
1212 )
1213 .unwrap();
1214
1215 store
1217 .record_event(
1218 "select",
1219 Some("foo"),
1220 Some(repo),
1221 Some("a.rb"),
1222 Some(1),
1223 None,
1224 )
1225 .unwrap();
1226 assert_eq!(store.aggregate_events(10).unwrap(), 1);
1227 assert_eq!(store.selections_for("foo").unwrap().len(), 1);
1228 assert_eq!(store.selections_for("foobar").unwrap().len(), 1);
1230 }
1231
1232 #[test]
1233 fn indexes_and_reports_coverage() {
1234 let mut store = Store::open_in_memory().unwrap();
1235 let id = RepoIdentity::Remote("github.com/dpep/rq".into());
1236 let repo = store.upsert_repository(&id, Some("main")).unwrap();
1237 store
1238 .upsert_checkout(repo, "/tmp/rq", Some("main"))
1239 .unwrap();
1240
1241 let symbols = vec![
1242 sym("User", Kind::Class, 1, None),
1243 sym("save", Kind::Method, 5, Some("User")),
1244 ];
1245 store
1246 .replace_file_symbols(
1247 repo,
1248 "app/models/user.rb",
1249 "ruby",
1250 Some(100),
1251 "h1",
1252 &symbols,
1253 )
1254 .unwrap();
1255 store.set_coverage(repo, 10, 1, "warming").unwrap();
1256
1257 let overview = store.coverage_overview().unwrap();
1258 assert_eq!(overview.len(), 1);
1259 assert_eq!(overview[0].identity, "github.com/dpep/rq");
1260 assert_eq!(overview[0].status, "warming");
1261 assert_eq!(overview[0].symbols, 2);
1262 }
1263
1264 #[test]
1265 fn reindexing_a_file_replaces_its_symbols() {
1266 let mut store = Store::open_in_memory().unwrap();
1267 let repo = store
1268 .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1269 .unwrap();
1270
1271 store
1272 .replace_file_symbols(
1273 repo,
1274 "a.rb",
1275 "ruby",
1276 None,
1277 "h1",
1278 &[sym("Old", Kind::Class, 1, None)],
1279 )
1280 .unwrap();
1281 store
1282 .replace_file_symbols(
1283 repo,
1284 "a.rb",
1285 "ruby",
1286 None,
1287 "h2",
1288 &[sym("New", Kind::Class, 1, None)],
1289 )
1290 .unwrap();
1291
1292 store.set_coverage(repo, 1, 1, "complete").unwrap();
1293 let overview = store.coverage_overview().unwrap();
1294 assert_eq!(overview[0].symbols, 1);
1296 }
1297
1298 #[test]
1299 fn file_unchanged_detects_matching_hash() {
1300 let mut store = Store::open_in_memory().unwrap();
1301 let repo = store
1302 .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1303 .unwrap();
1304 store
1305 .replace_file_symbols(repo, "a.rb", "ruby", None, "abc", &[])
1306 .unwrap();
1307
1308 assert!(store.file_unchanged(repo, "a.rb", "abc").unwrap());
1309 assert!(!store.file_unchanged(repo, "a.rb", "xyz").unwrap());
1310 assert!(!store.file_unchanged(repo, "missing.rb", "abc").unwrap());
1311 }
1312}