Skip to main content

ctx/db/
schema.rs

1//! SQLite schema and database operations.
2
3use 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
14/// Default embedding dimension for vector search.
15/// This matches OpenAI text-embedding-ada-002 (1536) and text-embedding-3-small (1536).
16/// For local embeddings with fastembed (384 dims), a separate table or dynamic dimension is needed.
17///
18/// TODO: Make this configurable per-provider or support multiple dimension tables.
19pub const DEFAULT_EMBEDDING_DIM: usize = 1536;
20
21/// Track whether sqlite-vec extension loaded successfully.
22static VEC_EXTENSION_AVAILABLE: AtomicBool = AtomicBool::new(false);
23
24/// Initialize the sqlite-vec extension for vector search.
25///
26/// This must be called before any Database connections are opened.
27/// It is safe to call multiple times - initialization only happens once.
28/// Returns true if the extension was loaded successfully.
29#[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        // sqlite3_auto_extension returns SQLITE_OK (0) on success
39        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
51/// Check if sqlite-vec extension is available for vector search.
52pub fn is_vec_extension_available() -> bool {
53    VEC_EXTENSION_AVAILABLE.load(Ordering::SeqCst)
54}
55
56/// SQLite database for code intelligence.
57pub struct Database {
58    conn: Connection,
59}
60
61impl Database {
62    /// Open or create a database at the given path.
63    pub fn open(path: &Path) -> Result<Self> {
64        // Initialize sqlite-vec extension before opening connection
65        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    /// Create an in-memory database (for testing).
74    #[allow(dead_code)]
75    pub fn open_in_memory() -> Result<Self> {
76        // Initialize sqlite-vec extension before opening connection
77        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    /// Configure the SQLite connection for optimal performance and concurrency.
86    fn configure_connection(conn: &Connection) -> Result<()> {
87        // Enable WAL mode for better concurrent access
88        // This allows multiple readers and one writer simultaneously
89        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    /// Initialize the database schema.
101    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        // Try to create the symbol_vectors virtual table for fast KNN search
215        // Uses sqlite-vec vec0 extension for indexed vector similarity search
216        // This is optional - if it fails, we fall back to the JSON embeddings table
217        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                    // Log warning but don't fail - vector search is optional
233                    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    /// Check if vector search is available (sqlite-vec extension loaded and table exists).
245    pub fn has_vector_search(&self) -> bool {
246        if !is_vec_extension_available() {
247            return false;
248        }
249        // Check if the table exists and is queryable
250        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    /// Begin a transaction.
260    #[allow(dead_code)]
261    pub fn transaction(&mut self) -> Result<Transaction<'_>> {
262        self.conn.transaction()
263    }
264
265    /// Get the content hash for a file.
266    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    /// Check if a file needs reindexing based on hash.
277    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    /// Insert or update a file record.
285    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    /// Delete a file and all associated data.
303    pub fn delete_file(&self, path: &str) -> Result<()> {
304        self.conn
305            .execute("DELETE FROM files WHERE path = ?", [path])?;
306        Ok(())
307    }
308
309    /// Delete all symbols for a file.
310    pub fn delete_symbols_for_file(&self, file_path: &str) -> Result<()> {
311        // Delete edges first (foreign key constraint)
312        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    /// Insert a symbol.
322    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    /// Insert an edge.
353    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    /// Insert module information.
373    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    /// Insert multiple symbols in a transaction (batch insert for parallel indexing).
393    #[allow(dead_code)] // Useful for future batch operations
394    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    /// Insert multiple edges in a transaction (batch insert for parallel indexing).
433    #[allow(dead_code)] // Useful for future batch operations
434    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    /// Find a symbol by ID.
462    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    /// Find symbols by name (exact or pattern).
478    pub fn find_symbols(&self, pattern: &str, limit: i32) -> Result<Vec<Symbol>> {
479        self.find_symbols_filtered(pattern, limit, None, None)
480    }
481
482    /// Find symbols by name with optional file path and kind filters.
483    ///
484    /// - `pattern`: Name pattern to search for
485    /// - `limit`: Maximum number of results
486    /// - `file_pattern`: Optional file path filter (supports glob syntax: `*`, `**`)
487    /// - `kind_filter`: Optional symbol kind filter (function, method, struct, etc.)
488    ///
489    /// Results are ordered by match quality: exact name match first, then prefix match,
490    /// then substring match.
491    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        // Escape SQL LIKE special characters in the pattern
499        let escaped_pattern = escape_like_pattern(pattern);
500        let like_pattern = format!("%{}%", escaped_pattern);
501        let starts_with_pattern = format!("{}%", escaped_pattern);
502
503        // Convert glob-style file pattern to SQL LIKE pattern
504        let file_like = file_pattern.map(glob_to_like_pattern);
505
506        // Build dynamic SQL based on filters
507        // We use separate parameters for exact match (?1), like match (?2), and starts_with (?3)
508        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        // Track next parameter position
519        let mut next_param = 4; // ?1=pattern, ?2=like_pattern, ?3=starts_with
520
521        // Add file pattern filter if provided
522        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        // Add kind filter if provided
532        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        // Add ORDER BY with proper exact match detection
542        // ?1 is the original pattern (for exact match)
543        // ?3 is the starts_with pattern (for prefix match)
544        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        // Execute with appropriate parameters based on which filters are active
559        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            // Handle impossible cases (filter provided but no param pos)
590            _ => unreachable!("Filter and param position should match"),
591        };
592
593        rows.into_iter().collect()
594    }
595
596    /// Get the source code for a symbol.
597    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    /// Get all symbols in a file.
608    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    /// Find symbols in a specific file (alias for get_file_symbols).
625    pub fn find_symbols_in_file(&self, file_path: &str) -> Result<Vec<Symbol>> {
626        self.get_file_symbols(file_path)
627    }
628
629    /// Get edges from a symbol.
630    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    /// Get edges to a symbol (callers).
645    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    /// Get codebase statistics.
660    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    /// Get all indexed file paths.
703    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    /// Normalize an FTS5 bm25 score into a 0-1 relevance value.
710    fn bm25_relevance(rank: f64) -> f64 {
711        if rank.is_finite() {
712            // Use magnitude to normalize regardless of sign: |rank|/(1+|rank|).
713            let abs_rank = rank.abs();
714            (abs_rank / (1.0 + abs_rank)).clamp(0.0, 1.0)
715        } else {
716            0.0 // Return 0 for invalid scores
717        }
718    }
719
720    /// Semantic search using FTS5 full-text search.
721    /// Searches across name, signature, brief, and docstring fields.
722    pub fn semantic_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64)>> {
723        // Preprocess query: split into keywords, handle natural language
724        let keywords = preprocess_search_query(query);
725
726        if keywords.is_empty() {
727            return Ok(Vec::new());
728        }
729
730        // Build FTS5 query with OR logic for broader matches
731        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    /// Hybrid search combining exact match with semantic search.
759    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        // Ensure we get at least 1 result from each source, even for small limits
764        let half_limit = (limit / 2).max(1);
765
766        // 1. Exact name matches (highest priority)
767        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 // Exact match
771            } else if symbol
772                .name
773                .to_lowercase()
774                .starts_with(&query.to_lowercase())
775            {
776                0.9 // Prefix match
777            } else {
778                0.7 // Contains match
779            };
780            results.insert(symbol.id.clone(), (symbol, score, "exact".to_string()));
781        }
782
783        // 2. Semantic matches (FTS5)
784        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        // Sort by score and return
796        let mut results: Vec<_> = results.into_values().collect();
797        // Guard against NaN/Inf by treating non-finite values as lower priority
798        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, // a (finite) is better than b (non-finite)
802                (false, true) => std::cmp::Ordering::Greater, // b (finite) is better than a (non-finite)
803                (false, false) => std::cmp::Ordering::Equal,
804            }
805        });
806        results.truncate(limit as usize);
807
808        Ok(results)
809    }
810
811    /// Rebuild the FTS index (useful after schema changes).
812    #[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    // ========== Embedding Operations ==========
823
824    /// Store an embedding for a symbol.
825    ///
826    /// This stores the embedding in two places:
827    /// 1. The `embeddings` table (JSON format, for compatibility)
828    /// 2. The `symbol_vectors` table (binary format, for fast KNN search via sqlite-vec)
829    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        // Store in JSON embeddings table
840        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        // Also store in vector table for fast KNN search (if available and dimension matches)
849        if self.has_vector_search() && vector.len() == DEFAULT_EMBEDDING_DIM {
850            // Convert to bytes for sqlite-vec (f32 little-endian)
851            let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
852
853            // Delete existing entry first (vec0 doesn't support REPLACE)
854            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    /// Get the embedding for a symbol.
869    #[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    /// Get all embeddings with their symbol metadata.
898    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    /// Count symbols that have embeddings.
936    pub fn count_embeddings(&self) -> Result<i64> {
937        self.conn
938            .query_row("SELECT COUNT(*) FROM embeddings", [], |row| row.get(0))
939    }
940
941    /// Get metadata about stored embeddings (provider, model, dimension, count).
942    ///
943    /// Returns a list of (provider, model, dimension, count) tuples for each
944    /// unique combination in the embeddings table. This is useful for detecting
945    /// dimension mismatches when querying with a different embedding provider.
946    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    /// Get symbols that don't have embeddings yet.
969    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    /// Migrate existing embeddings from JSON table to vector table for fast KNN search.
1007    ///
1008    /// This copies all embeddings with the correct dimension to the symbol_vectors table.
1009    /// Returns the number of embeddings migrated.
1010    #[allow(dead_code)] // Migration utility for future use
1011    pub fn migrate_embeddings_to_vec(&self) -> Result<usize> {
1012        if !self.has_vector_search() {
1013            return Ok(0);
1014        }
1015
1016        // Get all embeddings with matching dimension
1017        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            // Convert to bytes for sqlite-vec (f32 little-endian)
1039            let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1040
1041            // Skip if already exists
1042            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    /// Get the count of embeddings in the vector table.
1068    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    /// Fast vector similarity search using sqlite-vec.
1077    ///
1078    /// Returns the top-k most similar symbols to the query embedding.
1079    /// This uses indexed KNN search which is O(log n) instead of O(n).
1080    ///
1081    /// Returns (symbol_id, name, kind, file_path, line, distance) tuples.
1082    #[allow(clippy::type_complexity)]
1083    /// Distance is L2 distance (lower is more similar).
1084    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        // Convert query to bytes for sqlite-vec (f32 little-endian)
1098        let query_bytes: Vec<u8> = query_embedding
1099            .iter()
1100            .flat_map(|f| f.to_le_bytes())
1101            .collect();
1102
1103        // sqlite-vec KNN query - first get matching rowids, then join with symbols
1104        // The vec0 virtual table uses MATCH for KNN queries with LIMIT
1105        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    /// Check if the vector table has any embeddings.
1135    pub fn has_vector_embeddings(&self) -> bool {
1136        self.count_vector_embeddings().unwrap_or(0) > 0
1137    }
1138
1139    /// Delete embeddings for a specific provider/model.
1140    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    /// Resolve target_id for edges that only have target_name.
1153    ///
1154    /// This performs cross-file symbol resolution by matching target_name to symbols
1155    /// in the database. Resolution priority:
1156    /// 1. Context match: the call context contains the type name (e.g., "TypeScriptParser::new()")
1157    /// 2. Unique: only one symbol with that name exists in the codebase
1158    /// 3. Same file unique: only one symbol with that name exists in the same file
1159    ///
1160    /// We intentionally avoid aggressive same-file matching because calls like
1161    /// `Vec::new()` would incorrectly match a local `new` function.
1162    ///
1163    /// Returns the number of edges that were resolved.
1164    pub fn resolve_edge_targets(&self) -> Result<usize> {
1165        // Step 1: Resolve edges where the context contains the qualified name
1166        // e.g., context "TypeScriptParser::new()" matches symbol with qualified_name "TypeScriptParser::new"
1167        // Only resolve if exactly one symbol matches to avoid ambiguous resolution
1168        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        // Step 2: Resolve edges where target name is unique across the codebase
1194        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        // Step 3: Resolve edges where target is unique in the same file
1213        // Only match if:
1214        // - There's exactly one function with that name in the same file
1215        // - The context doesn't suggest an external type (no :: prefix before the name)
1216        // - The context doesn't suggest an external type/receiver call
1217        //   (no ::, ., or -> before the function name)
1218        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    /// Find duplicate code blocks using content hashing and similarity.
1265    pub fn find_duplicates(
1266        &self,
1267        similarity_threshold: u32,
1268        min_lines: u32,
1269    ) -> Result<Vec<DuplicateResult>> {
1270        // Get all function/method symbols with their source code
1271        // Use DISTINCT and unique id to avoid duplicate rows
1272        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        // Track seen pairs to avoid duplicates
1301        let mut seen_pairs: std::collections::HashSet<(String, String)> =
1302            std::collections::HashSet::new();
1303
1304        // Compare each pair of symbols
1305        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                // Skip if same symbol (by id or by file+line)
1311                if id1 == id2 || (file1 == file2 && line1 == line2) {
1312                    continue;
1313                }
1314
1315                // Create canonical pair key (smaller id first) to avoid duplicates
1316                let pair_key = if id1 < id2 {
1317                    (id1.clone(), id2.clone())
1318                } else {
1319                    (id2.clone(), id1.clone())
1320                };
1321
1322                // Skip if we've already seen this pair
1323                if seen_pairs.contains(&pair_key) {
1324                    continue;
1325                }
1326
1327                // Normalize and compare source code
1328                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                    // Mark this pair as seen
1335                    seen_pairs.insert(pair_key);
1336
1337                    // Create a content hash for grouping
1338                    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        // Sort by similarity (highest first), guarding against NaN/Inf
1356        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
1372/// Escape SQL LIKE special characters in a pattern.
1373///
1374/// SQLite LIKE uses `%` for any sequence and `_` for single character.
1375/// This function escapes these so they match literally.
1376fn escape_like_pattern(pattern: &str) -> String {
1377    pattern
1378        .replace('\\', "\\\\") // Escape backslash first
1379        .replace('%', "\\%")
1380        .replace('_', "\\_")
1381}
1382
1383/// Convert a glob-style pattern to a SQL LIKE pattern.
1384///
1385/// Supports:
1386/// - `*` -> `%` (match any sequence - note: in SQL LIKE this also matches `/`)
1387/// - `**` -> `%` (match any sequence including path separators)
1388/// - `**/` -> consumed, following pattern matches from any depth
1389/// - `?` -> `_` (match single character)
1390/// - Escapes literal `%` and `_` in the pattern
1391///
1392/// Limitations:
1393/// - SQL LIKE `%` matches across path separators, so `src/*.rs` will also match
1394///   `src/foo/bar.rs`. Use substring patterns like `*parser*` for simple filtering,
1395///   or rely on the prefix to narrow results.
1396///
1397/// Examples:
1398/// - `**/*.rs` -> `%.rs` (any .rs file at any depth)
1399/// - `src/**/*.rs` -> `src/%.rs` (any .rs file under src/)
1400/// - `*parser*` -> `%parser%` (any path containing "parser")
1401fn 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                // Check for **
1409                if chars.peek() == Some(&'*') {
1410                    chars.next();
1411                    // Check for **/ - this should match zero or more path segments
1412                    if chars.peek() == Some(&'/') {
1413                        chars.next();
1414                        // **/ means "any path prefix including empty"
1415                        // Don't add % here - the following pattern (likely *.ext) will add it
1416                        // This allows **/*.rs to become %.rs instead of %%.rs
1417                        // But if there's nothing after, or it's not a *, we need the %
1418                        if chars.peek().is_none() || chars.peek() == Some(&'*') {
1419                            // **/ at end or followed by another * - don't add redundant %
1420                            continue;
1421                        }
1422                        // **/ followed by something else - add % to match the path prefix
1423                        result.push('%');
1424                    } else {
1425                        // ** without trailing / - just match anything
1426                        result.push('%');
1427                    }
1428                } else {
1429                    // Single * - match anything (in SQL LIKE, same as %)
1430                    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
1444/// Normalize code for comparison (remove whitespace, comments, variable names).
1445fn normalize_code(code: &str) -> String {
1446    code.lines()
1447        .map(|line| {
1448            // Remove leading/trailing whitespace
1449            let trimmed = line.trim();
1450            // Remove single-line comments
1451            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
1465/// Calculate similarity between two strings using Jaccard similarity on tokens.
1466fn 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
1506/// Simple MD5-like hash for grouping duplicates (not cryptographically secure).
1507fn 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
1516/// Preprocess a search query into keywords.
1517fn preprocess_search_query(query: &str) -> Vec<String> {
1518    // Common words to filter out
1519    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            // Add wildcard suffix for prefix matching
1542            format!("{}*", s)
1543        })
1544        .collect()
1545}
1546
1547/// Helper to convert a row to a Symbol.
1548fn 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
1571/// Helper to convert a row to an Edge.
1572fn 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/// Result of duplicate detection.
1587#[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
1600/// Extension trait for optional query results.
1601trait 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        // Verify sqlite-vec is properly initialized
1630        let db = Database::open_in_memory().unwrap();
1631
1632        // Check that extension initialization was attempted
1633        // Note: init_vec_extension() is called during Database::open_in_memory()
1634        // so is_vec_extension_available() reflects whether it succeeded
1635        if !is_vec_extension_available() {
1636            eprintln!("Skipping sqlite-vec tests: extension not available on this platform");
1637            return;
1638        }
1639
1640        // Check vec_version() function exists (proves extension loaded)
1641        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                // Extension registered but function not available - this shouldn't happen
1655                // if is_vec_extension_available() returned true, but handle gracefully
1656                panic!(
1657                    "sqlite-vec extension reported available but vec_version() failed: {}",
1658                    e
1659                );
1660            }
1661        }
1662
1663        // Verify has_vector_search() works
1664        assert!(db.has_vector_search(), "vector search should be available");
1665
1666        // Verify symbol_vectors table exists and is queryable
1667        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        // Insert a file first (foreign key)
1679        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        // Insert a symbol
1689        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        // Find it
1709        let found = db.get_symbol("src/main.rs::main").unwrap();
1710        assert!(found.is_some());
1711        assert_eq!(found.unwrap().name, "main");
1712
1713        // Search for it
1714        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        // Insert a file
1723        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        // Insert symbols with similar names - order matters for testing
1733        // We insert in reverse order to ensure ordering comes from query, not insertion
1734        let symbols = vec![
1735            ("new_large", "src/test.rs::new_large"), // substring match
1736            ("new_item", "src/test.rs::new_item"),   // prefix match
1737            ("renew", "src/test.rs::renew"),         // substring match (contains "new")
1738            ("new", "src/test.rs::new"),             // exact match
1739            ("new_thing", "src/test.rs::new_thing"), // prefix match
1740        ];
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        // Search for "new" - should get exact match first, then prefix matches, then substring
1764        let results = db.find_symbols("new", 10).unwrap();
1765
1766        assert!(results.len() >= 4, "Should find at least 4 symbols");
1767
1768        // First result should be exact match "new"
1769        assert_eq!(results[0].name, "new", "Exact match 'new' should be first");
1770
1771        // Next results should be prefix matches (new_*), alphabetically sorted
1772        // new_item, new_large, new_thing should come before renew
1773        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        // "renew" should be last (substring but not prefix)
1781        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        // Insert two files
1793        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        // Insert "new" in both files
1805        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        // Without filter, should find both
1830        let all_results = db.find_symbols_filtered("new", 10, None, None).unwrap();
1831        assert_eq!(all_results.len(), 2);
1832
1833        // With file filter for parser, should find only one
1834        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        // With file filter for embeddings
1841        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        // Normal patterns should pass through
1851        assert_eq!(escape_like_pattern("new"), "new");
1852        assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
1853
1854        // Special SQL LIKE characters should be escaped
1855        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        // * becomes %
1863        assert_eq!(glob_to_like_pattern("*.rs"), "%.rs");
1864        assert_eq!(glob_to_like_pattern("src/*"), "src/%");
1865
1866        // **/ is consumed when followed by *, preventing double %
1867        // This ensures **/*.rs becomes %.rs (not %%.rs)
1868        assert_eq!(glob_to_like_pattern("**/*.rs"), "%.rs");
1869        assert_eq!(glob_to_like_pattern("src/**/*.rs"), "src/%.rs");
1870
1871        // ** without trailing / just becomes %
1872        assert_eq!(glob_to_like_pattern("src/**"), "src/%");
1873
1874        // **/ followed by non-* adds the %
1875        assert_eq!(glob_to_like_pattern("**/foo.rs"), "%foo.rs");
1876
1877        // ? becomes _
1878        assert_eq!(glob_to_like_pattern("file?.txt"), "file_.txt");
1879
1880        // Literal % and _ are escaped
1881        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        // Insert files at different depths
1890        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        // **/*.rs should match ALL .rs files at any depth
1927        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        // src/**/*.rs should match all .rs files under src/
1933        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        // Note: src/*.rs also matches nested files because SQL LIKE % matches /
1943        // This is a documented limitation. For precise matching, use substring patterns.
1944        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        // *parser* should match files with "parser" in the path
1954        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        // Tests that hybrid_search doesn't panic or return 0 results with limit=1
1967        // Previously, limit/2 = 0 caused no results to be returned
1968        let db = Database::open_in_memory().unwrap();
1969
1970        // Insert a file and symbol
1971        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        // hybrid_search with limit=1 should return exactly 1 result
2000        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        // Tests hybrid_search behavior with limit=3 (where limit/2 = 1)
2012        let db = Database::open_in_memory().unwrap();
2013
2014        // Insert a file
2015        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        // Insert multiple symbols
2025        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        // hybrid_search with limit=3 should work correctly
2047        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        // Tests that semantic search returns scores in valid 0-1 range
2058        let db = Database::open_in_memory().unwrap();
2059
2060        // Insert a file and symbol
2061        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        // Semantic search should return scores in 0-1 range
2090        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        // Tests the embedding metadata query used for dimension mismatch detection
2136        let db = Database::open_in_memory().unwrap();
2137
2138        // Insert a file first (foreign key)
2139        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        // Insert some symbols
2149        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        // No embeddings yet
2171        let metadata = db.get_embedding_metadata().unwrap();
2172        assert!(
2173            metadata.is_empty(),
2174            "Should have no embedding metadata initially"
2175        );
2176
2177        // Store embeddings with different dimensions (simulating local vs OpenAI)
2178        let local_vec: Vec<f32> = vec![0.1; 384]; // Local embedding dimension
2179        let openai_vec: Vec<f32> = vec![0.2; 1536]; // OpenAI embedding dimension
2180
2181        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        // Query metadata
2192        let metadata = db.get_embedding_metadata().unwrap();
2193
2194        // Should have two distinct provider/dimension combinations
2195        assert_eq!(
2196            metadata.len(),
2197            2,
2198            "Should have 2 embedding metadata entries"
2199        );
2200
2201        // Verify dimensions are recorded correctly
2202        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        // Verify providers are recorded correctly
2213        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        // Test the fast vector search using sqlite-vec
2221        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        // Insert a file first (foreign key)
2229        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        // Insert multiple symbols
2239        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        // Create embeddings with default dimension (1536)
2265        // Make foo and bar similar, baz different
2266        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; // Completely different direction
2278
2279        // Store embeddings (should also insert into symbol_vectors)
2280        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        // Verify vector embeddings were stored
2303        let vec_count = db.count_vector_embeddings().unwrap();
2304        assert_eq!(vec_count, 3, "Should have 3 vector embeddings");
2305
2306        // Search with a query similar to foo
2307        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        // First result should be foo (exact match)
2313        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        // Second result should be bar (similar)
2320        assert_eq!(
2321            results[1].0, "src/main.rs::bar",
2322            "Second result should be bar (similar)"
2323        );
2324
2325        // Third result should be baz (different)
2326        assert_eq!(
2327            results[2].0, "src/main.rs::baz",
2328            "Third result should be baz (different)"
2329        );
2330
2331        // baz should have larger distance than bar
2332        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        // Test migrating existing JSON embeddings to vector table
2341        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        // Insert a file and symbol
2349        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        // Manually insert an embedding only in the JSON table (simulating old data)
2378        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        // Verify not in vector table yet
2386        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        // Run migration
2393        let migrated = db.migrate_embeddings_to_vec().unwrap();
2394        assert_eq!(migrated, 1, "Should migrate 1 embedding");
2395
2396        // Verify now in vector table
2397        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        // Re-running migration should not duplicate
2404        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}