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 parent: Option<String>,
28 pub repository_id: i64,
29 pub repo_identity: String,
30 pub mtime: Option<i64>,
32 pub git_ts: Option<i64>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SelectionStat {
40 pub repository_id: i64,
41 pub file: String,
42 pub name: String,
43 pub selections: i64,
44 pub last_selected_at: i64,
45}
46
47const CANDIDATE_COLS: &str = "s.id, s.name, s.kind, s.language, fi.path, s.line, \
50 s.parent, s.repository_id, r.identity, fi.mtime, fi.git_ts";
51const CANDIDATE_FROM: &str = "FROM symbols s \
52 JOIN files fi ON fi.id = s.file_id \
53 JOIN repositories r ON r.id = s.repository_id";
54
55pub struct Store {
57 conn: Connection,
58}
59
60#[derive(Debug, Clone)]
63pub struct FileSymbols {
64 pub path: String,
65 pub language: String,
66 pub mtime: Option<i64>,
67 pub content_hash: String,
68 pub symbols: Vec<Symbol>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
74pub struct CoverageRow {
75 #[serde(rename = "repo")]
78 pub identity: String,
79 pub status: String,
80 pub files: i64,
81 pub symbols: i64,
82}
83
84impl Store {
85 pub fn open(path: &Path) -> Result<Store> {
88 let conn = Connection::open(path)?;
89 Self::init(conn)
90 }
91
92 pub fn open_in_memory() -> Result<Store> {
94 let conn = Connection::open_in_memory()?;
95 Self::init(conn)
96 }
97
98 fn init(conn: Connection) -> Result<Store> {
99 conn.execute_batch(
103 "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=3000; \
104 PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY; PRAGMA cache_size=-16384;",
105 )?;
106 let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
107 if version == 0 {
108 conn.execute_batch(schema::SCHEMA)?;
110 }
111 if version != 0 && version < 2 {
113 conn.execute_batch(schema::MIGRATION_V2)?;
114 }
115 if version != 0 && version < 3 {
116 conn.execute_batch(schema::MIGRATION_V3)?;
117 }
118 if version != schema::VERSION {
119 conn.pragma_update(None, "user_version", schema::VERSION)?;
120 }
121 Ok(Store { conn })
122 }
123
124 pub fn upsert_repository(
126 &self,
127 identity: &RepoIdentity,
128 default_branch: Option<&str>,
129 ) -> Result<i64> {
130 let now = now_unix();
131 self.conn.query_row(
132 "INSERT INTO repositories (identity, default_branch, created_at, updated_at)
133 VALUES (?1, ?2, ?3, ?3)
134 ON CONFLICT(identity) DO UPDATE SET
135 default_branch = COALESCE(excluded.default_branch, repositories.default_branch),
136 updated_at = excluded.updated_at
137 RETURNING id",
138 params![identity.to_string(), default_branch, now],
139 |r| r.get(0),
140 )
141 }
142
143 pub fn upsert_checkout(
145 &self,
146 repository_id: i64,
147 root_path: &str,
148 branch: Option<&str>,
149 ) -> Result<()> {
150 self.conn.execute(
151 "INSERT INTO checkouts (repository_id, root_path, current_branch)
152 VALUES (?1, ?2, ?3)
153 ON CONFLICT(root_path) DO UPDATE SET
154 repository_id = excluded.repository_id,
155 current_branch = excluded.current_branch",
156 params![repository_id, root_path, branch],
157 )?;
158 Ok(())
159 }
160
161 pub fn file_unchanged(
164 &self,
165 repository_id: i64,
166 path: &str,
167 content_hash: &str,
168 ) -> Result<bool> {
169 let stored: Option<String> = self
170 .conn
171 .query_row(
172 "SELECT content_hash FROM files WHERE repository_id = ?1 AND path = ?2",
173 params![repository_id, path],
174 |r| r.get(0),
175 )
176 .optional()?;
177 Ok(stored.as_deref() == Some(content_hash))
178 }
179
180 pub fn file_mtimes(&self, repository_id: i64) -> Result<HashMap<String, Option<i64>>> {
183 let mut stmt = self
184 .conn
185 .prepare("SELECT path, mtime FROM files WHERE repository_id = ?1")?;
186 let rows = stmt.query_map(params![repository_id], |r| {
187 Ok((r.get::<_, String>(0)?, r.get::<_, Option<i64>>(1)?))
188 })?;
189 let mut map = HashMap::new();
190 for row in rows {
191 let (path, mtime) = row?;
192 map.insert(path, mtime);
193 }
194 Ok(map)
195 }
196
197 pub fn replace_file_symbols(
200 &mut self,
201 repository_id: i64,
202 path: &str,
203 language: &str,
204 mtime: Option<i64>,
205 content_hash: &str,
206 symbols: &[Symbol],
207 ) -> Result<()> {
208 let now = now_unix();
209 let tx = self.conn.transaction()?;
210 let file_id: i64 = tx.query_row(
211 "INSERT INTO files (repository_id, path, language, mtime, content_hash, indexed_at)
212 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
213 ON CONFLICT(repository_id, path) DO UPDATE SET
214 language = excluded.language,
215 mtime = excluded.mtime,
216 content_hash = excluded.content_hash,
217 indexed_at = excluded.indexed_at
218 RETURNING id",
219 params![repository_id, path, language, mtime, content_hash, now],
220 |r| r.get(0),
221 )?;
222 tx.execute("DELETE FROM symbols WHERE file_id = ?1", params![file_id])?;
223 {
224 let mut stmt = tx.prepare(
225 "INSERT INTO symbols
226 (repository_id, file_id, name, name_lower, kind, language, line, parent)
227 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
228 )?;
229 for s in symbols {
230 stmt.execute(params![
231 repository_id,
232 file_id,
233 s.name,
234 s.name.to_lowercase(),
235 s.kind.as_str(),
236 s.language,
237 s.line,
238 s.parent,
239 ])?;
240 }
241 }
242 tx.commit()
243 }
244
245 pub fn replace_files(
251 &mut self,
252 repository_id: i64,
253 files: &[FileSymbols],
254 ) -> Result<(usize, usize)> {
255 const BATCH: usize = 512;
257
258 let now = now_unix();
259 let mut files_written = 0;
260 let mut symbols_written = 0;
261 for chunk in files.chunks(BATCH) {
262 let tx = self.conn.transaction()?;
263 {
264 let mut upsert = tx.prepare(
265 "INSERT INTO files (repository_id, path, language, mtime, content_hash, indexed_at)
266 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
267 ON CONFLICT(repository_id, path) DO UPDATE SET
268 language = excluded.language,
269 mtime = excluded.mtime,
270 content_hash = excluded.content_hash,
271 indexed_at = excluded.indexed_at
272 RETURNING id",
273 )?;
274 let mut current = tx.prepare(
275 "SELECT content_hash FROM files WHERE repository_id = ?1 AND path = ?2",
276 )?;
277 let mut clear = tx.prepare("DELETE FROM symbols WHERE file_id = ?1")?;
278 let mut insert = tx.prepare(
279 "INSERT INTO symbols
280 (repository_id, file_id, name, name_lower, kind, language, line, parent)
281 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
282 )?;
283 for f in chunk {
284 let stored: Option<String> = current
286 .query_row(params![repository_id, f.path], |r| r.get(0))
287 .optional()?;
288 if stored.as_deref() == Some(f.content_hash.as_str()) {
289 continue;
290 }
291 let file_id: i64 = upsert.query_row(
292 params![
293 repository_id,
294 f.path,
295 f.language,
296 f.mtime,
297 f.content_hash,
298 now
299 ],
300 |r| r.get(0),
301 )?;
302 clear.execute(params![file_id])?;
303 for s in &f.symbols {
304 insert.execute(params![
305 repository_id,
306 file_id,
307 s.name,
308 s.name.to_lowercase(),
309 s.kind.as_str(),
310 s.language,
311 s.line,
312 s.parent,
313 ])?;
314 }
315 files_written += 1;
316 symbols_written += f.symbols.len();
317 }
318 }
319 tx.commit()?;
320 }
321 Ok((files_written, symbols_written))
322 }
323
324 pub fn defer_fts_insert(&self) -> Result<()> {
330 self.conn
331 .execute_batch("DROP TRIGGER IF EXISTS symbols_ai;")?;
332 Ok(())
333 }
334
335 pub fn rebuild_fts(&self) -> Result<()> {
340 let sql = format!(
341 "INSERT INTO symbols_fts(symbols_fts) VALUES('rebuild');\n{}",
342 schema::FTS_INSERT_TRIGGER
343 );
344 self.conn.execute_batch(&sql)?;
345 Ok(())
346 }
347
348 pub fn set_coverage(
350 &self,
351 repository_id: i64,
352 files_seen: i64,
353 files_indexed: i64,
354 status: &str,
355 ) -> Result<()> {
356 let now = now_unix();
357 self.conn.execute(
358 "INSERT INTO coverage
359 (repository_id, scope, files_seen, files_indexed, status, last_indexed_at)
360 VALUES (?1, 'full', ?2, ?3, ?4, ?5)
361 ON CONFLICT(repository_id, scope) DO UPDATE SET
362 files_seen = excluded.files_seen,
363 files_indexed = excluded.files_indexed,
364 status = excluded.status,
365 last_indexed_at = excluded.last_indexed_at",
366 params![repository_id, files_seen, files_indexed, status, now],
367 )?;
368 Ok(())
369 }
370
371 pub fn set_file_git_ts(
374 &mut self,
375 repository_id: i64,
376 times: &HashMap<String, i64>,
377 ) -> Result<()> {
378 let tx = self.conn.transaction()?;
379 {
380 let mut stmt =
381 tx.prepare("UPDATE files SET git_ts = ?3 WHERE repository_id = ?1 AND path = ?2")?;
382 for (path, ts) in times {
383 stmt.execute(params![repository_id, path, ts])?;
384 }
385 }
386 tx.commit()
387 }
388
389 pub fn coverage_overview(&self) -> Result<Vec<CoverageRow>> {
391 let mut stmt = self.conn.prepare(
392 "SELECT r.identity,
393 COALESCE(c.status, 'never'),
394 (SELECT COUNT(*) FROM files fi WHERE fi.repository_id = r.id),
395 (SELECT COUNT(*) FROM symbols s WHERE s.repository_id = r.id)
396 FROM repositories r
397 LEFT JOIN coverage c ON c.repository_id = r.id AND c.scope = 'full'
398 ORDER BY r.identity",
399 )?;
400 let rows = stmt
401 .query_map([], |r| {
402 Ok(CoverageRow {
403 identity: r.get(0)?,
404 status: r.get(1)?,
405 files: r.get(2)?,
406 symbols: r.get(3)?,
407 })
408 })?
409 .collect::<Result<Vec<_>>>()?;
410 Ok(rows)
411 }
412
413 pub fn identity_for_root(&self, root: &str) -> Result<Option<String>> {
417 self.conn
418 .query_row(
419 "SELECT r.identity FROM repositories r
420 JOIN checkouts c ON c.repository_id = r.id
421 WHERE c.root_path = ?1",
422 params![root],
423 |r| r.get(0),
424 )
425 .optional()
426 }
427
428 pub fn repository_id(&self, identity: &str) -> Result<Option<i64>> {
430 self.conn
431 .query_row(
432 "SELECT id FROM repositories WHERE identity = ?1",
433 params![identity],
434 |r| r.get(0),
435 )
436 .optional()
437 }
438
439 pub fn coverage_status(&self, identity: &str) -> Result<Option<String>> {
442 self.conn
443 .query_row(
444 "SELECT c.status FROM coverage c
445 JOIN repositories r ON r.id = c.repository_id
446 WHERE r.identity = ?1 AND c.scope = 'full'",
447 params![identity],
448 |r| r.get(0),
449 )
450 .optional()
451 }
452
453 pub fn repo_totals(&self, repository_id: i64) -> Result<(i64, i64)> {
455 let files = self.conn.query_row(
456 "SELECT COUNT(*) FROM files WHERE repository_id = ?1",
457 params![repository_id],
458 |r| r.get(0),
459 )?;
460 let symbols = self.conn.query_row(
461 "SELECT COUNT(*) FROM symbols WHERE repository_id = ?1",
462 params![repository_id],
463 |r| r.get(0),
464 )?;
465 Ok((files, symbols))
466 }
467
468 pub fn symbols_in_file(&self, repository_id: i64, path: &str) -> Result<Vec<SymbolRow>> {
471 let sql = format!(
472 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
473 WHERE s.repository_id = ?1 AND fi.path = ?2 \
474 ORDER BY s.line, s.name"
475 );
476 let mut stmt = self.conn.prepare(&sql)?;
477 let rows = stmt.query_map(params![repository_id, path], row_to_candidate)?;
478 let mut out = Vec::new();
479 for row in rows {
480 out.push(row?.1);
481 }
482 Ok(out)
483 }
484
485 pub fn checkout_root(&self, repository_id: i64) -> Result<Option<String>> {
488 self.conn
489 .query_row(
490 "SELECT root_path FROM checkouts WHERE repository_id = ?1 ORDER BY id LIMIT 1",
491 params![repository_id],
492 |r| r.get(0),
493 )
494 .optional()
495 }
496
497 pub fn forget_file(&mut self, repository_id: i64, path: &str) -> Result<()> {
499 let tx = self.conn.transaction()?;
500 let file_id: Option<i64> = tx
501 .query_row(
502 "SELECT id FROM files WHERE repository_id = ?1 AND path = ?2",
503 params![repository_id, path],
504 |r| r.get(0),
505 )
506 .optional()?;
507 if let Some(fid) = file_id {
508 tx.execute("DELETE FROM symbols WHERE file_id = ?1", params![fid])?;
509 tx.execute("DELETE FROM files WHERE id = ?1", params![fid])?;
510 }
511 tx.commit()
512 }
513
514 pub fn drop_repository(&mut self, repository_id: i64) -> Result<()> {
519 let tx = self.conn.transaction()?;
520 for sql in [
521 "DELETE FROM symbols WHERE repository_id = ?1",
522 "DELETE FROM files WHERE repository_id = ?1",
523 "DELETE FROM coverage WHERE repository_id = ?1",
524 "DELETE FROM selection_stats WHERE repository_id = ?1",
525 "DELETE FROM events WHERE repository_id = ?1",
526 "DELETE FROM checkouts WHERE repository_id = ?1",
527 "DELETE FROM repositories WHERE id = ?1",
528 ] {
529 tx.execute(sql, params![repository_id])?;
530 }
531 tx.commit()
532 }
533
534 pub fn record_event(
539 &self,
540 kind: &str,
541 query: Option<&str>,
542 repository_id: Option<i64>,
543 path: Option<&str>,
544 line: Option<i64>,
545 branch: Option<&str>,
546 ) -> Result<()> {
547 self.conn.execute(
548 "INSERT INTO events (type, query, repository_id, path, line, branch, ts)
549 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
550 params![kind, query, repository_id, path, line, branch, now_unix()],
551 )?;
552 Ok(())
553 }
554
555 pub fn selections_for(&self, query_norm: &str) -> Result<Vec<SelectionStat>> {
559 let mut stmt = self.conn.prepare(
560 "SELECT repository_id, file, name, selections, last_selected_at
561 FROM selection_stats WHERE ?1 LIKE query_norm || '%'",
562 )?;
563 let rows = stmt
564 .query_map(params![query_norm], |r| {
565 Ok(SelectionStat {
566 repository_id: r.get(0)?,
567 file: r.get(1)?,
568 name: r.get(2)?,
569 selections: r.get(3)?,
570 last_selected_at: r.get::<_, Option<i64>>(4)?.unwrap_or(0),
571 })
572 })?
573 .collect::<Result<Vec<_>>>()?;
574 Ok(rows)
575 }
576
577 pub fn is_repeat_search(&self, repository_id: i64, query_norm: &str) -> Result<bool> {
581 let last: Option<(String, Option<String>)> = self
582 .conn
583 .query_row(
584 "SELECT type, query FROM events WHERE repository_id = ?1 ORDER BY id DESC LIMIT 1",
585 params![repository_id],
586 |r| Ok((r.get(0)?, r.get(1)?)),
587 )
588 .optional()?;
589 Ok(matches!(last, Some((kind, Some(q))) if kind == "search" && q == query_norm))
590 }
591
592 pub fn decay_selections(&self, repository_id: i64, query_norm: &str) -> Result<()> {
595 self.conn.execute(
596 "UPDATE selection_stats SET selections = selections - 1
597 WHERE repository_id = ?1 AND query_norm = ?2",
598 params![repository_id, query_norm],
599 )?;
600 self.conn.execute(
601 "DELETE FROM selection_stats
602 WHERE repository_id = ?1 AND query_norm = ?2 AND selections <= 0",
603 params![repository_id, query_norm],
604 )?;
605 Ok(())
606 }
607
608 pub fn aggregate_events(&mut self, batch: usize) -> Result<usize> {
614 let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
615
616 type Pending = (
617 i64,
618 Option<String>,
619 Option<i64>,
620 Option<String>,
621 Option<i64>,
622 i64,
623 );
624 let pending: Vec<Pending> = {
625 let mut stmt = self.conn.prepare(
626 "SELECT id, query, repository_id, path, line, ts FROM events
627 WHERE id > ?1 AND type IN ('select', 'open')
628 ORDER BY id LIMIT ?2",
629 )?;
630 stmt.query_map(params![hwm, batch as i64], |r| {
631 Ok((
632 r.get(0)?,
633 r.get(1)?,
634 r.get(2)?,
635 r.get(3)?,
636 r.get(4)?,
637 r.get(5)?,
638 ))
639 })?
640 .collect::<Result<Vec<_>>>()?
641 };
642
643 if pending.is_empty() {
644 let max_id: Option<i64> =
646 self.conn
647 .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
648 if let Some(m) = max_id.filter(|m| *m > hwm) {
649 self.meta_set_i64("events_hwm", m)?;
650 }
651 return Ok(0);
652 }
653
654 let drained = pending.len() < batch;
655 let max_pending_id = pending.iter().map(|p| p.0).max().unwrap_or(hwm);
656
657 let tx = self.conn.transaction()?;
658 let mut processed = 0;
659 for (_id, query, repo, path, line, ts) in &pending {
660 processed += 1;
661 let (Some(query), Some(repo), Some(path)) = (query, repo, path) else {
662 continue;
663 };
664 let name: Option<String> = match line {
665 Some(line) => tx
666 .query_row(
667 "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
668 WHERE s.repository_id = ?1 AND fi.path = ?2 AND s.line <= ?3
669 ORDER BY s.line DESC LIMIT 1",
670 params![repo, path, line],
671 |r| r.get(0),
672 )
673 .optional()?,
674 None => tx
675 .query_row(
676 "SELECT s.name FROM symbols s JOIN files fi ON fi.id = s.file_id
677 WHERE s.repository_id = ?1 AND fi.path = ?2
678 AND s.kind IN ('class', 'module')
679 ORDER BY s.line ASC LIMIT 1",
680 params![repo, path],
681 |r| r.get(0),
682 )
683 .optional()?,
684 };
685 if let Some(name) = name {
686 tx.execute(
687 "INSERT INTO selection_stats
688 (repository_id, query_norm, file, name, selections, last_selected_at)
689 VALUES (?1, ?2, ?3, ?4, 1, ?5)
690 ON CONFLICT(repository_id, query_norm, file, name) DO UPDATE SET
691 selections = selections + 1,
692 last_selected_at = max(last_selected_at, excluded.last_selected_at)",
693 params![repo, query, path, name, ts],
694 )?;
695 }
696 }
697
698 let new_hwm = if drained {
699 tx.query_row("SELECT MAX(id) FROM events", [], |r| {
700 r.get::<_, Option<i64>>(0)
701 })?
702 .unwrap_or(max_pending_id)
703 } else {
704 max_pending_id
705 };
706 tx.execute(
707 "INSERT INTO meta (key, value) VALUES ('events_hwm', ?1)
708 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
709 params![new_hwm.to_string()],
710 )?;
711 tx.commit()?;
712 Ok(processed)
713 }
714
715 pub fn prune_events(&self, keep_recent: i64) -> Result<usize> {
720 let hwm = self.meta_get_i64("events_hwm")?.unwrap_or(0);
721 let max_id: Option<i64> = self
722 .conn
723 .query_row("SELECT MAX(id) FROM events", [], |r| r.get(0))?;
724 let Some(max_id) = max_id else {
725 return Ok(0);
726 };
727 let cutoff = hwm.min(max_id - keep_recent);
728 if cutoff <= 0 {
729 return Ok(0);
730 }
731 let n = self
732 .conn
733 .execute("DELETE FROM events WHERE id <= ?1", params![cutoff])?;
734 Ok(n)
735 }
736
737 pub fn indexed_head(&self, repository_id: i64) -> Result<Option<String>> {
740 self.meta_get(&format!("head:{repository_id}"))
741 }
742
743 pub fn set_indexed_head(&self, repository_id: i64, head: &str) -> Result<()> {
745 self.meta_set(&format!("head:{repository_id}"), head)
746 }
747
748 fn meta_get(&self, key: &str) -> Result<Option<String>> {
749 self.conn
750 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
751 r.get(0)
752 })
753 .optional()
754 }
755
756 fn meta_set(&self, key: &str, value: &str) -> Result<()> {
757 self.conn.execute(
758 "INSERT INTO meta (key, value) VALUES (?1, ?2)
759 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
760 params![key, value],
761 )?;
762 Ok(())
763 }
764
765 fn meta_get_i64(&self, key: &str) -> Result<Option<i64>> {
766 let raw: Option<String> = self
767 .conn
768 .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
769 r.get(0)
770 })
771 .optional()?;
772 Ok(raw.and_then(|s| s.parse().ok()))
773 }
774
775 fn meta_set_i64(&self, key: &str, value: i64) -> Result<()> {
776 self.conn.execute(
777 "INSERT INTO meta (key, value) VALUES (?1, ?2)
778 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
779 params![key, value.to_string()],
780 )?;
781 Ok(())
782 }
783
784 pub fn search_candidates(
795 &self,
796 query: &str,
797 limit: usize,
798 force_fuzzy: bool,
799 ) -> Result<Vec<SymbolRow>> {
800 let q = query.to_ascii_lowercase();
801 let mut found: HashMap<i64, SymbolRow> = HashMap::new();
802
803 {
807 let sql = format!(
808 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} WHERE s.name_lower = ?1 LIMIT ?2"
809 );
810 let mut stmt = self.conn.prepare(&sql)?;
811 let rows = stmt.query_map(params![q, limit as i64], row_to_candidate)?;
812 for row in rows {
813 let (id, cand) = row?;
814 found.insert(id, cand);
815 }
816 }
817
818 {
822 let like = format!("{}%", escape_like(&q));
823 let sql = format!(
824 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
825 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
826 );
827 let mut stmt = self.conn.prepare(&sql)?;
828 let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
829 for row in rows {
830 let (id, cand) = row?;
831 found.entry(id).or_insert(cand);
832 }
833 }
834
835 if !force_fuzzy && !found.is_empty() {
840 return Ok(found.into_values().collect());
841 }
842
843 if let Some(first) = q.chars().next() {
848 let like = format!("{}%", escape_like(&first.to_string()));
849 let sql = format!(
850 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
851 WHERE s.name_lower LIKE ?1 ESCAPE '\\' LIMIT ?2"
852 );
853 let mut stmt = self.conn.prepare(&sql)?;
854 let rows = stmt.query_map(params![like, limit as i64], row_to_candidate)?;
855 for row in rows {
856 let (id, cand) = row?;
857 found.entry(id).or_insert(cand);
858 }
859 }
860
861 if let Some(match_expr) = trigram_or_query(&q) {
863 let sql = format!(
864 "SELECT {CANDIDATE_COLS} FROM symbols_fts f \
865 JOIN symbols s ON s.id = f.rowid \
866 JOIN files fi ON fi.id = s.file_id \
867 JOIN repositories r ON r.id = s.repository_id \
868 WHERE symbols_fts MATCH ?1 LIMIT ?2"
869 );
870 let mut stmt = self.conn.prepare(&sql)?;
871 let rows = stmt.query_map(params![match_expr, limit as i64], row_to_candidate)?;
872 for row in rows {
873 let (id, cand) = row?;
874 found.entry(id).or_insert(cand);
875 }
876 }
877
878 let path_like = format!("%{}%", escape_like(&q));
881 let sql = format!(
882 "SELECT {CANDIDATE_COLS} {CANDIDATE_FROM} \
883 WHERE fi.path LIKE ?1 ESCAPE '\\' AND s.kind IN ('class', 'module') LIMIT ?2"
884 );
885 {
886 let mut stmt = self.conn.prepare(&sql)?;
887 let rows = stmt.query_map(params![path_like, limit as i64], row_to_candidate)?;
888 for row in rows {
889 let (id, cand) = row?;
890 found.entry(id).or_insert(cand);
891 }
892 }
893
894 Ok(found.into_values().collect())
895 }
896}
897
898fn row_to_candidate(r: &rusqlite::Row) -> Result<(i64, SymbolRow)> {
899 Ok((
900 r.get(0)?,
901 SymbolRow {
902 name: r.get(1)?,
903 kind: r.get(2)?,
904 language: r.get(3)?,
905 file: r.get(4)?,
906 line: r.get(5)?,
907 parent: r.get(6)?,
908 repository_id: r.get(7)?,
909 repo_identity: r.get(8)?,
910 mtime: r.get(9)?,
911 git_ts: r.get(10)?,
912 },
913 ))
914}
915
916fn escape_like(s: &str) -> String {
918 s.replace('\\', "\\\\")
919 .replace('%', "\\%")
920 .replace('_', "\\_")
921}
922
923fn trigram_or_query(q: &str) -> Option<String> {
927 let cleaned: Vec<char> = q
928 .chars()
929 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
930 .collect();
931 if cleaned.len() < 3 {
932 return None;
933 }
934 let mut grams: Vec<String> = Vec::new();
935 for w in cleaned.windows(3) {
936 let gram: String = w.iter().collect();
937 let quoted = format!("\"{gram}\"");
938 if !grams.contains("ed) {
939 grams.push(quoted);
940 }
941 }
942 Some(grams.join(" OR "))
943}
944
945fn now_unix() -> i64 {
946 SystemTime::now()
947 .duration_since(UNIX_EPOCH)
948 .map(|d| d.as_secs() as i64)
949 .unwrap_or(0)
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use crate::core::Kind;
956
957 fn sym(name: &str, kind: Kind, line: u32, parent: Option<&str>) -> Symbol {
958 Symbol {
959 name: name.into(),
960 kind,
961 language: "ruby".into(),
962 file: "app/models/user.rb".into(),
963 line,
964 parent: parent.map(String::from),
965 }
966 }
967
968 #[test]
969 fn prune_events_drops_aggregated_but_keeps_recent() {
970 let mut store = Store::open_in_memory().unwrap();
971 let repo = store
972 .upsert_repository(&RepoIdentity::local("/x"), None)
973 .unwrap();
974 store
975 .replace_file_symbols(
976 repo,
977 "a.rb",
978 "ruby",
979 None,
980 "h",
981 &[sym("Foo", Kind::Class, 1, None)],
982 )
983 .unwrap();
984
985 store
987 .record_event(
988 "select",
989 Some("foo"),
990 Some(repo),
991 Some("a.rb"),
992 Some(1),
993 None,
994 )
995 .unwrap();
996 for _ in 0..10 {
997 store
998 .record_event("search", Some("foo"), Some(repo), None, None, None)
999 .unwrap();
1000 }
1001 store.aggregate_events(100).unwrap(); assert_eq!(store.prune_events(3).unwrap(), 8);
1005 assert_eq!(store.prune_events(3).unwrap(), 0);
1007 assert!(store.is_repeat_search(repo, "foo").unwrap());
1009 }
1010
1011 #[test]
1012 fn git_ts_is_stored_and_surfaced_on_candidates() {
1013 let mut store = Store::open_in_memory().unwrap();
1014 let repo = store
1015 .upsert_repository(&RepoIdentity::local("/x"), None)
1016 .unwrap();
1017 store
1018 .replace_file_symbols(
1019 repo,
1020 "a.rb",
1021 "ruby",
1022 None,
1023 "h",
1024 &[sym("Foo", Kind::Class, 1, None)],
1025 )
1026 .unwrap();
1027
1028 let times = HashMap::from([("a.rb".to_string(), 1_700_000_000_i64)]);
1029 store.set_file_git_ts(repo, ×).unwrap();
1030
1031 let cands = store.search_candidates("foo", 10, false).unwrap();
1032 assert_eq!(cands[0].git_ts, Some(1_700_000_000));
1033 }
1034
1035 #[test]
1036 fn aggregates_a_selection_and_decays_on_repeat() {
1037 let mut store = Store::open_in_memory().unwrap();
1038 let repo = store
1039 .upsert_repository(&RepoIdentity::local("/x"), None)
1040 .unwrap();
1041 store
1042 .replace_file_symbols(
1043 repo,
1044 "a.rb",
1045 "ruby",
1046 None,
1047 "h",
1048 &[sym("Foo", Kind::Class, 1, None)],
1049 )
1050 .unwrap();
1051
1052 store
1054 .record_event(
1055 "select",
1056 Some("foo"),
1057 Some(repo),
1058 Some("a.rb"),
1059 Some(1),
1060 None,
1061 )
1062 .unwrap();
1063 assert_eq!(store.aggregate_events(10).unwrap(), 1);
1064 assert_eq!(store.selections_for("foo").unwrap().len(), 1);
1065 assert_eq!(store.selections_for("foobar").unwrap().len(), 1);
1067
1068 assert!(!store.is_repeat_search(repo, "foo").unwrap());
1070 store
1072 .record_event("search", Some("foo"), Some(repo), None, None, None)
1073 .unwrap();
1074 assert!(store.is_repeat_search(repo, "foo").unwrap());
1075
1076 store.decay_selections(repo, "foo").unwrap();
1078 assert!(store.selections_for("foo").unwrap().is_empty());
1079 }
1080
1081 #[test]
1082 fn indexes_and_reports_coverage() {
1083 let mut store = Store::open_in_memory().unwrap();
1084 let id = RepoIdentity::Remote("github.com/dpep/rq".into());
1085 let repo = store.upsert_repository(&id, Some("main")).unwrap();
1086 store
1087 .upsert_checkout(repo, "/tmp/rq", Some("main"))
1088 .unwrap();
1089
1090 let symbols = vec![
1091 sym("User", Kind::Class, 1, None),
1092 sym("save", Kind::Method, 5, Some("User")),
1093 ];
1094 store
1095 .replace_file_symbols(
1096 repo,
1097 "app/models/user.rb",
1098 "ruby",
1099 Some(100),
1100 "h1",
1101 &symbols,
1102 )
1103 .unwrap();
1104 store.set_coverage(repo, 10, 1, "partial").unwrap();
1105
1106 let overview = store.coverage_overview().unwrap();
1107 assert_eq!(overview.len(), 1);
1108 assert_eq!(overview[0].identity, "github.com/dpep/rq");
1109 assert_eq!(overview[0].status, "partial");
1110 assert_eq!(overview[0].symbols, 2);
1111 }
1112
1113 #[test]
1114 fn reindexing_a_file_replaces_its_symbols() {
1115 let mut store = Store::open_in_memory().unwrap();
1116 let repo = store
1117 .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1118 .unwrap();
1119
1120 store
1121 .replace_file_symbols(
1122 repo,
1123 "a.rb",
1124 "ruby",
1125 None,
1126 "h1",
1127 &[sym("Old", Kind::Class, 1, None)],
1128 )
1129 .unwrap();
1130 store
1131 .replace_file_symbols(
1132 repo,
1133 "a.rb",
1134 "ruby",
1135 None,
1136 "h2",
1137 &[sym("New", Kind::Class, 1, None)],
1138 )
1139 .unwrap();
1140
1141 store.set_coverage(repo, 1, 1, "complete").unwrap();
1142 let overview = store.coverage_overview().unwrap();
1143 assert_eq!(overview[0].symbols, 1);
1145 }
1146
1147 #[test]
1148 fn file_unchanged_detects_matching_hash() {
1149 let mut store = Store::open_in_memory().unwrap();
1150 let repo = store
1151 .upsert_repository(&RepoIdentity::local("/tmp/rq"), None)
1152 .unwrap();
1153 store
1154 .replace_file_symbols(repo, "a.rb", "ruby", None, "abc", &[])
1155 .unwrap();
1156
1157 assert!(store.file_unchanged(repo, "a.rb", "abc").unwrap());
1158 assert!(!store.file_unchanged(repo, "a.rb", "xyz").unwrap());
1159 assert!(!store.file_unchanged(repo, "missing.rb", "abc").unwrap());
1160 }
1161}