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> {
1622 let context_resolved = self.conn.execute(
1626 r#"
1627 UPDATE edges
1628 SET target_id = (
1629 SELECT t.id
1630 FROM symbols t
1631 WHERE t.name = edges.target_name
1632 AND t.kind IN ('function', 'method')
1633 AND t.qualified_name IS NOT NULL
1634 AND edges.context LIKE '%' || t.qualified_name || '%'
1635 )
1636 WHERE target_id IS NULL
1637 AND context IS NOT NULL
1638 AND (
1639 SELECT COUNT(*)
1640 FROM symbols t
1641 WHERE t.name = edges.target_name
1642 AND t.kind IN ('function', 'method')
1643 AND t.qualified_name IS NOT NULL
1644 AND edges.context LIKE '%' || t.qualified_name || '%'
1645 ) = 1
1646 "#,
1647 [],
1648 )?;
1649
1650 let unique_resolved = self.conn.execute(
1652 r#"
1653 UPDATE edges
1654 SET target_id = (
1655 SELECT id FROM symbols
1656 WHERE name = edges.target_name
1657 AND kind IN ('function', 'method')
1658 )
1659 WHERE target_id IS NULL
1660 AND (
1661 SELECT COUNT(*) FROM symbols
1662 WHERE name = edges.target_name
1663 AND kind IN ('function', 'method')
1664 ) = 1
1665 "#,
1666 [],
1667 )?;
1668
1669 let same_file_unique = self.conn.execute(
1676 r#"
1677 UPDATE edges
1678 SET target_id = (
1679 SELECT t.id
1680 FROM symbols t
1681 JOIN symbols s ON s.id = edges.source_id
1682 WHERE t.name = edges.target_name
1683 AND t.file_path = s.file_path
1684 AND t.kind IN ('function', 'method')
1685 )
1686 WHERE target_id IS NULL
1687 AND (
1688 SELECT COUNT(*)
1689 FROM symbols t
1690 JOIN symbols s ON s.id = edges.source_id
1691 WHERE t.name = edges.target_name
1692 AND t.file_path = s.file_path
1693 AND t.kind IN ('function', 'method')
1694 ) = 1
1695 -- Exclude if context suggests an external type call (has :: before the function name)
1696 -- e.g., "Vec::new()" or "Parser::new()" should NOT match local "new" functions
1697 AND (
1698 context IS NULL
1699 OR (
1700 context NOT LIKE '%::' || target_name || '(%'
1701 AND context NOT LIKE '%.' || target_name || '(%'
1702 AND context NOT LIKE '%->' || target_name || '(%'
1703 )
1704 OR context LIKE '%' || (
1705 SELECT t.qualified_name
1706 FROM symbols t
1707 JOIN symbols s ON s.id = edges.source_id
1708 WHERE t.name = edges.target_name
1709 AND t.file_path = s.file_path
1710 AND t.kind IN ('function', 'method')
1711 LIMIT 1
1712 ) || '(%'
1713 )
1714 "#,
1715 [],
1716 )?;
1717
1718 Ok(context_resolved + unique_resolved + same_file_unique)
1719 }
1720
1721 pub fn insert_fingerprints_batch(&self, fingerprints: &[Fingerprint]) -> Result<usize> {
1723 if fingerprints.is_empty() {
1724 return Ok(0);
1725 }
1726 let tx = self.conn.unchecked_transaction()?;
1727 {
1728 let mut stmt = tx.prepare(
1729 r#"
1730 INSERT OR REPLACE INTO symbol_fingerprints (symbol_id, file_path, minhash, token_count)
1731 VALUES (?, ?, ?, ?)
1732 "#,
1733 )?;
1734 for fp in fingerprints {
1735 stmt.execute(params![
1736 fp.symbol_id,
1737 fp.file_path,
1738 fp.minhash,
1739 fp.token_count
1740 ])?;
1741 }
1742 }
1743 tx.commit()?;
1744 Ok(fingerprints.len())
1745 }
1746
1747 pub fn get_fingerprints(&self, min_tokens: i64) -> Result<Vec<Fingerprint>> {
1750 let mut stmt = self.conn.prepare(
1751 r#"
1752 SELECT symbol_id, file_path, minhash, token_count
1753 FROM symbol_fingerprints
1754 WHERE token_count >= ?
1755 ORDER BY symbol_id
1756 "#,
1757 )?;
1758 let rows = stmt.query_map([min_tokens], |row| {
1759 Ok(Fingerprint {
1760 symbol_id: row.get(0)?,
1761 file_path: row.get(1)?,
1762 minhash: row.get(2)?,
1763 token_count: row.get(3)?,
1764 })
1765 })?;
1766 rows.collect()
1767 }
1768}
1769
1770fn escape_like_pattern(pattern: &str) -> String {
1775 pattern
1776 .replace('\\', "\\\\") .replace('%', "\\%")
1778 .replace('_', "\\_")
1779}
1780
1781fn glob_to_like_pattern(pattern: &str) -> String {
1800 let mut result = String::with_capacity(pattern.len() * 2);
1801 let mut chars = pattern.chars().peekable();
1802
1803 while let Some(c) = chars.next() {
1804 match c {
1805 '*' => {
1806 if chars.peek() == Some(&'*') {
1808 chars.next();
1809 if chars.peek() == Some(&'/') {
1811 chars.next();
1812 if chars.peek().is_none() || chars.peek() == Some(&'*') {
1817 continue;
1819 }
1820 result.push('%');
1822 } else {
1823 result.push('%');
1825 }
1826 } else {
1827 result.push('%');
1829 }
1830 }
1831 '?' => result.push('_'),
1832 '%' => result.push_str("\\%"),
1833 '_' => result.push_str("\\_"),
1834 '\\' => result.push_str("\\\\"),
1835 _ => result.push(c),
1836 }
1837 }
1838
1839 result
1840}
1841
1842fn preprocess_search_query(query: &str) -> Vec<String> {
1844 let stop_words: std::collections::HashSet<&str> = [
1846 "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
1847 "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall",
1848 "can", "need", "dare", "and", "or", "but", "if", "then", "else", "when", "where", "why",
1849 "how", "what", "which", "who", "whom", "this", "that", "these", "those", "i", "you", "he",
1850 "she", "it", "we", "they", "me", "him", "her", "us", "them", "my", "your", "his", "its",
1851 "our", "their", "for", "to", "from", "with", "at", "by", "on", "in", "of", "about", "into",
1852 "through", "during", "before", "after", "above", "below", "find", "get", "search", "look",
1853 "all", "any", "each", "every",
1854 ]
1855 .iter()
1856 .copied()
1857 .collect();
1858
1859 query
1860 .to_lowercase()
1861 .split(|c: char| !c.is_alphanumeric() && c != '_')
1862 .filter(|word| {
1863 let word = word.trim();
1864 !word.is_empty() && word.len() > 1 && !stop_words.contains(word)
1865 })
1866 .map(|s| {
1867 format!("{}*", s)
1869 })
1870 .collect()
1871}
1872
1873fn symbol_from_row(row: &rusqlite::Row) -> Symbol {
1875 let kind_str: String = row.get(4).unwrap_or_default();
1876 let visibility_str: String = row.get(5).unwrap_or_default();
1877
1878 Symbol {
1879 id: row.get(0).unwrap_or_default(),
1880 file_path: row.get(1).unwrap_or_default(),
1881 name: row.get(2).unwrap_or_default(),
1882 qualified_name: row.get(3).ok(),
1883 kind: SymbolKind::from_str(&kind_str).unwrap_or(SymbolKind::Function),
1884 visibility: Visibility::from_str(&visibility_str).unwrap_or_default(),
1885 signature: row.get(6).ok(),
1886 brief: row.get(7).ok(),
1887 docstring: row.get(8).ok(),
1888 line_start: row.get(9).unwrap_or(0),
1889 line_end: row.get(10).unwrap_or(0),
1890 col_start: row.get(11).unwrap_or(0),
1891 col_end: row.get(12).unwrap_or(0),
1892 parent_id: row.get(13).ok(),
1893 source: row.get(14).ok(),
1894 }
1895}
1896
1897fn edge_from_row(row: &rusqlite::Row) -> Edge {
1899 let kind_str: String = row.get(3).unwrap_or_default();
1900
1901 Edge {
1902 source_id: row.get(0).unwrap_or_default(),
1903 target_id: row.get(1).ok(),
1904 target_name: row.get(2).unwrap_or_default(),
1905 kind: EdgeKind::from_str(&kind_str).unwrap_or(EdgeKind::Calls),
1906 line: row.get(4).ok(),
1907 col: row.get(5).ok(),
1908 context: row.get(6).ok(),
1909 }
1910}
1911
1912#[derive(Debug, Clone)]
1915pub struct MapSymbolRow {
1916 pub id: String,
1917 pub file_path: String,
1918 pub name: String,
1919 pub qualified_name: Option<String>,
1920 pub kind: String,
1921 pub signature: Option<String>,
1922 pub line_start: u32,
1923}
1924
1925#[derive(Debug, Clone, serde::Serialize)]
1927pub struct SymbolMetrics {
1928 pub id: String,
1929 pub name: String,
1930 pub qualified_name: Option<String>,
1931 pub kind: String,
1932 pub file_path: String,
1933 pub line_start: i64,
1934 pub line_end: i64,
1935 pub fan_in: i64,
1936 pub fan_out: i64,
1937 pub complexity: i64,
1938}
1939
1940#[derive(Debug, Clone)]
1943pub struct CrossFileEdge {
1944 pub source: EdgeSymbol,
1945 pub target: EdgeSymbol,
1946 pub kind: String,
1948 pub line: Option<i64>,
1950}
1951
1952#[derive(Debug, Clone)]
1954pub struct EdgeSymbol {
1955 pub name: String,
1956 pub qualified_name: Option<String>,
1957 pub kind: String,
1958 pub file_path: String,
1959 pub line_start: i64,
1960 pub line_end: i64,
1961}
1962
1963#[derive(Debug, Clone, serde::Serialize)]
1965pub struct FileComplexity {
1966 pub file_path: String,
1967 pub complexity: i64,
1968 pub fan_out: i64,
1969 pub symbol_count: i64,
1970}
1971
1972trait ResultExt<T> {
1974 fn optional(self) -> Result<Option<T>>;
1975}
1976
1977impl<T> ResultExt<T> for Result<T> {
1978 fn optional(self) -> Result<Option<T>> {
1979 match self {
1980 Ok(v) => Ok(Some(v)),
1981 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1982 Err(e) => Err(e),
1983 }
1984 }
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989 use super::*;
1990
1991 #[test]
1992 fn test_create_database() {
1993 let db = Database::open_in_memory().unwrap();
1994 let stats = db.get_stats().unwrap();
1995 assert_eq!(stats.files, 0);
1996 assert_eq!(stats.symbols, 0);
1997 }
1998
1999 #[test]
2000 fn test_fresh_database_is_stamped_with_schema_version() {
2001 let dir = tempfile::tempdir().unwrap();
2002 let db_path = dir.path().join("codebase.sqlite");
2003
2004 let db = Database::open(&db_path).unwrap();
2005 let version: i64 = db
2006 .conn
2007 .query_row("PRAGMA user_version", [], |row| row.get(0))
2008 .unwrap();
2009 assert_eq!(version, SCHEMA_VERSION);
2010 drop(db);
2011
2012 assert!(Database::open(&db_path).is_ok());
2014 }
2015
2016 #[test]
2017 fn test_legacy_v0_database_is_rejected() {
2018 let dir = tempfile::tempdir().unwrap();
2019 let db_path = dir.path().join("codebase.sqlite");
2020
2021 drop(Database::open(&db_path).unwrap());
2025 {
2026 let conn = Connection::open(&db_path).unwrap();
2027 conn.pragma_update(None, "user_version", 0).unwrap();
2028 }
2029
2030 let err = Database::open(&db_path).unwrap_err();
2031 match &err {
2032 crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2033 assert_eq!(*found, 0);
2034 assert_eq!(*expected, SCHEMA_VERSION);
2035 }
2036 other => panic!("expected SchemaVersionMismatch, got: {}", other),
2037 }
2038 assert!(err.to_string().contains("ctx index --force"));
2039 }
2040
2041 #[test]
2042 fn test_schema_version_mismatch_is_rejected() {
2043 let dir = tempfile::tempdir().unwrap();
2044 let db_path = dir.path().join("codebase.sqlite");
2045
2046 for stale_version in [1i64, 99] {
2049 drop(Database::open(&db_path).unwrap());
2050 {
2051 let conn = Connection::open(&db_path).unwrap();
2052 conn.pragma_update(None, "user_version", stale_version)
2053 .unwrap();
2054 }
2055
2056 let err = Database::open(&db_path).unwrap_err();
2057 match &err {
2058 crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2059 assert_eq!(*found, stale_version);
2060 assert_eq!(*expected, SCHEMA_VERSION);
2061 }
2062 other => panic!("expected SchemaVersionMismatch, got: {}", other),
2063 }
2064 assert!(err.to_string().contains("ctx index --force"));
2065
2066 let conn = Connection::open(&db_path).unwrap();
2068 conn.pragma_update(None, "user_version", SCHEMA_VERSION)
2069 .unwrap();
2070 }
2071 }
2072
2073 #[test]
2074 fn test_fingerprint_batch_roundtrip_and_min_tokens_filter() {
2075 let db = Database::open_in_memory().unwrap();
2076 db.upsert_file(
2077 &FileRecord {
2078 path: "src/a.rs".to_string(),
2079 content_hash: "h1".to_string(),
2080 size_bytes: 10,
2081 language: Some("rust".to_string()),
2082 last_indexed: 0,
2083 },
2084 None,
2085 )
2086 .unwrap();
2087 db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2088 .unwrap();
2089 db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2090 .unwrap();
2091
2092 let fps = vec![
2093 Fingerprint {
2094 symbol_id: "src/a.rs::alpha".to_string(),
2095 file_path: "src/a.rs".to_string(),
2096 minhash: vec![1u8; 1024],
2097 token_count: 80,
2098 },
2099 Fingerprint {
2100 symbol_id: "src/a.rs::beta".to_string(),
2101 file_path: "src/a.rs".to_string(),
2102 minhash: vec![2u8; 1024],
2103 token_count: 20,
2104 },
2105 ];
2106 assert_eq!(db.insert_fingerprints_batch(&fps).unwrap(), 2);
2107 assert_eq!(db.insert_fingerprints_batch(&[]).unwrap(), 0);
2108
2109 let all = db.get_fingerprints(0).unwrap();
2111 assert_eq!(all.len(), 2);
2112 assert_eq!(all[0].symbol_id, "src/a.rs::alpha");
2113 assert_eq!(all[0].minhash, vec![1u8; 1024]);
2114 assert_eq!(all[1].token_count, 20);
2115
2116 let filtered = db.get_fingerprints(50).unwrap();
2118 assert_eq!(filtered.len(), 1);
2119 assert_eq!(filtered[0].symbol_id, "src/a.rs::alpha");
2120
2121 db.delete_symbols_for_file("src/a.rs").unwrap();
2123 assert!(db.get_fingerprints(0).unwrap().is_empty());
2124 }
2125
2126 #[test]
2127 fn test_sqlite_vec_extension_loaded() {
2128 let db = Database::open_in_memory().unwrap();
2130
2131 if !is_vec_extension_available() {
2135 eprintln!("Skipping sqlite-vec tests: extension not available on this platform");
2136 return;
2137 }
2138
2139 let version: Result<String, _> = db
2141 .conn
2142 .query_row("SELECT vec_version()", [], |row| row.get(0));
2143
2144 match version {
2145 Ok(v) => {
2146 assert!(
2147 v.starts_with('v'),
2148 "vec_version should return version string, got: {}",
2149 v
2150 );
2151 }
2152 Err(e) => {
2153 panic!(
2156 "sqlite-vec extension reported available but vec_version() failed: {}",
2157 e
2158 );
2159 }
2160 }
2161
2162 assert!(db.has_vector_search(), "vector search should be available");
2164
2165 let count: i64 = db
2167 .conn
2168 .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
2169 .expect("symbol_vectors table should exist");
2170 assert_eq!(count, 0, "symbol_vectors should be empty initially");
2171 }
2172
2173 fn make_fn_symbol(id: &str, name: &str, file: &str, line: u32) -> Symbol {
2175 Symbol {
2176 id: id.to_string(),
2177 file_path: file.to_string(),
2178 name: name.to_string(),
2179 qualified_name: Some(name.to_string()),
2180 kind: SymbolKind::Function,
2181 visibility: Visibility::Public,
2182 signature: None,
2183 brief: None,
2184 docstring: None,
2185 line_start: line,
2186 line_end: line + 5,
2187 col_start: 0,
2188 col_end: 0,
2189 parent_id: None,
2190 source: None,
2191 }
2192 }
2193
2194 fn make_call_edge(source_id: &str, target_id: Option<&str>, target_name: &str) -> Edge {
2196 Edge {
2197 source_id: source_id.to_string(),
2198 target_id: target_id.map(|s| s.to_string()),
2199 target_name: target_name.to_string(),
2200 kind: EdgeKind::Calls,
2201 line: Some(1),
2202 col: None,
2203 context: None,
2204 }
2205 }
2206
2207 fn metrics_fixture() -> Database {
2210 let db = Database::open_in_memory().unwrap();
2211 db.upsert_file(
2212 &FileRecord {
2213 path: "src/a.rs".to_string(),
2214 content_hash: "h1".to_string(),
2215 size_bytes: 10,
2216 language: Some("rust".to_string()),
2217 last_indexed: 0,
2218 },
2219 None,
2220 )
2221 .unwrap();
2222
2223 db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2224 .unwrap();
2225 db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2226 .unwrap();
2227 db.insert_symbol(&make_fn_symbol("src/a.rs::gamma", "gamma", "src/a.rs", 20))
2228 .unwrap();
2229
2230 db.insert_edge(&make_call_edge(
2231 "src/a.rs::alpha",
2232 Some("src/a.rs::beta"),
2233 "beta",
2234 ))
2235 .unwrap();
2236 db.insert_edge(&make_call_edge("src/a.rs::alpha", None, "external"))
2237 .unwrap();
2238 db.insert_edge(&make_call_edge(
2239 "src/a.rs::beta",
2240 Some("src/a.rs::alpha"),
2241 "alpha",
2242 ))
2243 .unwrap();
2244
2245 db
2246 }
2247
2248 #[test]
2249 fn test_symbol_metrics_mirrors_complexity_formula() {
2250 let db = metrics_fixture();
2251 let metrics = db.symbol_metrics().unwrap();
2252 assert_eq!(metrics.len(), 3);
2253
2254 let get = |name: &str| metrics.iter().find(|m| m.name == name).unwrap();
2255
2256 let alpha = get("alpha");
2258 assert_eq!(alpha.fan_out, 2);
2259 assert_eq!(alpha.fan_in, 1);
2260 assert_eq!(alpha.complexity, 2 * 2 + 1);
2261 assert_eq!(alpha.file_path, "src/a.rs");
2262 assert_eq!(alpha.line_start, 1);
2263
2264 let beta = get("beta");
2266 assert_eq!(beta.fan_out, 1);
2267 assert_eq!(beta.fan_in, 1);
2268 assert_eq!(beta.complexity, 3); let gamma = get("gamma");
2272 assert_eq!(gamma.fan_out, 0);
2273 assert_eq!(gamma.fan_in, 0);
2274 assert_eq!(gamma.complexity, 0);
2275
2276 assert_eq!(metrics[0].name, "alpha");
2278 }
2279
2280 #[test]
2281 fn test_file_complexity_aggregates_per_file() {
2282 let db = metrics_fixture();
2283 let files = db.file_complexity().unwrap();
2284 assert_eq!(files.len(), 1);
2285
2286 let f = &files[0];
2287 assert_eq!(f.file_path, "src/a.rs");
2288 assert_eq!(f.symbol_count, 3);
2289 assert_eq!(f.fan_out, 3);
2290 assert_eq!(f.complexity, 8);
2292 }
2293
2294 #[test]
2295 fn test_fan_in_counts() {
2296 let db = metrics_fixture();
2297
2298 let ids = vec![
2299 "src/a.rs::alpha".to_string(),
2300 "src/a.rs::beta".to_string(),
2301 "src/a.rs::gamma".to_string(),
2302 ];
2303 let counts = db.fan_in_counts(&ids).unwrap();
2304 assert_eq!(counts.get("src/a.rs::alpha"), Some(&1));
2305 assert_eq!(counts.get("src/a.rs::beta"), Some(&1));
2306 assert!(!counts.contains_key("src/a.rs::gamma"));
2308
2309 assert!(db.fan_in_counts(&[]).unwrap().is_empty());
2311 }
2312
2313 #[test]
2314 fn test_insert_and_find_symbol() {
2315 let db = Database::open_in_memory().unwrap();
2316
2317 let file = FileRecord {
2319 path: "src/main.rs".to_string(),
2320 content_hash: "abc123".to_string(),
2321 size_bytes: 100,
2322 language: Some("rust".to_string()),
2323 last_indexed: 0,
2324 };
2325 db.upsert_file(&file, None).unwrap();
2326
2327 let symbol = Symbol {
2329 id: "src/main.rs::main".to_string(),
2330 file_path: "src/main.rs".to_string(),
2331 name: "main".to_string(),
2332 qualified_name: None,
2333 kind: SymbolKind::Function,
2334 visibility: Visibility::Private,
2335 signature: Some("fn main()".to_string()),
2336 brief: Some("Entry point".to_string()),
2337 docstring: None,
2338 line_start: 1,
2339 line_end: 5,
2340 col_start: 0,
2341 col_end: 1,
2342 parent_id: None,
2343 source: Some("fn main() {\n println!(\"Hello\");\n}".to_string()),
2344 };
2345 db.insert_symbol(&symbol).unwrap();
2346
2347 let found = db.get_symbol("src/main.rs::main").unwrap();
2349 assert!(found.is_some());
2350 assert_eq!(found.unwrap().name, "main");
2351
2352 let results = db.find_symbols("main", 10).unwrap();
2354 assert_eq!(results.len(), 1);
2355 }
2356
2357 #[test]
2358 fn test_find_symbols_exact_match_ordering() {
2359 let db = Database::open_in_memory().unwrap();
2360
2361 let file = FileRecord {
2363 path: "src/test.rs".to_string(),
2364 content_hash: "abc123".to_string(),
2365 size_bytes: 100,
2366 language: Some("rust".to_string()),
2367 last_indexed: 0,
2368 };
2369 db.upsert_file(&file, None).unwrap();
2370
2371 let symbols = vec![
2374 ("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"), ];
2380
2381 for (name, id) in &symbols {
2382 let symbol = Symbol {
2383 id: id.to_string(),
2384 file_path: "src/test.rs".to_string(),
2385 name: name.to_string(),
2386 qualified_name: None,
2387 kind: SymbolKind::Function,
2388 visibility: Visibility::Public,
2389 signature: None,
2390 brief: None,
2391 docstring: None,
2392 line_start: 1,
2393 line_end: 5,
2394 col_start: 0,
2395 col_end: 1,
2396 parent_id: None,
2397 source: None,
2398 };
2399 db.insert_symbol(&symbol).unwrap();
2400 }
2401
2402 let results = db.find_symbols("new", 10).unwrap();
2404
2405 assert!(results.len() >= 4, "Should find at least 4 symbols");
2406
2407 assert_eq!(results[0].name, "new", "Exact match 'new' should be first");
2409
2410 let prefix_matches: Vec<&str> = results[1..4].iter().map(|s| s.name.as_str()).collect();
2413 assert!(
2414 prefix_matches.iter().all(|n| n.starts_with("new")),
2415 "Positions 2-4 should be prefix matches, got: {:?}",
2416 prefix_matches
2417 );
2418
2419 let renew_pos = results.iter().position(|s| s.name == "renew");
2421 assert!(
2422 renew_pos.is_some() && renew_pos.unwrap() >= 4,
2423 "'renew' should be after prefix matches"
2424 );
2425 }
2426
2427 #[test]
2428 fn test_find_symbols_with_file_filter() {
2429 let db = Database::open_in_memory().unwrap();
2430
2431 for path in &["src/parser/rust.rs", "src/embeddings/local.rs"] {
2433 let file = FileRecord {
2434 path: path.to_string(),
2435 content_hash: "abc123".to_string(),
2436 size_bytes: 100,
2437 language: Some("rust".to_string()),
2438 last_indexed: 0,
2439 };
2440 db.upsert_file(&file, None).unwrap();
2441 }
2442
2443 for (path, id) in &[
2445 ("src/parser/rust.rs", "src/parser/rust.rs::new"),
2446 ("src/embeddings/local.rs", "src/embeddings/local.rs::new"),
2447 ] {
2448 let symbol = Symbol {
2449 id: id.to_string(),
2450 file_path: path.to_string(),
2451 name: "new".to_string(),
2452 qualified_name: None,
2453 kind: SymbolKind::Method,
2454 visibility: Visibility::Public,
2455 signature: None,
2456 brief: None,
2457 docstring: None,
2458 line_start: 1,
2459 line_end: 5,
2460 col_start: 0,
2461 col_end: 1,
2462 parent_id: None,
2463 source: None,
2464 };
2465 db.insert_symbol(&symbol).unwrap();
2466 }
2467
2468 let all_results = db.find_symbols_filtered("new", 10, None, None).unwrap();
2470 assert_eq!(all_results.len(), 2);
2471
2472 let filtered = db
2474 .find_symbols_filtered("new", 10, Some("*parser*"), None)
2475 .unwrap();
2476 assert_eq!(filtered.len(), 1);
2477 assert!(filtered[0].file_path.contains("parser"));
2478
2479 let filtered = db
2481 .find_symbols_filtered("new", 10, Some("*embeddings*"), None)
2482 .unwrap();
2483 assert_eq!(filtered.len(), 1);
2484 assert!(filtered[0].file_path.contains("embeddings"));
2485 }
2486
2487 #[test]
2488 fn test_find_symbols_with_underscore_name() {
2489 let db = Database::open_in_memory().unwrap();
2495
2496 db.upsert_file(
2498 &FileRecord {
2499 path: "src/commands/sql.rs".to_string(),
2500 content_hash: "abc123".to_string(),
2501 size_bytes: 100,
2502 language: Some("rust".to_string()),
2503 last_indexed: 0,
2504 },
2505 None,
2506 )
2507 .unwrap();
2508
2509 for (id, name) in &[
2510 ("a::run_sql", "run_sql"),
2511 ("b::run_sql_duckdb", "run_sql_duckdb"),
2512 ("c::runsql", "runsql"),
2513 ] {
2514 let symbol = Symbol {
2515 id: id.to_string(),
2516 file_path: "src/commands/sql.rs".to_string(),
2517 name: name.to_string(),
2518 qualified_name: None,
2519 kind: SymbolKind::Function,
2520 visibility: Visibility::Public,
2521 signature: None,
2522 brief: None,
2523 docstring: None,
2524 line_start: 1,
2525 line_end: 5,
2526 col_start: 0,
2527 col_end: 1,
2528 parent_id: None,
2529 source: None,
2530 };
2531 db.insert_symbol(&symbol).unwrap();
2532 }
2533
2534 let exact = db.find_symbols_filtered("run_sql", 10, None, None).unwrap();
2536 assert!(
2537 exact.iter().any(|s| s.name == "run_sql"),
2538 "exact underscore name 'run_sql' must be found"
2539 );
2540
2541 assert!(
2544 !exact.iter().any(|s| s.name == "runsql"),
2545 "'_' must be literal, not a wildcard matching 'runsql'"
2546 );
2547
2548 assert_eq!(
2550 exact.first().map(|s| s.name.as_str()),
2551 Some("run_sql"),
2552 "exact match should sort before 'run_sql_duckdb'"
2553 );
2554
2555 let prefix = db.find_symbols_filtered("run_sql", 10, None, None).unwrap();
2557 assert!(
2558 prefix.iter().any(|s| s.name == "run_sql_duckdb"),
2559 "substring search should still surface 'run_sql_duckdb'"
2560 );
2561 }
2562
2563 #[test]
2564 fn test_escape_like_pattern() {
2565 assert_eq!(escape_like_pattern("new"), "new");
2567 assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
2568
2569 assert_eq!(escape_like_pattern("100%"), "100\\%");
2571 assert_eq!(escape_like_pattern("a_b"), "a\\_b");
2572 assert_eq!(escape_like_pattern("a%b_c"), "a\\%b\\_c");
2573 }
2574
2575 #[test]
2576 fn test_glob_to_like_pattern() {
2577 assert_eq!(glob_to_like_pattern("*.rs"), "%.rs");
2579 assert_eq!(glob_to_like_pattern("src/*"), "src/%");
2580
2581 assert_eq!(glob_to_like_pattern("**/*.rs"), "%.rs");
2584 assert_eq!(glob_to_like_pattern("src/**/*.rs"), "src/%.rs");
2585
2586 assert_eq!(glob_to_like_pattern("src/**"), "src/%");
2588
2589 assert_eq!(glob_to_like_pattern("**/foo.rs"), "%foo.rs");
2591
2592 assert_eq!(glob_to_like_pattern("file?.txt"), "file_.txt");
2594
2595 assert_eq!(glob_to_like_pattern("100%"), "100\\%");
2597 assert_eq!(glob_to_like_pattern("a_b"), "a\\_b");
2598 }
2599
2600 #[test]
2601 fn test_glob_pattern_matches_files() {
2602 let db = Database::open_in_memory().unwrap();
2603
2604 for path in &[
2606 "main.rs",
2607 "src/lib.rs",
2608 "src/parser/mod.rs",
2609 "src/parser/rust.rs",
2610 "tests/test.rs",
2611 ] {
2612 let file = FileRecord {
2613 path: path.to_string(),
2614 content_hash: "abc".to_string(),
2615 size_bytes: 100,
2616 language: Some("rust".to_string()),
2617 last_indexed: 0,
2618 };
2619 db.upsert_file(&file, None).unwrap();
2620
2621 let symbol = Symbol {
2622 id: format!("{}::main", path),
2623 file_path: path.to_string(),
2624 name: "main".to_string(),
2625 qualified_name: None,
2626 kind: SymbolKind::Function,
2627 visibility: Visibility::Public,
2628 signature: None,
2629 brief: None,
2630 docstring: None,
2631 line_start: 1,
2632 line_end: 5,
2633 col_start: 0,
2634 col_end: 1,
2635 parent_id: None,
2636 source: None,
2637 };
2638 db.insert_symbol(&symbol).unwrap();
2639 }
2640
2641 let results = db
2643 .find_symbols_filtered("main", 20, Some("**/*.rs"), None)
2644 .unwrap();
2645 assert_eq!(results.len(), 5, "**/*.rs should match all .rs files");
2646
2647 let results = db
2649 .find_symbols_filtered("main", 20, Some("src/**/*.rs"), None)
2650 .unwrap();
2651 assert_eq!(
2652 results.len(),
2653 3,
2654 "src/**/*.rs should match src/lib.rs, src/parser/mod.rs, src/parser/rust.rs"
2655 );
2656
2657 let results = db
2660 .find_symbols_filtered("main", 20, Some("src/*.rs"), None)
2661 .unwrap();
2662 assert_eq!(
2663 results.len(),
2664 3,
2665 "src/*.rs matches all under src/ (SQL LIKE limitation)"
2666 );
2667
2668 let results = db
2670 .find_symbols_filtered("main", 20, Some("*parser*"), None)
2671 .unwrap();
2672 assert_eq!(
2673 results.len(),
2674 2,
2675 "*parser* should match parser directory files"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_hybrid_search_limit_one() {
2681 let db = Database::open_in_memory().unwrap();
2684
2685 let file = FileRecord {
2687 path: "src/main.rs".to_string(),
2688 content_hash: "abc123".to_string(),
2689 size_bytes: 100,
2690 language: Some("rust".to_string()),
2691 last_indexed: 0,
2692 };
2693 db.upsert_file(&file, None).unwrap();
2694
2695 let symbol = Symbol {
2696 id: "src/main.rs::authenticate".to_string(),
2697 file_path: "src/main.rs".to_string(),
2698 name: "authenticate".to_string(),
2699 qualified_name: None,
2700 kind: SymbolKind::Function,
2701 visibility: Visibility::Public,
2702 signature: Some("fn authenticate(user: &str)".to_string()),
2703 brief: Some("Authenticate a user".to_string()),
2704 docstring: None,
2705 line_start: 1,
2706 line_end: 5,
2707 col_start: 0,
2708 col_end: 1,
2709 parent_id: None,
2710 source: None,
2711 };
2712 db.insert_symbol(&symbol).unwrap();
2713
2714 let results = db.hybrid_search("authenticate", 1).unwrap();
2716 assert_eq!(
2717 results.len(),
2718 1,
2719 "hybrid_search with limit=1 should return 1 result"
2720 );
2721 assert_eq!(results[0].0.name, "authenticate");
2722 }
2723
2724 #[test]
2725 fn test_hybrid_search_limit_three() {
2726 let db = Database::open_in_memory().unwrap();
2728
2729 let file = FileRecord {
2731 path: "src/test.rs".to_string(),
2732 content_hash: "abc123".to_string(),
2733 size_bytes: 100,
2734 language: Some("rust".to_string()),
2735 last_indexed: 0,
2736 };
2737 db.upsert_file(&file, None).unwrap();
2738
2739 for (name, i) in &[("login", 1), ("logout", 2), ("log_error", 3)] {
2741 let symbol = Symbol {
2742 id: format!("src/test.rs::{}", name),
2743 file_path: "src/test.rs".to_string(),
2744 name: name.to_string(),
2745 qualified_name: None,
2746 kind: SymbolKind::Function,
2747 visibility: Visibility::Public,
2748 signature: Some(format!("fn {}()", name)),
2749 brief: Some(format!("{} function", name)),
2750 docstring: None,
2751 line_start: *i,
2752 line_end: *i + 5,
2753 col_start: 0,
2754 col_end: 1,
2755 parent_id: None,
2756 source: None,
2757 };
2758 db.insert_symbol(&symbol).unwrap();
2759 }
2760
2761 let results = db.hybrid_search("log", 3).unwrap();
2763 assert!(
2764 !results.is_empty(),
2765 "hybrid_search with limit=3 should return results"
2766 );
2767 assert!(results.len() <= 3, "hybrid_search should respect limit");
2768 }
2769
2770 #[test]
2771 fn test_semantic_search_score_range() {
2772 let db = Database::open_in_memory().unwrap();
2774
2775 let file = FileRecord {
2777 path: "src/main.rs".to_string(),
2778 content_hash: "abc123".to_string(),
2779 size_bytes: 100,
2780 language: Some("rust".to_string()),
2781 last_indexed: 0,
2782 };
2783 db.upsert_file(&file, None).unwrap();
2784
2785 let symbol = Symbol {
2786 id: "src/main.rs::process_data".to_string(),
2787 file_path: "src/main.rs".to_string(),
2788 name: "process_data".to_string(),
2789 qualified_name: None,
2790 kind: SymbolKind::Function,
2791 visibility: Visibility::Public,
2792 signature: Some("fn process_data(input: &str) -> Result<()>".to_string()),
2793 brief: Some("Process incoming data".to_string()),
2794 docstring: Some("Processes the incoming data and returns a result.".to_string()),
2795 line_start: 1,
2796 line_end: 10,
2797 col_start: 0,
2798 col_end: 1,
2799 parent_id: None,
2800 source: None,
2801 };
2802 db.insert_symbol(&symbol).unwrap();
2803
2804 let results = db.semantic_search("process data", 10).unwrap();
2806 for (symbol, score) in &results {
2807 assert!(
2808 *score >= 0.0 && *score <= 1.0,
2809 "Score for '{}' should be in 0-1 range, got {}",
2810 symbol.name,
2811 score
2812 );
2813 assert!(
2814 score.is_finite(),
2815 "Score for '{}' should be finite, got {}",
2816 symbol.name,
2817 score
2818 );
2819 }
2820 }
2821
2822 #[test]
2823 fn test_bm25_relevance_mapping() {
2824 let cases = [
2825 (0.0, 0.0),
2826 (-1.0, 0.5),
2827 (1.0, 0.5),
2828 (-10.0, 10.0 / 11.0),
2829 (10.0, 10.0 / 11.0),
2830 ];
2831
2832 for (rank, expected) in cases {
2833 let actual = Database::bm25_relevance(rank);
2834 assert!(
2835 (actual - expected).abs() < 1e-12,
2836 "rank {} expected {} got {}",
2837 rank,
2838 expected,
2839 actual
2840 );
2841 }
2842
2843 assert_eq!(Database::bm25_relevance(f64::INFINITY), 0.0);
2844 assert_eq!(Database::bm25_relevance(f64::NEG_INFINITY), 0.0);
2845 assert_eq!(Database::bm25_relevance(f64::NAN), 0.0);
2846 }
2847
2848 #[test]
2849 fn test_get_embedding_metadata() {
2850 let db = Database::open_in_memory().unwrap();
2852
2853 let file = FileRecord {
2855 path: "src/main.rs".to_string(),
2856 content_hash: "abc123".to_string(),
2857 size_bytes: 100,
2858 language: Some("rust".to_string()),
2859 last_indexed: 0,
2860 };
2861 db.upsert_file(&file, None).unwrap();
2862
2863 for (name, id) in &[("foo", "src/main.rs::foo"), ("bar", "src/main.rs::bar")] {
2865 let symbol = Symbol {
2866 id: id.to_string(),
2867 file_path: "src/main.rs".to_string(),
2868 name: name.to_string(),
2869 qualified_name: None,
2870 kind: SymbolKind::Function,
2871 visibility: Visibility::Public,
2872 signature: None,
2873 brief: None,
2874 docstring: None,
2875 line_start: 1,
2876 line_end: 5,
2877 col_start: 0,
2878 col_end: 1,
2879 parent_id: None,
2880 source: None,
2881 };
2882 db.insert_symbol(&symbol).unwrap();
2883 }
2884
2885 let metadata = db.get_embedding_metadata().unwrap();
2887 assert!(
2888 metadata.is_empty(),
2889 "Should have no embedding metadata initially"
2890 );
2891
2892 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)
2897 .unwrap();
2898 db.store_embedding(
2899 "src/main.rs::bar",
2900 "openai",
2901 "text-embedding-3-small",
2902 &openai_vec,
2903 )
2904 .unwrap();
2905
2906 let metadata = db.get_embedding_metadata().unwrap();
2908
2909 assert_eq!(
2911 metadata.len(),
2912 2,
2913 "Should have 2 embedding metadata entries"
2914 );
2915
2916 let dims: Vec<i64> = metadata.iter().map(|(_, _, dim, _)| *dim).collect();
2918 assert!(
2919 dims.contains(&384),
2920 "Should have local embedding dimension 384"
2921 );
2922 assert!(
2923 dims.contains(&1536),
2924 "Should have OpenAI embedding dimension 1536"
2925 );
2926
2927 let providers: Vec<&str> = metadata.iter().map(|(p, _, _, _)| p.as_str()).collect();
2929 assert!(providers.contains(&"local"), "Should have local provider");
2930 assert!(providers.contains(&"openai"), "Should have openai provider");
2931 }
2932
2933 #[test]
2934 fn test_vector_search() {
2935 let db = Database::open_in_memory().unwrap();
2937
2938 if !is_vec_extension_available() {
2939 eprintln!("Skipping vector search test: sqlite-vec not available");
2940 return;
2941 }
2942
2943 let file = FileRecord {
2945 path: "src/main.rs".to_string(),
2946 content_hash: "abc123".to_string(),
2947 size_bytes: 100,
2948 language: Some("rust".to_string()),
2949 last_indexed: 0,
2950 };
2951 db.upsert_file(&file, None).unwrap();
2952
2953 for (name, id, line) in &[
2955 ("foo", "src/main.rs::foo", 1),
2956 ("bar", "src/main.rs::bar", 10),
2957 ("baz", "src/main.rs::baz", 20),
2958 ] {
2959 let symbol = Symbol {
2960 id: id.to_string(),
2961 file_path: "src/main.rs".to_string(),
2962 name: name.to_string(),
2963 qualified_name: None,
2964 kind: SymbolKind::Function,
2965 visibility: Visibility::Public,
2966 signature: None,
2967 brief: None,
2968 docstring: None,
2969 line_start: *line,
2970 line_end: *line + 5,
2971 col_start: 0,
2972 col_end: 1,
2973 parent_id: None,
2974 source: None,
2975 };
2976 db.insert_symbol(&symbol).unwrap();
2977 }
2978
2979 let mut foo_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2982 foo_vec[0] = 1.0;
2983 foo_vec[1] = 0.5;
2984
2985 let mut bar_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2986 bar_vec[0] = 0.9;
2987 bar_vec[1] = 0.6;
2988
2989 let mut baz_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2990 baz_vec[0] = 0.0;
2991 baz_vec[1] = 0.0;
2992 baz_vec[2] = 1.0; db.store_embedding(
2996 "src/main.rs::foo",
2997 "openai",
2998 "text-embedding-3-small",
2999 &foo_vec,
3000 )
3001 .unwrap();
3002 db.store_embedding(
3003 "src/main.rs::bar",
3004 "openai",
3005 "text-embedding-3-small",
3006 &bar_vec,
3007 )
3008 .unwrap();
3009 db.store_embedding(
3010 "src/main.rs::baz",
3011 "openai",
3012 "text-embedding-3-small",
3013 &baz_vec,
3014 )
3015 .unwrap();
3016
3017 let vec_count = db.count_vector_embeddings().unwrap();
3019 assert_eq!(vec_count, 3, "Should have 3 vector embeddings");
3020
3021 let query: Vec<f32> = foo_vec.clone();
3023 let results = db.vector_search(&query, 3).unwrap();
3024
3025 assert_eq!(results.len(), 3, "Should return 3 results");
3026
3027 assert_eq!(
3029 results[0].0, "src/main.rs::foo",
3030 "First result should be foo (exact match)"
3031 );
3032 assert_eq!(results[0].5, 0.0, "Exact match should have distance 0");
3033
3034 assert_eq!(
3036 results[1].0, "src/main.rs::bar",
3037 "Second result should be bar (similar)"
3038 );
3039
3040 assert_eq!(
3042 results[2].0, "src/main.rs::baz",
3043 "Third result should be baz (different)"
3044 );
3045
3046 assert!(
3048 results[2].5 > results[1].5,
3049 "baz should have larger distance than bar"
3050 );
3051 }
3052
3053 #[test]
3054 fn test_migrate_embeddings_to_vec() {
3055 let db = Database::open_in_memory().unwrap();
3057
3058 if !is_vec_extension_available() {
3059 eprintln!("Skipping migration test: sqlite-vec not available");
3060 return;
3061 }
3062
3063 let file = FileRecord {
3065 path: "src/main.rs".to_string(),
3066 content_hash: "abc123".to_string(),
3067 size_bytes: 100,
3068 language: Some("rust".to_string()),
3069 last_indexed: 0,
3070 };
3071 db.upsert_file(&file, None).unwrap();
3072
3073 let symbol = Symbol {
3074 id: "src/main.rs::foo".to_string(),
3075 file_path: "src/main.rs".to_string(),
3076 name: "foo".to_string(),
3077 qualified_name: None,
3078 kind: SymbolKind::Function,
3079 visibility: Visibility::Public,
3080 signature: None,
3081 brief: None,
3082 docstring: None,
3083 line_start: 1,
3084 line_end: 5,
3085 col_start: 0,
3086 col_end: 1,
3087 parent_id: None,
3088 source: None,
3089 };
3090 db.insert_symbol(&symbol).unwrap();
3091
3092 let vec: Vec<f32> = vec![0.1; DEFAULT_EMBEDDING_DIM];
3094 let vec_json = serde_json::to_string(&vec).unwrap();
3095 db.conn.execute(
3096 "INSERT INTO embeddings (symbol_id, provider, model, dimension, vector) VALUES (?, ?, ?, ?, ?)",
3097 params!["src/main.rs::foo", "openai", "test", DEFAULT_EMBEDDING_DIM as i64, vec_json],
3098 ).unwrap();
3099
3100 let vec_count_before = db.count_vector_embeddings().unwrap();
3102 assert_eq!(
3103 vec_count_before, 0,
3104 "Should have no vector embeddings before migration"
3105 );
3106
3107 let migrated = db.migrate_embeddings_to_vec().unwrap();
3109 assert_eq!(migrated, 1, "Should migrate 1 embedding");
3110
3111 let vec_count_after = db.count_vector_embeddings().unwrap();
3113 assert_eq!(
3114 vec_count_after, 1,
3115 "Should have 1 vector embedding after migration"
3116 );
3117
3118 let migrated_again = db.migrate_embeddings_to_vec().unwrap();
3120 assert_eq!(
3121 migrated_again, 0,
3122 "Should not re-migrate existing embeddings"
3123 );
3124 }
3125}