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 DEFAULT_EMBEDDING_DIM: usize = 1536;
20
21static VEC_EXTENSION_AVAILABLE: AtomicBool = AtomicBool::new(false);
23
24#[allow(clippy::missing_transmute_annotations)]
30pub fn init_vec_extension() -> bool {
31 static INIT: Once = Once::new();
32 INIT.call_once(|| {
33 let result = unsafe {
34 sqlite3_auto_extension(Some(std::mem::transmute(
35 sqlite3_vec_init as *const (),
36 )))
37 };
38 if result == 0 {
40 VEC_EXTENSION_AVAILABLE.store(true, Ordering::SeqCst);
41 } else {
42 eprintln!(
43 "Warning: Failed to register sqlite-vec extension (error code: {}). Vector search will be unavailable.",
44 result
45 );
46 }
47 });
48 VEC_EXTENSION_AVAILABLE.load(Ordering::SeqCst)
49}
50
51pub fn is_vec_extension_available() -> bool {
53 VEC_EXTENSION_AVAILABLE.load(Ordering::SeqCst)
54}
55
56pub struct Database {
58 conn: Connection,
59}
60
61impl Database {
62 pub fn open(path: &Path) -> Result<Self> {
64 init_vec_extension();
66 let conn = Connection::open(path)?;
67 Self::configure_connection(&conn)?;
68 let db = Self { conn };
69 db.init_schema()?;
70 Ok(db)
71 }
72
73 #[allow(dead_code)]
75 pub fn open_in_memory() -> Result<Self> {
76 init_vec_extension();
78 let conn = Connection::open_in_memory()?;
79 Self::configure_connection(&conn)?;
80 let db = Self { conn };
81 db.init_schema()?;
82 Ok(db)
83 }
84
85 fn configure_connection(conn: &Connection) -> Result<()> {
87 conn.execute_batch(
90 r#"
91 PRAGMA journal_mode = WAL;
92 PRAGMA synchronous = NORMAL;
93 PRAGMA busy_timeout = 5000;
94 PRAGMA cache_size = -64000;
95 "#,
96 )?;
97 Ok(())
98 }
99
100 fn init_schema(&self) -> Result<()> {
102 self.conn.execute_batch(
103 r#"
104 -- Enable foreign keys
105 PRAGMA foreign_keys = ON;
106
107 -- File tracking for incremental updates
108 CREATE TABLE IF NOT EXISTS files (
109 path TEXT PRIMARY KEY,
110 content_hash TEXT NOT NULL,
111 size_bytes INTEGER,
112 language TEXT,
113 last_indexed INTEGER DEFAULT (unixepoch()),
114 source BLOB
115 );
116
117 -- All symbols (functions, structs, etc.)
118 CREATE TABLE IF NOT EXISTS symbols (
119 id TEXT PRIMARY KEY,
120 file_path TEXT NOT NULL,
121 name TEXT NOT NULL,
122 qualified_name TEXT,
123 kind TEXT NOT NULL,
124 visibility TEXT DEFAULT 'private',
125 signature TEXT,
126 brief TEXT,
127 docstring TEXT,
128 line_start INTEGER,
129 line_end INTEGER,
130 col_start INTEGER,
131 col_end INTEGER,
132 parent_id TEXT,
133 source TEXT,
134 FOREIGN KEY (file_path) REFERENCES files(path) ON DELETE CASCADE
135 );
136
137 -- Relationships between symbols
138 CREATE TABLE IF NOT EXISTS edges (
139 id INTEGER PRIMARY KEY AUTOINCREMENT,
140 source_id TEXT NOT NULL,
141 target_id TEXT,
142 target_name TEXT NOT NULL,
143 kind TEXT NOT NULL,
144 line INTEGER,
145 col INTEGER,
146 context TEXT,
147 FOREIGN KEY (source_id) REFERENCES symbols(id) ON DELETE CASCADE
148 );
149
150 -- Module-level information
151 CREATE TABLE IF NOT EXISTS modules (
152 file_path TEXT PRIMARY KEY,
153 module_name TEXT,
154 exports TEXT,
155 imports TEXT,
156 FOREIGN KEY (file_path) REFERENCES files(path) ON DELETE CASCADE
157 );
158
159 -- Indexes for fast lookups
160 CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
161 CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path);
162 CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);
163 CREATE INDEX IF NOT EXISTS idx_symbols_parent ON symbols(parent_id);
164 CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
165 CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_name);
166 CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
167 CREATE INDEX IF NOT EXISTS idx_files_hash ON files(content_hash);
168
169 -- Embeddings for semantic search
170 CREATE TABLE IF NOT EXISTS embeddings (
171 symbol_id TEXT PRIMARY KEY,
172 provider TEXT NOT NULL,
173 model TEXT NOT NULL,
174 dimension INTEGER NOT NULL,
175 vector TEXT NOT NULL, -- JSON array of floats
176 created_at INTEGER DEFAULT (unixepoch()),
177 FOREIGN KEY (symbol_id) REFERENCES symbols(id) ON DELETE CASCADE
178 );
179
180 CREATE INDEX IF NOT EXISTS idx_embeddings_provider ON embeddings(provider);
181
182 -- Full-text search index for semantic search
183 CREATE VIRTUAL TABLE IF NOT EXISTS symbol_fts USING fts5(
184 id,
185 name,
186 kind,
187 signature,
188 brief,
189 docstring,
190 content='symbols',
191 content_rowid='rowid'
192 );
193
194 -- Triggers to keep FTS index in sync
195 CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
196 INSERT INTO symbol_fts(rowid, id, name, kind, signature, brief, docstring)
197 VALUES (NEW.rowid, NEW.id, NEW.name, NEW.kind, NEW.signature, NEW.brief, NEW.docstring);
198 END;
199
200 CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
201 INSERT INTO symbol_fts(symbol_fts, rowid, id, name, kind, signature, brief, docstring)
202 VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.kind, OLD.signature, OLD.brief, OLD.docstring);
203 END;
204
205 CREATE TRIGGER IF NOT EXISTS symbols_au AFTER UPDATE ON symbols BEGIN
206 INSERT INTO symbol_fts(symbol_fts, rowid, id, name, kind, signature, brief, docstring)
207 VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.kind, OLD.signature, OLD.brief, OLD.docstring);
208 INSERT INTO symbol_fts(rowid, id, name, kind, signature, brief, docstring)
209 VALUES (NEW.rowid, NEW.id, NEW.name, NEW.kind, NEW.signature, NEW.brief, NEW.docstring);
210 END;
211 "#,
212 )?;
213
214 if is_vec_extension_available() {
218 match self.conn.execute(
219 &format!(
220 r#"
221 CREATE VIRTUAL TABLE IF NOT EXISTS symbol_vectors USING vec0(
222 embedding float[{}],
223 +symbol_id TEXT
224 )
225 "#,
226 DEFAULT_EMBEDDING_DIM
227 ),
228 [],
229 ) {
230 Ok(_) => {}
231 Err(e) => {
232 eprintln!(
234 "Warning: Failed to create symbol_vectors table: {}. Vector search will be unavailable.",
235 e
236 );
237 }
238 }
239 }
240
241 Ok(())
242 }
243
244 pub fn has_vector_search(&self) -> bool {
246 if !is_vec_extension_available() {
247 return false;
248 }
249 self.conn
251 .query_row(
252 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbol_vectors'",
253 [],
254 |_| Ok(()),
255 )
256 .is_ok()
257 }
258
259 #[allow(dead_code)]
261 pub fn transaction(&mut self) -> Result<Transaction<'_>> {
262 self.conn.transaction()
263 }
264
265 pub fn get_file_hash(&self, path: &str) -> Result<Option<String>> {
267 self.conn
268 .query_row(
269 "SELECT content_hash FROM files WHERE path = ?",
270 [path],
271 |row| row.get(0),
272 )
273 .optional()
274 }
275
276 pub fn needs_update(&self, path: &str, new_hash: &str) -> Result<bool> {
278 match self.get_file_hash(path)? {
279 Some(stored_hash) => Ok(stored_hash != new_hash),
280 None => Ok(true),
281 }
282 }
283
284 pub fn upsert_file(&self, file: &FileRecord, source: Option<&[u8]>) -> Result<()> {
286 self.conn.execute(
287 r#"
288 INSERT OR REPLACE INTO files (path, content_hash, size_bytes, language, last_indexed, source)
289 VALUES (?, ?, ?, ?, unixepoch(), ?)
290 "#,
291 params![
292 file.path,
293 file.content_hash,
294 file.size_bytes,
295 file.language,
296 source
297 ],
298 )?;
299 Ok(())
300 }
301
302 pub fn delete_file(&self, path: &str) -> Result<()> {
304 self.conn
305 .execute("DELETE FROM files WHERE path = ?", [path])?;
306 Ok(())
307 }
308
309 pub fn delete_symbols_for_file(&self, file_path: &str) -> Result<()> {
311 self.conn.execute(
313 "DELETE FROM edges WHERE source_id IN (SELECT id FROM symbols WHERE file_path = ?)",
314 [file_path],
315 )?;
316 self.conn
317 .execute("DELETE FROM symbols WHERE file_path = ?", [file_path])?;
318 Ok(())
319 }
320
321 pub fn insert_symbol(&self, symbol: &Symbol) -> Result<()> {
323 self.conn.execute(
324 r#"
325 INSERT INTO symbols (
326 id, file_path, name, qualified_name, kind, visibility,
327 signature, brief, docstring, line_start, line_end,
328 col_start, col_end, parent_id, source
329 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
330 "#,
331 params![
332 symbol.id,
333 symbol.file_path,
334 symbol.name,
335 symbol.qualified_name,
336 symbol.kind.as_str(),
337 symbol.visibility.as_str(),
338 symbol.signature,
339 symbol.brief,
340 symbol.docstring,
341 symbol.line_start,
342 symbol.line_end,
343 symbol.col_start,
344 symbol.col_end,
345 symbol.parent_id,
346 symbol.source,
347 ],
348 )?;
349 Ok(())
350 }
351
352 pub fn insert_edge(&self, edge: &Edge) -> Result<()> {
354 self.conn.execute(
355 r#"
356 INSERT INTO edges (source_id, target_id, target_name, kind, line, col, context)
357 VALUES (?, ?, ?, ?, ?, ?, ?)
358 "#,
359 params![
360 edge.source_id,
361 edge.target_id,
362 edge.target_name,
363 edge.kind.as_str(),
364 edge.line,
365 edge.col,
366 edge.context,
367 ],
368 )?;
369 Ok(())
370 }
371
372 pub fn upsert_module(&self, module: &ModuleInfo) -> Result<()> {
374 let exports_json = serde_json::to_string(&module.exports).unwrap_or_default();
375 let imports_json = serde_json::to_string(&module.imports).unwrap_or_default();
376
377 self.conn.execute(
378 r#"
379 INSERT OR REPLACE INTO modules (file_path, module_name, exports, imports)
380 VALUES (?, ?, ?, ?)
381 "#,
382 params![
383 module.file_path,
384 module.module_name,
385 exports_json,
386 imports_json,
387 ],
388 )?;
389 Ok(())
390 }
391
392 #[allow(dead_code)] pub fn insert_symbols_batch(&self, symbols: &[Symbol]) -> Result<usize> {
395 let tx = self.conn.unchecked_transaction()?;
396 let mut count = 0;
397
398 for symbol in symbols {
399 tx.execute(
400 r#"
401 INSERT INTO symbols (
402 id, file_path, name, qualified_name, kind, visibility,
403 signature, brief, docstring, line_start, line_end,
404 col_start, col_end, parent_id, source
405 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
406 "#,
407 params![
408 symbol.id,
409 symbol.file_path,
410 symbol.name,
411 symbol.qualified_name,
412 symbol.kind.as_str(),
413 symbol.visibility.as_str(),
414 symbol.signature,
415 symbol.brief,
416 symbol.docstring,
417 symbol.line_start,
418 symbol.line_end,
419 symbol.col_start,
420 symbol.col_end,
421 symbol.parent_id,
422 symbol.source,
423 ],
424 )?;
425 count += 1;
426 }
427
428 tx.commit()?;
429 Ok(count)
430 }
431
432 #[allow(dead_code)] pub fn insert_edges_batch(&self, edges: &[Edge]) -> Result<usize> {
435 let tx = self.conn.unchecked_transaction()?;
436 let mut count = 0;
437
438 for edge in edges {
439 tx.execute(
440 r#"
441 INSERT INTO edges (source_id, target_id, target_name, kind, line, col, context)
442 VALUES (?, ?, ?, ?, ?, ?, ?)
443 "#,
444 params![
445 edge.source_id,
446 edge.target_id,
447 edge.target_name,
448 edge.kind.as_str(),
449 edge.line,
450 edge.col,
451 edge.context,
452 ],
453 )?;
454 count += 1;
455 }
456
457 tx.commit()?;
458 Ok(count)
459 }
460
461 pub fn get_symbol(&self, id: &str) -> Result<Option<Symbol>> {
463 self.conn
464 .query_row(
465 r#"
466 SELECT id, file_path, name, qualified_name, kind, visibility,
467 signature, brief, docstring, line_start, line_end,
468 col_start, col_end, parent_id, source
469 FROM symbols WHERE id = ?
470 "#,
471 [id],
472 |row| Ok(symbol_from_row(row)),
473 )
474 .optional()
475 }
476
477 pub fn find_symbols(&self, pattern: &str, limit: i32) -> Result<Vec<Symbol>> {
479 self.find_symbols_filtered(pattern, limit, None, None)
480 }
481
482 pub fn find_symbols_filtered(
492 &self,
493 pattern: &str,
494 limit: i32,
495 file_pattern: Option<&str>,
496 kind_filter: Option<&str>,
497 ) -> Result<Vec<Symbol>> {
498 let escaped_pattern = escape_like_pattern(pattern);
500 let like_pattern = format!("%{}%", escaped_pattern);
501 let starts_with_pattern = format!("{}%", escaped_pattern);
502
503 let file_like = file_pattern.map(glob_to_like_pattern);
505
506 let mut sql = String::from(
509 r#"
510 SELECT id, file_path, name, qualified_name, kind, visibility,
511 signature, brief, docstring, line_start, line_end,
512 col_start, col_end, parent_id, source
513 FROM symbols
514 WHERE (name LIKE ?2 OR qualified_name LIKE ?2)
515 "#,
516 );
517
518 let mut next_param = 4; let file_param_pos = if file_pattern.is_some() {
523 let pos = next_param;
524 sql.push_str(&format!(" AND file_path LIKE ?{}", pos));
525 next_param += 1;
526 Some(pos)
527 } else {
528 None
529 };
530
531 let kind_param_pos = if kind_filter.is_some() {
533 let pos = next_param;
534 sql.push_str(&format!(" AND kind = ?{}", pos));
535 next_param += 1;
536 Some(pos)
537 } else {
538 None
539 };
540
541 sql.push_str(&format!(
545 r#"
546 ORDER BY
547 CASE WHEN name = ?1 THEN 0
548 WHEN name LIKE ?3 THEN 1
549 ELSE 2 END,
550 name
551 LIMIT ?{}
552 "#,
553 next_param
554 ));
555
556 let mut stmt = self.conn.prepare(&sql)?;
557
558 let rows: Vec<Result<Symbol>> = match (
560 file_like.as_deref(),
561 kind_filter,
562 file_param_pos,
563 kind_param_pos,
564 ) {
565 (Some(fp), Some(kf), Some(_), Some(_)) => stmt
566 .query_map(
567 params![pattern, like_pattern, starts_with_pattern, fp, kf, limit],
568 |row| Ok(symbol_from_row(row)),
569 )?
570 .collect(),
571 (Some(fp), None, Some(_), None) => stmt
572 .query_map(
573 params![pattern, like_pattern, starts_with_pattern, fp, limit],
574 |row| Ok(symbol_from_row(row)),
575 )?
576 .collect(),
577 (None, Some(kf), None, Some(_)) => stmt
578 .query_map(
579 params![pattern, like_pattern, starts_with_pattern, kf, limit],
580 |row| Ok(symbol_from_row(row)),
581 )?
582 .collect(),
583 (None, None, None, None) => stmt
584 .query_map(
585 params![pattern, like_pattern, starts_with_pattern, limit],
586 |row| Ok(symbol_from_row(row)),
587 )?
588 .collect(),
589 _ => unreachable!("Filter and param position should match"),
591 };
592
593 rows.into_iter().collect()
594 }
595
596 pub fn get_source(&self, symbol_id: &str) -> Result<Option<String>> {
598 self.conn
599 .query_row(
600 "SELECT source FROM symbols WHERE id = ?",
601 [symbol_id],
602 |row| row.get(0),
603 )
604 .optional()
605 }
606
607 pub fn get_file_symbols(&self, file_path: &str) -> Result<Vec<Symbol>> {
609 let mut stmt = self.conn.prepare(
610 r#"
611 SELECT id, file_path, name, qualified_name, kind, visibility,
612 signature, brief, docstring, line_start, line_end,
613 col_start, col_end, parent_id, source
614 FROM symbols
615 WHERE file_path = ?
616 ORDER BY line_start
617 "#,
618 )?;
619
620 let rows = stmt.query_map([file_path], |row| Ok(symbol_from_row(row)))?;
621 rows.collect()
622 }
623
624 pub fn find_symbols_in_file(&self, file_path: &str) -> Result<Vec<Symbol>> {
626 self.get_file_symbols(file_path)
627 }
628
629 pub fn get_outgoing_edges(&self, symbol_id: &str) -> Result<Vec<Edge>> {
631 let mut stmt = self.conn.prepare(
632 r#"
633 SELECT source_id, target_id, target_name, kind, line, col, context
634 FROM edges
635 WHERE source_id = ?
636 ORDER BY line
637 "#,
638 )?;
639
640 let rows = stmt.query_map([symbol_id], |row| Ok(edge_from_row(row)))?;
641 rows.collect()
642 }
643
644 pub fn get_incoming_edges(&self, target_name: &str) -> Result<Vec<Edge>> {
646 let mut stmt = self.conn.prepare(
647 r#"
648 SELECT source_id, target_id, target_name, kind, line, col, context
649 FROM edges
650 WHERE target_name = ? OR target_id = ?
651 ORDER BY source_id
652 "#,
653 )?;
654
655 let rows = stmt.query_map([target_name, target_name], |row| Ok(edge_from_row(row)))?;
656 rows.collect()
657 }
658
659 pub fn get_stats(&self) -> Result<CodebaseStats> {
661 let files: i64 = self
662 .conn
663 .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
664 let symbols: i64 = self
665 .conn
666 .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))?;
667 let edges: i64 = self
668 .conn
669 .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?;
670 let functions: i64 = self.conn.query_row(
671 "SELECT COUNT(*) FROM symbols WHERE kind IN ('function', 'method')",
672 [],
673 |row| row.get(0),
674 )?;
675 let structs: i64 = self.conn.query_row(
676 "SELECT COUNT(*) FROM symbols WHERE kind IN ('struct', 'class')",
677 [],
678 |row| row.get(0),
679 )?;
680 let enums: i64 = self.conn.query_row(
681 "SELECT COUNT(*) FROM symbols WHERE kind = 'enum'",
682 [],
683 |row| row.get(0),
684 )?;
685 let traits: i64 = self.conn.query_row(
686 "SELECT COUNT(*) FROM symbols WHERE kind IN ('trait', 'interface')",
687 [],
688 |row| row.get(0),
689 )?;
690
691 Ok(CodebaseStats {
692 files,
693 symbols,
694 edges,
695 functions,
696 structs,
697 enums,
698 traits,
699 })
700 }
701
702 pub fn get_indexed_files(&self) -> Result<Vec<String>> {
704 let mut stmt = self.conn.prepare("SELECT path FROM files ORDER BY path")?;
705 let rows = stmt.query_map([], |row| row.get(0))?;
706 rows.collect()
707 }
708
709 fn bm25_relevance(rank: f64) -> f64 {
711 if rank.is_finite() {
712 let abs_rank = rank.abs();
714 (abs_rank / (1.0 + abs_rank)).clamp(0.0, 1.0)
715 } else {
716 0.0 }
718 }
719
720 pub fn semantic_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64)>> {
723 let keywords = preprocess_search_query(query);
725
726 if keywords.is_empty() {
727 return Ok(Vec::new());
728 }
729
730 let fts_query = keywords.join(" OR ");
732
733 let mut stmt = self.conn.prepare(
734 r#"
735 SELECT
736 s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
737 s.signature, s.brief, s.docstring, s.line_start, s.line_end,
738 s.col_start, s.col_end, s.parent_id, s.source,
739 bm25(symbol_fts) as rank
740 FROM symbol_fts
741 JOIN symbols s ON symbol_fts.id = s.id
742 WHERE symbol_fts MATCH ?
743 ORDER BY rank
744 LIMIT ?
745 "#,
746 )?;
747
748 let rows = stmt.query_map(params![fts_query, limit], |row| {
749 let symbol = symbol_from_row(row);
750 let rank: f64 = row.get(15)?;
751 let relevance = Self::bm25_relevance(rank);
752 Ok((symbol, relevance))
753 })?;
754
755 rows.collect()
756 }
757
758 pub fn hybrid_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64, String)>> {
760 let mut results: std::collections::HashMap<String, (Symbol, f64, String)> =
761 std::collections::HashMap::new();
762
763 let half_limit = (limit / 2).max(1);
765
766 let exact_matches = self.find_symbols(query, half_limit)?;
768 for symbol in exact_matches {
769 let score = if symbol.name.eq_ignore_ascii_case(query) {
770 1.0 } else if symbol
772 .name
773 .to_lowercase()
774 .starts_with(&query.to_lowercase())
775 {
776 0.9 } else {
778 0.7 };
780 results.insert(symbol.id.clone(), (symbol, score, "exact".to_string()));
781 }
782
783 if let Ok(semantic_matches) = self.semantic_search(query, half_limit) {
785 for (symbol, relevance) in semantic_matches {
786 results
787 .entry(symbol.id.clone())
788 .and_modify(|(_, existing_score, _)| {
789 *existing_score = existing_score.max(relevance);
790 })
791 .or_insert((symbol, relevance, "semantic".to_string()));
792 }
793 }
794
795 let mut results: Vec<_> = results.into_values().collect();
797 results.sort_by(|a, b| {
799 match (a.1.is_finite(), b.1.is_finite()) {
800 (true, true) => b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal),
801 (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, (false, false) => std::cmp::Ordering::Equal,
804 }
805 });
806 results.truncate(limit as usize);
807
808 Ok(results)
809 }
810
811 #[allow(dead_code)]
813 pub fn rebuild_fts_index(&self) -> Result<()> {
814 self.conn.execute_batch(
815 r#"
816 INSERT INTO symbol_fts(symbol_fts) VALUES('rebuild');
817 "#,
818 )?;
819 Ok(())
820 }
821
822 pub fn store_embedding(
830 &self,
831 symbol_id: &str,
832 provider: &str,
833 model: &str,
834 vector: &[f32],
835 ) -> Result<()> {
836 let vector_json = serde_json::to_string(vector)
837 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
838
839 self.conn.execute(
841 r#"
842 INSERT OR REPLACE INTO embeddings (symbol_id, provider, model, dimension, vector)
843 VALUES (?, ?, ?, ?, ?)
844 "#,
845 params![symbol_id, provider, model, vector.len(), vector_json],
846 )?;
847
848 if self.has_vector_search() && vector.len() == DEFAULT_EMBEDDING_DIM {
850 let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
852
853 let _ = self.conn.execute(
855 "DELETE FROM symbol_vectors WHERE symbol_id = ?",
856 [symbol_id],
857 );
858
859 self.conn.execute(
860 "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
861 params![vector_bytes, symbol_id],
862 )?;
863 }
864
865 Ok(())
866 }
867
868 #[allow(dead_code)]
870 pub fn get_embedding(&self, symbol_id: &str) -> Result<Option<Vec<f32>>> {
871 let result = self.conn.query_row(
872 "SELECT vector FROM embeddings WHERE symbol_id = ?",
873 [symbol_id],
874 |row| {
875 let json: String = row.get(0)?;
876 Ok(json)
877 },
878 );
879
880 match result {
881 Ok(json) => {
882 let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
883 rusqlite::Error::FromSqlConversionFailure(
884 0,
885 rusqlite::types::Type::Text,
886 Box::new(e),
887 )
888 })?;
889 Ok(Some(vector))
890 }
891 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
892 Err(e) => Err(e),
893 }
894 }
895
896 #[allow(clippy::type_complexity)]
897 pub fn get_all_embeddings(
899 &self,
900 ) -> Result<Vec<(String, String, String, String, u32, Vec<f32>)>> {
901 let mut stmt = self.conn.prepare(
902 r#"
903 SELECT e.symbol_id, s.name, s.kind, s.file_path, s.line_start, e.vector
904 FROM embeddings e
905 JOIN symbols s ON e.symbol_id = s.id
906 "#,
907 )?;
908
909 let rows = stmt.query_map([], |row| {
910 let symbol_id: String = row.get(0)?;
911 let name: String = row.get(1)?;
912 let kind: String = row.get(2)?;
913 let file_path: String = row.get(3)?;
914 let line: u32 = row.get(4)?;
915 let json: String = row.get(5)?;
916 Ok((symbol_id, name, kind, file_path, line, json))
917 })?;
918
919 let mut results = Vec::new();
920 for row in rows {
921 let (symbol_id, name, kind, file_path, line, json) = row?;
922 let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
923 rusqlite::Error::FromSqlConversionFailure(
924 0,
925 rusqlite::types::Type::Text,
926 Box::new(e),
927 )
928 })?;
929 results.push((symbol_id, name, kind, file_path, line, vector));
930 }
931
932 Ok(results)
933 }
934
935 pub fn count_embeddings(&self) -> Result<i64> {
937 self.conn
938 .query_row("SELECT COUNT(*) FROM embeddings", [], |row| row.get(0))
939 }
940
941 pub fn get_embedding_metadata(&self) -> Result<Vec<(String, String, i64, i64)>> {
947 let mut stmt = self.conn.prepare(
948 r#"
949 SELECT provider, model, dimension, COUNT(*) as count
950 FROM embeddings
951 GROUP BY provider, model, dimension
952 ORDER BY count DESC
953 "#,
954 )?;
955
956 let rows = stmt.query_map([], |row| {
957 Ok((
958 row.get::<_, String>(0)?,
959 row.get::<_, String>(1)?,
960 row.get::<_, i64>(2)?,
961 row.get::<_, i64>(3)?,
962 ))
963 })?;
964
965 rows.collect()
966 }
967
968 pub fn get_symbols_without_embeddings(&self, limit: i64) -> Result<Vec<Symbol>> {
970 let mut stmt = self.conn.prepare(
971 r#"
972 SELECT s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
973 s.signature, s.brief, s.docstring, s.line_start, s.line_end,
974 s.col_start, s.col_end, s.parent_id, s.source
975 FROM symbols s
976 LEFT JOIN embeddings e ON s.id = e.symbol_id
977 WHERE e.symbol_id IS NULL
978 LIMIT ?
979 "#,
980 )?;
981
982 let rows = stmt.query_map([limit], |row| {
983 Ok(Symbol {
984 id: row.get(0)?,
985 file_path: row.get(1)?,
986 name: row.get(2)?,
987 qualified_name: row.get(3)?,
988 kind: SymbolKind::from_str(&row.get::<_, String>(4)?)
989 .unwrap_or(SymbolKind::Function),
990 visibility: Visibility::from_str(&row.get::<_, String>(5)?).unwrap_or_default(),
991 signature: row.get(6)?,
992 brief: row.get(7)?,
993 docstring: row.get(8)?,
994 line_start: row.get(9)?,
995 line_end: row.get(10)?,
996 col_start: row.get(11)?,
997 col_end: row.get(12)?,
998 parent_id: row.get(13)?,
999 source: row.get(14)?,
1000 })
1001 })?;
1002
1003 rows.collect()
1004 }
1005
1006 #[allow(dead_code)] pub fn migrate_embeddings_to_vec(&self) -> Result<usize> {
1012 if !self.has_vector_search() {
1013 return Ok(0);
1014 }
1015
1016 let mut stmt = self.conn.prepare(
1018 r#"
1019 SELECT symbol_id, vector FROM embeddings
1020 WHERE dimension = ?
1021 "#,
1022 )?;
1023
1024 let rows = stmt.query_map([DEFAULT_EMBEDDING_DIM as i64], |row| {
1025 let symbol_id: String = row.get(0)?;
1026 let json: String = row.get(1)?;
1027 Ok((symbol_id, json))
1028 })?;
1029
1030 let mut count = 0;
1031 for row in rows {
1032 let (symbol_id, json) = row?;
1033 let vector: Vec<f32> = match serde_json::from_str(&json) {
1034 Ok(v) => v,
1035 Err(_) => continue,
1036 };
1037
1038 let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1040
1041 let exists: bool = self
1043 .conn
1044 .query_row(
1045 "SELECT 1 FROM symbol_vectors WHERE symbol_id = ?",
1046 [&symbol_id],
1047 |_| Ok(true),
1048 )
1049 .unwrap_or(false);
1050
1051 if !exists
1052 && self
1053 .conn
1054 .execute(
1055 "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1056 params![vector_bytes, symbol_id],
1057 )
1058 .is_ok()
1059 {
1060 count += 1;
1061 }
1062 }
1063
1064 Ok(count)
1065 }
1066
1067 pub fn count_vector_embeddings(&self) -> Result<i64> {
1069 if !self.has_vector_search() {
1070 return Ok(0);
1071 }
1072 self.conn
1073 .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
1074 }
1075
1076 #[allow(clippy::type_complexity)]
1083 pub fn vector_search(
1085 &self,
1086 query_embedding: &[f32],
1087 limit: usize,
1088 ) -> Result<Vec<(String, String, String, String, u32, f32)>> {
1089 if !self.has_vector_search() {
1090 return Ok(Vec::new());
1091 }
1092
1093 if query_embedding.len() != DEFAULT_EMBEDDING_DIM {
1094 return Ok(Vec::new());
1095 }
1096
1097 let query_bytes: Vec<u8> = query_embedding
1099 .iter()
1100 .flat_map(|f| f.to_le_bytes())
1101 .collect();
1102
1103 let mut stmt = self.conn.prepare(
1106 r#"
1107 SELECT knn.symbol_id, s.name, s.kind, s.file_path, s.line_start, knn.distance
1108 FROM (
1109 SELECT symbol_id, distance
1110 FROM symbol_vectors
1111 WHERE embedding MATCH ?
1112 ORDER BY distance
1113 LIMIT ?
1114 ) knn
1115 JOIN symbols s ON knn.symbol_id = s.id
1116 ORDER BY knn.distance
1117 "#,
1118 )?;
1119
1120 let rows = stmt.query_map(params![query_bytes, limit as i64], |row| {
1121 Ok((
1122 row.get::<_, String>(0)?,
1123 row.get::<_, String>(1)?,
1124 row.get::<_, String>(2)?,
1125 row.get::<_, String>(3)?,
1126 row.get::<_, u32>(4)?,
1127 row.get::<_, f32>(5)?,
1128 ))
1129 })?;
1130
1131 rows.collect()
1132 }
1133
1134 pub fn has_vector_embeddings(&self) -> bool {
1136 self.count_vector_embeddings().unwrap_or(0) > 0
1137 }
1138
1139 pub fn delete_embeddings(&self, provider: &str, model: Option<&str>) -> Result<usize> {
1141 if let Some(model) = model {
1142 self.conn.execute(
1143 "DELETE FROM embeddings WHERE provider = ? AND model = ?",
1144 params![provider, model],
1145 )
1146 } else {
1147 self.conn
1148 .execute("DELETE FROM embeddings WHERE provider = ?", [provider])
1149 }
1150 }
1151
1152 pub fn resolve_edge_targets(&self) -> Result<usize> {
1165 let context_resolved = self.conn.execute(
1169 r#"
1170 UPDATE edges
1171 SET target_id = (
1172 SELECT t.id
1173 FROM symbols t
1174 WHERE t.name = edges.target_name
1175 AND t.kind IN ('function', 'method')
1176 AND t.qualified_name IS NOT NULL
1177 AND edges.context LIKE '%' || t.qualified_name || '%'
1178 )
1179 WHERE target_id IS NULL
1180 AND context IS NOT NULL
1181 AND (
1182 SELECT COUNT(*)
1183 FROM symbols t
1184 WHERE t.name = edges.target_name
1185 AND t.kind IN ('function', 'method')
1186 AND t.qualified_name IS NOT NULL
1187 AND edges.context LIKE '%' || t.qualified_name || '%'
1188 ) = 1
1189 "#,
1190 [],
1191 )?;
1192
1193 let unique_resolved = self.conn.execute(
1195 r#"
1196 UPDATE edges
1197 SET target_id = (
1198 SELECT id FROM symbols
1199 WHERE name = edges.target_name
1200 AND kind IN ('function', 'method')
1201 )
1202 WHERE target_id IS NULL
1203 AND (
1204 SELECT COUNT(*) FROM symbols
1205 WHERE name = edges.target_name
1206 AND kind IN ('function', 'method')
1207 ) = 1
1208 "#,
1209 [],
1210 )?;
1211
1212 let same_file_unique = self.conn.execute(
1219 r#"
1220 UPDATE edges
1221 SET target_id = (
1222 SELECT t.id
1223 FROM symbols t
1224 JOIN symbols s ON s.id = edges.source_id
1225 WHERE t.name = edges.target_name
1226 AND t.file_path = s.file_path
1227 AND t.kind IN ('function', 'method')
1228 )
1229 WHERE target_id IS NULL
1230 AND (
1231 SELECT COUNT(*)
1232 FROM symbols t
1233 JOIN symbols s ON s.id = edges.source_id
1234 WHERE t.name = edges.target_name
1235 AND t.file_path = s.file_path
1236 AND t.kind IN ('function', 'method')
1237 ) = 1
1238 -- Exclude if context suggests an external type call (has :: before the function name)
1239 -- e.g., "Vec::new()" or "Parser::new()" should NOT match local "new" functions
1240 AND (
1241 context IS NULL
1242 OR (
1243 context NOT LIKE '%::' || target_name || '(%'
1244 AND context NOT LIKE '%.' || target_name || '(%'
1245 AND context NOT LIKE '%->' || target_name || '(%'
1246 )
1247 OR context LIKE '%' || (
1248 SELECT t.qualified_name
1249 FROM symbols t
1250 JOIN symbols s ON s.id = edges.source_id
1251 WHERE t.name = edges.target_name
1252 AND t.file_path = s.file_path
1253 AND t.kind IN ('function', 'method')
1254 LIMIT 1
1255 ) || '(%'
1256 )
1257 "#,
1258 [],
1259 )?;
1260
1261 Ok(context_resolved + unique_resolved + same_file_unique)
1262 }
1263
1264 pub fn find_duplicates(
1266 &self,
1267 similarity_threshold: u32,
1268 min_lines: u32,
1269 ) -> Result<Vec<DuplicateResult>> {
1270 let mut stmt = self.conn.prepare(
1273 r#"
1274 SELECT DISTINCT id, name, file_path, line_start, line_end, source
1275 FROM symbols
1276 WHERE kind IN ('function', 'method')
1277 AND source IS NOT NULL
1278 AND (line_end - line_start) >= ?
1279 ORDER BY id
1280 "#,
1281 )?;
1282
1283 let symbols: Vec<(String, String, String, u32, u32, String)> = stmt
1284 .query_map([min_lines], |row| {
1285 Ok((
1286 row.get(0)?,
1287 row.get(1)?,
1288 row.get(2)?,
1289 row.get(3)?,
1290 row.get(4)?,
1291 row.get(5)?,
1292 ))
1293 })?
1294 .filter_map(|r| r.ok())
1295 .collect();
1296
1297 let mut duplicates = Vec::new();
1298 let threshold = similarity_threshold as f64 / 100.0;
1299
1300 let mut seen_pairs: std::collections::HashSet<(String, String)> =
1302 std::collections::HashSet::new();
1303
1304 for i in 0..symbols.len() {
1306 for j in (i + 1)..symbols.len() {
1307 let (id1, name1, file1, line1, end1, source1) = &symbols[i];
1308 let (id2, name2, file2, line2, end2, source2) = &symbols[j];
1309
1310 if id1 == id2 || (file1 == file2 && line1 == line2) {
1312 continue;
1313 }
1314
1315 let pair_key = if id1 < id2 {
1317 (id1.clone(), id2.clone())
1318 } else {
1319 (id2.clone(), id1.clone())
1320 };
1321
1322 if seen_pairs.contains(&pair_key) {
1324 continue;
1325 }
1326
1327 let norm1 = normalize_code(source1);
1329 let norm2 = normalize_code(source2);
1330
1331 let similarity = calculate_similarity(&norm1, &norm2);
1332
1333 if similarity >= threshold {
1334 seen_pairs.insert(pair_key);
1336
1337 let hash = format!("{:x}", md5_hash(&norm1));
1339
1340 duplicates.push(DuplicateResult {
1341 name1: name1.clone(),
1342 file1: file1.clone(),
1343 line1: *line1,
1344 name2: name2.clone(),
1345 file2: file2.clone(),
1346 line2: *line2,
1347 similarity: similarity * 100.0,
1348 lines: ((end1 - line1) + (end2 - line2)) / 2,
1349 hash,
1350 });
1351 }
1352 }
1353 }
1354
1355 duplicates.sort_by(
1357 |a, b| match (a.similarity.is_finite(), b.similarity.is_finite()) {
1358 (true, true) => b
1359 .similarity
1360 .partial_cmp(&a.similarity)
1361 .unwrap_or(std::cmp::Ordering::Equal),
1362 (true, false) => std::cmp::Ordering::Less,
1363 (false, true) => std::cmp::Ordering::Greater,
1364 (false, false) => std::cmp::Ordering::Equal,
1365 },
1366 );
1367
1368 Ok(duplicates)
1369 }
1370}
1371
1372fn escape_like_pattern(pattern: &str) -> String {
1377 pattern
1378 .replace('\\', "\\\\") .replace('%', "\\%")
1380 .replace('_', "\\_")
1381}
1382
1383fn glob_to_like_pattern(pattern: &str) -> String {
1402 let mut result = String::with_capacity(pattern.len() * 2);
1403 let mut chars = pattern.chars().peekable();
1404
1405 while let Some(c) = chars.next() {
1406 match c {
1407 '*' => {
1408 if chars.peek() == Some(&'*') {
1410 chars.next();
1411 if chars.peek() == Some(&'/') {
1413 chars.next();
1414 if chars.peek().is_none() || chars.peek() == Some(&'*') {
1419 continue;
1421 }
1422 result.push('%');
1424 } else {
1425 result.push('%');
1427 }
1428 } else {
1429 result.push('%');
1431 }
1432 }
1433 '?' => result.push('_'),
1434 '%' => result.push_str("\\%"),
1435 '_' => result.push_str("\\_"),
1436 '\\' => result.push_str("\\\\"),
1437 _ => result.push(c),
1438 }
1439 }
1440
1441 result
1442}
1443
1444fn normalize_code(code: &str) -> String {
1446 code.lines()
1447 .map(|line| {
1448 let trimmed = line.trim();
1450 let without_comment = if let Some(idx) = trimmed.find("//") {
1452 &trimmed[..idx]
1453 } else if let Some(idx) = trimmed.find('#') {
1454 &trimmed[..idx]
1455 } else {
1456 trimmed
1457 };
1458 without_comment.trim()
1459 })
1460 .filter(|line| !line.is_empty())
1461 .collect::<Vec<_>>()
1462 .join("\n")
1463}
1464
1465fn calculate_similarity(s1: &str, s2: &str) -> f64 {
1467 let tokens1: std::collections::HashSet<&str> = s1
1468 .split(|c: char| {
1469 c.is_whitespace()
1470 || c == '('
1471 || c == ')'
1472 || c == '{'
1473 || c == '}'
1474 || c == ';'
1475 || c == ','
1476 })
1477 .filter(|t| !t.is_empty())
1478 .collect();
1479
1480 let tokens2: std::collections::HashSet<&str> = s2
1481 .split(|c: char| {
1482 c.is_whitespace()
1483 || c == '('
1484 || c == ')'
1485 || c == '{'
1486 || c == '}'
1487 || c == ';'
1488 || c == ','
1489 })
1490 .filter(|t| !t.is_empty())
1491 .collect();
1492
1493 if tokens1.is_empty() && tokens2.is_empty() {
1494 return 1.0;
1495 }
1496 if tokens1.is_empty() || tokens2.is_empty() {
1497 return 0.0;
1498 }
1499
1500 let intersection = tokens1.intersection(&tokens2).count();
1501 let union = tokens1.union(&tokens2).count();
1502
1503 intersection as f64 / union as f64
1504}
1505
1506fn md5_hash(s: &str) -> u64 {
1508 use std::collections::hash_map::DefaultHasher;
1509 use std::hash::{Hash, Hasher};
1510
1511 let mut hasher = DefaultHasher::new();
1512 s.hash(&mut hasher);
1513 hasher.finish()
1514}
1515
1516fn preprocess_search_query(query: &str) -> Vec<String> {
1518 let stop_words: std::collections::HashSet<&str> = [
1520 "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
1521 "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall",
1522 "can", "need", "dare", "and", "or", "but", "if", "then", "else", "when", "where", "why",
1523 "how", "what", "which", "who", "whom", "this", "that", "these", "those", "i", "you", "he",
1524 "she", "it", "we", "they", "me", "him", "her", "us", "them", "my", "your", "his", "its",
1525 "our", "their", "for", "to", "from", "with", "at", "by", "on", "in", "of", "about", "into",
1526 "through", "during", "before", "after", "above", "below", "find", "get", "search", "look",
1527 "all", "any", "each", "every",
1528 ]
1529 .iter()
1530 .copied()
1531 .collect();
1532
1533 query
1534 .to_lowercase()
1535 .split(|c: char| !c.is_alphanumeric() && c != '_')
1536 .filter(|word| {
1537 let word = word.trim();
1538 !word.is_empty() && word.len() > 1 && !stop_words.contains(word)
1539 })
1540 .map(|s| {
1541 format!("{}*", s)
1543 })
1544 .collect()
1545}
1546
1547fn symbol_from_row(row: &rusqlite::Row) -> Symbol {
1549 let kind_str: String = row.get(4).unwrap_or_default();
1550 let visibility_str: String = row.get(5).unwrap_or_default();
1551
1552 Symbol {
1553 id: row.get(0).unwrap_or_default(),
1554 file_path: row.get(1).unwrap_or_default(),
1555 name: row.get(2).unwrap_or_default(),
1556 qualified_name: row.get(3).ok(),
1557 kind: SymbolKind::from_str(&kind_str).unwrap_or(SymbolKind::Function),
1558 visibility: Visibility::from_str(&visibility_str).unwrap_or_default(),
1559 signature: row.get(6).ok(),
1560 brief: row.get(7).ok(),
1561 docstring: row.get(8).ok(),
1562 line_start: row.get(9).unwrap_or(0),
1563 line_end: row.get(10).unwrap_or(0),
1564 col_start: row.get(11).unwrap_or(0),
1565 col_end: row.get(12).unwrap_or(0),
1566 parent_id: row.get(13).ok(),
1567 source: row.get(14).ok(),
1568 }
1569}
1570
1571fn edge_from_row(row: &rusqlite::Row) -> Edge {
1573 let kind_str: String = row.get(3).unwrap_or_default();
1574
1575 Edge {
1576 source_id: row.get(0).unwrap_or_default(),
1577 target_id: row.get(1).ok(),
1578 target_name: row.get(2).unwrap_or_default(),
1579 kind: EdgeKind::from_str(&kind_str).unwrap_or(EdgeKind::Calls),
1580 line: row.get(4).ok(),
1581 col: row.get(5).ok(),
1582 context: row.get(6).ok(),
1583 }
1584}
1585
1586#[derive(Debug, Clone)]
1588pub struct DuplicateResult {
1589 pub name1: String,
1590 pub file1: String,
1591 pub line1: u32,
1592 pub name2: String,
1593 pub file2: String,
1594 pub line2: u32,
1595 pub similarity: f64,
1596 pub lines: u32,
1597 pub hash: String,
1598}
1599
1600trait ResultExt<T> {
1602 fn optional(self) -> Result<Option<T>>;
1603}
1604
1605impl<T> ResultExt<T> for Result<T> {
1606 fn optional(self) -> Result<Option<T>> {
1607 match self {
1608 Ok(v) => Ok(Some(v)),
1609 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1610 Err(e) => Err(e),
1611 }
1612 }
1613}
1614
1615#[cfg(test)]
1616mod tests {
1617 use super::*;
1618
1619 #[test]
1620 fn test_create_database() {
1621 let db = Database::open_in_memory().unwrap();
1622 let stats = db.get_stats().unwrap();
1623 assert_eq!(stats.files, 0);
1624 assert_eq!(stats.symbols, 0);
1625 }
1626
1627 #[test]
1628 fn test_sqlite_vec_extension_loaded() {
1629 let db = Database::open_in_memory().unwrap();
1631
1632 if !is_vec_extension_available() {
1636 eprintln!("Skipping sqlite-vec tests: extension not available on this platform");
1637 return;
1638 }
1639
1640 let version: Result<String, _> = db
1642 .conn
1643 .query_row("SELECT vec_version()", [], |row| row.get(0));
1644
1645 match version {
1646 Ok(v) => {
1647 assert!(
1648 v.starts_with('v'),
1649 "vec_version should return version string, got: {}",
1650 v
1651 );
1652 }
1653 Err(e) => {
1654 panic!(
1657 "sqlite-vec extension reported available but vec_version() failed: {}",
1658 e
1659 );
1660 }
1661 }
1662
1663 assert!(db.has_vector_search(), "vector search should be available");
1665
1666 let count: i64 = db
1668 .conn
1669 .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
1670 .expect("symbol_vectors table should exist");
1671 assert_eq!(count, 0, "symbol_vectors should be empty initially");
1672 }
1673
1674 #[test]
1675 fn test_insert_and_find_symbol() {
1676 let db = Database::open_in_memory().unwrap();
1677
1678 let file = FileRecord {
1680 path: "src/main.rs".to_string(),
1681 content_hash: "abc123".to_string(),
1682 size_bytes: 100,
1683 language: Some("rust".to_string()),
1684 last_indexed: 0,
1685 };
1686 db.upsert_file(&file, None).unwrap();
1687
1688 let symbol = Symbol {
1690 id: "src/main.rs::main".to_string(),
1691 file_path: "src/main.rs".to_string(),
1692 name: "main".to_string(),
1693 qualified_name: None,
1694 kind: SymbolKind::Function,
1695 visibility: Visibility::Private,
1696 signature: Some("fn main()".to_string()),
1697 brief: Some("Entry point".to_string()),
1698 docstring: None,
1699 line_start: 1,
1700 line_end: 5,
1701 col_start: 0,
1702 col_end: 1,
1703 parent_id: None,
1704 source: Some("fn main() {\n println!(\"Hello\");\n}".to_string()),
1705 };
1706 db.insert_symbol(&symbol).unwrap();
1707
1708 let found = db.get_symbol("src/main.rs::main").unwrap();
1710 assert!(found.is_some());
1711 assert_eq!(found.unwrap().name, "main");
1712
1713 let results = db.find_symbols("main", 10).unwrap();
1715 assert_eq!(results.len(), 1);
1716 }
1717
1718 #[test]
1719 fn test_find_symbols_exact_match_ordering() {
1720 let db = Database::open_in_memory().unwrap();
1721
1722 let file = FileRecord {
1724 path: "src/test.rs".to_string(),
1725 content_hash: "abc123".to_string(),
1726 size_bytes: 100,
1727 language: Some("rust".to_string()),
1728 last_indexed: 0,
1729 };
1730 db.upsert_file(&file, None).unwrap();
1731
1732 let symbols = vec![
1735 ("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"), ];
1741
1742 for (name, id) in &symbols {
1743 let symbol = Symbol {
1744 id: id.to_string(),
1745 file_path: "src/test.rs".to_string(),
1746 name: name.to_string(),
1747 qualified_name: None,
1748 kind: SymbolKind::Function,
1749 visibility: Visibility::Public,
1750 signature: None,
1751 brief: None,
1752 docstring: None,
1753 line_start: 1,
1754 line_end: 5,
1755 col_start: 0,
1756 col_end: 1,
1757 parent_id: None,
1758 source: None,
1759 };
1760 db.insert_symbol(&symbol).unwrap();
1761 }
1762
1763 let results = db.find_symbols("new", 10).unwrap();
1765
1766 assert!(results.len() >= 4, "Should find at least 4 symbols");
1767
1768 assert_eq!(results[0].name, "new", "Exact match 'new' should be first");
1770
1771 let prefix_matches: Vec<&str> = results[1..4].iter().map(|s| s.name.as_str()).collect();
1774 assert!(
1775 prefix_matches.iter().all(|n| n.starts_with("new")),
1776 "Positions 2-4 should be prefix matches, got: {:?}",
1777 prefix_matches
1778 );
1779
1780 let renew_pos = results.iter().position(|s| s.name == "renew");
1782 assert!(
1783 renew_pos.is_some() && renew_pos.unwrap() >= 4,
1784 "'renew' should be after prefix matches"
1785 );
1786 }
1787
1788 #[test]
1789 fn test_find_symbols_with_file_filter() {
1790 let db = Database::open_in_memory().unwrap();
1791
1792 for path in &["src/parser/rust.rs", "src/embeddings/local.rs"] {
1794 let file = FileRecord {
1795 path: path.to_string(),
1796 content_hash: "abc123".to_string(),
1797 size_bytes: 100,
1798 language: Some("rust".to_string()),
1799 last_indexed: 0,
1800 };
1801 db.upsert_file(&file, None).unwrap();
1802 }
1803
1804 for (path, id) in &[
1806 ("src/parser/rust.rs", "src/parser/rust.rs::new"),
1807 ("src/embeddings/local.rs", "src/embeddings/local.rs::new"),
1808 ] {
1809 let symbol = Symbol {
1810 id: id.to_string(),
1811 file_path: path.to_string(),
1812 name: "new".to_string(),
1813 qualified_name: None,
1814 kind: SymbolKind::Method,
1815 visibility: Visibility::Public,
1816 signature: None,
1817 brief: None,
1818 docstring: None,
1819 line_start: 1,
1820 line_end: 5,
1821 col_start: 0,
1822 col_end: 1,
1823 parent_id: None,
1824 source: None,
1825 };
1826 db.insert_symbol(&symbol).unwrap();
1827 }
1828
1829 let all_results = db.find_symbols_filtered("new", 10, None, None).unwrap();
1831 assert_eq!(all_results.len(), 2);
1832
1833 let filtered = db
1835 .find_symbols_filtered("new", 10, Some("*parser*"), None)
1836 .unwrap();
1837 assert_eq!(filtered.len(), 1);
1838 assert!(filtered[0].file_path.contains("parser"));
1839
1840 let filtered = db
1842 .find_symbols_filtered("new", 10, Some("*embeddings*"), None)
1843 .unwrap();
1844 assert_eq!(filtered.len(), 1);
1845 assert!(filtered[0].file_path.contains("embeddings"));
1846 }
1847
1848 #[test]
1849 fn test_escape_like_pattern() {
1850 assert_eq!(escape_like_pattern("new"), "new");
1852 assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
1853
1854 assert_eq!(escape_like_pattern("100%"), "100\\%");
1856 assert_eq!(escape_like_pattern("a_b"), "a\\_b");
1857 assert_eq!(escape_like_pattern("a%b_c"), "a\\%b\\_c");
1858 }
1859
1860 #[test]
1861 fn test_glob_to_like_pattern() {
1862 assert_eq!(glob_to_like_pattern("*.rs"), "%.rs");
1864 assert_eq!(glob_to_like_pattern("src/*"), "src/%");
1865
1866 assert_eq!(glob_to_like_pattern("**/*.rs"), "%.rs");
1869 assert_eq!(glob_to_like_pattern("src/**/*.rs"), "src/%.rs");
1870
1871 assert_eq!(glob_to_like_pattern("src/**"), "src/%");
1873
1874 assert_eq!(glob_to_like_pattern("**/foo.rs"), "%foo.rs");
1876
1877 assert_eq!(glob_to_like_pattern("file?.txt"), "file_.txt");
1879
1880 assert_eq!(glob_to_like_pattern("100%"), "100\\%");
1882 assert_eq!(glob_to_like_pattern("a_b"), "a\\_b");
1883 }
1884
1885 #[test]
1886 fn test_glob_pattern_matches_files() {
1887 let db = Database::open_in_memory().unwrap();
1888
1889 for path in &[
1891 "main.rs",
1892 "src/lib.rs",
1893 "src/parser/mod.rs",
1894 "src/parser/rust.rs",
1895 "tests/test.rs",
1896 ] {
1897 let file = FileRecord {
1898 path: path.to_string(),
1899 content_hash: "abc".to_string(),
1900 size_bytes: 100,
1901 language: Some("rust".to_string()),
1902 last_indexed: 0,
1903 };
1904 db.upsert_file(&file, None).unwrap();
1905
1906 let symbol = Symbol {
1907 id: format!("{}::main", path),
1908 file_path: path.to_string(),
1909 name: "main".to_string(),
1910 qualified_name: None,
1911 kind: SymbolKind::Function,
1912 visibility: Visibility::Public,
1913 signature: None,
1914 brief: None,
1915 docstring: None,
1916 line_start: 1,
1917 line_end: 5,
1918 col_start: 0,
1919 col_end: 1,
1920 parent_id: None,
1921 source: None,
1922 };
1923 db.insert_symbol(&symbol).unwrap();
1924 }
1925
1926 let results = db
1928 .find_symbols_filtered("main", 20, Some("**/*.rs"), None)
1929 .unwrap();
1930 assert_eq!(results.len(), 5, "**/*.rs should match all .rs files");
1931
1932 let results = db
1934 .find_symbols_filtered("main", 20, Some("src/**/*.rs"), None)
1935 .unwrap();
1936 assert_eq!(
1937 results.len(),
1938 3,
1939 "src/**/*.rs should match src/lib.rs, src/parser/mod.rs, src/parser/rust.rs"
1940 );
1941
1942 let results = db
1945 .find_symbols_filtered("main", 20, Some("src/*.rs"), None)
1946 .unwrap();
1947 assert_eq!(
1948 results.len(),
1949 3,
1950 "src/*.rs matches all under src/ (SQL LIKE limitation)"
1951 );
1952
1953 let results = db
1955 .find_symbols_filtered("main", 20, Some("*parser*"), None)
1956 .unwrap();
1957 assert_eq!(
1958 results.len(),
1959 2,
1960 "*parser* should match parser directory files"
1961 );
1962 }
1963
1964 #[test]
1965 fn test_hybrid_search_limit_one() {
1966 let db = Database::open_in_memory().unwrap();
1969
1970 let file = FileRecord {
1972 path: "src/main.rs".to_string(),
1973 content_hash: "abc123".to_string(),
1974 size_bytes: 100,
1975 language: Some("rust".to_string()),
1976 last_indexed: 0,
1977 };
1978 db.upsert_file(&file, None).unwrap();
1979
1980 let symbol = Symbol {
1981 id: "src/main.rs::authenticate".to_string(),
1982 file_path: "src/main.rs".to_string(),
1983 name: "authenticate".to_string(),
1984 qualified_name: None,
1985 kind: SymbolKind::Function,
1986 visibility: Visibility::Public,
1987 signature: Some("fn authenticate(user: &str)".to_string()),
1988 brief: Some("Authenticate a user".to_string()),
1989 docstring: None,
1990 line_start: 1,
1991 line_end: 5,
1992 col_start: 0,
1993 col_end: 1,
1994 parent_id: None,
1995 source: None,
1996 };
1997 db.insert_symbol(&symbol).unwrap();
1998
1999 let results = db.hybrid_search("authenticate", 1).unwrap();
2001 assert_eq!(
2002 results.len(),
2003 1,
2004 "hybrid_search with limit=1 should return 1 result"
2005 );
2006 assert_eq!(results[0].0.name, "authenticate");
2007 }
2008
2009 #[test]
2010 fn test_hybrid_search_limit_three() {
2011 let db = Database::open_in_memory().unwrap();
2013
2014 let file = FileRecord {
2016 path: "src/test.rs".to_string(),
2017 content_hash: "abc123".to_string(),
2018 size_bytes: 100,
2019 language: Some("rust".to_string()),
2020 last_indexed: 0,
2021 };
2022 db.upsert_file(&file, None).unwrap();
2023
2024 for (name, i) in &[("login", 1), ("logout", 2), ("log_error", 3)] {
2026 let symbol = Symbol {
2027 id: format!("src/test.rs::{}", name),
2028 file_path: "src/test.rs".to_string(),
2029 name: name.to_string(),
2030 qualified_name: None,
2031 kind: SymbolKind::Function,
2032 visibility: Visibility::Public,
2033 signature: Some(format!("fn {}()", name)),
2034 brief: Some(format!("{} function", name)),
2035 docstring: None,
2036 line_start: *i,
2037 line_end: *i + 5,
2038 col_start: 0,
2039 col_end: 1,
2040 parent_id: None,
2041 source: None,
2042 };
2043 db.insert_symbol(&symbol).unwrap();
2044 }
2045
2046 let results = db.hybrid_search("log", 3).unwrap();
2048 assert!(
2049 !results.is_empty(),
2050 "hybrid_search with limit=3 should return results"
2051 );
2052 assert!(results.len() <= 3, "hybrid_search should respect limit");
2053 }
2054
2055 #[test]
2056 fn test_semantic_search_score_range() {
2057 let db = Database::open_in_memory().unwrap();
2059
2060 let file = FileRecord {
2062 path: "src/main.rs".to_string(),
2063 content_hash: "abc123".to_string(),
2064 size_bytes: 100,
2065 language: Some("rust".to_string()),
2066 last_indexed: 0,
2067 };
2068 db.upsert_file(&file, None).unwrap();
2069
2070 let symbol = Symbol {
2071 id: "src/main.rs::process_data".to_string(),
2072 file_path: "src/main.rs".to_string(),
2073 name: "process_data".to_string(),
2074 qualified_name: None,
2075 kind: SymbolKind::Function,
2076 visibility: Visibility::Public,
2077 signature: Some("fn process_data(input: &str) -> Result<()>".to_string()),
2078 brief: Some("Process incoming data".to_string()),
2079 docstring: Some("Processes the incoming data and returns a result.".to_string()),
2080 line_start: 1,
2081 line_end: 10,
2082 col_start: 0,
2083 col_end: 1,
2084 parent_id: None,
2085 source: None,
2086 };
2087 db.insert_symbol(&symbol).unwrap();
2088
2089 let results = db.semantic_search("process data", 10).unwrap();
2091 for (symbol, score) in &results {
2092 assert!(
2093 *score >= 0.0 && *score <= 1.0,
2094 "Score for '{}' should be in 0-1 range, got {}",
2095 symbol.name,
2096 score
2097 );
2098 assert!(
2099 score.is_finite(),
2100 "Score for '{}' should be finite, got {}",
2101 symbol.name,
2102 score
2103 );
2104 }
2105 }
2106
2107 #[test]
2108 fn test_bm25_relevance_mapping() {
2109 let cases = [
2110 (0.0, 0.0),
2111 (-1.0, 0.5),
2112 (1.0, 0.5),
2113 (-10.0, 10.0 / 11.0),
2114 (10.0, 10.0 / 11.0),
2115 ];
2116
2117 for (rank, expected) in cases {
2118 let actual = Database::bm25_relevance(rank);
2119 assert!(
2120 (actual - expected).abs() < 1e-12,
2121 "rank {} expected {} got {}",
2122 rank,
2123 expected,
2124 actual
2125 );
2126 }
2127
2128 assert_eq!(Database::bm25_relevance(f64::INFINITY), 0.0);
2129 assert_eq!(Database::bm25_relevance(f64::NEG_INFINITY), 0.0);
2130 assert_eq!(Database::bm25_relevance(f64::NAN), 0.0);
2131 }
2132
2133 #[test]
2134 fn test_get_embedding_metadata() {
2135 let db = Database::open_in_memory().unwrap();
2137
2138 let file = FileRecord {
2140 path: "src/main.rs".to_string(),
2141 content_hash: "abc123".to_string(),
2142 size_bytes: 100,
2143 language: Some("rust".to_string()),
2144 last_indexed: 0,
2145 };
2146 db.upsert_file(&file, None).unwrap();
2147
2148 for (name, id) in &[("foo", "src/main.rs::foo"), ("bar", "src/main.rs::bar")] {
2150 let symbol = Symbol {
2151 id: id.to_string(),
2152 file_path: "src/main.rs".to_string(),
2153 name: name.to_string(),
2154 qualified_name: None,
2155 kind: SymbolKind::Function,
2156 visibility: Visibility::Public,
2157 signature: None,
2158 brief: None,
2159 docstring: None,
2160 line_start: 1,
2161 line_end: 5,
2162 col_start: 0,
2163 col_end: 1,
2164 parent_id: None,
2165 source: None,
2166 };
2167 db.insert_symbol(&symbol).unwrap();
2168 }
2169
2170 let metadata = db.get_embedding_metadata().unwrap();
2172 assert!(
2173 metadata.is_empty(),
2174 "Should have no embedding metadata initially"
2175 );
2176
2177 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)
2182 .unwrap();
2183 db.store_embedding(
2184 "src/main.rs::bar",
2185 "openai",
2186 "text-embedding-3-small",
2187 &openai_vec,
2188 )
2189 .unwrap();
2190
2191 let metadata = db.get_embedding_metadata().unwrap();
2193
2194 assert_eq!(
2196 metadata.len(),
2197 2,
2198 "Should have 2 embedding metadata entries"
2199 );
2200
2201 let dims: Vec<i64> = metadata.iter().map(|(_, _, dim, _)| *dim).collect();
2203 assert!(
2204 dims.contains(&384),
2205 "Should have local embedding dimension 384"
2206 );
2207 assert!(
2208 dims.contains(&1536),
2209 "Should have OpenAI embedding dimension 1536"
2210 );
2211
2212 let providers: Vec<&str> = metadata.iter().map(|(p, _, _, _)| p.as_str()).collect();
2214 assert!(providers.contains(&"local"), "Should have local provider");
2215 assert!(providers.contains(&"openai"), "Should have openai provider");
2216 }
2217
2218 #[test]
2219 fn test_vector_search() {
2220 let db = Database::open_in_memory().unwrap();
2222
2223 if !is_vec_extension_available() {
2224 eprintln!("Skipping vector search test: sqlite-vec not available");
2225 return;
2226 }
2227
2228 let file = FileRecord {
2230 path: "src/main.rs".to_string(),
2231 content_hash: "abc123".to_string(),
2232 size_bytes: 100,
2233 language: Some("rust".to_string()),
2234 last_indexed: 0,
2235 };
2236 db.upsert_file(&file, None).unwrap();
2237
2238 for (name, id, line) in &[
2240 ("foo", "src/main.rs::foo", 1),
2241 ("bar", "src/main.rs::bar", 10),
2242 ("baz", "src/main.rs::baz", 20),
2243 ] {
2244 let symbol = Symbol {
2245 id: id.to_string(),
2246 file_path: "src/main.rs".to_string(),
2247 name: name.to_string(),
2248 qualified_name: None,
2249 kind: SymbolKind::Function,
2250 visibility: Visibility::Public,
2251 signature: None,
2252 brief: None,
2253 docstring: None,
2254 line_start: *line,
2255 line_end: *line + 5,
2256 col_start: 0,
2257 col_end: 1,
2258 parent_id: None,
2259 source: None,
2260 };
2261 db.insert_symbol(&symbol).unwrap();
2262 }
2263
2264 let mut foo_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2267 foo_vec[0] = 1.0;
2268 foo_vec[1] = 0.5;
2269
2270 let mut bar_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2271 bar_vec[0] = 0.9;
2272 bar_vec[1] = 0.6;
2273
2274 let mut baz_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2275 baz_vec[0] = 0.0;
2276 baz_vec[1] = 0.0;
2277 baz_vec[2] = 1.0; db.store_embedding(
2281 "src/main.rs::foo",
2282 "openai",
2283 "text-embedding-3-small",
2284 &foo_vec,
2285 )
2286 .unwrap();
2287 db.store_embedding(
2288 "src/main.rs::bar",
2289 "openai",
2290 "text-embedding-3-small",
2291 &bar_vec,
2292 )
2293 .unwrap();
2294 db.store_embedding(
2295 "src/main.rs::baz",
2296 "openai",
2297 "text-embedding-3-small",
2298 &baz_vec,
2299 )
2300 .unwrap();
2301
2302 let vec_count = db.count_vector_embeddings().unwrap();
2304 assert_eq!(vec_count, 3, "Should have 3 vector embeddings");
2305
2306 let query: Vec<f32> = foo_vec.clone();
2308 let results = db.vector_search(&query, 3).unwrap();
2309
2310 assert_eq!(results.len(), 3, "Should return 3 results");
2311
2312 assert_eq!(
2314 results[0].0, "src/main.rs::foo",
2315 "First result should be foo (exact match)"
2316 );
2317 assert_eq!(results[0].5, 0.0, "Exact match should have distance 0");
2318
2319 assert_eq!(
2321 results[1].0, "src/main.rs::bar",
2322 "Second result should be bar (similar)"
2323 );
2324
2325 assert_eq!(
2327 results[2].0, "src/main.rs::baz",
2328 "Third result should be baz (different)"
2329 );
2330
2331 assert!(
2333 results[2].5 > results[1].5,
2334 "baz should have larger distance than bar"
2335 );
2336 }
2337
2338 #[test]
2339 fn test_migrate_embeddings_to_vec() {
2340 let db = Database::open_in_memory().unwrap();
2342
2343 if !is_vec_extension_available() {
2344 eprintln!("Skipping migration test: sqlite-vec not available");
2345 return;
2346 }
2347
2348 let file = FileRecord {
2350 path: "src/main.rs".to_string(),
2351 content_hash: "abc123".to_string(),
2352 size_bytes: 100,
2353 language: Some("rust".to_string()),
2354 last_indexed: 0,
2355 };
2356 db.upsert_file(&file, None).unwrap();
2357
2358 let symbol = Symbol {
2359 id: "src/main.rs::foo".to_string(),
2360 file_path: "src/main.rs".to_string(),
2361 name: "foo".to_string(),
2362 qualified_name: None,
2363 kind: SymbolKind::Function,
2364 visibility: Visibility::Public,
2365 signature: None,
2366 brief: None,
2367 docstring: None,
2368 line_start: 1,
2369 line_end: 5,
2370 col_start: 0,
2371 col_end: 1,
2372 parent_id: None,
2373 source: None,
2374 };
2375 db.insert_symbol(&symbol).unwrap();
2376
2377 let vec: Vec<f32> = vec![0.1; DEFAULT_EMBEDDING_DIM];
2379 let vec_json = serde_json::to_string(&vec).unwrap();
2380 db.conn.execute(
2381 "INSERT INTO embeddings (symbol_id, provider, model, dimension, vector) VALUES (?, ?, ?, ?, ?)",
2382 params!["src/main.rs::foo", "openai", "test", DEFAULT_EMBEDDING_DIM as i64, vec_json],
2383 ).unwrap();
2384
2385 let vec_count_before = db.count_vector_embeddings().unwrap();
2387 assert_eq!(
2388 vec_count_before, 0,
2389 "Should have no vector embeddings before migration"
2390 );
2391
2392 let migrated = db.migrate_embeddings_to_vec().unwrap();
2394 assert_eq!(migrated, 1, "Should migrate 1 embedding");
2395
2396 let vec_count_after = db.count_vector_embeddings().unwrap();
2398 assert_eq!(
2399 vec_count_after, 1,
2400 "Should have 1 vector embedding after migration"
2401 );
2402
2403 let migrated_again = db.migrate_embeddings_to_vec().unwrap();
2405 assert_eq!(
2406 migrated_again, 0,
2407 "Should not re-migrate existing embeddings"
2408 );
2409 }
2410}