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(
595 r#"
596 SELECT id, file_path, name, qualified_name, kind, visibility,
597 signature, brief, docstring, line_start, line_end,
598 col_start, col_end, parent_id, source
599 FROM symbols
600 WHERE (name LIKE ?2 ESCAPE '\' OR qualified_name LIKE ?2 ESCAPE '\')
601 "#,
602 );
603
604 let mut next_param = 4; let file_param_pos = if file_pattern.is_some() {
609 let pos = next_param;
610 sql.push_str(&format!(" AND file_path LIKE ?{} ESCAPE '\\'", pos));
613 next_param += 1;
614 Some(pos)
615 } else {
616 None
617 };
618
619 let kind_param_pos = if kind_filter.is_some() {
621 let pos = next_param;
622 sql.push_str(&format!(" AND kind = ?{}", pos));
623 next_param += 1;
624 Some(pos)
625 } else {
626 None
627 };
628
629 sql.push_str(&format!(
633 r#"
634 ORDER BY
635 CASE WHEN name = ?1 THEN 0
636 WHEN name LIKE ?3 ESCAPE '\' THEN 1
637 ELSE 2 END,
638 name
639 LIMIT ?{}
640 "#,
641 next_param
642 ));
643
644 let mut stmt = self.conn.prepare(&sql)?;
645
646 let rows: Vec<Result<Symbol>> = match (
648 file_like.as_deref(),
649 kind_filter,
650 file_param_pos,
651 kind_param_pos,
652 ) {
653 (Some(fp), Some(kf), Some(_), Some(_)) => stmt
654 .query_map(
655 params![pattern, like_pattern, starts_with_pattern, fp, kf, limit],
656 |row| Ok(symbol_from_row(row)),
657 )?
658 .collect(),
659 (Some(fp), None, Some(_), None) => stmt
660 .query_map(
661 params![pattern, like_pattern, starts_with_pattern, fp, limit],
662 |row| Ok(symbol_from_row(row)),
663 )?
664 .collect(),
665 (None, Some(kf), None, Some(_)) => stmt
666 .query_map(
667 params![pattern, like_pattern, starts_with_pattern, kf, limit],
668 |row| Ok(symbol_from_row(row)),
669 )?
670 .collect(),
671 (None, None, None, None) => stmt
672 .query_map(
673 params![pattern, like_pattern, starts_with_pattern, limit],
674 |row| Ok(symbol_from_row(row)),
675 )?
676 .collect(),
677 _ => unreachable!("Filter and param position should match"),
679 };
680
681 rows.into_iter().collect()
682 }
683
684 pub fn get_source(&self, symbol_id: &str) -> Result<Option<String>> {
686 self.conn
687 .query_row(
688 "SELECT source FROM symbols WHERE id = ?",
689 [symbol_id],
690 |row| row.get(0),
691 )
692 .optional()
693 }
694
695 pub fn get_file_symbols(&self, file_path: &str) -> Result<Vec<Symbol>> {
697 let mut stmt = self.conn.prepare(
698 r#"
699 SELECT id, file_path, name, qualified_name, kind, visibility,
700 signature, brief, docstring, line_start, line_end,
701 col_start, col_end, parent_id, source
702 FROM symbols
703 WHERE file_path = ?
704 ORDER BY line_start
705 "#,
706 )?;
707
708 let rows = stmt.query_map([file_path], |row| Ok(symbol_from_row(row)))?;
709 rows.collect()
710 }
711
712 pub fn find_symbols_in_file(&self, file_path: &str) -> Result<Vec<Symbol>> {
714 self.get_file_symbols(file_path)
715 }
716
717 pub fn get_outgoing_edges(&self, symbol_id: &str) -> Result<Vec<Edge>> {
719 let mut stmt = self.conn.prepare(
720 r#"
721 SELECT source_id, target_id, target_name, kind, line, col, context
722 FROM edges
723 WHERE source_id = ?
724 ORDER BY line
725 "#,
726 )?;
727
728 let rows = stmt.query_map([symbol_id], |row| Ok(edge_from_row(row)))?;
729 rows.collect()
730 }
731
732 pub fn get_incoming_edges(&self, target_name: &str) -> Result<Vec<Edge>> {
734 let mut stmt = self.conn.prepare(
735 r#"
736 SELECT source_id, target_id, target_name, kind, line, col, context
737 FROM edges
738 WHERE target_name = ? OR target_id = ?
739 ORDER BY source_id
740 "#,
741 )?;
742
743 let rows = stmt.query_map([target_name, target_name], |row| Ok(edge_from_row(row)))?;
744 rows.collect()
745 }
746
747 pub fn get_stats(&self) -> Result<CodebaseStats> {
749 let files: i64 = self
750 .conn
751 .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
752 let symbols: i64 = self
753 .conn
754 .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))?;
755 let edges: i64 = self
756 .conn
757 .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?;
758 let functions: i64 = self.conn.query_row(
759 "SELECT COUNT(*) FROM symbols WHERE kind IN ('function', 'method')",
760 [],
761 |row| row.get(0),
762 )?;
763 let structs: i64 = self.conn.query_row(
764 "SELECT COUNT(*) FROM symbols WHERE kind IN ('struct', 'class')",
765 [],
766 |row| row.get(0),
767 )?;
768 let enums: i64 = self.conn.query_row(
769 "SELECT COUNT(*) FROM symbols WHERE kind = 'enum'",
770 [],
771 |row| row.get(0),
772 )?;
773 let traits: i64 = self.conn.query_row(
774 "SELECT COUNT(*) FROM symbols WHERE kind IN ('trait', 'interface')",
775 [],
776 |row| row.get(0),
777 )?;
778
779 Ok(CodebaseStats {
780 files,
781 symbols,
782 edges,
783 functions,
784 structs,
785 enums,
786 traits,
787 })
788 }
789
790 pub fn symbol_metrics(&self) -> Result<Vec<SymbolMetrics>> {
801 let mut stmt = self.conn.prepare(
802 r#"
803 WITH fo AS (
804 SELECT source_id AS id, COUNT(*) AS n
805 FROM edges
806 WHERE kind = 'calls'
807 GROUP BY source_id
808 ),
809 fi AS (
810 SELECT target_id AS id, COUNT(*) AS n
811 FROM edges
812 WHERE kind = 'calls' AND target_id IS NOT NULL
813 GROUP BY target_id
814 )
815 SELECT
816 s.id,
817 s.name,
818 s.qualified_name,
819 s.kind,
820 s.file_path,
821 s.line_start,
822 s.line_end,
823 COALESCE(fi.n, 0) AS fan_in,
824 COALESCE(fo.n, 0) AS fan_out,
825 (COALESCE(fo.n, 0) * 2 + COALESCE(fi.n, 0)) AS complexity
826 FROM symbols s
827 LEFT JOIN fo ON s.id = fo.id
828 LEFT JOIN fi ON s.id = fi.id
829 WHERE s.kind IN ('function', 'method')
830 ORDER BY complexity DESC, s.file_path, s.line_start
831 "#,
832 )?;
833
834 let rows = stmt.query_map([], |row| {
835 Ok(SymbolMetrics {
836 id: row.get(0)?,
837 name: row.get(1)?,
838 qualified_name: row.get(2)?,
839 kind: row.get(3)?,
840 file_path: row.get(4)?,
841 line_start: row.get(5)?,
842 line_end: row.get(6)?,
843 fan_in: row.get(7)?,
844 fan_out: row.get(8)?,
845 complexity: row.get(9)?,
846 })
847 })?;
848
849 rows.collect()
850 }
851
852 pub fn file_complexity(&self) -> Result<Vec<FileComplexity>> {
858 let mut stmt = self.conn.prepare(
859 r#"
860 WITH fo AS (
861 SELECT source_id AS id, COUNT(*) AS n
862 FROM edges
863 WHERE kind = 'calls'
864 GROUP BY source_id
865 ),
866 fi AS (
867 SELECT target_id AS id, COUNT(*) AS n
868 FROM edges
869 WHERE kind = 'calls' AND target_id IS NOT NULL
870 GROUP BY target_id
871 )
872 SELECT
873 s.file_path,
874 SUM(COALESCE(fo.n, 0) * 2 + COALESCE(fi.n, 0)) AS complexity,
875 SUM(COALESCE(fo.n, 0)) AS fan_out,
876 COUNT(*) AS symbol_count
877 FROM symbols s
878 LEFT JOIN fo ON s.id = fo.id
879 LEFT JOIN fi ON s.id = fi.id
880 GROUP BY s.file_path
881 ORDER BY complexity DESC, s.file_path
882 "#,
883 )?;
884
885 let rows = stmt.query_map([], |row| {
886 Ok(FileComplexity {
887 file_path: row.get(0)?,
888 complexity: row.get(1)?,
889 fan_out: row.get(2)?,
890 symbol_count: row.get(3)?,
891 })
892 })?;
893
894 rows.collect()
895 }
896
897 pub fn file_call_edges(&self, file_path: &str) -> Result<Vec<(String, String)>> {
904 let mut stmt = self.conn.prepare(
905 r#"
906 SELECT e.source_id, e.target_name
907 FROM edges e
908 JOIN symbols s ON s.id = e.source_id
909 WHERE s.file_path = ?1 AND e.kind = 'calls'
910 ORDER BY e.id
911 "#,
912 )?;
913
914 let rows = stmt.query_map([file_path], |row| Ok((row.get(0)?, row.get(1)?)))?;
915 rows.collect()
916 }
917
918 pub fn fan_in_counts(&self, ids: &[String]) -> Result<std::collections::HashMap<String, i64>> {
922 let mut counts = std::collections::HashMap::new();
923 if ids.is_empty() {
924 return Ok(counts);
925 }
926
927 let placeholders = vec!["?"; ids.len()].join(", ");
928 let sql = format!(
929 "SELECT target_id, COUNT(*) FROM edges \
930 WHERE target_id IN ({}) AND kind = 'calls' \
931 GROUP BY target_id",
932 placeholders
933 );
934
935 let mut stmt = self.conn.prepare(&sql)?;
936 let rows = stmt.query_map(rusqlite::params_from_iter(ids.iter()), |row| {
937 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
938 })?;
939
940 for row in rows {
941 let (id, n) = row?;
942 counts.insert(id, n);
943 }
944
945 Ok(counts)
946 }
947
948 pub fn get_indexed_files(&self) -> Result<Vec<String>> {
950 let mut stmt = self.conn.prepare("SELECT path FROM files ORDER BY path")?;
951 let rows = stmt.query_map([], |row| row.get(0))?;
952 rows.collect()
953 }
954
955 pub fn get_all_symbol_ids(&self) -> Result<Vec<String>> {
963 let mut stmt = self.conn.prepare("SELECT id FROM symbols ORDER BY id")?;
964 let rows = stmt.query_map([], |row| row.get(0))?;
965 rows.collect()
966 }
967
968 pub fn get_rank_edges(&self) -> Result<Vec<(String, String)>> {
971 let mut stmt = self.conn.prepare(
972 r#"
973 SELECT DISTINCT source_id, target_id
974 FROM edges
975 WHERE target_id IS NOT NULL
976 AND kind IN ('calls', 'imports', 'extends', 'implements')
977 ORDER BY source_id, target_id
978 "#,
979 )?;
980 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
981 rows.collect()
982 }
983
984 pub fn clear_symbol_rank(&self) -> Result<()> {
986 self.conn.execute("DELETE FROM symbol_rank", [])?;
987 Ok(())
988 }
989
990 pub fn store_symbol_ranks(&self, ranks: &[(String, f64)]) -> Result<()> {
993 let tx = self.conn.unchecked_transaction()?;
994 tx.execute("DELETE FROM symbol_rank", [])?;
995 {
996 let mut stmt = tx.prepare("INSERT INTO symbol_rank (symbol_id, rank) VALUES (?, ?)")?;
997 for (id, rank) in ranks {
998 stmt.execute(params![id, rank])?;
999 }
1000 }
1001 tx.commit()
1002 }
1003
1004 pub fn load_symbol_ranks(&self) -> Result<Vec<(String, f64)>> {
1006 let mut stmt = self
1007 .conn
1008 .prepare("SELECT symbol_id, rank FROM symbol_rank ORDER BY symbol_id")?;
1009 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1010 rows.collect()
1011 }
1012
1013 pub fn count_symbols(&self) -> Result<i64> {
1015 self.conn
1016 .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
1017 }
1018
1019 pub fn count_symbol_ranks(&self) -> Result<i64> {
1021 self.conn
1022 .query_row("SELECT COUNT(*) FROM symbol_rank", [], |row| row.get(0))
1023 }
1024
1025 pub fn get_files_with_sizes(&self) -> Result<Vec<(String, i64)>> {
1027 let mut stmt = self
1028 .conn
1029 .prepare("SELECT path, COALESCE(size_bytes, 0) FROM files ORDER BY path")?;
1030 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1031 rows.collect()
1032 }
1033
1034 pub fn get_symbol_ids_in_file(&self, file_path: &str) -> Result<Vec<String>> {
1036 let mut stmt = self
1037 .conn
1038 .prepare("SELECT id FROM symbols WHERE file_path = ? ORDER BY id")?;
1039 let rows = stmt.query_map([file_path], |row| row.get(0))?;
1040 rows.collect()
1041 }
1042
1043 pub fn get_symbol_ids_by_name(&self, name: &str) -> Result<Vec<String>> {
1045 let mut stmt = self
1046 .conn
1047 .prepare("SELECT id FROM symbols WHERE name = ?1 OR qualified_name = ?1 ORDER BY id")?;
1048 let rows = stmt.query_map([name], |row| row.get(0))?;
1049 rows.collect()
1050 }
1051
1052 pub fn get_map_symbols(&self) -> Result<Vec<MapSymbolRow>> {
1055 let mut stmt = self.conn.prepare(
1056 r#"
1057 SELECT id, file_path, name, qualified_name, kind, signature, line_start
1058 FROM symbols
1059 WHERE kind IN ('function', 'method', 'struct', 'class', 'enum', 'trait', 'interface')
1060 ORDER BY id
1061 "#,
1062 )?;
1063 let rows = stmt.query_map([], |row| {
1064 Ok(MapSymbolRow {
1065 id: row.get(0)?,
1066 file_path: row.get(1)?,
1067 name: row.get(2)?,
1068 qualified_name: row.get(3)?,
1069 kind: row.get(4)?,
1070 signature: row.get(5)?,
1071 line_start: row.get::<_, Option<u32>>(6)?.unwrap_or(0),
1072 })
1073 })?;
1074 rows.collect()
1075 }
1076
1077 pub fn get_cross_file_edges(&self) -> Result<Vec<CrossFileEdge>> {
1084 let mut stmt = self.conn.prepare(
1085 r#"
1086 SELECT
1087 s1.name, s1.qualified_name, s1.kind, s1.file_path, s1.line_start, s1.line_end,
1088 s2.name, s2.qualified_name, s2.kind, s2.file_path, s2.line_start, s2.line_end,
1089 e.kind, e.line
1090 FROM edges e
1091 JOIN symbols s1 ON e.source_id = s1.id
1092 JOIN symbols s2 ON e.target_id = s2.id
1093 WHERE e.target_id IS NOT NULL
1094 AND s1.file_path <> s2.file_path
1095 AND e.kind IN ('calls', 'implements', 'extends', 'uses')
1096 ORDER BY s1.file_path, e.line, s2.file_path
1097 "#,
1098 )?;
1099
1100 let rows = stmt.query_map([], |row| {
1101 Ok(CrossFileEdge {
1102 source: EdgeSymbol {
1103 name: row.get(0)?,
1104 qualified_name: row.get(1)?,
1105 kind: row.get(2)?,
1106 file_path: row.get(3)?,
1107 line_start: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
1108 line_end: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
1109 },
1110 target: EdgeSymbol {
1111 name: row.get(6)?,
1112 qualified_name: row.get(7)?,
1113 kind: row.get(8)?,
1114 file_path: row.get(9)?,
1115 line_start: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
1116 line_end: row.get::<_, Option<i64>>(11)?.unwrap_or(0),
1117 },
1118 kind: row.get(12)?,
1119 line: row.get(13)?,
1120 })
1121 })?;
1122
1123 rows.collect()
1124 }
1125
1126 pub fn get_file_imports(&self) -> Result<Vec<(String, Vec<ImportInfo>)>> {
1131 let mut stmt = self.conn.prepare(
1132 "SELECT file_path, imports FROM modules \
1133 WHERE imports IS NOT NULL AND imports <> '' \
1134 ORDER BY file_path",
1135 )?;
1136 let rows = stmt.query_map([], |row| {
1137 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1138 })?;
1139
1140 let mut result = Vec::new();
1141 for row in rows {
1142 let (file_path, json) = row?;
1143 if let Ok(imports) = serde_json::from_str::<Vec<ImportInfo>>(&json) {
1144 if !imports.is_empty() {
1145 result.push((file_path, imports));
1146 }
1147 }
1148 }
1149 Ok(result)
1150 }
1151
1152 pub fn get_import_edges(&self) -> Result<Vec<(String, String, Option<i64>)>> {
1158 let mut stmt = self.conn.prepare(
1159 "SELECT source_id, target_name, line FROM edges \
1160 WHERE kind = 'imports' ORDER BY source_id, line",
1161 )?;
1162 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
1163 rows.collect()
1164 }
1165
1166 fn bm25_relevance(rank: f64) -> f64 {
1168 if rank.is_finite() {
1169 let abs_rank = rank.abs();
1171 (abs_rank / (1.0 + abs_rank)).clamp(0.0, 1.0)
1172 } else {
1173 0.0 }
1175 }
1176
1177 pub fn semantic_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64)>> {
1180 let keywords = preprocess_search_query(query);
1182
1183 if keywords.is_empty() {
1184 return Ok(Vec::new());
1185 }
1186
1187 let fts_query = keywords.join(" OR ");
1189
1190 let mut stmt = self.conn.prepare(
1191 r#"
1192 SELECT
1193 s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
1194 s.signature, s.brief, s.docstring, s.line_start, s.line_end,
1195 s.col_start, s.col_end, s.parent_id, s.source,
1196 bm25(symbol_fts) as rank
1197 FROM symbol_fts
1198 JOIN symbols s ON symbol_fts.id = s.id
1199 WHERE symbol_fts MATCH ?
1200 ORDER BY rank
1201 LIMIT ?
1202 "#,
1203 )?;
1204
1205 let rows = stmt.query_map(params![fts_query, limit], |row| {
1206 let symbol = symbol_from_row(row);
1207 let rank: f64 = row.get(15)?;
1208 let relevance = Self::bm25_relevance(rank);
1209 Ok((symbol, relevance))
1210 })?;
1211
1212 rows.collect()
1213 }
1214
1215 pub fn hybrid_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64, String)>> {
1217 let mut results: std::collections::HashMap<String, (Symbol, f64, String)> =
1218 std::collections::HashMap::new();
1219
1220 let half_limit = (limit / 2).max(1);
1222
1223 let exact_matches = self.find_symbols(query, half_limit)?;
1225 for symbol in exact_matches {
1226 let score = if symbol.name.eq_ignore_ascii_case(query) {
1227 1.0 } else if symbol
1229 .name
1230 .to_lowercase()
1231 .starts_with(&query.to_lowercase())
1232 {
1233 0.9 } else {
1235 0.7 };
1237 results.insert(symbol.id.clone(), (symbol, score, "exact".to_string()));
1238 }
1239
1240 if let Ok(semantic_matches) = self.semantic_search(query, half_limit) {
1242 for (symbol, relevance) in semantic_matches {
1243 results
1244 .entry(symbol.id.clone())
1245 .and_modify(|(_, existing_score, _)| {
1246 *existing_score = existing_score.max(relevance);
1247 })
1248 .or_insert((symbol, relevance, "semantic".to_string()));
1249 }
1250 }
1251
1252 let mut results: Vec<_> = results.into_values().collect();
1254 results.sort_by(|a, b| {
1256 match (a.1.is_finite(), b.1.is_finite()) {
1257 (true, true) => b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal),
1258 (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, (false, false) => std::cmp::Ordering::Equal,
1261 }
1262 });
1263 results.truncate(limit as usize);
1264
1265 Ok(results)
1266 }
1267
1268 #[allow(dead_code)]
1270 pub fn rebuild_fts_index(&self) -> Result<()> {
1271 self.conn.execute_batch(
1272 r#"
1273 INSERT INTO symbol_fts(symbol_fts) VALUES('rebuild');
1274 "#,
1275 )?;
1276 Ok(())
1277 }
1278
1279 pub fn store_embedding(
1287 &self,
1288 symbol_id: &str,
1289 provider: &str,
1290 model: &str,
1291 vector: &[f32],
1292 ) -> Result<()> {
1293 let vector_json = serde_json::to_string(vector)
1294 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
1295
1296 self.conn.execute(
1298 r#"
1299 INSERT OR REPLACE INTO embeddings (symbol_id, provider, model, dimension, vector)
1300 VALUES (?, ?, ?, ?, ?)
1301 "#,
1302 params![symbol_id, provider, model, vector.len(), vector_json],
1303 )?;
1304
1305 if self.has_vector_search() && vector.len() == DEFAULT_EMBEDDING_DIM {
1307 let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1309
1310 let _ = self.conn.execute(
1312 "DELETE FROM symbol_vectors WHERE symbol_id = ?",
1313 [symbol_id],
1314 );
1315
1316 self.conn.execute(
1317 "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1318 params![vector_bytes, symbol_id],
1319 )?;
1320 }
1321
1322 Ok(())
1323 }
1324
1325 #[allow(dead_code)]
1327 pub fn get_embedding(&self, symbol_id: &str) -> Result<Option<Vec<f32>>> {
1328 let result = self.conn.query_row(
1329 "SELECT vector FROM embeddings WHERE symbol_id = ?",
1330 [symbol_id],
1331 |row| {
1332 let json: String = row.get(0)?;
1333 Ok(json)
1334 },
1335 );
1336
1337 match result {
1338 Ok(json) => {
1339 let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
1340 rusqlite::Error::FromSqlConversionFailure(
1341 0,
1342 rusqlite::types::Type::Text,
1343 Box::new(e),
1344 )
1345 })?;
1346 Ok(Some(vector))
1347 }
1348 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1349 Err(e) => Err(e),
1350 }
1351 }
1352
1353 #[allow(clippy::type_complexity)]
1354 pub fn get_all_embeddings(
1356 &self,
1357 ) -> Result<Vec<(String, String, String, String, u32, Vec<f32>)>> {
1358 let mut stmt = self.conn.prepare(
1359 r#"
1360 SELECT e.symbol_id, s.name, s.kind, s.file_path, s.line_start, e.vector
1361 FROM embeddings e
1362 JOIN symbols s ON e.symbol_id = s.id
1363 "#,
1364 )?;
1365
1366 let rows = stmt.query_map([], |row| {
1367 let symbol_id: String = row.get(0)?;
1368 let name: String = row.get(1)?;
1369 let kind: String = row.get(2)?;
1370 let file_path: String = row.get(3)?;
1371 let line: u32 = row.get(4)?;
1372 let json: String = row.get(5)?;
1373 Ok((symbol_id, name, kind, file_path, line, json))
1374 })?;
1375
1376 let mut results = Vec::new();
1377 for row in rows {
1378 let (symbol_id, name, kind, file_path, line, json) = row?;
1379 let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
1380 rusqlite::Error::FromSqlConversionFailure(
1381 0,
1382 rusqlite::types::Type::Text,
1383 Box::new(e),
1384 )
1385 })?;
1386 results.push((symbol_id, name, kind, file_path, line, vector));
1387 }
1388
1389 Ok(results)
1390 }
1391
1392 pub fn count_embeddings(&self) -> Result<i64> {
1394 self.conn
1395 .query_row("SELECT COUNT(*) FROM embeddings", [], |row| row.get(0))
1396 }
1397
1398 pub fn get_embedding_metadata(&self) -> Result<Vec<(String, String, i64, i64)>> {
1404 let mut stmt = self.conn.prepare(
1405 r#"
1406 SELECT provider, model, dimension, COUNT(*) as count
1407 FROM embeddings
1408 GROUP BY provider, model, dimension
1409 ORDER BY count DESC
1410 "#,
1411 )?;
1412
1413 let rows = stmt.query_map([], |row| {
1414 Ok((
1415 row.get::<_, String>(0)?,
1416 row.get::<_, String>(1)?,
1417 row.get::<_, i64>(2)?,
1418 row.get::<_, i64>(3)?,
1419 ))
1420 })?;
1421
1422 rows.collect()
1423 }
1424
1425 pub fn get_symbols_without_embeddings(&self, limit: i64) -> Result<Vec<Symbol>> {
1427 let mut stmt = self.conn.prepare(
1428 r#"
1429 SELECT s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
1430 s.signature, s.brief, s.docstring, s.line_start, s.line_end,
1431 s.col_start, s.col_end, s.parent_id, s.source
1432 FROM symbols s
1433 LEFT JOIN embeddings e ON s.id = e.symbol_id
1434 WHERE e.symbol_id IS NULL
1435 LIMIT ?
1436 "#,
1437 )?;
1438
1439 let rows = stmt.query_map([limit], |row| {
1440 Ok(Symbol {
1441 id: row.get(0)?,
1442 file_path: row.get(1)?,
1443 name: row.get(2)?,
1444 qualified_name: row.get(3)?,
1445 kind: SymbolKind::from_str(&row.get::<_, String>(4)?)
1446 .unwrap_or(SymbolKind::Function),
1447 visibility: Visibility::from_str(&row.get::<_, String>(5)?).unwrap_or_default(),
1448 signature: row.get(6)?,
1449 brief: row.get(7)?,
1450 docstring: row.get(8)?,
1451 line_start: row.get(9)?,
1452 line_end: row.get(10)?,
1453 col_start: row.get(11)?,
1454 col_end: row.get(12)?,
1455 parent_id: row.get(13)?,
1456 source: row.get(14)?,
1457 })
1458 })?;
1459
1460 rows.collect()
1461 }
1462
1463 #[allow(dead_code)] pub fn migrate_embeddings_to_vec(&self) -> Result<usize> {
1469 if !self.has_vector_search() {
1470 return Ok(0);
1471 }
1472
1473 let mut stmt = self.conn.prepare(
1475 r#"
1476 SELECT symbol_id, vector FROM embeddings
1477 WHERE dimension = ?
1478 "#,
1479 )?;
1480
1481 let rows = stmt.query_map([DEFAULT_EMBEDDING_DIM as i64], |row| {
1482 let symbol_id: String = row.get(0)?;
1483 let json: String = row.get(1)?;
1484 Ok((symbol_id, json))
1485 })?;
1486
1487 let mut count = 0;
1488 for row in rows {
1489 let (symbol_id, json) = row?;
1490 let vector: Vec<f32> = match serde_json::from_str(&json) {
1491 Ok(v) => v,
1492 Err(_) => continue,
1493 };
1494
1495 let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1497
1498 let exists: bool = self
1500 .conn
1501 .query_row(
1502 "SELECT 1 FROM symbol_vectors WHERE symbol_id = ?",
1503 [&symbol_id],
1504 |_| Ok(true),
1505 )
1506 .unwrap_or(false);
1507
1508 if !exists
1509 && self
1510 .conn
1511 .execute(
1512 "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1513 params![vector_bytes, symbol_id],
1514 )
1515 .is_ok()
1516 {
1517 count += 1;
1518 }
1519 }
1520
1521 Ok(count)
1522 }
1523
1524 pub fn count_vector_embeddings(&self) -> Result<i64> {
1526 if !self.has_vector_search() {
1527 return Ok(0);
1528 }
1529 self.conn
1530 .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
1531 }
1532
1533 #[allow(clippy::type_complexity)]
1540 pub fn vector_search(
1542 &self,
1543 query_embedding: &[f32],
1544 limit: usize,
1545 ) -> Result<Vec<(String, String, String, String, u32, f32)>> {
1546 if !self.has_vector_search() {
1547 return Ok(Vec::new());
1548 }
1549
1550 if query_embedding.len() != DEFAULT_EMBEDDING_DIM {
1551 return Ok(Vec::new());
1552 }
1553
1554 let query_bytes: Vec<u8> = query_embedding
1556 .iter()
1557 .flat_map(|f| f.to_le_bytes())
1558 .collect();
1559
1560 let mut stmt = self.conn.prepare(
1563 r#"
1564 SELECT knn.symbol_id, s.name, s.kind, s.file_path, s.line_start, knn.distance
1565 FROM (
1566 SELECT symbol_id, distance
1567 FROM symbol_vectors
1568 WHERE embedding MATCH ?
1569 ORDER BY distance
1570 LIMIT ?
1571 ) knn
1572 JOIN symbols s ON knn.symbol_id = s.id
1573 ORDER BY knn.distance
1574 "#,
1575 )?;
1576
1577 let rows = stmt.query_map(params![query_bytes, limit as i64], |row| {
1578 Ok((
1579 row.get::<_, String>(0)?,
1580 row.get::<_, String>(1)?,
1581 row.get::<_, String>(2)?,
1582 row.get::<_, String>(3)?,
1583 row.get::<_, u32>(4)?,
1584 row.get::<_, f32>(5)?,
1585 ))
1586 })?;
1587
1588 rows.collect()
1589 }
1590
1591 pub fn has_vector_embeddings(&self) -> bool {
1593 self.count_vector_embeddings().unwrap_or(0) > 0
1594 }
1595
1596 pub fn delete_embeddings(&self, provider: &str, model: Option<&str>) -> Result<usize> {
1598 if let Some(model) = model {
1599 self.conn.execute(
1600 "DELETE FROM embeddings WHERE provider = ? AND model = ?",
1601 params![provider, model],
1602 )
1603 } else {
1604 self.conn
1605 .execute("DELETE FROM embeddings WHERE provider = ?", [provider])
1606 }
1607 }
1608
1609 pub fn resolve_edge_targets(&self) -> Result<usize> {
1625 let qualified_resolved = self.conn.execute(
1632 r#"
1633 UPDATE edges
1634 SET target_id = (
1635 SELECT id FROM symbols
1636 WHERE qualified_name = edges.context
1637 AND kind IN ('function', 'method')
1638 LIMIT 1
1639 )
1640 WHERE target_id IS NULL
1641 AND context IS NOT NULL
1642 AND (
1643 SELECT COUNT(*) FROM symbols
1644 WHERE qualified_name = edges.context
1645 AND kind IN ('function', 'method')
1646 ) = 1
1647 "#,
1648 [],
1649 )?;
1650
1651 let context_resolved = self.conn.execute(
1655 r#"
1656 UPDATE edges
1657 SET target_id = (
1658 SELECT t.id
1659 FROM symbols t
1660 WHERE t.name = edges.target_name
1661 AND t.kind IN ('function', 'method')
1662 AND t.qualified_name IS NOT NULL
1663 AND edges.context LIKE '%' || t.qualified_name || '%'
1664 )
1665 WHERE target_id IS NULL
1666 AND context IS NOT NULL
1667 AND (
1668 SELECT COUNT(*)
1669 FROM symbols t
1670 WHERE t.name = edges.target_name
1671 AND t.kind IN ('function', 'method')
1672 AND t.qualified_name IS NOT NULL
1673 AND edges.context LIKE '%' || t.qualified_name || '%'
1674 ) = 1
1675 "#,
1676 [],
1677 )?;
1678
1679 let unique_resolved = self.conn.execute(
1681 r#"
1682 UPDATE edges
1683 SET target_id = (
1684 SELECT id FROM symbols
1685 WHERE name = edges.target_name
1686 AND kind IN ('function', 'method')
1687 )
1688 WHERE target_id IS NULL
1689 AND (
1690 SELECT COUNT(*) FROM symbols
1691 WHERE name = edges.target_name
1692 AND kind IN ('function', 'method')
1693 ) = 1
1694 "#,
1695 [],
1696 )?;
1697
1698 let same_file_unique = self.conn.execute(
1705 r#"
1706 UPDATE edges
1707 SET target_id = (
1708 SELECT t.id
1709 FROM symbols t
1710 JOIN symbols s ON s.id = edges.source_id
1711 WHERE t.name = edges.target_name
1712 AND t.file_path = s.file_path
1713 AND t.kind IN ('function', 'method')
1714 )
1715 WHERE target_id IS NULL
1716 AND (
1717 SELECT COUNT(*)
1718 FROM symbols t
1719 JOIN symbols s ON s.id = edges.source_id
1720 WHERE t.name = edges.target_name
1721 AND t.file_path = s.file_path
1722 AND t.kind IN ('function', 'method')
1723 ) = 1
1724 -- Exclude if context suggests an external type call (has :: before the function name)
1725 -- e.g., "Vec::new()" or "Parser::new()" should NOT match local "new" functions
1726 AND (
1727 context IS NULL
1728 OR (
1729 context NOT LIKE '%::' || target_name || '(%'
1730 AND context NOT LIKE '%.' || target_name || '(%'
1731 AND context NOT LIKE '%->' || target_name || '(%'
1732 )
1733 OR context LIKE '%' || (
1734 SELECT t.qualified_name
1735 FROM symbols t
1736 JOIN symbols s ON s.id = edges.source_id
1737 WHERE t.name = edges.target_name
1738 AND t.file_path = s.file_path
1739 AND t.kind IN ('function', 'method')
1740 LIMIT 1
1741 ) || '(%'
1742 )
1743 "#,
1744 [],
1745 )?;
1746
1747 Ok(qualified_resolved + context_resolved + unique_resolved + same_file_unique)
1748 }
1749
1750 pub fn insert_fingerprints_batch(&self, fingerprints: &[Fingerprint]) -> Result<usize> {
1752 if fingerprints.is_empty() {
1753 return Ok(0);
1754 }
1755 let tx = self.conn.unchecked_transaction()?;
1756 {
1757 let mut stmt = tx.prepare(
1758 r#"
1759 INSERT OR REPLACE INTO symbol_fingerprints (symbol_id, file_path, minhash, token_count)
1760 VALUES (?, ?, ?, ?)
1761 "#,
1762 )?;
1763 for fp in fingerprints {
1764 stmt.execute(params![
1765 fp.symbol_id,
1766 fp.file_path,
1767 fp.minhash,
1768 fp.token_count
1769 ])?;
1770 }
1771 }
1772 tx.commit()?;
1773 Ok(fingerprints.len())
1774 }
1775
1776 pub fn get_fingerprints(&self, min_tokens: i64) -> Result<Vec<Fingerprint>> {
1779 let mut stmt = self.conn.prepare(
1780 r#"
1781 SELECT symbol_id, file_path, minhash, token_count
1782 FROM symbol_fingerprints
1783 WHERE token_count >= ?
1784 ORDER BY symbol_id
1785 "#,
1786 )?;
1787 let rows = stmt.query_map([min_tokens], |row| {
1788 Ok(Fingerprint {
1789 symbol_id: row.get(0)?,
1790 file_path: row.get(1)?,
1791 minhash: row.get(2)?,
1792 token_count: row.get(3)?,
1793 })
1794 })?;
1795 rows.collect()
1796 }
1797}
1798
1799fn escape_like_pattern(pattern: &str) -> String {
1804 pattern
1805 .replace('\\', "\\\\") .replace('%', "\\%")
1807 .replace('_', "\\_")
1808}
1809
1810fn glob_to_like_pattern(pattern: &str) -> String {
1829 let mut result = String::with_capacity(pattern.len() * 2);
1830 let mut chars = pattern.chars().peekable();
1831
1832 while let Some(c) = chars.next() {
1833 match c {
1834 '*' => {
1835 if chars.peek() == Some(&'*') {
1837 chars.next();
1838 if chars.peek() == Some(&'/') {
1840 chars.next();
1841 if chars.peek().is_none() || chars.peek() == Some(&'*') {
1846 continue;
1848 }
1849 result.push('%');
1851 } else {
1852 result.push('%');
1854 }
1855 } else {
1856 result.push('%');
1858 }
1859 }
1860 '?' => result.push('_'),
1861 '%' => result.push_str("\\%"),
1862 '_' => result.push_str("\\_"),
1863 '\\' => result.push_str("\\\\"),
1864 _ => result.push(c),
1865 }
1866 }
1867
1868 result
1869}
1870
1871fn preprocess_search_query(query: &str) -> Vec<String> {
1873 let stop_words: std::collections::HashSet<&str> = [
1875 "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
1876 "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall",
1877 "can", "need", "dare", "and", "or", "but", "if", "then", "else", "when", "where", "why",
1878 "how", "what", "which", "who", "whom", "this", "that", "these", "those", "i", "you", "he",
1879 "she", "it", "we", "they", "me", "him", "her", "us", "them", "my", "your", "his", "its",
1880 "our", "their", "for", "to", "from", "with", "at", "by", "on", "in", "of", "about", "into",
1881 "through", "during", "before", "after", "above", "below", "find", "get", "search", "look",
1882 "all", "any", "each", "every",
1883 ]
1884 .iter()
1885 .copied()
1886 .collect();
1887
1888 query
1889 .to_lowercase()
1890 .split(|c: char| !c.is_alphanumeric() && c != '_')
1891 .filter(|word| {
1892 let word = word.trim();
1893 !word.is_empty() && word.len() > 1 && !stop_words.contains(word)
1894 })
1895 .map(|s| {
1896 format!("{}*", s)
1898 })
1899 .collect()
1900}
1901
1902fn symbol_from_row(row: &rusqlite::Row) -> Symbol {
1904 let kind_str: String = row.get(4).unwrap_or_default();
1905 let visibility_str: String = row.get(5).unwrap_or_default();
1906
1907 Symbol {
1908 id: row.get(0).unwrap_or_default(),
1909 file_path: row.get(1).unwrap_or_default(),
1910 name: row.get(2).unwrap_or_default(),
1911 qualified_name: row.get(3).ok(),
1912 kind: SymbolKind::from_str(&kind_str).unwrap_or(SymbolKind::Function),
1913 visibility: Visibility::from_str(&visibility_str).unwrap_or_default(),
1914 signature: row.get(6).ok(),
1915 brief: row.get(7).ok(),
1916 docstring: row.get(8).ok(),
1917 line_start: row.get(9).unwrap_or(0),
1918 line_end: row.get(10).unwrap_or(0),
1919 col_start: row.get(11).unwrap_or(0),
1920 col_end: row.get(12).unwrap_or(0),
1921 parent_id: row.get(13).ok(),
1922 source: row.get(14).ok(),
1923 }
1924}
1925
1926fn edge_from_row(row: &rusqlite::Row) -> Edge {
1928 let kind_str: String = row.get(3).unwrap_or_default();
1929
1930 Edge {
1931 source_id: row.get(0).unwrap_or_default(),
1932 target_id: row.get(1).ok(),
1933 target_name: row.get(2).unwrap_or_default(),
1934 kind: EdgeKind::from_str(&kind_str).unwrap_or(EdgeKind::Calls),
1935 line: row.get(4).ok(),
1936 col: row.get(5).ok(),
1937 context: row.get(6).ok(),
1938 }
1939}
1940
1941#[derive(Debug, Clone)]
1944pub struct MapSymbolRow {
1945 pub id: String,
1946 pub file_path: String,
1947 pub name: String,
1948 pub qualified_name: Option<String>,
1949 pub kind: String,
1950 pub signature: Option<String>,
1951 pub line_start: u32,
1952}
1953
1954#[derive(Debug, Clone, serde::Serialize)]
1956pub struct SymbolMetrics {
1957 pub id: String,
1958 pub name: String,
1959 pub qualified_name: Option<String>,
1960 pub kind: String,
1961 pub file_path: String,
1962 pub line_start: i64,
1963 pub line_end: i64,
1964 pub fan_in: i64,
1965 pub fan_out: i64,
1966 pub complexity: i64,
1967}
1968
1969#[derive(Debug, Clone)]
1972pub struct CrossFileEdge {
1973 pub source: EdgeSymbol,
1974 pub target: EdgeSymbol,
1975 pub kind: String,
1977 pub line: Option<i64>,
1979}
1980
1981#[derive(Debug, Clone)]
1983pub struct EdgeSymbol {
1984 pub name: String,
1985 pub qualified_name: Option<String>,
1986 pub kind: String,
1987 pub file_path: String,
1988 pub line_start: i64,
1989 pub line_end: i64,
1990}
1991
1992#[derive(Debug, Clone, serde::Serialize)]
1994pub struct FileComplexity {
1995 pub file_path: String,
1996 pub complexity: i64,
1997 pub fan_out: i64,
1998 pub symbol_count: i64,
1999}
2000
2001trait ResultExt<T> {
2003 fn optional(self) -> Result<Option<T>>;
2004}
2005
2006impl<T> ResultExt<T> for Result<T> {
2007 fn optional(self) -> Result<Option<T>> {
2008 match self {
2009 Ok(v) => Ok(Some(v)),
2010 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2011 Err(e) => Err(e),
2012 }
2013 }
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018 use super::*;
2019
2020 #[test]
2021 fn test_create_database() {
2022 let db = Database::open_in_memory().unwrap();
2023 let stats = db.get_stats().unwrap();
2024 assert_eq!(stats.files, 0);
2025 assert_eq!(stats.symbols, 0);
2026 }
2027
2028 #[test]
2029 fn test_fresh_database_is_stamped_with_schema_version() {
2030 let dir = tempfile::tempdir().unwrap();
2031 let db_path = dir.path().join("codebase.sqlite");
2032
2033 let db = Database::open(&db_path).unwrap();
2034 let version: i64 = db
2035 .conn
2036 .query_row("PRAGMA user_version", [], |row| row.get(0))
2037 .unwrap();
2038 assert_eq!(version, SCHEMA_VERSION);
2039 drop(db);
2040
2041 assert!(Database::open(&db_path).is_ok());
2043 }
2044
2045 #[test]
2046 fn test_legacy_v0_database_is_rejected() {
2047 let dir = tempfile::tempdir().unwrap();
2048 let db_path = dir.path().join("codebase.sqlite");
2049
2050 drop(Database::open(&db_path).unwrap());
2054 {
2055 let conn = Connection::open(&db_path).unwrap();
2056 conn.pragma_update(None, "user_version", 0).unwrap();
2057 }
2058
2059 let err = Database::open(&db_path).unwrap_err();
2060 match &err {
2061 crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2062 assert_eq!(*found, 0);
2063 assert_eq!(*expected, SCHEMA_VERSION);
2064 }
2065 other => panic!("expected SchemaVersionMismatch, got: {}", other),
2066 }
2067 assert!(err.to_string().contains("ctx index --force"));
2068 }
2069
2070 #[test]
2071 fn test_schema_version_mismatch_is_rejected() {
2072 let dir = tempfile::tempdir().unwrap();
2073 let db_path = dir.path().join("codebase.sqlite");
2074
2075 for stale_version in [1i64, 99] {
2078 drop(Database::open(&db_path).unwrap());
2079 {
2080 let conn = Connection::open(&db_path).unwrap();
2081 conn.pragma_update(None, "user_version", stale_version)
2082 .unwrap();
2083 }
2084
2085 let err = Database::open(&db_path).unwrap_err();
2086 match &err {
2087 crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2088 assert_eq!(*found, stale_version);
2089 assert_eq!(*expected, SCHEMA_VERSION);
2090 }
2091 other => panic!("expected SchemaVersionMismatch, got: {}", other),
2092 }
2093 assert!(err.to_string().contains("ctx index --force"));
2094
2095 let conn = Connection::open(&db_path).unwrap();
2097 conn.pragma_update(None, "user_version", SCHEMA_VERSION)
2098 .unwrap();
2099 }
2100 }
2101
2102 #[test]
2103 fn test_fingerprint_batch_roundtrip_and_min_tokens_filter() {
2104 let db = Database::open_in_memory().unwrap();
2105 db.upsert_file(
2106 &FileRecord {
2107 path: "src/a.rs".to_string(),
2108 content_hash: "h1".to_string(),
2109 size_bytes: 10,
2110 language: Some("rust".to_string()),
2111 last_indexed: 0,
2112 },
2113 None,
2114 )
2115 .unwrap();
2116 db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2117 .unwrap();
2118 db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2119 .unwrap();
2120
2121 let fps = vec![
2122 Fingerprint {
2123 symbol_id: "src/a.rs::alpha".to_string(),
2124 file_path: "src/a.rs".to_string(),
2125 minhash: vec![1u8; 1024],
2126 token_count: 80,
2127 },
2128 Fingerprint {
2129 symbol_id: "src/a.rs::beta".to_string(),
2130 file_path: "src/a.rs".to_string(),
2131 minhash: vec![2u8; 1024],
2132 token_count: 20,
2133 },
2134 ];
2135 assert_eq!(db.insert_fingerprints_batch(&fps).unwrap(), 2);
2136 assert_eq!(db.insert_fingerprints_batch(&[]).unwrap(), 0);
2137
2138 let all = db.get_fingerprints(0).unwrap();
2140 assert_eq!(all.len(), 2);
2141 assert_eq!(all[0].symbol_id, "src/a.rs::alpha");
2142 assert_eq!(all[0].minhash, vec![1u8; 1024]);
2143 assert_eq!(all[1].token_count, 20);
2144
2145 let filtered = db.get_fingerprints(50).unwrap();
2147 assert_eq!(filtered.len(), 1);
2148 assert_eq!(filtered[0].symbol_id, "src/a.rs::alpha");
2149
2150 db.delete_symbols_for_file("src/a.rs").unwrap();
2152 assert!(db.get_fingerprints(0).unwrap().is_empty());
2153 }
2154
2155 #[test]
2156 fn test_sqlite_vec_extension_loaded() {
2157 let db = Database::open_in_memory().unwrap();
2159
2160 if !is_vec_extension_available() {
2164 eprintln!("Skipping sqlite-vec tests: extension not available on this platform");
2165 return;
2166 }
2167
2168 let version: Result<String, _> = db
2170 .conn
2171 .query_row("SELECT vec_version()", [], |row| row.get(0));
2172
2173 match version {
2174 Ok(v) => {
2175 assert!(
2176 v.starts_with('v'),
2177 "vec_version should return version string, got: {}",
2178 v
2179 );
2180 }
2181 Err(e) => {
2182 panic!(
2185 "sqlite-vec extension reported available but vec_version() failed: {}",
2186 e
2187 );
2188 }
2189 }
2190
2191 assert!(db.has_vector_search(), "vector search should be available");
2193
2194 let count: i64 = db
2196 .conn
2197 .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
2198 .expect("symbol_vectors table should exist");
2199 assert_eq!(count, 0, "symbol_vectors should be empty initially");
2200 }
2201
2202 fn make_fn_symbol(id: &str, name: &str, file: &str, line: u32) -> Symbol {
2204 Symbol {
2205 id: id.to_string(),
2206 file_path: file.to_string(),
2207 name: name.to_string(),
2208 qualified_name: Some(name.to_string()),
2209 kind: SymbolKind::Function,
2210 visibility: Visibility::Public,
2211 signature: None,
2212 brief: None,
2213 docstring: None,
2214 line_start: line,
2215 line_end: line + 5,
2216 col_start: 0,
2217 col_end: 0,
2218 parent_id: None,
2219 source: None,
2220 }
2221 }
2222
2223 fn make_call_edge(source_id: &str, target_id: Option<&str>, target_name: &str) -> Edge {
2225 Edge {
2226 source_id: source_id.to_string(),
2227 target_id: target_id.map(|s| s.to_string()),
2228 target_name: target_name.to_string(),
2229 kind: EdgeKind::Calls,
2230 line: Some(1),
2231 col: None,
2232 context: None,
2233 }
2234 }
2235
2236 fn metrics_fixture() -> Database {
2239 let db = Database::open_in_memory().unwrap();
2240 db.upsert_file(
2241 &FileRecord {
2242 path: "src/a.rs".to_string(),
2243 content_hash: "h1".to_string(),
2244 size_bytes: 10,
2245 language: Some("rust".to_string()),
2246 last_indexed: 0,
2247 },
2248 None,
2249 )
2250 .unwrap();
2251
2252 db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2253 .unwrap();
2254 db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2255 .unwrap();
2256 db.insert_symbol(&make_fn_symbol("src/a.rs::gamma", "gamma", "src/a.rs", 20))
2257 .unwrap();
2258
2259 db.insert_edge(&make_call_edge(
2260 "src/a.rs::alpha",
2261 Some("src/a.rs::beta"),
2262 "beta",
2263 ))
2264 .unwrap();
2265 db.insert_edge(&make_call_edge("src/a.rs::alpha", None, "external"))
2266 .unwrap();
2267 db.insert_edge(&make_call_edge(
2268 "src/a.rs::beta",
2269 Some("src/a.rs::alpha"),
2270 "alpha",
2271 ))
2272 .unwrap();
2273
2274 db
2275 }
2276
2277 #[test]
2278 fn test_symbol_metrics_mirrors_complexity_formula() {
2279 let db = metrics_fixture();
2280 let metrics = db.symbol_metrics().unwrap();
2281 assert_eq!(metrics.len(), 3);
2282
2283 let get = |name: &str| metrics.iter().find(|m| m.name == name).unwrap();
2284
2285 let alpha = get("alpha");
2287 assert_eq!(alpha.fan_out, 2);
2288 assert_eq!(alpha.fan_in, 1);
2289 assert_eq!(alpha.complexity, 2 * 2 + 1);
2290 assert_eq!(alpha.file_path, "src/a.rs");
2291 assert_eq!(alpha.line_start, 1);
2292
2293 let beta = get("beta");
2295 assert_eq!(beta.fan_out, 1);
2296 assert_eq!(beta.fan_in, 1);
2297 assert_eq!(beta.complexity, 3); let gamma = get("gamma");
2301 assert_eq!(gamma.fan_out, 0);
2302 assert_eq!(gamma.fan_in, 0);
2303 assert_eq!(gamma.complexity, 0);
2304
2305 assert_eq!(metrics[0].name, "alpha");
2307 }
2308
2309 #[test]
2310 fn test_file_complexity_aggregates_per_file() {
2311 let db = metrics_fixture();
2312 let files = db.file_complexity().unwrap();
2313 assert_eq!(files.len(), 1);
2314
2315 let f = &files[0];
2316 assert_eq!(f.file_path, "src/a.rs");
2317 assert_eq!(f.symbol_count, 3);
2318 assert_eq!(f.fan_out, 3);
2319 assert_eq!(f.complexity, 8);
2321 }
2322
2323 #[test]
2324 fn test_fan_in_counts() {
2325 let db = metrics_fixture();
2326
2327 let ids = vec![
2328 "src/a.rs::alpha".to_string(),
2329 "src/a.rs::beta".to_string(),
2330 "src/a.rs::gamma".to_string(),
2331 ];
2332 let counts = db.fan_in_counts(&ids).unwrap();
2333 assert_eq!(counts.get("src/a.rs::alpha"), Some(&1));
2334 assert_eq!(counts.get("src/a.rs::beta"), Some(&1));
2335 assert!(!counts.contains_key("src/a.rs::gamma"));
2337
2338 assert!(db.fan_in_counts(&[]).unwrap().is_empty());
2340 }
2341
2342 #[test]
2343 fn test_insert_and_find_symbol() {
2344 let db = Database::open_in_memory().unwrap();
2345
2346 let file = FileRecord {
2348 path: "src/main.rs".to_string(),
2349 content_hash: "abc123".to_string(),
2350 size_bytes: 100,
2351 language: Some("rust".to_string()),
2352 last_indexed: 0,
2353 };
2354 db.upsert_file(&file, None).unwrap();
2355
2356 let symbol = Symbol {
2358 id: "src/main.rs::main".to_string(),
2359 file_path: "src/main.rs".to_string(),
2360 name: "main".to_string(),
2361 qualified_name: None,
2362 kind: SymbolKind::Function,
2363 visibility: Visibility::Private,
2364 signature: Some("fn main()".to_string()),
2365 brief: Some("Entry point".to_string()),
2366 docstring: None,
2367 line_start: 1,
2368 line_end: 5,
2369 col_start: 0,
2370 col_end: 1,
2371 parent_id: None,
2372 source: Some("fn main() {\n println!(\"Hello\");\n}".to_string()),
2373 };
2374 db.insert_symbol(&symbol).unwrap();
2375
2376 let found = db.get_symbol("src/main.rs::main").unwrap();
2378 assert!(found.is_some());
2379 assert_eq!(found.unwrap().name, "main");
2380
2381 let results = db.find_symbols("main", 10).unwrap();
2383 assert_eq!(results.len(), 1);
2384 }
2385
2386 #[test]
2387 fn test_find_symbols_exact_match_ordering() {
2388 let db = Database::open_in_memory().unwrap();
2389
2390 let file = FileRecord {
2392 path: "src/test.rs".to_string(),
2393 content_hash: "abc123".to_string(),
2394 size_bytes: 100,
2395 language: Some("rust".to_string()),
2396 last_indexed: 0,
2397 };
2398 db.upsert_file(&file, None).unwrap();
2399
2400 let symbols = vec![
2403 ("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"), ];
2409
2410 for (name, id) in &symbols {
2411 let symbol = Symbol {
2412 id: id.to_string(),
2413 file_path: "src/test.rs".to_string(),
2414 name: name.to_string(),
2415 qualified_name: None,
2416 kind: SymbolKind::Function,
2417 visibility: Visibility::Public,
2418 signature: None,
2419 brief: None,
2420 docstring: None,
2421 line_start: 1,
2422 line_end: 5,
2423 col_start: 0,
2424 col_end: 1,
2425 parent_id: None,
2426 source: None,
2427 };
2428 db.insert_symbol(&symbol).unwrap();
2429 }
2430
2431 let results = db.find_symbols("new", 10).unwrap();
2433
2434 assert!(results.len() >= 4, "Should find at least 4 symbols");
2435
2436 assert_eq!(results[0].name, "new", "Exact match 'new' should be first");
2438
2439 let prefix_matches: Vec<&str> = results[1..4].iter().map(|s| s.name.as_str()).collect();
2442 assert!(
2443 prefix_matches.iter().all(|n| n.starts_with("new")),
2444 "Positions 2-4 should be prefix matches, got: {:?}",
2445 prefix_matches
2446 );
2447
2448 let renew_pos = results.iter().position(|s| s.name == "renew");
2450 assert!(
2451 renew_pos.is_some() && renew_pos.unwrap() >= 4,
2452 "'renew' should be after prefix matches"
2453 );
2454 }
2455
2456 #[test]
2457 fn test_find_symbols_with_file_filter() {
2458 let db = Database::open_in_memory().unwrap();
2459
2460 for path in &["src/parser/rust.rs", "src/embeddings/local.rs"] {
2462 let file = FileRecord {
2463 path: path.to_string(),
2464 content_hash: "abc123".to_string(),
2465 size_bytes: 100,
2466 language: Some("rust".to_string()),
2467 last_indexed: 0,
2468 };
2469 db.upsert_file(&file, None).unwrap();
2470 }
2471
2472 for (path, id) in &[
2474 ("src/parser/rust.rs", "src/parser/rust.rs::new"),
2475 ("src/embeddings/local.rs", "src/embeddings/local.rs::new"),
2476 ] {
2477 let symbol = Symbol {
2478 id: id.to_string(),
2479 file_path: path.to_string(),
2480 name: "new".to_string(),
2481 qualified_name: None,
2482 kind: SymbolKind::Method,
2483 visibility: Visibility::Public,
2484 signature: None,
2485 brief: None,
2486 docstring: None,
2487 line_start: 1,
2488 line_end: 5,
2489 col_start: 0,
2490 col_end: 1,
2491 parent_id: None,
2492 source: None,
2493 };
2494 db.insert_symbol(&symbol).unwrap();
2495 }
2496
2497 let all_results = db.find_symbols_filtered("new", 10, None, None).unwrap();
2499 assert_eq!(all_results.len(), 2);
2500
2501 let filtered = db
2503 .find_symbols_filtered("new", 10, Some("*parser*"), None)
2504 .unwrap();
2505 assert_eq!(filtered.len(), 1);
2506 assert!(filtered[0].file_path.contains("parser"));
2507
2508 let filtered = db
2510 .find_symbols_filtered("new", 10, Some("*embeddings*"), None)
2511 .unwrap();
2512 assert_eq!(filtered.len(), 1);
2513 assert!(filtered[0].file_path.contains("embeddings"));
2514 }
2515
2516 #[test]
2517 fn test_find_symbols_with_underscore_name() {
2518 let db = Database::open_in_memory().unwrap();
2524
2525 db.upsert_file(
2527 &FileRecord {
2528 path: "src/commands/sql.rs".to_string(),
2529 content_hash: "abc123".to_string(),
2530 size_bytes: 100,
2531 language: Some("rust".to_string()),
2532 last_indexed: 0,
2533 },
2534 None,
2535 )
2536 .unwrap();
2537
2538 for (id, name) in &[
2539 ("a::run_sql", "run_sql"),
2540 ("b::run_sql_duckdb", "run_sql_duckdb"),
2541 ("c::runsql", "runsql"),
2542 ] {
2543 let symbol = Symbol {
2544 id: id.to_string(),
2545 file_path: "src/commands/sql.rs".to_string(),
2546 name: name.to_string(),
2547 qualified_name: None,
2548 kind: SymbolKind::Function,
2549 visibility: Visibility::Public,
2550 signature: None,
2551 brief: None,
2552 docstring: None,
2553 line_start: 1,
2554 line_end: 5,
2555 col_start: 0,
2556 col_end: 1,
2557 parent_id: None,
2558 source: None,
2559 };
2560 db.insert_symbol(&symbol).unwrap();
2561 }
2562
2563 let exact = db.find_symbols_filtered("run_sql", 10, None, None).unwrap();
2565 assert!(
2566 exact.iter().any(|s| s.name == "run_sql"),
2567 "exact underscore name 'run_sql' must be found"
2568 );
2569
2570 assert!(
2573 !exact.iter().any(|s| s.name == "runsql"),
2574 "'_' must be literal, not a wildcard matching 'runsql'"
2575 );
2576
2577 assert_eq!(
2579 exact.first().map(|s| s.name.as_str()),
2580 Some("run_sql"),
2581 "exact match should sort before 'run_sql_duckdb'"
2582 );
2583
2584 let prefix = db.find_symbols_filtered("run_sql", 10, None, None).unwrap();
2586 assert!(
2587 prefix.iter().any(|s| s.name == "run_sql_duckdb"),
2588 "substring search should still surface 'run_sql_duckdb'"
2589 );
2590 }
2591
2592 #[test]
2593 fn test_escape_like_pattern() {
2594 assert_eq!(escape_like_pattern("new"), "new");
2596 assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
2597
2598 assert_eq!(escape_like_pattern("100%"), "100\\%");
2600 assert_eq!(escape_like_pattern("a_b"), "a\\_b");
2601 assert_eq!(escape_like_pattern("a%b_c"), "a\\%b\\_c");
2602 }
2603
2604 #[test]
2605 fn test_glob_to_like_pattern() {
2606 assert_eq!(glob_to_like_pattern("*.rs"), "%.rs");
2608 assert_eq!(glob_to_like_pattern("src/*"), "src/%");
2609
2610 assert_eq!(glob_to_like_pattern("**/*.rs"), "%.rs");
2613 assert_eq!(glob_to_like_pattern("src/**/*.rs"), "src/%.rs");
2614
2615 assert_eq!(glob_to_like_pattern("src/**"), "src/%");
2617
2618 assert_eq!(glob_to_like_pattern("**/foo.rs"), "%foo.rs");
2620
2621 assert_eq!(glob_to_like_pattern("file?.txt"), "file_.txt");
2623
2624 assert_eq!(glob_to_like_pattern("100%"), "100\\%");
2626 assert_eq!(glob_to_like_pattern("a_b"), "a\\_b");
2627 }
2628
2629 #[test]
2630 fn test_glob_pattern_matches_files() {
2631 let db = Database::open_in_memory().unwrap();
2632
2633 for path in &[
2635 "main.rs",
2636 "src/lib.rs",
2637 "src/parser/mod.rs",
2638 "src/parser/rust.rs",
2639 "tests/test.rs",
2640 ] {
2641 let file = FileRecord {
2642 path: path.to_string(),
2643 content_hash: "abc".to_string(),
2644 size_bytes: 100,
2645 language: Some("rust".to_string()),
2646 last_indexed: 0,
2647 };
2648 db.upsert_file(&file, None).unwrap();
2649
2650 let symbol = Symbol {
2651 id: format!("{}::main", path),
2652 file_path: path.to_string(),
2653 name: "main".to_string(),
2654 qualified_name: None,
2655 kind: SymbolKind::Function,
2656 visibility: Visibility::Public,
2657 signature: None,
2658 brief: None,
2659 docstring: None,
2660 line_start: 1,
2661 line_end: 5,
2662 col_start: 0,
2663 col_end: 1,
2664 parent_id: None,
2665 source: None,
2666 };
2667 db.insert_symbol(&symbol).unwrap();
2668 }
2669
2670 let results = db
2672 .find_symbols_filtered("main", 20, Some("**/*.rs"), None)
2673 .unwrap();
2674 assert_eq!(results.len(), 5, "**/*.rs should match all .rs files");
2675
2676 let results = db
2678 .find_symbols_filtered("main", 20, Some("src/**/*.rs"), None)
2679 .unwrap();
2680 assert_eq!(
2681 results.len(),
2682 3,
2683 "src/**/*.rs should match src/lib.rs, src/parser/mod.rs, src/parser/rust.rs"
2684 );
2685
2686 let results = db
2689 .find_symbols_filtered("main", 20, Some("src/*.rs"), None)
2690 .unwrap();
2691 assert_eq!(
2692 results.len(),
2693 3,
2694 "src/*.rs matches all under src/ (SQL LIKE limitation)"
2695 );
2696
2697 let results = db
2699 .find_symbols_filtered("main", 20, Some("*parser*"), None)
2700 .unwrap();
2701 assert_eq!(
2702 results.len(),
2703 2,
2704 "*parser* should match parser directory files"
2705 );
2706 }
2707
2708 #[test]
2709 fn test_hybrid_search_limit_one() {
2710 let db = Database::open_in_memory().unwrap();
2713
2714 let file = FileRecord {
2716 path: "src/main.rs".to_string(),
2717 content_hash: "abc123".to_string(),
2718 size_bytes: 100,
2719 language: Some("rust".to_string()),
2720 last_indexed: 0,
2721 };
2722 db.upsert_file(&file, None).unwrap();
2723
2724 let symbol = Symbol {
2725 id: "src/main.rs::authenticate".to_string(),
2726 file_path: "src/main.rs".to_string(),
2727 name: "authenticate".to_string(),
2728 qualified_name: None,
2729 kind: SymbolKind::Function,
2730 visibility: Visibility::Public,
2731 signature: Some("fn authenticate(user: &str)".to_string()),
2732 brief: Some("Authenticate a user".to_string()),
2733 docstring: None,
2734 line_start: 1,
2735 line_end: 5,
2736 col_start: 0,
2737 col_end: 1,
2738 parent_id: None,
2739 source: None,
2740 };
2741 db.insert_symbol(&symbol).unwrap();
2742
2743 let results = db.hybrid_search("authenticate", 1).unwrap();
2745 assert_eq!(
2746 results.len(),
2747 1,
2748 "hybrid_search with limit=1 should return 1 result"
2749 );
2750 assert_eq!(results[0].0.name, "authenticate");
2751 }
2752
2753 #[test]
2754 fn test_hybrid_search_limit_three() {
2755 let db = Database::open_in_memory().unwrap();
2757
2758 let file = FileRecord {
2760 path: "src/test.rs".to_string(),
2761 content_hash: "abc123".to_string(),
2762 size_bytes: 100,
2763 language: Some("rust".to_string()),
2764 last_indexed: 0,
2765 };
2766 db.upsert_file(&file, None).unwrap();
2767
2768 for (name, i) in &[("login", 1), ("logout", 2), ("log_error", 3)] {
2770 let symbol = Symbol {
2771 id: format!("src/test.rs::{}", name),
2772 file_path: "src/test.rs".to_string(),
2773 name: name.to_string(),
2774 qualified_name: None,
2775 kind: SymbolKind::Function,
2776 visibility: Visibility::Public,
2777 signature: Some(format!("fn {}()", name)),
2778 brief: Some(format!("{} function", name)),
2779 docstring: None,
2780 line_start: *i,
2781 line_end: *i + 5,
2782 col_start: 0,
2783 col_end: 1,
2784 parent_id: None,
2785 source: None,
2786 };
2787 db.insert_symbol(&symbol).unwrap();
2788 }
2789
2790 let results = db.hybrid_search("log", 3).unwrap();
2792 assert!(
2793 !results.is_empty(),
2794 "hybrid_search with limit=3 should return results"
2795 );
2796 assert!(results.len() <= 3, "hybrid_search should respect limit");
2797 }
2798
2799 #[test]
2800 fn test_semantic_search_score_range() {
2801 let db = Database::open_in_memory().unwrap();
2803
2804 let file = FileRecord {
2806 path: "src/main.rs".to_string(),
2807 content_hash: "abc123".to_string(),
2808 size_bytes: 100,
2809 language: Some("rust".to_string()),
2810 last_indexed: 0,
2811 };
2812 db.upsert_file(&file, None).unwrap();
2813
2814 let symbol = Symbol {
2815 id: "src/main.rs::process_data".to_string(),
2816 file_path: "src/main.rs".to_string(),
2817 name: "process_data".to_string(),
2818 qualified_name: None,
2819 kind: SymbolKind::Function,
2820 visibility: Visibility::Public,
2821 signature: Some("fn process_data(input: &str) -> Result<()>".to_string()),
2822 brief: Some("Process incoming data".to_string()),
2823 docstring: Some("Processes the incoming data and returns a result.".to_string()),
2824 line_start: 1,
2825 line_end: 10,
2826 col_start: 0,
2827 col_end: 1,
2828 parent_id: None,
2829 source: None,
2830 };
2831 db.insert_symbol(&symbol).unwrap();
2832
2833 let results = db.semantic_search("process data", 10).unwrap();
2835 for (symbol, score) in &results {
2836 assert!(
2837 *score >= 0.0 && *score <= 1.0,
2838 "Score for '{}' should be in 0-1 range, got {}",
2839 symbol.name,
2840 score
2841 );
2842 assert!(
2843 score.is_finite(),
2844 "Score for '{}' should be finite, got {}",
2845 symbol.name,
2846 score
2847 );
2848 }
2849 }
2850
2851 #[test]
2852 fn test_bm25_relevance_mapping() {
2853 let cases = [
2854 (0.0, 0.0),
2855 (-1.0, 0.5),
2856 (1.0, 0.5),
2857 (-10.0, 10.0 / 11.0),
2858 (10.0, 10.0 / 11.0),
2859 ];
2860
2861 for (rank, expected) in cases {
2862 let actual = Database::bm25_relevance(rank);
2863 assert!(
2864 (actual - expected).abs() < 1e-12,
2865 "rank {} expected {} got {}",
2866 rank,
2867 expected,
2868 actual
2869 );
2870 }
2871
2872 assert_eq!(Database::bm25_relevance(f64::INFINITY), 0.0);
2873 assert_eq!(Database::bm25_relevance(f64::NEG_INFINITY), 0.0);
2874 assert_eq!(Database::bm25_relevance(f64::NAN), 0.0);
2875 }
2876
2877 #[test]
2878 fn test_get_embedding_metadata() {
2879 let db = Database::open_in_memory().unwrap();
2881
2882 let file = FileRecord {
2884 path: "src/main.rs".to_string(),
2885 content_hash: "abc123".to_string(),
2886 size_bytes: 100,
2887 language: Some("rust".to_string()),
2888 last_indexed: 0,
2889 };
2890 db.upsert_file(&file, None).unwrap();
2891
2892 for (name, id) in &[("foo", "src/main.rs::foo"), ("bar", "src/main.rs::bar")] {
2894 let symbol = Symbol {
2895 id: id.to_string(),
2896 file_path: "src/main.rs".to_string(),
2897 name: name.to_string(),
2898 qualified_name: None,
2899 kind: SymbolKind::Function,
2900 visibility: Visibility::Public,
2901 signature: None,
2902 brief: None,
2903 docstring: None,
2904 line_start: 1,
2905 line_end: 5,
2906 col_start: 0,
2907 col_end: 1,
2908 parent_id: None,
2909 source: None,
2910 };
2911 db.insert_symbol(&symbol).unwrap();
2912 }
2913
2914 let metadata = db.get_embedding_metadata().unwrap();
2916 assert!(
2917 metadata.is_empty(),
2918 "Should have no embedding metadata initially"
2919 );
2920
2921 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)
2926 .unwrap();
2927 db.store_embedding(
2928 "src/main.rs::bar",
2929 "openai",
2930 "text-embedding-3-small",
2931 &openai_vec,
2932 )
2933 .unwrap();
2934
2935 let metadata = db.get_embedding_metadata().unwrap();
2937
2938 assert_eq!(
2940 metadata.len(),
2941 2,
2942 "Should have 2 embedding metadata entries"
2943 );
2944
2945 let dims: Vec<i64> = metadata.iter().map(|(_, _, dim, _)| *dim).collect();
2947 assert!(
2948 dims.contains(&384),
2949 "Should have local embedding dimension 384"
2950 );
2951 assert!(
2952 dims.contains(&1536),
2953 "Should have OpenAI embedding dimension 1536"
2954 );
2955
2956 let providers: Vec<&str> = metadata.iter().map(|(p, _, _, _)| p.as_str()).collect();
2958 assert!(providers.contains(&"local"), "Should have local provider");
2959 assert!(providers.contains(&"openai"), "Should have openai provider");
2960 }
2961
2962 #[test]
2963 fn test_vector_search() {
2964 let db = Database::open_in_memory().unwrap();
2966
2967 if !is_vec_extension_available() {
2968 eprintln!("Skipping vector search test: sqlite-vec not available");
2969 return;
2970 }
2971
2972 let file = FileRecord {
2974 path: "src/main.rs".to_string(),
2975 content_hash: "abc123".to_string(),
2976 size_bytes: 100,
2977 language: Some("rust".to_string()),
2978 last_indexed: 0,
2979 };
2980 db.upsert_file(&file, None).unwrap();
2981
2982 for (name, id, line) in &[
2984 ("foo", "src/main.rs::foo", 1),
2985 ("bar", "src/main.rs::bar", 10),
2986 ("baz", "src/main.rs::baz", 20),
2987 ] {
2988 let symbol = Symbol {
2989 id: id.to_string(),
2990 file_path: "src/main.rs".to_string(),
2991 name: name.to_string(),
2992 qualified_name: None,
2993 kind: SymbolKind::Function,
2994 visibility: Visibility::Public,
2995 signature: None,
2996 brief: None,
2997 docstring: None,
2998 line_start: *line,
2999 line_end: *line + 5,
3000 col_start: 0,
3001 col_end: 1,
3002 parent_id: None,
3003 source: None,
3004 };
3005 db.insert_symbol(&symbol).unwrap();
3006 }
3007
3008 let mut foo_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
3011 foo_vec[0] = 1.0;
3012 foo_vec[1] = 0.5;
3013
3014 let mut bar_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
3015 bar_vec[0] = 0.9;
3016 bar_vec[1] = 0.6;
3017
3018 let mut baz_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
3019 baz_vec[0] = 0.0;
3020 baz_vec[1] = 0.0;
3021 baz_vec[2] = 1.0; db.store_embedding(
3025 "src/main.rs::foo",
3026 "openai",
3027 "text-embedding-3-small",
3028 &foo_vec,
3029 )
3030 .unwrap();
3031 db.store_embedding(
3032 "src/main.rs::bar",
3033 "openai",
3034 "text-embedding-3-small",
3035 &bar_vec,
3036 )
3037 .unwrap();
3038 db.store_embedding(
3039 "src/main.rs::baz",
3040 "openai",
3041 "text-embedding-3-small",
3042 &baz_vec,
3043 )
3044 .unwrap();
3045
3046 let vec_count = db.count_vector_embeddings().unwrap();
3048 assert_eq!(vec_count, 3, "Should have 3 vector embeddings");
3049
3050 let query: Vec<f32> = foo_vec.clone();
3052 let results = db.vector_search(&query, 3).unwrap();
3053
3054 assert_eq!(results.len(), 3, "Should return 3 results");
3055
3056 assert_eq!(
3058 results[0].0, "src/main.rs::foo",
3059 "First result should be foo (exact match)"
3060 );
3061 assert_eq!(results[0].5, 0.0, "Exact match should have distance 0");
3062
3063 assert_eq!(
3065 results[1].0, "src/main.rs::bar",
3066 "Second result should be bar (similar)"
3067 );
3068
3069 assert_eq!(
3071 results[2].0, "src/main.rs::baz",
3072 "Third result should be baz (different)"
3073 );
3074
3075 assert!(
3077 results[2].5 > results[1].5,
3078 "baz should have larger distance than bar"
3079 );
3080 }
3081
3082 #[test]
3083 fn test_migrate_embeddings_to_vec() {
3084 let db = Database::open_in_memory().unwrap();
3086
3087 if !is_vec_extension_available() {
3088 eprintln!("Skipping migration test: sqlite-vec not available");
3089 return;
3090 }
3091
3092 let file = FileRecord {
3094 path: "src/main.rs".to_string(),
3095 content_hash: "abc123".to_string(),
3096 size_bytes: 100,
3097 language: Some("rust".to_string()),
3098 last_indexed: 0,
3099 };
3100 db.upsert_file(&file, None).unwrap();
3101
3102 let symbol = Symbol {
3103 id: "src/main.rs::foo".to_string(),
3104 file_path: "src/main.rs".to_string(),
3105 name: "foo".to_string(),
3106 qualified_name: None,
3107 kind: SymbolKind::Function,
3108 visibility: Visibility::Public,
3109 signature: None,
3110 brief: None,
3111 docstring: None,
3112 line_start: 1,
3113 line_end: 5,
3114 col_start: 0,
3115 col_end: 1,
3116 parent_id: None,
3117 source: None,
3118 };
3119 db.insert_symbol(&symbol).unwrap();
3120
3121 let vec: Vec<f32> = vec![0.1; DEFAULT_EMBEDDING_DIM];
3123 let vec_json = serde_json::to_string(&vec).unwrap();
3124 db.conn.execute(
3125 "INSERT INTO embeddings (symbol_id, provider, model, dimension, vector) VALUES (?, ?, ?, ?, ?)",
3126 params!["src/main.rs::foo", "openai", "test", DEFAULT_EMBEDDING_DIM as i64, vec_json],
3127 ).unwrap();
3128
3129 let vec_count_before = db.count_vector_embeddings().unwrap();
3131 assert_eq!(
3132 vec_count_before, 0,
3133 "Should have no vector embeddings before migration"
3134 );
3135
3136 let migrated = db.migrate_embeddings_to_vec().unwrap();
3138 assert_eq!(migrated, 1, "Should migrate 1 embedding");
3139
3140 let vec_count_after = db.count_vector_embeddings().unwrap();
3142 assert_eq!(
3143 vec_count_after, 1,
3144 "Should have 1 vector embedding after migration"
3145 );
3146
3147 let migrated_again = db.migrate_embeddings_to_vec().unwrap();
3149 assert_eq!(
3150 migrated_again, 0,
3151 "Should not re-migrate existing embeddings"
3152 );
3153 }
3154}