1use std::path::Path;
4use std::str::FromStr;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Once;
7
8use rusqlite::ffi::sqlite3_auto_extension;
9use rusqlite::{params, Connection, Result, Transaction};
10use sqlite_vec::sqlite3_vec_init;
11
12use super::models::*;
13
14pub const SCHEMA_VERSION: i64 = 2;
27
28pub const DEFAULT_EMBEDDING_DIM: usize = 1536;
34
35static VEC_EXTENSION_AVAILABLE: AtomicBool = AtomicBool::new(false);
37
38#[allow(clippy::missing_transmute_annotations)]
44pub fn init_vec_extension() -> bool {
45 static INIT: Once = Once::new();
46 INIT.call_once(|| {
47 let result = unsafe {
48 sqlite3_auto_extension(Some(std::mem::transmute(
49 sqlite3_vec_init as *const (),
50 )))
51 };
52 if result == 0 {
54 VEC_EXTENSION_AVAILABLE.store(true, Ordering::SeqCst);
55 } else {
56 eprintln!(
57 "Warning: Failed to register sqlite-vec extension (error code: {}). Vector search will be unavailable.",
58 result
59 );
60 }
61 });
62 VEC_EXTENSION_AVAILABLE.load(Ordering::SeqCst)
63}
64
65pub fn is_vec_extension_available() -> bool {
67 VEC_EXTENSION_AVAILABLE.load(Ordering::SeqCst)
68}
69
70#[derive(Debug)]
72pub struct Database {
73 conn: Connection,
74}
75
76impl Database {
77 pub fn open(path: &Path) -> crate::error::Result<Self> {
85 init_vec_extension();
87 let conn = Connection::open(path)?;
88 Self::configure_connection(&conn)?;
89 let db = Self { conn };
90 db.check_schema_version()?;
91 db.init_schema()?;
92 Ok(db)
93 }
94
95 #[allow(dead_code)]
97 pub fn open_in_memory() -> crate::error::Result<Self> {
98 init_vec_extension();
100 let conn = Connection::open_in_memory()?;
101 Self::configure_connection(&conn)?;
102 let db = Self { conn };
103 db.check_schema_version()?;
104 db.init_schema()?;
105 Ok(db)
106 }
107
108 fn check_schema_version(&self) -> crate::error::Result<()> {
115 let found: i64 = self
116 .conn
117 .query_row("PRAGMA user_version", [], |row| row.get(0))?;
118
119 if found == SCHEMA_VERSION {
120 return Ok(());
121 }
122
123 if found == 0 {
124 let has_tables: bool = self
125 .conn
126 .query_row(
127 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'files'",
128 [],
129 |_| Ok(true),
130 )
131 .optional()?
132 .unwrap_or(false);
133 if !has_tables {
134 self.conn
135 .pragma_update(None, "user_version", SCHEMA_VERSION)?;
136 return Ok(());
137 }
138 }
139
140 Err(crate::error::CtxError::SchemaVersionMismatch {
141 found,
142 expected: SCHEMA_VERSION,
143 })
144 }
145
146 fn configure_connection(conn: &Connection) -> Result<()> {
148 conn.execute_batch(
151 r#"
152 PRAGMA journal_mode = WAL;
153 PRAGMA synchronous = NORMAL;
154 PRAGMA busy_timeout = 5000;
155 PRAGMA cache_size = -64000;
156 "#,
157 )?;
158 Ok(())
159 }
160
161 fn init_schema(&self) -> Result<()> {
163 self.conn.execute_batch(
164 r#"
165 -- Enable foreign keys
166 PRAGMA foreign_keys = ON;
167
168 -- File tracking for incremental updates
169 CREATE TABLE IF NOT EXISTS files (
170 path TEXT PRIMARY KEY,
171 content_hash TEXT NOT NULL,
172 size_bytes INTEGER,
173 language TEXT,
174 last_indexed INTEGER DEFAULT (unixepoch()),
175 source BLOB
176 );
177
178 -- All symbols (functions, structs, etc.)
179 CREATE TABLE IF NOT EXISTS symbols (
180 id TEXT PRIMARY KEY,
181 file_path TEXT NOT NULL,
182 name TEXT NOT NULL,
183 qualified_name TEXT,
184 kind TEXT NOT NULL,
185 visibility TEXT DEFAULT 'private',
186 signature TEXT,
187 brief TEXT,
188 docstring TEXT,
189 line_start INTEGER,
190 line_end INTEGER,
191 col_start INTEGER,
192 col_end INTEGER,
193 parent_id TEXT,
194 source TEXT,
195 FOREIGN KEY (file_path) REFERENCES files(path) ON DELETE CASCADE
196 );
197
198 -- Relationships between symbols
199 CREATE TABLE IF NOT EXISTS edges (
200 id INTEGER PRIMARY KEY AUTOINCREMENT,
201 source_id TEXT NOT NULL,
202 target_id TEXT,
203 target_name TEXT NOT NULL,
204 kind TEXT NOT NULL,
205 line INTEGER,
206 col INTEGER,
207 context TEXT,
208 FOREIGN KEY (source_id) REFERENCES symbols(id) ON DELETE CASCADE
209 );
210
211 -- Module-level information
212 CREATE TABLE IF NOT EXISTS modules (
213 file_path TEXT PRIMARY KEY,
214 module_name TEXT,
215 exports TEXT,
216 imports TEXT,
217 FOREIGN KEY (file_path) REFERENCES files(path) ON DELETE CASCADE
218 );
219
220 -- Cached PageRank scores for `ctx map`.
221 -- Lazily rebuilt: cleared whenever the index changes and
222 -- recomputed on the next `ctx map` invocation, so pre-existing
223 -- databases self-heal without a schema version bump.
224 CREATE TABLE IF NOT EXISTS symbol_rank (
225 symbol_id TEXT PRIMARY KEY REFERENCES symbols(id) ON DELETE CASCADE,
226 rank REAL NOT NULL
227 );
228
229 -- Indexes for fast lookups
230 CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
231 CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path);
232 CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);
233 CREATE INDEX IF NOT EXISTS idx_symbols_parent ON symbols(parent_id);
234 CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
235 CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_name);
236 CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
237 CREATE INDEX IF NOT EXISTS idx_files_hash ON files(content_hash);
238
239 -- Embeddings for semantic search
240 CREATE TABLE IF NOT EXISTS embeddings (
241 symbol_id TEXT PRIMARY KEY,
242 provider TEXT NOT NULL,
243 model TEXT NOT NULL,
244 dimension INTEGER NOT NULL,
245 vector TEXT NOT NULL, -- JSON array of floats
246 created_at INTEGER DEFAULT (unixepoch()),
247 FOREIGN KEY (symbol_id) REFERENCES symbols(id) ON DELETE CASCADE
248 );
249
250 CREATE INDEX IF NOT EXISTS idx_embeddings_provider ON embeddings(provider);
251
252 -- MinHash fingerprints for near-duplicate function detection
253 -- (see src/fingerprint.rs). Rows are cascade-deleted with their
254 -- symbols when a file is re-indexed or removed.
255 CREATE TABLE IF NOT EXISTS symbol_fingerprints (
256 symbol_id TEXT PRIMARY KEY REFERENCES symbols(id) ON DELETE CASCADE,
257 file_path TEXT NOT NULL,
258 minhash BLOB NOT NULL,
259 token_count INTEGER NOT NULL
260 );
261
262 CREATE INDEX IF NOT EXISTS idx_fingerprints_file ON symbol_fingerprints(file_path);
263
264 -- Full-text search index for semantic search
265 CREATE VIRTUAL TABLE IF NOT EXISTS symbol_fts USING fts5(
266 id,
267 name,
268 kind,
269 signature,
270 brief,
271 docstring,
272 content='symbols',
273 content_rowid='rowid'
274 );
275
276 -- Triggers to keep FTS index in sync
277 CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
278 INSERT INTO symbol_fts(rowid, id, name, kind, signature, brief, docstring)
279 VALUES (NEW.rowid, NEW.id, NEW.name, NEW.kind, NEW.signature, NEW.brief, NEW.docstring);
280 END;
281
282 CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
283 INSERT INTO symbol_fts(symbol_fts, rowid, id, name, kind, signature, brief, docstring)
284 VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.kind, OLD.signature, OLD.brief, OLD.docstring);
285 END;
286
287 CREATE TRIGGER IF NOT EXISTS symbols_au AFTER UPDATE ON symbols BEGIN
288 INSERT INTO symbol_fts(symbol_fts, rowid, id, name, kind, signature, brief, docstring)
289 VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.kind, OLD.signature, OLD.brief, OLD.docstring);
290 INSERT INTO symbol_fts(rowid, id, name, kind, signature, brief, docstring)
291 VALUES (NEW.rowid, NEW.id, NEW.name, NEW.kind, NEW.signature, NEW.brief, NEW.docstring);
292 END;
293 "#,
294 )?;
295
296 if is_vec_extension_available() {
300 match self.conn.execute(
301 &format!(
302 r#"
303 CREATE VIRTUAL TABLE IF NOT EXISTS symbol_vectors USING vec0(
304 embedding float[{}],
305 +symbol_id TEXT
306 )
307 "#,
308 DEFAULT_EMBEDDING_DIM
309 ),
310 [],
311 ) {
312 Ok(_) => {}
313 Err(e) => {
314 eprintln!(
316 "Warning: Failed to create symbol_vectors table: {}. Vector search will be unavailable.",
317 e
318 );
319 }
320 }
321 }
322
323 Ok(())
324 }
325
326 pub fn has_vector_search(&self) -> bool {
328 if !is_vec_extension_available() {
329 return false;
330 }
331 self.conn
333 .query_row(
334 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbol_vectors'",
335 [],
336 |_| Ok(()),
337 )
338 .is_ok()
339 }
340
341 #[allow(dead_code)]
343 pub fn transaction(&mut self) -> Result<Transaction<'_>> {
344 self.conn.transaction()
345 }
346
347 pub fn get_file_hash(&self, path: &str) -> Result<Option<String>> {
349 self.conn
350 .query_row(
351 "SELECT content_hash FROM files WHERE path = ?",
352 [path],
353 |row| row.get(0),
354 )
355 .optional()
356 }
357
358 pub fn needs_update(&self, path: &str, new_hash: &str) -> Result<bool> {
360 match self.get_file_hash(path)? {
361 Some(stored_hash) => Ok(stored_hash != new_hash),
362 None => Ok(true),
363 }
364 }
365
366 pub fn upsert_file(&self, file: &FileRecord, source: Option<&[u8]>) -> Result<()> {
368 self.conn.execute(
369 r#"
370 INSERT OR REPLACE INTO files (path, content_hash, size_bytes, language, last_indexed, source)
371 VALUES (?, ?, ?, ?, unixepoch(), ?)
372 "#,
373 params![
374 file.path,
375 file.content_hash,
376 file.size_bytes,
377 file.language,
378 source
379 ],
380 )?;
381 Ok(())
382 }
383
384 pub fn delete_file(&self, path: &str) -> Result<()> {
386 self.conn
387 .execute("DELETE FROM files WHERE path = ?", [path])?;
388 Ok(())
389 }
390
391 pub fn delete_symbols_for_file(&self, file_path: &str) -> Result<()> {
393 self.conn.execute(
395 "DELETE FROM edges WHERE source_id IN (SELECT id FROM symbols WHERE file_path = ?)",
396 [file_path],
397 )?;
398 self.conn
399 .execute("DELETE FROM symbols WHERE file_path = ?", [file_path])?;
400 Ok(())
401 }
402
403 pub fn insert_symbol(&self, symbol: &Symbol) -> Result<()> {
405 self.conn.execute(
406 r#"
407 INSERT INTO symbols (
408 id, file_path, name, qualified_name, kind, visibility,
409 signature, brief, docstring, line_start, line_end,
410 col_start, col_end, parent_id, source
411 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
412 "#,
413 params![
414 symbol.id,
415 symbol.file_path,
416 symbol.name,
417 symbol.qualified_name,
418 symbol.kind.as_str(),
419 symbol.visibility.as_str(),
420 symbol.signature,
421 symbol.brief,
422 symbol.docstring,
423 symbol.line_start,
424 symbol.line_end,
425 symbol.col_start,
426 symbol.col_end,
427 symbol.parent_id,
428 symbol.source,
429 ],
430 )?;
431 Ok(())
432 }
433
434 pub fn insert_edge(&self, edge: &Edge) -> Result<()> {
436 self.conn.execute(
437 r#"
438 INSERT INTO edges (source_id, target_id, target_name, kind, line, col, context)
439 VALUES (?, ?, ?, ?, ?, ?, ?)
440 "#,
441 params![
442 edge.source_id,
443 edge.target_id,
444 edge.target_name,
445 edge.kind.as_str(),
446 edge.line,
447 edge.col,
448 edge.context,
449 ],
450 )?;
451 Ok(())
452 }
453
454 pub fn upsert_module(&self, module: &ModuleInfo) -> Result<()> {
456 let exports_json = serde_json::to_string(&module.exports).unwrap_or_default();
457 let imports_json = serde_json::to_string(&module.imports).unwrap_or_default();
458
459 self.conn.execute(
460 r#"
461 INSERT OR REPLACE INTO modules (file_path, module_name, exports, imports)
462 VALUES (?, ?, ?, ?)
463 "#,
464 params![
465 module.file_path,
466 module.module_name,
467 exports_json,
468 imports_json,
469 ],
470 )?;
471 Ok(())
472 }
473
474 #[allow(dead_code)] pub fn insert_symbols_batch(&self, symbols: &[Symbol]) -> Result<usize> {
477 let tx = self.conn.unchecked_transaction()?;
478 let mut count = 0;
479
480 for symbol in symbols {
481 tx.execute(
482 r#"
483 INSERT INTO symbols (
484 id, file_path, name, qualified_name, kind, visibility,
485 signature, brief, docstring, line_start, line_end,
486 col_start, col_end, parent_id, source
487 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
488 "#,
489 params![
490 symbol.id,
491 symbol.file_path,
492 symbol.name,
493 symbol.qualified_name,
494 symbol.kind.as_str(),
495 symbol.visibility.as_str(),
496 symbol.signature,
497 symbol.brief,
498 symbol.docstring,
499 symbol.line_start,
500 symbol.line_end,
501 symbol.col_start,
502 symbol.col_end,
503 symbol.parent_id,
504 symbol.source,
505 ],
506 )?;
507 count += 1;
508 }
509
510 tx.commit()?;
511 Ok(count)
512 }
513
514 #[allow(dead_code)] pub fn insert_edges_batch(&self, edges: &[Edge]) -> Result<usize> {
517 let tx = self.conn.unchecked_transaction()?;
518 let mut count = 0;
519
520 for edge in edges {
521 tx.execute(
522 r#"
523 INSERT INTO edges (source_id, target_id, target_name, kind, line, col, context)
524 VALUES (?, ?, ?, ?, ?, ?, ?)
525 "#,
526 params![
527 edge.source_id,
528 edge.target_id,
529 edge.target_name,
530 edge.kind.as_str(),
531 edge.line,
532 edge.col,
533 edge.context,
534 ],
535 )?;
536 count += 1;
537 }
538
539 tx.commit()?;
540 Ok(count)
541 }
542
543 pub fn get_symbol(&self, id: &str) -> Result<Option<Symbol>> {
545 self.conn
546 .query_row(
547 r#"
548 SELECT id, file_path, name, qualified_name, kind, visibility,
549 signature, brief, docstring, line_start, line_end,
550 col_start, col_end, parent_id, source
551 FROM symbols WHERE id = ?
552 "#,
553 [id],
554 |row| Ok(symbol_from_row(row)),
555 )
556 .optional()
557 }
558
559 pub fn find_symbols(&self, pattern: &str, limit: i32) -> Result<Vec<Symbol>> {
561 self.find_symbols_filtered(pattern, limit, None, None)
562 }
563
564 pub fn find_symbols_filtered(
574 &self,
575 pattern: &str,
576 limit: i32,
577 file_pattern: Option<&str>,
578 kind_filter: Option<&str>,
579 ) -> Result<Vec<Symbol>> {
580 let escaped_pattern = escape_like_pattern(pattern);
582 let like_pattern = format!("%{}%", escaped_pattern);
583 let starts_with_pattern = format!("{}%", escaped_pattern);
584
585 let file_like = file_pattern.map(glob_to_like_pattern);
587
588 let mut sql = String::from(
591 r#"
592 SELECT id, file_path, name, qualified_name, kind, visibility,
593 signature, brief, docstring, line_start, line_end,
594 col_start, col_end, parent_id, source
595 FROM symbols
596 WHERE (name LIKE ?2 OR qualified_name LIKE ?2)
597 "#,
598 );
599
600 let mut next_param = 4; let file_param_pos = if file_pattern.is_some() {
605 let pos = next_param;
606 sql.push_str(&format!(" AND file_path LIKE ?{}", pos));
607 next_param += 1;
608 Some(pos)
609 } else {
610 None
611 };
612
613 let kind_param_pos = if kind_filter.is_some() {
615 let pos = next_param;
616 sql.push_str(&format!(" AND kind = ?{}", pos));
617 next_param += 1;
618 Some(pos)
619 } else {
620 None
621 };
622
623 sql.push_str(&format!(
627 r#"
628 ORDER BY
629 CASE WHEN name = ?1 THEN 0
630 WHEN name LIKE ?3 THEN 1
631 ELSE 2 END,
632 name
633 LIMIT ?{}
634 "#,
635 next_param
636 ));
637
638 let mut stmt = self.conn.prepare(&sql)?;
639
640 let rows: Vec<Result<Symbol>> = match (
642 file_like.as_deref(),
643 kind_filter,
644 file_param_pos,
645 kind_param_pos,
646 ) {
647 (Some(fp), Some(kf), Some(_), Some(_)) => stmt
648 .query_map(
649 params![pattern, like_pattern, starts_with_pattern, fp, kf, limit],
650 |row| Ok(symbol_from_row(row)),
651 )?
652 .collect(),
653 (Some(fp), None, Some(_), None) => stmt
654 .query_map(
655 params![pattern, like_pattern, starts_with_pattern, fp, limit],
656 |row| Ok(symbol_from_row(row)),
657 )?
658 .collect(),
659 (None, Some(kf), None, Some(_)) => stmt
660 .query_map(
661 params![pattern, like_pattern, starts_with_pattern, kf, limit],
662 |row| Ok(symbol_from_row(row)),
663 )?
664 .collect(),
665 (None, None, None, None) => stmt
666 .query_map(
667 params![pattern, like_pattern, starts_with_pattern, limit],
668 |row| Ok(symbol_from_row(row)),
669 )?
670 .collect(),
671 _ => unreachable!("Filter and param position should match"),
673 };
674
675 rows.into_iter().collect()
676 }
677
678 pub fn get_source(&self, symbol_id: &str) -> Result<Option<String>> {
680 self.conn
681 .query_row(
682 "SELECT source FROM symbols WHERE id = ?",
683 [symbol_id],
684 |row| row.get(0),
685 )
686 .optional()
687 }
688
689 pub fn get_file_symbols(&self, file_path: &str) -> Result<Vec<Symbol>> {
691 let mut stmt = self.conn.prepare(
692 r#"
693 SELECT id, file_path, name, qualified_name, kind, visibility,
694 signature, brief, docstring, line_start, line_end,
695 col_start, col_end, parent_id, source
696 FROM symbols
697 WHERE file_path = ?
698 ORDER BY line_start
699 "#,
700 )?;
701
702 let rows = stmt.query_map([file_path], |row| Ok(symbol_from_row(row)))?;
703 rows.collect()
704 }
705
706 pub fn find_symbols_in_file(&self, file_path: &str) -> Result<Vec<Symbol>> {
708 self.get_file_symbols(file_path)
709 }
710
711 pub fn get_outgoing_edges(&self, symbol_id: &str) -> Result<Vec<Edge>> {
713 let mut stmt = self.conn.prepare(
714 r#"
715 SELECT source_id, target_id, target_name, kind, line, col, context
716 FROM edges
717 WHERE source_id = ?
718 ORDER BY line
719 "#,
720 )?;
721
722 let rows = stmt.query_map([symbol_id], |row| Ok(edge_from_row(row)))?;
723 rows.collect()
724 }
725
726 pub fn get_incoming_edges(&self, target_name: &str) -> Result<Vec<Edge>> {
728 let mut stmt = self.conn.prepare(
729 r#"
730 SELECT source_id, target_id, target_name, kind, line, col, context
731 FROM edges
732 WHERE target_name = ? OR target_id = ?
733 ORDER BY source_id
734 "#,
735 )?;
736
737 let rows = stmt.query_map([target_name, target_name], |row| Ok(edge_from_row(row)))?;
738 rows.collect()
739 }
740
741 pub fn get_stats(&self) -> Result<CodebaseStats> {
743 let files: i64 = self
744 .conn
745 .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
746 let symbols: i64 = self
747 .conn
748 .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))?;
749 let edges: i64 = self
750 .conn
751 .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?;
752 let functions: i64 = self.conn.query_row(
753 "SELECT COUNT(*) FROM symbols WHERE kind IN ('function', 'method')",
754 [],
755 |row| row.get(0),
756 )?;
757 let structs: i64 = self.conn.query_row(
758 "SELECT COUNT(*) FROM symbols WHERE kind IN ('struct', 'class')",
759 [],
760 |row| row.get(0),
761 )?;
762 let enums: i64 = self.conn.query_row(
763 "SELECT COUNT(*) FROM symbols WHERE kind = 'enum'",
764 [],
765 |row| row.get(0),
766 )?;
767 let traits: i64 = self.conn.query_row(
768 "SELECT COUNT(*) FROM symbols WHERE kind IN ('trait', 'interface')",
769 [],
770 |row| row.get(0),
771 )?;
772
773 Ok(CodebaseStats {
774 files,
775 symbols,
776 edges,
777 functions,
778 structs,
779 enums,
780 traits,
781 })
782 }
783
784 pub fn symbol_metrics(&self) -> Result<Vec<SymbolMetrics>> {
795 let mut stmt = self.conn.prepare(
796 r#"
797 WITH fo AS (
798 SELECT source_id AS id, COUNT(*) AS n
799 FROM edges
800 WHERE kind = 'calls'
801 GROUP BY source_id
802 ),
803 fi AS (
804 SELECT target_id AS id, COUNT(*) AS n
805 FROM edges
806 WHERE kind = 'calls' AND target_id IS NOT NULL
807 GROUP BY target_id
808 )
809 SELECT
810 s.id,
811 s.name,
812 s.qualified_name,
813 s.kind,
814 s.file_path,
815 s.line_start,
816 s.line_end,
817 COALESCE(fi.n, 0) AS fan_in,
818 COALESCE(fo.n, 0) AS fan_out,
819 (COALESCE(fo.n, 0) * 2 + COALESCE(fi.n, 0)) AS complexity
820 FROM symbols s
821 LEFT JOIN fo ON s.id = fo.id
822 LEFT JOIN fi ON s.id = fi.id
823 WHERE s.kind IN ('function', 'method')
824 ORDER BY complexity DESC, s.file_path, s.line_start
825 "#,
826 )?;
827
828 let rows = stmt.query_map([], |row| {
829 Ok(SymbolMetrics {
830 id: row.get(0)?,
831 name: row.get(1)?,
832 qualified_name: row.get(2)?,
833 kind: row.get(3)?,
834 file_path: row.get(4)?,
835 line_start: row.get(5)?,
836 line_end: row.get(6)?,
837 fan_in: row.get(7)?,
838 fan_out: row.get(8)?,
839 complexity: row.get(9)?,
840 })
841 })?;
842
843 rows.collect()
844 }
845
846 pub fn file_complexity(&self) -> Result<Vec<FileComplexity>> {
852 let mut stmt = self.conn.prepare(
853 r#"
854 WITH fo AS (
855 SELECT source_id AS id, COUNT(*) AS n
856 FROM edges
857 WHERE kind = 'calls'
858 GROUP BY source_id
859 ),
860 fi AS (
861 SELECT target_id AS id, COUNT(*) AS n
862 FROM edges
863 WHERE kind = 'calls' AND target_id IS NOT NULL
864 GROUP BY target_id
865 )
866 SELECT
867 s.file_path,
868 SUM(COALESCE(fo.n, 0) * 2 + COALESCE(fi.n, 0)) AS complexity,
869 SUM(COALESCE(fo.n, 0)) AS fan_out,
870 COUNT(*) AS symbol_count
871 FROM symbols s
872 LEFT JOIN fo ON s.id = fo.id
873 LEFT JOIN fi ON s.id = fi.id
874 GROUP BY s.file_path
875 ORDER BY complexity DESC, s.file_path
876 "#,
877 )?;
878
879 let rows = stmt.query_map([], |row| {
880 Ok(FileComplexity {
881 file_path: row.get(0)?,
882 complexity: row.get(1)?,
883 fan_out: row.get(2)?,
884 symbol_count: row.get(3)?,
885 })
886 })?;
887
888 rows.collect()
889 }
890
891 pub fn file_call_edges(&self, file_path: &str) -> Result<Vec<(String, String)>> {
898 let mut stmt = self.conn.prepare(
899 r#"
900 SELECT e.source_id, e.target_name
901 FROM edges e
902 JOIN symbols s ON s.id = e.source_id
903 WHERE s.file_path = ?1 AND e.kind = 'calls'
904 ORDER BY e.id
905 "#,
906 )?;
907
908 let rows = stmt.query_map([file_path], |row| Ok((row.get(0)?, row.get(1)?)))?;
909 rows.collect()
910 }
911
912 pub fn fan_in_counts(&self, ids: &[String]) -> Result<std::collections::HashMap<String, i64>> {
916 let mut counts = std::collections::HashMap::new();
917 if ids.is_empty() {
918 return Ok(counts);
919 }
920
921 let placeholders = vec!["?"; ids.len()].join(", ");
922 let sql = format!(
923 "SELECT target_id, COUNT(*) FROM edges \
924 WHERE target_id IN ({}) AND kind = 'calls' \
925 GROUP BY target_id",
926 placeholders
927 );
928
929 let mut stmt = self.conn.prepare(&sql)?;
930 let rows = stmt.query_map(rusqlite::params_from_iter(ids.iter()), |row| {
931 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
932 })?;
933
934 for row in rows {
935 let (id, n) = row?;
936 counts.insert(id, n);
937 }
938
939 Ok(counts)
940 }
941
942 pub fn get_indexed_files(&self) -> Result<Vec<String>> {
944 let mut stmt = self.conn.prepare("SELECT path FROM files ORDER BY path")?;
945 let rows = stmt.query_map([], |row| row.get(0))?;
946 rows.collect()
947 }
948
949 pub fn get_all_symbol_ids(&self) -> Result<Vec<String>> {
957 let mut stmt = self.conn.prepare("SELECT id FROM symbols ORDER BY id")?;
958 let rows = stmt.query_map([], |row| row.get(0))?;
959 rows.collect()
960 }
961
962 pub fn get_rank_edges(&self) -> Result<Vec<(String, String)>> {
965 let mut stmt = self.conn.prepare(
966 r#"
967 SELECT DISTINCT source_id, target_id
968 FROM edges
969 WHERE target_id IS NOT NULL
970 AND kind IN ('calls', 'imports', 'extends', 'implements')
971 ORDER BY source_id, target_id
972 "#,
973 )?;
974 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
975 rows.collect()
976 }
977
978 pub fn clear_symbol_rank(&self) -> Result<()> {
980 self.conn.execute("DELETE FROM symbol_rank", [])?;
981 Ok(())
982 }
983
984 pub fn store_symbol_ranks(&self, ranks: &[(String, f64)]) -> Result<()> {
987 let tx = self.conn.unchecked_transaction()?;
988 tx.execute("DELETE FROM symbol_rank", [])?;
989 {
990 let mut stmt = tx.prepare("INSERT INTO symbol_rank (symbol_id, rank) VALUES (?, ?)")?;
991 for (id, rank) in ranks {
992 stmt.execute(params![id, rank])?;
993 }
994 }
995 tx.commit()
996 }
997
998 pub fn load_symbol_ranks(&self) -> Result<Vec<(String, f64)>> {
1000 let mut stmt = self
1001 .conn
1002 .prepare("SELECT symbol_id, rank FROM symbol_rank ORDER BY symbol_id")?;
1003 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1004 rows.collect()
1005 }
1006
1007 pub fn count_symbols(&self) -> Result<i64> {
1009 self.conn
1010 .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
1011 }
1012
1013 pub fn count_symbol_ranks(&self) -> Result<i64> {
1015 self.conn
1016 .query_row("SELECT COUNT(*) FROM symbol_rank", [], |row| row.get(0))
1017 }
1018
1019 pub fn get_files_with_sizes(&self) -> Result<Vec<(String, i64)>> {
1021 let mut stmt = self
1022 .conn
1023 .prepare("SELECT path, COALESCE(size_bytes, 0) FROM files ORDER BY path")?;
1024 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1025 rows.collect()
1026 }
1027
1028 pub fn get_symbol_ids_in_file(&self, file_path: &str) -> Result<Vec<String>> {
1030 let mut stmt = self
1031 .conn
1032 .prepare("SELECT id FROM symbols WHERE file_path = ? ORDER BY id")?;
1033 let rows = stmt.query_map([file_path], |row| row.get(0))?;
1034 rows.collect()
1035 }
1036
1037 pub fn get_symbol_ids_by_name(&self, name: &str) -> Result<Vec<String>> {
1039 let mut stmt = self
1040 .conn
1041 .prepare("SELECT id FROM symbols WHERE name = ?1 OR qualified_name = ?1 ORDER BY id")?;
1042 let rows = stmt.query_map([name], |row| row.get(0))?;
1043 rows.collect()
1044 }
1045
1046 pub fn get_map_symbols(&self) -> Result<Vec<MapSymbolRow>> {
1049 let mut stmt = self.conn.prepare(
1050 r#"
1051 SELECT id, file_path, name, qualified_name, kind, signature, line_start
1052 FROM symbols
1053 WHERE kind IN ('function', 'method', 'struct', 'class', 'enum', 'trait', 'interface')
1054 ORDER BY id
1055 "#,
1056 )?;
1057 let rows = stmt.query_map([], |row| {
1058 Ok(MapSymbolRow {
1059 id: row.get(0)?,
1060 file_path: row.get(1)?,
1061 name: row.get(2)?,
1062 qualified_name: row.get(3)?,
1063 kind: row.get(4)?,
1064 signature: row.get(5)?,
1065 line_start: row.get::<_, Option<u32>>(6)?.unwrap_or(0),
1066 })
1067 })?;
1068 rows.collect()
1069 }
1070
1071 pub fn get_cross_file_edges(&self) -> Result<Vec<CrossFileEdge>> {
1078 let mut stmt = self.conn.prepare(
1079 r#"
1080 SELECT
1081 s1.name, s1.qualified_name, s1.kind, s1.file_path, s1.line_start, s1.line_end,
1082 s2.name, s2.qualified_name, s2.kind, s2.file_path, s2.line_start, s2.line_end,
1083 e.kind, e.line
1084 FROM edges e
1085 JOIN symbols s1 ON e.source_id = s1.id
1086 JOIN symbols s2 ON e.target_id = s2.id
1087 WHERE e.target_id IS NOT NULL
1088 AND s1.file_path <> s2.file_path
1089 AND e.kind IN ('calls', 'implements', 'extends', 'uses')
1090 ORDER BY s1.file_path, e.line, s2.file_path
1091 "#,
1092 )?;
1093
1094 let rows = stmt.query_map([], |row| {
1095 Ok(CrossFileEdge {
1096 source: EdgeSymbol {
1097 name: row.get(0)?,
1098 qualified_name: row.get(1)?,
1099 kind: row.get(2)?,
1100 file_path: row.get(3)?,
1101 line_start: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
1102 line_end: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
1103 },
1104 target: EdgeSymbol {
1105 name: row.get(6)?,
1106 qualified_name: row.get(7)?,
1107 kind: row.get(8)?,
1108 file_path: row.get(9)?,
1109 line_start: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
1110 line_end: row.get::<_, Option<i64>>(11)?.unwrap_or(0),
1111 },
1112 kind: row.get(12)?,
1113 line: row.get(13)?,
1114 })
1115 })?;
1116
1117 rows.collect()
1118 }
1119
1120 pub fn get_file_imports(&self) -> Result<Vec<(String, Vec<ImportInfo>)>> {
1125 let mut stmt = self.conn.prepare(
1126 "SELECT file_path, imports FROM modules \
1127 WHERE imports IS NOT NULL AND imports <> '' \
1128 ORDER BY file_path",
1129 )?;
1130 let rows = stmt.query_map([], |row| {
1131 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1132 })?;
1133
1134 let mut result = Vec::new();
1135 for row in rows {
1136 let (file_path, json) = row?;
1137 if let Ok(imports) = serde_json::from_str::<Vec<ImportInfo>>(&json) {
1138 if !imports.is_empty() {
1139 result.push((file_path, imports));
1140 }
1141 }
1142 }
1143 Ok(result)
1144 }
1145
1146 pub fn get_import_edges(&self) -> Result<Vec<(String, String, Option<i64>)>> {
1152 let mut stmt = self.conn.prepare(
1153 "SELECT source_id, target_name, line FROM edges \
1154 WHERE kind = 'imports' ORDER BY source_id, line",
1155 )?;
1156 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
1157 rows.collect()
1158 }
1159
1160 fn bm25_relevance(rank: f64) -> f64 {
1162 if rank.is_finite() {
1163 let abs_rank = rank.abs();
1165 (abs_rank / (1.0 + abs_rank)).clamp(0.0, 1.0)
1166 } else {
1167 0.0 }
1169 }
1170
1171 pub fn semantic_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64)>> {
1174 let keywords = preprocess_search_query(query);
1176
1177 if keywords.is_empty() {
1178 return Ok(Vec::new());
1179 }
1180
1181 let fts_query = keywords.join(" OR ");
1183
1184 let mut stmt = self.conn.prepare(
1185 r#"
1186 SELECT
1187 s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
1188 s.signature, s.brief, s.docstring, s.line_start, s.line_end,
1189 s.col_start, s.col_end, s.parent_id, s.source,
1190 bm25(symbol_fts) as rank
1191 FROM symbol_fts
1192 JOIN symbols s ON symbol_fts.id = s.id
1193 WHERE symbol_fts MATCH ?
1194 ORDER BY rank
1195 LIMIT ?
1196 "#,
1197 )?;
1198
1199 let rows = stmt.query_map(params![fts_query, limit], |row| {
1200 let symbol = symbol_from_row(row);
1201 let rank: f64 = row.get(15)?;
1202 let relevance = Self::bm25_relevance(rank);
1203 Ok((symbol, relevance))
1204 })?;
1205
1206 rows.collect()
1207 }
1208
1209 pub fn hybrid_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64, String)>> {
1211 let mut results: std::collections::HashMap<String, (Symbol, f64, String)> =
1212 std::collections::HashMap::new();
1213
1214 let half_limit = (limit / 2).max(1);
1216
1217 let exact_matches = self.find_symbols(query, half_limit)?;
1219 for symbol in exact_matches {
1220 let score = if symbol.name.eq_ignore_ascii_case(query) {
1221 1.0 } else if symbol
1223 .name
1224 .to_lowercase()
1225 .starts_with(&query.to_lowercase())
1226 {
1227 0.9 } else {
1229 0.7 };
1231 results.insert(symbol.id.clone(), (symbol, score, "exact".to_string()));
1232 }
1233
1234 if let Ok(semantic_matches) = self.semantic_search(query, half_limit) {
1236 for (symbol, relevance) in semantic_matches {
1237 results
1238 .entry(symbol.id.clone())
1239 .and_modify(|(_, existing_score, _)| {
1240 *existing_score = existing_score.max(relevance);
1241 })
1242 .or_insert((symbol, relevance, "semantic".to_string()));
1243 }
1244 }
1245
1246 let mut results: Vec<_> = results.into_values().collect();
1248 results.sort_by(|a, b| {
1250 match (a.1.is_finite(), b.1.is_finite()) {
1251 (true, true) => b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal),
1252 (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, (false, false) => std::cmp::Ordering::Equal,
1255 }
1256 });
1257 results.truncate(limit as usize);
1258
1259 Ok(results)
1260 }
1261
1262 #[allow(dead_code)]
1264 pub fn rebuild_fts_index(&self) -> Result<()> {
1265 self.conn.execute_batch(
1266 r#"
1267 INSERT INTO symbol_fts(symbol_fts) VALUES('rebuild');
1268 "#,
1269 )?;
1270 Ok(())
1271 }
1272
1273 pub fn store_embedding(
1281 &self,
1282 symbol_id: &str,
1283 provider: &str,
1284 model: &str,
1285 vector: &[f32],
1286 ) -> Result<()> {
1287 let vector_json = serde_json::to_string(vector)
1288 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
1289
1290 self.conn.execute(
1292 r#"
1293 INSERT OR REPLACE INTO embeddings (symbol_id, provider, model, dimension, vector)
1294 VALUES (?, ?, ?, ?, ?)
1295 "#,
1296 params![symbol_id, provider, model, vector.len(), vector_json],
1297 )?;
1298
1299 if self.has_vector_search() && vector.len() == DEFAULT_EMBEDDING_DIM {
1301 let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1303
1304 let _ = self.conn.execute(
1306 "DELETE FROM symbol_vectors WHERE symbol_id = ?",
1307 [symbol_id],
1308 );
1309
1310 self.conn.execute(
1311 "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1312 params![vector_bytes, symbol_id],
1313 )?;
1314 }
1315
1316 Ok(())
1317 }
1318
1319 #[allow(dead_code)]
1321 pub fn get_embedding(&self, symbol_id: &str) -> Result<Option<Vec<f32>>> {
1322 let result = self.conn.query_row(
1323 "SELECT vector FROM embeddings WHERE symbol_id = ?",
1324 [symbol_id],
1325 |row| {
1326 let json: String = row.get(0)?;
1327 Ok(json)
1328 },
1329 );
1330
1331 match result {
1332 Ok(json) => {
1333 let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
1334 rusqlite::Error::FromSqlConversionFailure(
1335 0,
1336 rusqlite::types::Type::Text,
1337 Box::new(e),
1338 )
1339 })?;
1340 Ok(Some(vector))
1341 }
1342 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1343 Err(e) => Err(e),
1344 }
1345 }
1346
1347 #[allow(clippy::type_complexity)]
1348 pub fn get_all_embeddings(
1350 &self,
1351 ) -> Result<Vec<(String, String, String, String, u32, Vec<f32>)>> {
1352 let mut stmt = self.conn.prepare(
1353 r#"
1354 SELECT e.symbol_id, s.name, s.kind, s.file_path, s.line_start, e.vector
1355 FROM embeddings e
1356 JOIN symbols s ON e.symbol_id = s.id
1357 "#,
1358 )?;
1359
1360 let rows = stmt.query_map([], |row| {
1361 let symbol_id: String = row.get(0)?;
1362 let name: String = row.get(1)?;
1363 let kind: String = row.get(2)?;
1364 let file_path: String = row.get(3)?;
1365 let line: u32 = row.get(4)?;
1366 let json: String = row.get(5)?;
1367 Ok((symbol_id, name, kind, file_path, line, json))
1368 })?;
1369
1370 let mut results = Vec::new();
1371 for row in rows {
1372 let (symbol_id, name, kind, file_path, line, json) = row?;
1373 let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
1374 rusqlite::Error::FromSqlConversionFailure(
1375 0,
1376 rusqlite::types::Type::Text,
1377 Box::new(e),
1378 )
1379 })?;
1380 results.push((symbol_id, name, kind, file_path, line, vector));
1381 }
1382
1383 Ok(results)
1384 }
1385
1386 pub fn count_embeddings(&self) -> Result<i64> {
1388 self.conn
1389 .query_row("SELECT COUNT(*) FROM embeddings", [], |row| row.get(0))
1390 }
1391
1392 pub fn get_embedding_metadata(&self) -> Result<Vec<(String, String, i64, i64)>> {
1398 let mut stmt = self.conn.prepare(
1399 r#"
1400 SELECT provider, model, dimension, COUNT(*) as count
1401 FROM embeddings
1402 GROUP BY provider, model, dimension
1403 ORDER BY count DESC
1404 "#,
1405 )?;
1406
1407 let rows = stmt.query_map([], |row| {
1408 Ok((
1409 row.get::<_, String>(0)?,
1410 row.get::<_, String>(1)?,
1411 row.get::<_, i64>(2)?,
1412 row.get::<_, i64>(3)?,
1413 ))
1414 })?;
1415
1416 rows.collect()
1417 }
1418
1419 pub fn get_symbols_without_embeddings(&self, limit: i64) -> Result<Vec<Symbol>> {
1421 let mut stmt = self.conn.prepare(
1422 r#"
1423 SELECT s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
1424 s.signature, s.brief, s.docstring, s.line_start, s.line_end,
1425 s.col_start, s.col_end, s.parent_id, s.source
1426 FROM symbols s
1427 LEFT JOIN embeddings e ON s.id = e.symbol_id
1428 WHERE e.symbol_id IS NULL
1429 LIMIT ?
1430 "#,
1431 )?;
1432
1433 let rows = stmt.query_map([limit], |row| {
1434 Ok(Symbol {
1435 id: row.get(0)?,
1436 file_path: row.get(1)?,
1437 name: row.get(2)?,
1438 qualified_name: row.get(3)?,
1439 kind: SymbolKind::from_str(&row.get::<_, String>(4)?)
1440 .unwrap_or(SymbolKind::Function),
1441 visibility: Visibility::from_str(&row.get::<_, String>(5)?).unwrap_or_default(),
1442 signature: row.get(6)?,
1443 brief: row.get(7)?,
1444 docstring: row.get(8)?,
1445 line_start: row.get(9)?,
1446 line_end: row.get(10)?,
1447 col_start: row.get(11)?,
1448 col_end: row.get(12)?,
1449 parent_id: row.get(13)?,
1450 source: row.get(14)?,
1451 })
1452 })?;
1453
1454 rows.collect()
1455 }
1456
1457 #[allow(dead_code)] pub fn migrate_embeddings_to_vec(&self) -> Result<usize> {
1463 if !self.has_vector_search() {
1464 return Ok(0);
1465 }
1466
1467 let mut stmt = self.conn.prepare(
1469 r#"
1470 SELECT symbol_id, vector FROM embeddings
1471 WHERE dimension = ?
1472 "#,
1473 )?;
1474
1475 let rows = stmt.query_map([DEFAULT_EMBEDDING_DIM as i64], |row| {
1476 let symbol_id: String = row.get(0)?;
1477 let json: String = row.get(1)?;
1478 Ok((symbol_id, json))
1479 })?;
1480
1481 let mut count = 0;
1482 for row in rows {
1483 let (symbol_id, json) = row?;
1484 let vector: Vec<f32> = match serde_json::from_str(&json) {
1485 Ok(v) => v,
1486 Err(_) => continue,
1487 };
1488
1489 let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1491
1492 let exists: bool = self
1494 .conn
1495 .query_row(
1496 "SELECT 1 FROM symbol_vectors WHERE symbol_id = ?",
1497 [&symbol_id],
1498 |_| Ok(true),
1499 )
1500 .unwrap_or(false);
1501
1502 if !exists
1503 && self
1504 .conn
1505 .execute(
1506 "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1507 params![vector_bytes, symbol_id],
1508 )
1509 .is_ok()
1510 {
1511 count += 1;
1512 }
1513 }
1514
1515 Ok(count)
1516 }
1517
1518 pub fn count_vector_embeddings(&self) -> Result<i64> {
1520 if !self.has_vector_search() {
1521 return Ok(0);
1522 }
1523 self.conn
1524 .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
1525 }
1526
1527 #[allow(clippy::type_complexity)]
1534 pub fn vector_search(
1536 &self,
1537 query_embedding: &[f32],
1538 limit: usize,
1539 ) -> Result<Vec<(String, String, String, String, u32, f32)>> {
1540 if !self.has_vector_search() {
1541 return Ok(Vec::new());
1542 }
1543
1544 if query_embedding.len() != DEFAULT_EMBEDDING_DIM {
1545 return Ok(Vec::new());
1546 }
1547
1548 let query_bytes: Vec<u8> = query_embedding
1550 .iter()
1551 .flat_map(|f| f.to_le_bytes())
1552 .collect();
1553
1554 let mut stmt = self.conn.prepare(
1557 r#"
1558 SELECT knn.symbol_id, s.name, s.kind, s.file_path, s.line_start, knn.distance
1559 FROM (
1560 SELECT symbol_id, distance
1561 FROM symbol_vectors
1562 WHERE embedding MATCH ?
1563 ORDER BY distance
1564 LIMIT ?
1565 ) knn
1566 JOIN symbols s ON knn.symbol_id = s.id
1567 ORDER BY knn.distance
1568 "#,
1569 )?;
1570
1571 let rows = stmt.query_map(params![query_bytes, limit as i64], |row| {
1572 Ok((
1573 row.get::<_, String>(0)?,
1574 row.get::<_, String>(1)?,
1575 row.get::<_, String>(2)?,
1576 row.get::<_, String>(3)?,
1577 row.get::<_, u32>(4)?,
1578 row.get::<_, f32>(5)?,
1579 ))
1580 })?;
1581
1582 rows.collect()
1583 }
1584
1585 pub fn has_vector_embeddings(&self) -> bool {
1587 self.count_vector_embeddings().unwrap_or(0) > 0
1588 }
1589
1590 pub fn delete_embeddings(&self, provider: &str, model: Option<&str>) -> Result<usize> {
1592 if let Some(model) = model {
1593 self.conn.execute(
1594 "DELETE FROM embeddings WHERE provider = ? AND model = ?",
1595 params![provider, model],
1596 )
1597 } else {
1598 self.conn
1599 .execute("DELETE FROM embeddings WHERE provider = ?", [provider])
1600 }
1601 }
1602
1603 pub fn resolve_edge_targets(&self) -> Result<usize> {
1616 let context_resolved = self.conn.execute(
1620 r#"
1621 UPDATE edges
1622 SET target_id = (
1623 SELECT t.id
1624 FROM symbols t
1625 WHERE t.name = edges.target_name
1626 AND t.kind IN ('function', 'method')
1627 AND t.qualified_name IS NOT NULL
1628 AND edges.context LIKE '%' || t.qualified_name || '%'
1629 )
1630 WHERE target_id IS NULL
1631 AND context IS NOT NULL
1632 AND (
1633 SELECT COUNT(*)
1634 FROM symbols t
1635 WHERE t.name = edges.target_name
1636 AND t.kind IN ('function', 'method')
1637 AND t.qualified_name IS NOT NULL
1638 AND edges.context LIKE '%' || t.qualified_name || '%'
1639 ) = 1
1640 "#,
1641 [],
1642 )?;
1643
1644 let unique_resolved = self.conn.execute(
1646 r#"
1647 UPDATE edges
1648 SET target_id = (
1649 SELECT id FROM symbols
1650 WHERE name = edges.target_name
1651 AND kind IN ('function', 'method')
1652 )
1653 WHERE target_id IS NULL
1654 AND (
1655 SELECT COUNT(*) FROM symbols
1656 WHERE name = edges.target_name
1657 AND kind IN ('function', 'method')
1658 ) = 1
1659 "#,
1660 [],
1661 )?;
1662
1663 let same_file_unique = self.conn.execute(
1670 r#"
1671 UPDATE edges
1672 SET target_id = (
1673 SELECT t.id
1674 FROM symbols t
1675 JOIN symbols s ON s.id = edges.source_id
1676 WHERE t.name = edges.target_name
1677 AND t.file_path = s.file_path
1678 AND t.kind IN ('function', 'method')
1679 )
1680 WHERE target_id IS NULL
1681 AND (
1682 SELECT COUNT(*)
1683 FROM symbols t
1684 JOIN symbols s ON s.id = edges.source_id
1685 WHERE t.name = edges.target_name
1686 AND t.file_path = s.file_path
1687 AND t.kind IN ('function', 'method')
1688 ) = 1
1689 -- Exclude if context suggests an external type call (has :: before the function name)
1690 -- e.g., "Vec::new()" or "Parser::new()" should NOT match local "new" functions
1691 AND (
1692 context IS NULL
1693 OR (
1694 context NOT LIKE '%::' || target_name || '(%'
1695 AND context NOT LIKE '%.' || target_name || '(%'
1696 AND context NOT LIKE '%->' || target_name || '(%'
1697 )
1698 OR context LIKE '%' || (
1699 SELECT t.qualified_name
1700 FROM symbols t
1701 JOIN symbols s ON s.id = edges.source_id
1702 WHERE t.name = edges.target_name
1703 AND t.file_path = s.file_path
1704 AND t.kind IN ('function', 'method')
1705 LIMIT 1
1706 ) || '(%'
1707 )
1708 "#,
1709 [],
1710 )?;
1711
1712 Ok(context_resolved + unique_resolved + same_file_unique)
1713 }
1714
1715 pub fn insert_fingerprints_batch(&self, fingerprints: &[Fingerprint]) -> Result<usize> {
1717 if fingerprints.is_empty() {
1718 return Ok(0);
1719 }
1720 let tx = self.conn.unchecked_transaction()?;
1721 {
1722 let mut stmt = tx.prepare(
1723 r#"
1724 INSERT OR REPLACE INTO symbol_fingerprints (symbol_id, file_path, minhash, token_count)
1725 VALUES (?, ?, ?, ?)
1726 "#,
1727 )?;
1728 for fp in fingerprints {
1729 stmt.execute(params![
1730 fp.symbol_id,
1731 fp.file_path,
1732 fp.minhash,
1733 fp.token_count
1734 ])?;
1735 }
1736 }
1737 tx.commit()?;
1738 Ok(fingerprints.len())
1739 }
1740
1741 pub fn get_fingerprints(&self, min_tokens: i64) -> Result<Vec<Fingerprint>> {
1744 let mut stmt = self.conn.prepare(
1745 r#"
1746 SELECT symbol_id, file_path, minhash, token_count
1747 FROM symbol_fingerprints
1748 WHERE token_count >= ?
1749 ORDER BY symbol_id
1750 "#,
1751 )?;
1752 let rows = stmt.query_map([min_tokens], |row| {
1753 Ok(Fingerprint {
1754 symbol_id: row.get(0)?,
1755 file_path: row.get(1)?,
1756 minhash: row.get(2)?,
1757 token_count: row.get(3)?,
1758 })
1759 })?;
1760 rows.collect()
1761 }
1762}
1763
1764fn escape_like_pattern(pattern: &str) -> String {
1769 pattern
1770 .replace('\\', "\\\\") .replace('%', "\\%")
1772 .replace('_', "\\_")
1773}
1774
1775fn glob_to_like_pattern(pattern: &str) -> String {
1794 let mut result = String::with_capacity(pattern.len() * 2);
1795 let mut chars = pattern.chars().peekable();
1796
1797 while let Some(c) = chars.next() {
1798 match c {
1799 '*' => {
1800 if chars.peek() == Some(&'*') {
1802 chars.next();
1803 if chars.peek() == Some(&'/') {
1805 chars.next();
1806 if chars.peek().is_none() || chars.peek() == Some(&'*') {
1811 continue;
1813 }
1814 result.push('%');
1816 } else {
1817 result.push('%');
1819 }
1820 } else {
1821 result.push('%');
1823 }
1824 }
1825 '?' => result.push('_'),
1826 '%' => result.push_str("\\%"),
1827 '_' => result.push_str("\\_"),
1828 '\\' => result.push_str("\\\\"),
1829 _ => result.push(c),
1830 }
1831 }
1832
1833 result
1834}
1835
1836fn preprocess_search_query(query: &str) -> Vec<String> {
1838 let stop_words: std::collections::HashSet<&str> = [
1840 "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
1841 "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall",
1842 "can", "need", "dare", "and", "or", "but", "if", "then", "else", "when", "where", "why",
1843 "how", "what", "which", "who", "whom", "this", "that", "these", "those", "i", "you", "he",
1844 "she", "it", "we", "they", "me", "him", "her", "us", "them", "my", "your", "his", "its",
1845 "our", "their", "for", "to", "from", "with", "at", "by", "on", "in", "of", "about", "into",
1846 "through", "during", "before", "after", "above", "below", "find", "get", "search", "look",
1847 "all", "any", "each", "every",
1848 ]
1849 .iter()
1850 .copied()
1851 .collect();
1852
1853 query
1854 .to_lowercase()
1855 .split(|c: char| !c.is_alphanumeric() && c != '_')
1856 .filter(|word| {
1857 let word = word.trim();
1858 !word.is_empty() && word.len() > 1 && !stop_words.contains(word)
1859 })
1860 .map(|s| {
1861 format!("{}*", s)
1863 })
1864 .collect()
1865}
1866
1867fn symbol_from_row(row: &rusqlite::Row) -> Symbol {
1869 let kind_str: String = row.get(4).unwrap_or_default();
1870 let visibility_str: String = row.get(5).unwrap_or_default();
1871
1872 Symbol {
1873 id: row.get(0).unwrap_or_default(),
1874 file_path: row.get(1).unwrap_or_default(),
1875 name: row.get(2).unwrap_or_default(),
1876 qualified_name: row.get(3).ok(),
1877 kind: SymbolKind::from_str(&kind_str).unwrap_or(SymbolKind::Function),
1878 visibility: Visibility::from_str(&visibility_str).unwrap_or_default(),
1879 signature: row.get(6).ok(),
1880 brief: row.get(7).ok(),
1881 docstring: row.get(8).ok(),
1882 line_start: row.get(9).unwrap_or(0),
1883 line_end: row.get(10).unwrap_or(0),
1884 col_start: row.get(11).unwrap_or(0),
1885 col_end: row.get(12).unwrap_or(0),
1886 parent_id: row.get(13).ok(),
1887 source: row.get(14).ok(),
1888 }
1889}
1890
1891fn edge_from_row(row: &rusqlite::Row) -> Edge {
1893 let kind_str: String = row.get(3).unwrap_or_default();
1894
1895 Edge {
1896 source_id: row.get(0).unwrap_or_default(),
1897 target_id: row.get(1).ok(),
1898 target_name: row.get(2).unwrap_or_default(),
1899 kind: EdgeKind::from_str(&kind_str).unwrap_or(EdgeKind::Calls),
1900 line: row.get(4).ok(),
1901 col: row.get(5).ok(),
1902 context: row.get(6).ok(),
1903 }
1904}
1905
1906#[derive(Debug, Clone)]
1909pub struct MapSymbolRow {
1910 pub id: String,
1911 pub file_path: String,
1912 pub name: String,
1913 pub qualified_name: Option<String>,
1914 pub kind: String,
1915 pub signature: Option<String>,
1916 pub line_start: u32,
1917}
1918
1919#[derive(Debug, Clone, serde::Serialize)]
1921pub struct SymbolMetrics {
1922 pub id: String,
1923 pub name: String,
1924 pub qualified_name: Option<String>,
1925 pub kind: String,
1926 pub file_path: String,
1927 pub line_start: i64,
1928 pub line_end: i64,
1929 pub fan_in: i64,
1930 pub fan_out: i64,
1931 pub complexity: i64,
1932}
1933
1934#[derive(Debug, Clone)]
1937pub struct CrossFileEdge {
1938 pub source: EdgeSymbol,
1939 pub target: EdgeSymbol,
1940 pub kind: String,
1942 pub line: Option<i64>,
1944}
1945
1946#[derive(Debug, Clone)]
1948pub struct EdgeSymbol {
1949 pub name: String,
1950 pub qualified_name: Option<String>,
1951 pub kind: String,
1952 pub file_path: String,
1953 pub line_start: i64,
1954 pub line_end: i64,
1955}
1956
1957#[derive(Debug, Clone, serde::Serialize)]
1959pub struct FileComplexity {
1960 pub file_path: String,
1961 pub complexity: i64,
1962 pub fan_out: i64,
1963 pub symbol_count: i64,
1964}
1965
1966trait ResultExt<T> {
1968 fn optional(self) -> Result<Option<T>>;
1969}
1970
1971impl<T> ResultExt<T> for Result<T> {
1972 fn optional(self) -> Result<Option<T>> {
1973 match self {
1974 Ok(v) => Ok(Some(v)),
1975 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1976 Err(e) => Err(e),
1977 }
1978 }
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983 use super::*;
1984
1985 #[test]
1986 fn test_create_database() {
1987 let db = Database::open_in_memory().unwrap();
1988 let stats = db.get_stats().unwrap();
1989 assert_eq!(stats.files, 0);
1990 assert_eq!(stats.symbols, 0);
1991 }
1992
1993 #[test]
1994 fn test_fresh_database_is_stamped_with_schema_version() {
1995 let dir = tempfile::tempdir().unwrap();
1996 let db_path = dir.path().join("codebase.sqlite");
1997
1998 let db = Database::open(&db_path).unwrap();
1999 let version: i64 = db
2000 .conn
2001 .query_row("PRAGMA user_version", [], |row| row.get(0))
2002 .unwrap();
2003 assert_eq!(version, SCHEMA_VERSION);
2004 drop(db);
2005
2006 assert!(Database::open(&db_path).is_ok());
2008 }
2009
2010 #[test]
2011 fn test_legacy_v0_database_is_rejected() {
2012 let dir = tempfile::tempdir().unwrap();
2013 let db_path = dir.path().join("codebase.sqlite");
2014
2015 drop(Database::open(&db_path).unwrap());
2019 {
2020 let conn = Connection::open(&db_path).unwrap();
2021 conn.pragma_update(None, "user_version", 0).unwrap();
2022 }
2023
2024 let err = Database::open(&db_path).unwrap_err();
2025 match &err {
2026 crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2027 assert_eq!(*found, 0);
2028 assert_eq!(*expected, SCHEMA_VERSION);
2029 }
2030 other => panic!("expected SchemaVersionMismatch, got: {}", other),
2031 }
2032 assert!(err.to_string().contains("ctx index --force"));
2033 }
2034
2035 #[test]
2036 fn test_schema_version_mismatch_is_rejected() {
2037 let dir = tempfile::tempdir().unwrap();
2038 let db_path = dir.path().join("codebase.sqlite");
2039
2040 for stale_version in [1i64, 99] {
2043 drop(Database::open(&db_path).unwrap());
2044 {
2045 let conn = Connection::open(&db_path).unwrap();
2046 conn.pragma_update(None, "user_version", stale_version)
2047 .unwrap();
2048 }
2049
2050 let err = Database::open(&db_path).unwrap_err();
2051 match &err {
2052 crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2053 assert_eq!(*found, stale_version);
2054 assert_eq!(*expected, SCHEMA_VERSION);
2055 }
2056 other => panic!("expected SchemaVersionMismatch, got: {}", other),
2057 }
2058 assert!(err.to_string().contains("ctx index --force"));
2059
2060 let conn = Connection::open(&db_path).unwrap();
2062 conn.pragma_update(None, "user_version", SCHEMA_VERSION)
2063 .unwrap();
2064 }
2065 }
2066
2067 #[test]
2068 fn test_fingerprint_batch_roundtrip_and_min_tokens_filter() {
2069 let db = Database::open_in_memory().unwrap();
2070 db.upsert_file(
2071 &FileRecord {
2072 path: "src/a.rs".to_string(),
2073 content_hash: "h1".to_string(),
2074 size_bytes: 10,
2075 language: Some("rust".to_string()),
2076 last_indexed: 0,
2077 },
2078 None,
2079 )
2080 .unwrap();
2081 db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2082 .unwrap();
2083 db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2084 .unwrap();
2085
2086 let fps = vec![
2087 Fingerprint {
2088 symbol_id: "src/a.rs::alpha".to_string(),
2089 file_path: "src/a.rs".to_string(),
2090 minhash: vec![1u8; 1024],
2091 token_count: 80,
2092 },
2093 Fingerprint {
2094 symbol_id: "src/a.rs::beta".to_string(),
2095 file_path: "src/a.rs".to_string(),
2096 minhash: vec![2u8; 1024],
2097 token_count: 20,
2098 },
2099 ];
2100 assert_eq!(db.insert_fingerprints_batch(&fps).unwrap(), 2);
2101 assert_eq!(db.insert_fingerprints_batch(&[]).unwrap(), 0);
2102
2103 let all = db.get_fingerprints(0).unwrap();
2105 assert_eq!(all.len(), 2);
2106 assert_eq!(all[0].symbol_id, "src/a.rs::alpha");
2107 assert_eq!(all[0].minhash, vec![1u8; 1024]);
2108 assert_eq!(all[1].token_count, 20);
2109
2110 let filtered = db.get_fingerprints(50).unwrap();
2112 assert_eq!(filtered.len(), 1);
2113 assert_eq!(filtered[0].symbol_id, "src/a.rs::alpha");
2114
2115 db.delete_symbols_for_file("src/a.rs").unwrap();
2117 assert!(db.get_fingerprints(0).unwrap().is_empty());
2118 }
2119
2120 #[test]
2121 fn test_sqlite_vec_extension_loaded() {
2122 let db = Database::open_in_memory().unwrap();
2124
2125 if !is_vec_extension_available() {
2129 eprintln!("Skipping sqlite-vec tests: extension not available on this platform");
2130 return;
2131 }
2132
2133 let version: Result<String, _> = db
2135 .conn
2136 .query_row("SELECT vec_version()", [], |row| row.get(0));
2137
2138 match version {
2139 Ok(v) => {
2140 assert!(
2141 v.starts_with('v'),
2142 "vec_version should return version string, got: {}",
2143 v
2144 );
2145 }
2146 Err(e) => {
2147 panic!(
2150 "sqlite-vec extension reported available but vec_version() failed: {}",
2151 e
2152 );
2153 }
2154 }
2155
2156 assert!(db.has_vector_search(), "vector search should be available");
2158
2159 let count: i64 = db
2161 .conn
2162 .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
2163 .expect("symbol_vectors table should exist");
2164 assert_eq!(count, 0, "symbol_vectors should be empty initially");
2165 }
2166
2167 fn make_fn_symbol(id: &str, name: &str, file: &str, line: u32) -> Symbol {
2169 Symbol {
2170 id: id.to_string(),
2171 file_path: file.to_string(),
2172 name: name.to_string(),
2173 qualified_name: Some(name.to_string()),
2174 kind: SymbolKind::Function,
2175 visibility: Visibility::Public,
2176 signature: None,
2177 brief: None,
2178 docstring: None,
2179 line_start: line,
2180 line_end: line + 5,
2181 col_start: 0,
2182 col_end: 0,
2183 parent_id: None,
2184 source: None,
2185 }
2186 }
2187
2188 fn make_call_edge(source_id: &str, target_id: Option<&str>, target_name: &str) -> Edge {
2190 Edge {
2191 source_id: source_id.to_string(),
2192 target_id: target_id.map(|s| s.to_string()),
2193 target_name: target_name.to_string(),
2194 kind: EdgeKind::Calls,
2195 line: Some(1),
2196 col: None,
2197 context: None,
2198 }
2199 }
2200
2201 fn metrics_fixture() -> Database {
2204 let db = Database::open_in_memory().unwrap();
2205 db.upsert_file(
2206 &FileRecord {
2207 path: "src/a.rs".to_string(),
2208 content_hash: "h1".to_string(),
2209 size_bytes: 10,
2210 language: Some("rust".to_string()),
2211 last_indexed: 0,
2212 },
2213 None,
2214 )
2215 .unwrap();
2216
2217 db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2218 .unwrap();
2219 db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2220 .unwrap();
2221 db.insert_symbol(&make_fn_symbol("src/a.rs::gamma", "gamma", "src/a.rs", 20))
2222 .unwrap();
2223
2224 db.insert_edge(&make_call_edge(
2225 "src/a.rs::alpha",
2226 Some("src/a.rs::beta"),
2227 "beta",
2228 ))
2229 .unwrap();
2230 db.insert_edge(&make_call_edge("src/a.rs::alpha", None, "external"))
2231 .unwrap();
2232 db.insert_edge(&make_call_edge(
2233 "src/a.rs::beta",
2234 Some("src/a.rs::alpha"),
2235 "alpha",
2236 ))
2237 .unwrap();
2238
2239 db
2240 }
2241
2242 #[test]
2243 fn test_symbol_metrics_mirrors_complexity_formula() {
2244 let db = metrics_fixture();
2245 let metrics = db.symbol_metrics().unwrap();
2246 assert_eq!(metrics.len(), 3);
2247
2248 let get = |name: &str| metrics.iter().find(|m| m.name == name).unwrap();
2249
2250 let alpha = get("alpha");
2252 assert_eq!(alpha.fan_out, 2);
2253 assert_eq!(alpha.fan_in, 1);
2254 assert_eq!(alpha.complexity, 2 * 2 + 1);
2255 assert_eq!(alpha.file_path, "src/a.rs");
2256 assert_eq!(alpha.line_start, 1);
2257
2258 let beta = get("beta");
2260 assert_eq!(beta.fan_out, 1);
2261 assert_eq!(beta.fan_in, 1);
2262 assert_eq!(beta.complexity, 3); let gamma = get("gamma");
2266 assert_eq!(gamma.fan_out, 0);
2267 assert_eq!(gamma.fan_in, 0);
2268 assert_eq!(gamma.complexity, 0);
2269
2270 assert_eq!(metrics[0].name, "alpha");
2272 }
2273
2274 #[test]
2275 fn test_file_complexity_aggregates_per_file() {
2276 let db = metrics_fixture();
2277 let files = db.file_complexity().unwrap();
2278 assert_eq!(files.len(), 1);
2279
2280 let f = &files[0];
2281 assert_eq!(f.file_path, "src/a.rs");
2282 assert_eq!(f.symbol_count, 3);
2283 assert_eq!(f.fan_out, 3);
2284 assert_eq!(f.complexity, 8);
2286 }
2287
2288 #[test]
2289 fn test_fan_in_counts() {
2290 let db = metrics_fixture();
2291
2292 let ids = vec![
2293 "src/a.rs::alpha".to_string(),
2294 "src/a.rs::beta".to_string(),
2295 "src/a.rs::gamma".to_string(),
2296 ];
2297 let counts = db.fan_in_counts(&ids).unwrap();
2298 assert_eq!(counts.get("src/a.rs::alpha"), Some(&1));
2299 assert_eq!(counts.get("src/a.rs::beta"), Some(&1));
2300 assert!(!counts.contains_key("src/a.rs::gamma"));
2302
2303 assert!(db.fan_in_counts(&[]).unwrap().is_empty());
2305 }
2306
2307 #[test]
2308 fn test_insert_and_find_symbol() {
2309 let db = Database::open_in_memory().unwrap();
2310
2311 let file = FileRecord {
2313 path: "src/main.rs".to_string(),
2314 content_hash: "abc123".to_string(),
2315 size_bytes: 100,
2316 language: Some("rust".to_string()),
2317 last_indexed: 0,
2318 };
2319 db.upsert_file(&file, None).unwrap();
2320
2321 let symbol = Symbol {
2323 id: "src/main.rs::main".to_string(),
2324 file_path: "src/main.rs".to_string(),
2325 name: "main".to_string(),
2326 qualified_name: None,
2327 kind: SymbolKind::Function,
2328 visibility: Visibility::Private,
2329 signature: Some("fn main()".to_string()),
2330 brief: Some("Entry point".to_string()),
2331 docstring: None,
2332 line_start: 1,
2333 line_end: 5,
2334 col_start: 0,
2335 col_end: 1,
2336 parent_id: None,
2337 source: Some("fn main() {\n println!(\"Hello\");\n}".to_string()),
2338 };
2339 db.insert_symbol(&symbol).unwrap();
2340
2341 let found = db.get_symbol("src/main.rs::main").unwrap();
2343 assert!(found.is_some());
2344 assert_eq!(found.unwrap().name, "main");
2345
2346 let results = db.find_symbols("main", 10).unwrap();
2348 assert_eq!(results.len(), 1);
2349 }
2350
2351 #[test]
2352 fn test_find_symbols_exact_match_ordering() {
2353 let db = Database::open_in_memory().unwrap();
2354
2355 let file = FileRecord {
2357 path: "src/test.rs".to_string(),
2358 content_hash: "abc123".to_string(),
2359 size_bytes: 100,
2360 language: Some("rust".to_string()),
2361 last_indexed: 0,
2362 };
2363 db.upsert_file(&file, None).unwrap();
2364
2365 let symbols = vec![
2368 ("new_large", "src/test.rs::new_large"), ("new_item", "src/test.rs::new_item"), ("renew", "src/test.rs::renew"), ("new", "src/test.rs::new"), ("new_thing", "src/test.rs::new_thing"), ];
2374
2375 for (name, id) in &symbols {
2376 let symbol = Symbol {
2377 id: id.to_string(),
2378 file_path: "src/test.rs".to_string(),
2379 name: name.to_string(),
2380 qualified_name: None,
2381 kind: SymbolKind::Function,
2382 visibility: Visibility::Public,
2383 signature: None,
2384 brief: None,
2385 docstring: None,
2386 line_start: 1,
2387 line_end: 5,
2388 col_start: 0,
2389 col_end: 1,
2390 parent_id: None,
2391 source: None,
2392 };
2393 db.insert_symbol(&symbol).unwrap();
2394 }
2395
2396 let results = db.find_symbols("new", 10).unwrap();
2398
2399 assert!(results.len() >= 4, "Should find at least 4 symbols");
2400
2401 assert_eq!(results[0].name, "new", "Exact match 'new' should be first");
2403
2404 let prefix_matches: Vec<&str> = results[1..4].iter().map(|s| s.name.as_str()).collect();
2407 assert!(
2408 prefix_matches.iter().all(|n| n.starts_with("new")),
2409 "Positions 2-4 should be prefix matches, got: {:?}",
2410 prefix_matches
2411 );
2412
2413 let renew_pos = results.iter().position(|s| s.name == "renew");
2415 assert!(
2416 renew_pos.is_some() && renew_pos.unwrap() >= 4,
2417 "'renew' should be after prefix matches"
2418 );
2419 }
2420
2421 #[test]
2422 fn test_find_symbols_with_file_filter() {
2423 let db = Database::open_in_memory().unwrap();
2424
2425 for path in &["src/parser/rust.rs", "src/embeddings/local.rs"] {
2427 let file = FileRecord {
2428 path: path.to_string(),
2429 content_hash: "abc123".to_string(),
2430 size_bytes: 100,
2431 language: Some("rust".to_string()),
2432 last_indexed: 0,
2433 };
2434 db.upsert_file(&file, None).unwrap();
2435 }
2436
2437 for (path, id) in &[
2439 ("src/parser/rust.rs", "src/parser/rust.rs::new"),
2440 ("src/embeddings/local.rs", "src/embeddings/local.rs::new"),
2441 ] {
2442 let symbol = Symbol {
2443 id: id.to_string(),
2444 file_path: path.to_string(),
2445 name: "new".to_string(),
2446 qualified_name: None,
2447 kind: SymbolKind::Method,
2448 visibility: Visibility::Public,
2449 signature: None,
2450 brief: None,
2451 docstring: None,
2452 line_start: 1,
2453 line_end: 5,
2454 col_start: 0,
2455 col_end: 1,
2456 parent_id: None,
2457 source: None,
2458 };
2459 db.insert_symbol(&symbol).unwrap();
2460 }
2461
2462 let all_results = db.find_symbols_filtered("new", 10, None, None).unwrap();
2464 assert_eq!(all_results.len(), 2);
2465
2466 let filtered = db
2468 .find_symbols_filtered("new", 10, Some("*parser*"), None)
2469 .unwrap();
2470 assert_eq!(filtered.len(), 1);
2471 assert!(filtered[0].file_path.contains("parser"));
2472
2473 let filtered = db
2475 .find_symbols_filtered("new", 10, Some("*embeddings*"), None)
2476 .unwrap();
2477 assert_eq!(filtered.len(), 1);
2478 assert!(filtered[0].file_path.contains("embeddings"));
2479 }
2480
2481 #[test]
2482 fn test_escape_like_pattern() {
2483 assert_eq!(escape_like_pattern("new"), "new");
2485 assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
2486
2487 assert_eq!(escape_like_pattern("100%"), "100\\%");
2489 assert_eq!(escape_like_pattern("a_b"), "a\\_b");
2490 assert_eq!(escape_like_pattern("a%b_c"), "a\\%b\\_c");
2491 }
2492
2493 #[test]
2494 fn test_glob_to_like_pattern() {
2495 assert_eq!(glob_to_like_pattern("*.rs"), "%.rs");
2497 assert_eq!(glob_to_like_pattern("src/*"), "src/%");
2498
2499 assert_eq!(glob_to_like_pattern("**/*.rs"), "%.rs");
2502 assert_eq!(glob_to_like_pattern("src/**/*.rs"), "src/%.rs");
2503
2504 assert_eq!(glob_to_like_pattern("src/**"), "src/%");
2506
2507 assert_eq!(glob_to_like_pattern("**/foo.rs"), "%foo.rs");
2509
2510 assert_eq!(glob_to_like_pattern("file?.txt"), "file_.txt");
2512
2513 assert_eq!(glob_to_like_pattern("100%"), "100\\%");
2515 assert_eq!(glob_to_like_pattern("a_b"), "a\\_b");
2516 }
2517
2518 #[test]
2519 fn test_glob_pattern_matches_files() {
2520 let db = Database::open_in_memory().unwrap();
2521
2522 for path in &[
2524 "main.rs",
2525 "src/lib.rs",
2526 "src/parser/mod.rs",
2527 "src/parser/rust.rs",
2528 "tests/test.rs",
2529 ] {
2530 let file = FileRecord {
2531 path: path.to_string(),
2532 content_hash: "abc".to_string(),
2533 size_bytes: 100,
2534 language: Some("rust".to_string()),
2535 last_indexed: 0,
2536 };
2537 db.upsert_file(&file, None).unwrap();
2538
2539 let symbol = Symbol {
2540 id: format!("{}::main", path),
2541 file_path: path.to_string(),
2542 name: "main".to_string(),
2543 qualified_name: None,
2544 kind: SymbolKind::Function,
2545 visibility: Visibility::Public,
2546 signature: None,
2547 brief: None,
2548 docstring: None,
2549 line_start: 1,
2550 line_end: 5,
2551 col_start: 0,
2552 col_end: 1,
2553 parent_id: None,
2554 source: None,
2555 };
2556 db.insert_symbol(&symbol).unwrap();
2557 }
2558
2559 let results = db
2561 .find_symbols_filtered("main", 20, Some("**/*.rs"), None)
2562 .unwrap();
2563 assert_eq!(results.len(), 5, "**/*.rs should match all .rs files");
2564
2565 let results = db
2567 .find_symbols_filtered("main", 20, Some("src/**/*.rs"), None)
2568 .unwrap();
2569 assert_eq!(
2570 results.len(),
2571 3,
2572 "src/**/*.rs should match src/lib.rs, src/parser/mod.rs, src/parser/rust.rs"
2573 );
2574
2575 let results = db
2578 .find_symbols_filtered("main", 20, Some("src/*.rs"), None)
2579 .unwrap();
2580 assert_eq!(
2581 results.len(),
2582 3,
2583 "src/*.rs matches all under src/ (SQL LIKE limitation)"
2584 );
2585
2586 let results = db
2588 .find_symbols_filtered("main", 20, Some("*parser*"), None)
2589 .unwrap();
2590 assert_eq!(
2591 results.len(),
2592 2,
2593 "*parser* should match parser directory files"
2594 );
2595 }
2596
2597 #[test]
2598 fn test_hybrid_search_limit_one() {
2599 let db = Database::open_in_memory().unwrap();
2602
2603 let file = FileRecord {
2605 path: "src/main.rs".to_string(),
2606 content_hash: "abc123".to_string(),
2607 size_bytes: 100,
2608 language: Some("rust".to_string()),
2609 last_indexed: 0,
2610 };
2611 db.upsert_file(&file, None).unwrap();
2612
2613 let symbol = Symbol {
2614 id: "src/main.rs::authenticate".to_string(),
2615 file_path: "src/main.rs".to_string(),
2616 name: "authenticate".to_string(),
2617 qualified_name: None,
2618 kind: SymbolKind::Function,
2619 visibility: Visibility::Public,
2620 signature: Some("fn authenticate(user: &str)".to_string()),
2621 brief: Some("Authenticate a user".to_string()),
2622 docstring: None,
2623 line_start: 1,
2624 line_end: 5,
2625 col_start: 0,
2626 col_end: 1,
2627 parent_id: None,
2628 source: None,
2629 };
2630 db.insert_symbol(&symbol).unwrap();
2631
2632 let results = db.hybrid_search("authenticate", 1).unwrap();
2634 assert_eq!(
2635 results.len(),
2636 1,
2637 "hybrid_search with limit=1 should return 1 result"
2638 );
2639 assert_eq!(results[0].0.name, "authenticate");
2640 }
2641
2642 #[test]
2643 fn test_hybrid_search_limit_three() {
2644 let db = Database::open_in_memory().unwrap();
2646
2647 let file = FileRecord {
2649 path: "src/test.rs".to_string(),
2650 content_hash: "abc123".to_string(),
2651 size_bytes: 100,
2652 language: Some("rust".to_string()),
2653 last_indexed: 0,
2654 };
2655 db.upsert_file(&file, None).unwrap();
2656
2657 for (name, i) in &[("login", 1), ("logout", 2), ("log_error", 3)] {
2659 let symbol = Symbol {
2660 id: format!("src/test.rs::{}", name),
2661 file_path: "src/test.rs".to_string(),
2662 name: name.to_string(),
2663 qualified_name: None,
2664 kind: SymbolKind::Function,
2665 visibility: Visibility::Public,
2666 signature: Some(format!("fn {}()", name)),
2667 brief: Some(format!("{} function", name)),
2668 docstring: None,
2669 line_start: *i,
2670 line_end: *i + 5,
2671 col_start: 0,
2672 col_end: 1,
2673 parent_id: None,
2674 source: None,
2675 };
2676 db.insert_symbol(&symbol).unwrap();
2677 }
2678
2679 let results = db.hybrid_search("log", 3).unwrap();
2681 assert!(
2682 !results.is_empty(),
2683 "hybrid_search with limit=3 should return results"
2684 );
2685 assert!(results.len() <= 3, "hybrid_search should respect limit");
2686 }
2687
2688 #[test]
2689 fn test_semantic_search_score_range() {
2690 let db = Database::open_in_memory().unwrap();
2692
2693 let file = FileRecord {
2695 path: "src/main.rs".to_string(),
2696 content_hash: "abc123".to_string(),
2697 size_bytes: 100,
2698 language: Some("rust".to_string()),
2699 last_indexed: 0,
2700 };
2701 db.upsert_file(&file, None).unwrap();
2702
2703 let symbol = Symbol {
2704 id: "src/main.rs::process_data".to_string(),
2705 file_path: "src/main.rs".to_string(),
2706 name: "process_data".to_string(),
2707 qualified_name: None,
2708 kind: SymbolKind::Function,
2709 visibility: Visibility::Public,
2710 signature: Some("fn process_data(input: &str) -> Result<()>".to_string()),
2711 brief: Some("Process incoming data".to_string()),
2712 docstring: Some("Processes the incoming data and returns a result.".to_string()),
2713 line_start: 1,
2714 line_end: 10,
2715 col_start: 0,
2716 col_end: 1,
2717 parent_id: None,
2718 source: None,
2719 };
2720 db.insert_symbol(&symbol).unwrap();
2721
2722 let results = db.semantic_search("process data", 10).unwrap();
2724 for (symbol, score) in &results {
2725 assert!(
2726 *score >= 0.0 && *score <= 1.0,
2727 "Score for '{}' should be in 0-1 range, got {}",
2728 symbol.name,
2729 score
2730 );
2731 assert!(
2732 score.is_finite(),
2733 "Score for '{}' should be finite, got {}",
2734 symbol.name,
2735 score
2736 );
2737 }
2738 }
2739
2740 #[test]
2741 fn test_bm25_relevance_mapping() {
2742 let cases = [
2743 (0.0, 0.0),
2744 (-1.0, 0.5),
2745 (1.0, 0.5),
2746 (-10.0, 10.0 / 11.0),
2747 (10.0, 10.0 / 11.0),
2748 ];
2749
2750 for (rank, expected) in cases {
2751 let actual = Database::bm25_relevance(rank);
2752 assert!(
2753 (actual - expected).abs() < 1e-12,
2754 "rank {} expected {} got {}",
2755 rank,
2756 expected,
2757 actual
2758 );
2759 }
2760
2761 assert_eq!(Database::bm25_relevance(f64::INFINITY), 0.0);
2762 assert_eq!(Database::bm25_relevance(f64::NEG_INFINITY), 0.0);
2763 assert_eq!(Database::bm25_relevance(f64::NAN), 0.0);
2764 }
2765
2766 #[test]
2767 fn test_get_embedding_metadata() {
2768 let db = Database::open_in_memory().unwrap();
2770
2771 let file = FileRecord {
2773 path: "src/main.rs".to_string(),
2774 content_hash: "abc123".to_string(),
2775 size_bytes: 100,
2776 language: Some("rust".to_string()),
2777 last_indexed: 0,
2778 };
2779 db.upsert_file(&file, None).unwrap();
2780
2781 for (name, id) in &[("foo", "src/main.rs::foo"), ("bar", "src/main.rs::bar")] {
2783 let symbol = Symbol {
2784 id: id.to_string(),
2785 file_path: "src/main.rs".to_string(),
2786 name: name.to_string(),
2787 qualified_name: None,
2788 kind: SymbolKind::Function,
2789 visibility: Visibility::Public,
2790 signature: None,
2791 brief: None,
2792 docstring: None,
2793 line_start: 1,
2794 line_end: 5,
2795 col_start: 0,
2796 col_end: 1,
2797 parent_id: None,
2798 source: None,
2799 };
2800 db.insert_symbol(&symbol).unwrap();
2801 }
2802
2803 let metadata = db.get_embedding_metadata().unwrap();
2805 assert!(
2806 metadata.is_empty(),
2807 "Should have no embedding metadata initially"
2808 );
2809
2810 let local_vec: Vec<f32> = vec![0.1; 384]; let openai_vec: Vec<f32> = vec![0.2; 1536]; db.store_embedding("src/main.rs::foo", "local", "all-MiniLM-L6-v2", &local_vec)
2815 .unwrap();
2816 db.store_embedding(
2817 "src/main.rs::bar",
2818 "openai",
2819 "text-embedding-3-small",
2820 &openai_vec,
2821 )
2822 .unwrap();
2823
2824 let metadata = db.get_embedding_metadata().unwrap();
2826
2827 assert_eq!(
2829 metadata.len(),
2830 2,
2831 "Should have 2 embedding metadata entries"
2832 );
2833
2834 let dims: Vec<i64> = metadata.iter().map(|(_, _, dim, _)| *dim).collect();
2836 assert!(
2837 dims.contains(&384),
2838 "Should have local embedding dimension 384"
2839 );
2840 assert!(
2841 dims.contains(&1536),
2842 "Should have OpenAI embedding dimension 1536"
2843 );
2844
2845 let providers: Vec<&str> = metadata.iter().map(|(p, _, _, _)| p.as_str()).collect();
2847 assert!(providers.contains(&"local"), "Should have local provider");
2848 assert!(providers.contains(&"openai"), "Should have openai provider");
2849 }
2850
2851 #[test]
2852 fn test_vector_search() {
2853 let db = Database::open_in_memory().unwrap();
2855
2856 if !is_vec_extension_available() {
2857 eprintln!("Skipping vector search test: sqlite-vec not available");
2858 return;
2859 }
2860
2861 let file = FileRecord {
2863 path: "src/main.rs".to_string(),
2864 content_hash: "abc123".to_string(),
2865 size_bytes: 100,
2866 language: Some("rust".to_string()),
2867 last_indexed: 0,
2868 };
2869 db.upsert_file(&file, None).unwrap();
2870
2871 for (name, id, line) in &[
2873 ("foo", "src/main.rs::foo", 1),
2874 ("bar", "src/main.rs::bar", 10),
2875 ("baz", "src/main.rs::baz", 20),
2876 ] {
2877 let symbol = Symbol {
2878 id: id.to_string(),
2879 file_path: "src/main.rs".to_string(),
2880 name: name.to_string(),
2881 qualified_name: None,
2882 kind: SymbolKind::Function,
2883 visibility: Visibility::Public,
2884 signature: None,
2885 brief: None,
2886 docstring: None,
2887 line_start: *line,
2888 line_end: *line + 5,
2889 col_start: 0,
2890 col_end: 1,
2891 parent_id: None,
2892 source: None,
2893 };
2894 db.insert_symbol(&symbol).unwrap();
2895 }
2896
2897 let mut foo_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2900 foo_vec[0] = 1.0;
2901 foo_vec[1] = 0.5;
2902
2903 let mut bar_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2904 bar_vec[0] = 0.9;
2905 bar_vec[1] = 0.6;
2906
2907 let mut baz_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2908 baz_vec[0] = 0.0;
2909 baz_vec[1] = 0.0;
2910 baz_vec[2] = 1.0; db.store_embedding(
2914 "src/main.rs::foo",
2915 "openai",
2916 "text-embedding-3-small",
2917 &foo_vec,
2918 )
2919 .unwrap();
2920 db.store_embedding(
2921 "src/main.rs::bar",
2922 "openai",
2923 "text-embedding-3-small",
2924 &bar_vec,
2925 )
2926 .unwrap();
2927 db.store_embedding(
2928 "src/main.rs::baz",
2929 "openai",
2930 "text-embedding-3-small",
2931 &baz_vec,
2932 )
2933 .unwrap();
2934
2935 let vec_count = db.count_vector_embeddings().unwrap();
2937 assert_eq!(vec_count, 3, "Should have 3 vector embeddings");
2938
2939 let query: Vec<f32> = foo_vec.clone();
2941 let results = db.vector_search(&query, 3).unwrap();
2942
2943 assert_eq!(results.len(), 3, "Should return 3 results");
2944
2945 assert_eq!(
2947 results[0].0, "src/main.rs::foo",
2948 "First result should be foo (exact match)"
2949 );
2950 assert_eq!(results[0].5, 0.0, "Exact match should have distance 0");
2951
2952 assert_eq!(
2954 results[1].0, "src/main.rs::bar",
2955 "Second result should be bar (similar)"
2956 );
2957
2958 assert_eq!(
2960 results[2].0, "src/main.rs::baz",
2961 "Third result should be baz (different)"
2962 );
2963
2964 assert!(
2966 results[2].5 > results[1].5,
2967 "baz should have larger distance than bar"
2968 );
2969 }
2970
2971 #[test]
2972 fn test_migrate_embeddings_to_vec() {
2973 let db = Database::open_in_memory().unwrap();
2975
2976 if !is_vec_extension_available() {
2977 eprintln!("Skipping migration test: sqlite-vec not available");
2978 return;
2979 }
2980
2981 let file = FileRecord {
2983 path: "src/main.rs".to_string(),
2984 content_hash: "abc123".to_string(),
2985 size_bytes: 100,
2986 language: Some("rust".to_string()),
2987 last_indexed: 0,
2988 };
2989 db.upsert_file(&file, None).unwrap();
2990
2991 let symbol = Symbol {
2992 id: "src/main.rs::foo".to_string(),
2993 file_path: "src/main.rs".to_string(),
2994 name: "foo".to_string(),
2995 qualified_name: None,
2996 kind: SymbolKind::Function,
2997 visibility: Visibility::Public,
2998 signature: None,
2999 brief: None,
3000 docstring: None,
3001 line_start: 1,
3002 line_end: 5,
3003 col_start: 0,
3004 col_end: 1,
3005 parent_id: None,
3006 source: None,
3007 };
3008 db.insert_symbol(&symbol).unwrap();
3009
3010 let vec: Vec<f32> = vec![0.1; DEFAULT_EMBEDDING_DIM];
3012 let vec_json = serde_json::to_string(&vec).unwrap();
3013 db.conn.execute(
3014 "INSERT INTO embeddings (symbol_id, provider, model, dimension, vector) VALUES (?, ?, ?, ?, ?)",
3015 params!["src/main.rs::foo", "openai", "test", DEFAULT_EMBEDDING_DIM as i64, vec_json],
3016 ).unwrap();
3017
3018 let vec_count_before = db.count_vector_embeddings().unwrap();
3020 assert_eq!(
3021 vec_count_before, 0,
3022 "Should have no vector embeddings before migration"
3023 );
3024
3025 let migrated = db.migrate_embeddings_to_vec().unwrap();
3027 assert_eq!(migrated, 1, "Should migrate 1 embedding");
3028
3029 let vec_count_after = db.count_vector_embeddings().unwrap();
3031 assert_eq!(
3032 vec_count_after, 1,
3033 "Should have 1 vector embedding after migration"
3034 );
3035
3036 let migrated_again = db.migrate_embeddings_to_vec().unwrap();
3038 assert_eq!(
3039 migrated_again, 0,
3040 "Should not re-migrate existing embeddings"
3041 );
3042 }
3043}