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/// Current index schema version, stamped into SQLite's `PRAGMA user_version`.
15///
16/// Bump this whenever the schema changes in a way that is incompatible with
17/// existing databases. Fresh databases (no tables yet) are initialized and
18/// stamped with the current version; any existing database with a different
19/// version -- including pre-versioning (v0) databases, which lack the
20/// `symbol_fingerprints` table -- is reported as
21/// [`crate::error::CtxError::SchemaVersionMismatch`].
22///
23/// History:
24/// - v1: initial versioned schema
25/// - v2: adds `symbol_fingerprints` (MinHash near-duplicate detection)
26pub const SCHEMA_VERSION: i64 = 2;
27
28/// Default embedding dimension for vector search.
29/// This matches OpenAI text-embedding-ada-002 (1536) and text-embedding-3-small (1536).
30/// For local embeddings with fastembed (384 dims), a separate table or dynamic dimension is needed.
31///
32/// TODO: Make this configurable per-provider or support multiple dimension tables.
33pub const DEFAULT_EMBEDDING_DIM: usize = 1536;
34
35/// Track whether sqlite-vec extension loaded successfully.
36static VEC_EXTENSION_AVAILABLE: AtomicBool = AtomicBool::new(false);
37
38/// Initialize the sqlite-vec extension for vector search.
39///
40/// This must be called before any Database connections are opened.
41/// It is safe to call multiple times - initialization only happens once.
42/// Returns true if the extension was loaded successfully.
43#[allow(clippy::missing_transmute_annotations)]
44pub fn init_vec_extension() -> bool {
45    static INIT: Once = Once::new();
46    INIT.call_once(|| {
47        let result = unsafe {
48            sqlite3_auto_extension(Some(std::mem::transmute(
49                sqlite3_vec_init as *const (),
50            )))
51        };
52        // sqlite3_auto_extension returns SQLITE_OK (0) on success
53        if result == 0 {
54            VEC_EXTENSION_AVAILABLE.store(true, Ordering::SeqCst);
55        } else {
56            eprintln!(
57                "Warning: Failed to register sqlite-vec extension (error code: {}). Vector search will be unavailable.",
58                result
59            );
60        }
61    });
62    VEC_EXTENSION_AVAILABLE.load(Ordering::SeqCst)
63}
64
65/// Check if sqlite-vec extension is available for vector search.
66pub fn is_vec_extension_available() -> bool {
67    VEC_EXTENSION_AVAILABLE.load(Ordering::SeqCst)
68}
69
70/// SQLite database for code intelligence.
71#[derive(Debug)]
72pub struct Database {
73    conn: Connection,
74}
75
76impl Database {
77    /// Open or create a database at the given path.
78    ///
79    /// Verifies the schema version stored in `PRAGMA user_version`:
80    /// - `0` (fresh or pre-versioning database): the schema is initialized and
81    ///   the version is stamped to [`SCHEMA_VERSION`].
82    /// - [`SCHEMA_VERSION`]: opened normally.
83    /// - anything else: returns [`crate::error::CtxError::SchemaVersionMismatch`].
84    pub fn open(path: &Path) -> crate::error::Result<Self> {
85        // Initialize sqlite-vec extension before opening connection
86        init_vec_extension();
87        let conn = Connection::open(path)?;
88        Self::configure_connection(&conn)?;
89        let db = Self { conn };
90        db.check_schema_version()?;
91        db.init_schema()?;
92        Ok(db)
93    }
94
95    /// Create an in-memory database (for testing).
96    #[allow(dead_code)]
97    pub fn open_in_memory() -> crate::error::Result<Self> {
98        // Initialize sqlite-vec extension before opening connection
99        init_vec_extension();
100        let conn = Connection::open_in_memory()?;
101        Self::configure_connection(&conn)?;
102        let db = Self { conn };
103        db.check_schema_version()?;
104        db.init_schema()?;
105        Ok(db)
106    }
107
108    /// Validate `PRAGMA user_version` against [`SCHEMA_VERSION`].
109    ///
110    /// A fresh database (version `0` and no tables yet) is silently stamped
111    /// to the current version. A pre-versioning (v0) database that already
112    /// has tables lacks the `symbol_fingerprints` table, so it is rejected
113    /// like any other version mismatch.
114    fn check_schema_version(&self) -> crate::error::Result<()> {
115        let found: i64 = self
116            .conn
117            .query_row("PRAGMA user_version", [], |row| row.get(0))?;
118
119        if found == SCHEMA_VERSION {
120            return Ok(());
121        }
122
123        if found == 0 {
124            let has_tables: bool = self
125                .conn
126                .query_row(
127                    "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'files'",
128                    [],
129                    |_| Ok(true),
130                )
131                .optional()?
132                .unwrap_or(false);
133            if !has_tables {
134                self.conn
135                    .pragma_update(None, "user_version", SCHEMA_VERSION)?;
136                return Ok(());
137            }
138        }
139
140        Err(crate::error::CtxError::SchemaVersionMismatch {
141            found,
142            expected: SCHEMA_VERSION,
143        })
144    }
145
146    /// Configure the SQLite connection for optimal performance and concurrency.
147    fn configure_connection(conn: &Connection) -> Result<()> {
148        // Enable WAL mode for better concurrent access
149        // This allows multiple readers and one writer simultaneously
150        conn.execute_batch(
151            r#"
152            PRAGMA journal_mode = WAL;
153            PRAGMA synchronous = NORMAL;
154            PRAGMA busy_timeout = 5000;
155            PRAGMA cache_size = -64000;
156            "#,
157        )?;
158        Ok(())
159    }
160
161    /// Initialize the database schema.
162    fn init_schema(&self) -> Result<()> {
163        self.conn.execute_batch(
164            r#"
165            -- Enable foreign keys
166            PRAGMA foreign_keys = ON;
167
168            -- File tracking for incremental updates
169            CREATE TABLE IF NOT EXISTS files (
170                path TEXT PRIMARY KEY,
171                content_hash TEXT NOT NULL,
172                size_bytes INTEGER,
173                language TEXT,
174                last_indexed INTEGER DEFAULT (unixepoch()),
175                source BLOB
176            );
177
178            -- All symbols (functions, structs, etc.)
179            CREATE TABLE IF NOT EXISTS symbols (
180                id TEXT PRIMARY KEY,
181                file_path TEXT NOT NULL,
182                name TEXT NOT NULL,
183                qualified_name TEXT,
184                kind TEXT NOT NULL,
185                visibility TEXT DEFAULT 'private',
186                signature TEXT,
187                brief TEXT,
188                docstring TEXT,
189                line_start INTEGER,
190                line_end INTEGER,
191                col_start INTEGER,
192                col_end INTEGER,
193                parent_id TEXT,
194                source TEXT,
195                FOREIGN KEY (file_path) REFERENCES files(path) ON DELETE CASCADE
196            );
197
198            -- Relationships between symbols
199            CREATE TABLE IF NOT EXISTS edges (
200                id INTEGER PRIMARY KEY AUTOINCREMENT,
201                source_id TEXT NOT NULL,
202                target_id TEXT,
203                target_name TEXT NOT NULL,
204                kind TEXT NOT NULL,
205                line INTEGER,
206                col INTEGER,
207                context TEXT,
208                FOREIGN KEY (source_id) REFERENCES symbols(id) ON DELETE CASCADE
209            );
210
211            -- Module-level information
212            CREATE TABLE IF NOT EXISTS modules (
213                file_path TEXT PRIMARY KEY,
214                module_name TEXT,
215                exports TEXT,
216                imports TEXT,
217                FOREIGN KEY (file_path) REFERENCES files(path) ON DELETE CASCADE
218            );
219
220            -- Cached PageRank scores for `ctx map`.
221            -- Lazily rebuilt: cleared whenever the index changes and
222            -- recomputed on the next `ctx map` invocation, so pre-existing
223            -- databases self-heal without a schema version bump.
224            CREATE TABLE IF NOT EXISTS symbol_rank (
225                symbol_id TEXT PRIMARY KEY REFERENCES symbols(id) ON DELETE CASCADE,
226                rank REAL NOT NULL
227            );
228
229            -- Indexes for fast lookups
230            CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
231            CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path);
232            CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);
233            CREATE INDEX IF NOT EXISTS idx_symbols_parent ON symbols(parent_id);
234            CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
235            CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_name);
236            CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
237            CREATE INDEX IF NOT EXISTS idx_files_hash ON files(content_hash);
238
239            -- Embeddings for semantic search
240            CREATE TABLE IF NOT EXISTS embeddings (
241                symbol_id TEXT PRIMARY KEY,
242                provider TEXT NOT NULL,
243                model TEXT NOT NULL,
244                dimension INTEGER NOT NULL,
245                vector TEXT NOT NULL,  -- JSON array of floats
246                created_at INTEGER DEFAULT (unixepoch()),
247                FOREIGN KEY (symbol_id) REFERENCES symbols(id) ON DELETE CASCADE
248            );
249
250            CREATE INDEX IF NOT EXISTS idx_embeddings_provider ON embeddings(provider);
251
252            -- MinHash fingerprints for near-duplicate function detection
253            -- (see src/fingerprint.rs). Rows are cascade-deleted with their
254            -- symbols when a file is re-indexed or removed.
255            CREATE TABLE IF NOT EXISTS symbol_fingerprints (
256                symbol_id TEXT PRIMARY KEY REFERENCES symbols(id) ON DELETE CASCADE,
257                file_path TEXT NOT NULL,
258                minhash BLOB NOT NULL,
259                token_count INTEGER NOT NULL
260            );
261
262            CREATE INDEX IF NOT EXISTS idx_fingerprints_file ON symbol_fingerprints(file_path);
263
264            -- Full-text search index for semantic search
265            CREATE VIRTUAL TABLE IF NOT EXISTS symbol_fts USING fts5(
266                id,
267                name,
268                kind,
269                signature,
270                brief,
271                docstring,
272                content='symbols',
273                content_rowid='rowid'
274            );
275
276            -- Triggers to keep FTS index in sync
277            CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
278                INSERT INTO symbol_fts(rowid, id, name, kind, signature, brief, docstring)
279                VALUES (NEW.rowid, NEW.id, NEW.name, NEW.kind, NEW.signature, NEW.brief, NEW.docstring);
280            END;
281
282            CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
283                INSERT INTO symbol_fts(symbol_fts, rowid, id, name, kind, signature, brief, docstring)
284                VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.kind, OLD.signature, OLD.brief, OLD.docstring);
285            END;
286
287            CREATE TRIGGER IF NOT EXISTS symbols_au AFTER UPDATE ON symbols BEGIN
288                INSERT INTO symbol_fts(symbol_fts, rowid, id, name, kind, signature, brief, docstring)
289                VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.kind, OLD.signature, OLD.brief, OLD.docstring);
290                INSERT INTO symbol_fts(rowid, id, name, kind, signature, brief, docstring)
291                VALUES (NEW.rowid, NEW.id, NEW.name, NEW.kind, NEW.signature, NEW.brief, NEW.docstring);
292            END;
293            "#,
294        )?;
295
296        // Try to create the symbol_vectors virtual table for fast KNN search
297        // Uses sqlite-vec vec0 extension for indexed vector similarity search
298        // This is optional - if it fails, we fall back to the JSON embeddings table
299        if is_vec_extension_available() {
300            match self.conn.execute(
301                &format!(
302                    r#"
303                    CREATE VIRTUAL TABLE IF NOT EXISTS symbol_vectors USING vec0(
304                        embedding float[{}],
305                        +symbol_id TEXT
306                    )
307                    "#,
308                    DEFAULT_EMBEDDING_DIM
309                ),
310                [],
311            ) {
312                Ok(_) => {}
313                Err(e) => {
314                    // Log warning but don't fail - vector search is optional
315                    eprintln!(
316                        "Warning: Failed to create symbol_vectors table: {}. Vector search will be unavailable.",
317                        e
318                    );
319                }
320            }
321        }
322
323        Ok(())
324    }
325
326    /// Check if vector search is available (sqlite-vec extension loaded and table exists).
327    pub fn has_vector_search(&self) -> bool {
328        if !is_vec_extension_available() {
329            return false;
330        }
331        // Check if the table exists and is queryable
332        self.conn
333            .query_row(
334                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbol_vectors'",
335                [],
336                |_| Ok(()),
337            )
338            .is_ok()
339    }
340
341    /// Begin a transaction.
342    #[allow(dead_code)]
343    pub fn transaction(&mut self) -> Result<Transaction<'_>> {
344        self.conn.transaction()
345    }
346
347    /// Get the content hash for a file.
348    pub fn get_file_hash(&self, path: &str) -> Result<Option<String>> {
349        self.conn
350            .query_row(
351                "SELECT content_hash FROM files WHERE path = ?",
352                [path],
353                |row| row.get(0),
354            )
355            .optional()
356    }
357
358    /// Check if a file needs reindexing based on hash.
359    pub fn needs_update(&self, path: &str, new_hash: &str) -> Result<bool> {
360        match self.get_file_hash(path)? {
361            Some(stored_hash) => Ok(stored_hash != new_hash),
362            None => Ok(true),
363        }
364    }
365
366    /// Insert or update a file record.
367    pub fn upsert_file(&self, file: &FileRecord, source: Option<&[u8]>) -> Result<()> {
368        self.conn.execute(
369            r#"
370            INSERT OR REPLACE INTO files (path, content_hash, size_bytes, language, last_indexed, source)
371            VALUES (?, ?, ?, ?, unixepoch(), ?)
372            "#,
373            params![
374                file.path,
375                file.content_hash,
376                file.size_bytes,
377                file.language,
378                source
379            ],
380        )?;
381        Ok(())
382    }
383
384    /// Delete a file and all associated data.
385    pub fn delete_file(&self, path: &str) -> Result<()> {
386        self.conn
387            .execute("DELETE FROM files WHERE path = ?", [path])?;
388        Ok(())
389    }
390
391    /// Delete all symbols for a file.
392    pub fn delete_symbols_for_file(&self, file_path: &str) -> Result<()> {
393        // Delete edges first (foreign key constraint)
394        self.conn.execute(
395            "DELETE FROM edges WHERE source_id IN (SELECT id FROM symbols WHERE file_path = ?)",
396            [file_path],
397        )?;
398        self.conn
399            .execute("DELETE FROM symbols WHERE file_path = ?", [file_path])?;
400        Ok(())
401    }
402
403    /// Insert a symbol.
404    pub fn insert_symbol(&self, symbol: &Symbol) -> Result<()> {
405        self.conn.execute(
406            r#"
407            INSERT INTO symbols (
408                id, file_path, name, qualified_name, kind, visibility,
409                signature, brief, docstring, line_start, line_end,
410                col_start, col_end, parent_id, source
411            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
412            "#,
413            params![
414                symbol.id,
415                symbol.file_path,
416                symbol.name,
417                symbol.qualified_name,
418                symbol.kind.as_str(),
419                symbol.visibility.as_str(),
420                symbol.signature,
421                symbol.brief,
422                symbol.docstring,
423                symbol.line_start,
424                symbol.line_end,
425                symbol.col_start,
426                symbol.col_end,
427                symbol.parent_id,
428                symbol.source,
429            ],
430        )?;
431        Ok(())
432    }
433
434    /// Insert an edge.
435    pub fn insert_edge(&self, edge: &Edge) -> Result<()> {
436        self.conn.execute(
437            r#"
438            INSERT INTO edges (source_id, target_id, target_name, kind, line, col, context)
439            VALUES (?, ?, ?, ?, ?, ?, ?)
440            "#,
441            params![
442                edge.source_id,
443                edge.target_id,
444                edge.target_name,
445                edge.kind.as_str(),
446                edge.line,
447                edge.col,
448                edge.context,
449            ],
450        )?;
451        Ok(())
452    }
453
454    /// Insert module information.
455    pub fn upsert_module(&self, module: &ModuleInfo) -> Result<()> {
456        let exports_json = serde_json::to_string(&module.exports).unwrap_or_default();
457        let imports_json = serde_json::to_string(&module.imports).unwrap_or_default();
458
459        self.conn.execute(
460            r#"
461            INSERT OR REPLACE INTO modules (file_path, module_name, exports, imports)
462            VALUES (?, ?, ?, ?)
463            "#,
464            params![
465                module.file_path,
466                module.module_name,
467                exports_json,
468                imports_json,
469            ],
470        )?;
471        Ok(())
472    }
473
474    /// Insert multiple symbols in a transaction (batch insert for parallel indexing).
475    #[allow(dead_code)] // Useful for future batch operations
476    pub fn insert_symbols_batch(&self, symbols: &[Symbol]) -> Result<usize> {
477        let tx = self.conn.unchecked_transaction()?;
478        let mut count = 0;
479
480        for symbol in symbols {
481            tx.execute(
482                r#"
483                INSERT INTO symbols (
484                    id, file_path, name, qualified_name, kind, visibility,
485                    signature, brief, docstring, line_start, line_end,
486                    col_start, col_end, parent_id, source
487                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
488                "#,
489                params![
490                    symbol.id,
491                    symbol.file_path,
492                    symbol.name,
493                    symbol.qualified_name,
494                    symbol.kind.as_str(),
495                    symbol.visibility.as_str(),
496                    symbol.signature,
497                    symbol.brief,
498                    symbol.docstring,
499                    symbol.line_start,
500                    symbol.line_end,
501                    symbol.col_start,
502                    symbol.col_end,
503                    symbol.parent_id,
504                    symbol.source,
505                ],
506            )?;
507            count += 1;
508        }
509
510        tx.commit()?;
511        Ok(count)
512    }
513
514    /// Insert multiple edges in a transaction (batch insert for parallel indexing).
515    #[allow(dead_code)] // Useful for future batch operations
516    pub fn insert_edges_batch(&self, edges: &[Edge]) -> Result<usize> {
517        let tx = self.conn.unchecked_transaction()?;
518        let mut count = 0;
519
520        for edge in edges {
521            tx.execute(
522                r#"
523                INSERT INTO edges (source_id, target_id, target_name, kind, line, col, context)
524                VALUES (?, ?, ?, ?, ?, ?, ?)
525                "#,
526                params![
527                    edge.source_id,
528                    edge.target_id,
529                    edge.target_name,
530                    edge.kind.as_str(),
531                    edge.line,
532                    edge.col,
533                    edge.context,
534                ],
535            )?;
536            count += 1;
537        }
538
539        tx.commit()?;
540        Ok(count)
541    }
542
543    /// Find a symbol by ID.
544    pub fn get_symbol(&self, id: &str) -> Result<Option<Symbol>> {
545        self.conn
546            .query_row(
547                r#"
548                SELECT id, file_path, name, qualified_name, kind, visibility,
549                       signature, brief, docstring, line_start, line_end,
550                       col_start, col_end, parent_id, source
551                FROM symbols WHERE id = ?
552                "#,
553                [id],
554                |row| Ok(symbol_from_row(row)),
555            )
556            .optional()
557    }
558
559    /// Find symbols by name (exact or pattern).
560    pub fn find_symbols(&self, pattern: &str, limit: i32) -> Result<Vec<Symbol>> {
561        self.find_symbols_filtered(pattern, limit, None, None)
562    }
563
564    /// Find symbols by name with optional file path and kind filters.
565    ///
566    /// - `pattern`: Name pattern to search for
567    /// - `limit`: Maximum number of results
568    /// - `file_pattern`: Optional file path filter (supports glob syntax: `*`, `**`)
569    /// - `kind_filter`: Optional symbol kind filter (function, method, struct, etc.)
570    ///
571    /// Results are ordered by match quality: exact name match first, then prefix match,
572    /// then substring match.
573    pub fn find_symbols_filtered(
574        &self,
575        pattern: &str,
576        limit: i32,
577        file_pattern: Option<&str>,
578        kind_filter: Option<&str>,
579    ) -> Result<Vec<Symbol>> {
580        // Escape SQL LIKE special characters in the pattern
581        let escaped_pattern = escape_like_pattern(pattern);
582        let like_pattern = format!("%{}%", escaped_pattern);
583        let starts_with_pattern = format!("{}%", escaped_pattern);
584
585        // Convert glob-style file pattern to SQL LIKE pattern
586        let file_like = file_pattern.map(glob_to_like_pattern);
587
588        // Build dynamic SQL based on filters
589        // We use separate parameters for exact match (?1), like match (?2), and starts_with (?3).
590        // The like/starts_with patterns are produced by escape_like_pattern, which escapes the
591        // LIKE metacharacters (`%`, `_`, `\`) with a backslash, so every LIKE that consumes them
592        // must declare `ESCAPE '\'` — otherwise the escaped `\_`/`\%` are treated literally and
593        // names containing underscores (e.g. `run_sql`) never match.
594        let mut sql = String::from(
595            r#"
596            SELECT id, file_path, name, qualified_name, kind, visibility,
597                   signature, brief, docstring, line_start, line_end,
598                   col_start, col_end, parent_id, source
599            FROM symbols
600            WHERE (name LIKE ?2 ESCAPE '\' OR qualified_name LIKE ?2 ESCAPE '\')
601            "#,
602        );
603
604        // Track next parameter position
605        let mut next_param = 4; // ?1=pattern, ?2=like_pattern, ?3=starts_with
606
607        // Add file pattern filter if provided
608        let file_param_pos = if file_pattern.is_some() {
609            let pos = next_param;
610            // file_path patterns come from glob_to_like_pattern, which also backslash-escapes
611            // literal `%`/`_`/`\`, so this LIKE needs the same ESCAPE clause.
612            sql.push_str(&format!(" AND file_path LIKE ?{} ESCAPE '\\'", pos));
613            next_param += 1;
614            Some(pos)
615        } else {
616            None
617        };
618
619        // Add kind filter if provided
620        let kind_param_pos = if kind_filter.is_some() {
621            let pos = next_param;
622            sql.push_str(&format!(" AND kind = ?{}", pos));
623            next_param += 1;
624            Some(pos)
625        } else {
626            None
627        };
628
629        // Add ORDER BY with proper exact match detection
630        // ?1 is the original pattern (for exact match)
631        // ?3 is the starts_with pattern (for prefix match)
632        sql.push_str(&format!(
633            r#"
634            ORDER BY
635                CASE WHEN name = ?1 THEN 0
636                     WHEN name LIKE ?3 ESCAPE '\' THEN 1
637                     ELSE 2 END,
638                name
639            LIMIT ?{}
640            "#,
641            next_param
642        ));
643
644        let mut stmt = self.conn.prepare(&sql)?;
645
646        // Execute with appropriate parameters based on which filters are active
647        let rows: Vec<Result<Symbol>> = match (
648            file_like.as_deref(),
649            kind_filter,
650            file_param_pos,
651            kind_param_pos,
652        ) {
653            (Some(fp), Some(kf), Some(_), Some(_)) => stmt
654                .query_map(
655                    params![pattern, like_pattern, starts_with_pattern, fp, kf, limit],
656                    |row| Ok(symbol_from_row(row)),
657                )?
658                .collect(),
659            (Some(fp), None, Some(_), None) => stmt
660                .query_map(
661                    params![pattern, like_pattern, starts_with_pattern, fp, limit],
662                    |row| Ok(symbol_from_row(row)),
663                )?
664                .collect(),
665            (None, Some(kf), None, Some(_)) => stmt
666                .query_map(
667                    params![pattern, like_pattern, starts_with_pattern, kf, limit],
668                    |row| Ok(symbol_from_row(row)),
669                )?
670                .collect(),
671            (None, None, None, None) => stmt
672                .query_map(
673                    params![pattern, like_pattern, starts_with_pattern, limit],
674                    |row| Ok(symbol_from_row(row)),
675                )?
676                .collect(),
677            // Handle impossible cases (filter provided but no param pos)
678            _ => unreachable!("Filter and param position should match"),
679        };
680
681        rows.into_iter().collect()
682    }
683
684    /// Get the source code for a symbol.
685    pub fn get_source(&self, symbol_id: &str) -> Result<Option<String>> {
686        self.conn
687            .query_row(
688                "SELECT source FROM symbols WHERE id = ?",
689                [symbol_id],
690                |row| row.get(0),
691            )
692            .optional()
693    }
694
695    /// Get all symbols in a file.
696    pub fn get_file_symbols(&self, file_path: &str) -> Result<Vec<Symbol>> {
697        let mut stmt = self.conn.prepare(
698            r#"
699            SELECT id, file_path, name, qualified_name, kind, visibility,
700                   signature, brief, docstring, line_start, line_end,
701                   col_start, col_end, parent_id, source
702            FROM symbols
703            WHERE file_path = ?
704            ORDER BY line_start
705            "#,
706        )?;
707
708        let rows = stmt.query_map([file_path], |row| Ok(symbol_from_row(row)))?;
709        rows.collect()
710    }
711
712    /// Find symbols in a specific file (alias for get_file_symbols).
713    pub fn find_symbols_in_file(&self, file_path: &str) -> Result<Vec<Symbol>> {
714        self.get_file_symbols(file_path)
715    }
716
717    /// Get edges from a symbol.
718    pub fn get_outgoing_edges(&self, symbol_id: &str) -> Result<Vec<Edge>> {
719        let mut stmt = self.conn.prepare(
720            r#"
721            SELECT source_id, target_id, target_name, kind, line, col, context
722            FROM edges
723            WHERE source_id = ?
724            ORDER BY line
725            "#,
726        )?;
727
728        let rows = stmt.query_map([symbol_id], |row| Ok(edge_from_row(row)))?;
729        rows.collect()
730    }
731
732    /// Get edges to a symbol (callers).
733    pub fn get_incoming_edges(&self, target_name: &str) -> Result<Vec<Edge>> {
734        let mut stmt = self.conn.prepare(
735            r#"
736            SELECT source_id, target_id, target_name, kind, line, col, context
737            FROM edges
738            WHERE target_name = ? OR target_id = ?
739            ORDER BY source_id
740            "#,
741        )?;
742
743        let rows = stmt.query_map([target_name, target_name], |row| Ok(edge_from_row(row)))?;
744        rows.collect()
745    }
746
747    /// Get codebase statistics.
748    pub fn get_stats(&self) -> Result<CodebaseStats> {
749        let files: i64 = self
750            .conn
751            .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
752        let symbols: i64 = self
753            .conn
754            .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))?;
755        let edges: i64 = self
756            .conn
757            .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?;
758        let functions: i64 = self.conn.query_row(
759            "SELECT COUNT(*) FROM symbols WHERE kind IN ('function', 'method')",
760            [],
761            |row| row.get(0),
762        )?;
763        let structs: i64 = self.conn.query_row(
764            "SELECT COUNT(*) FROM symbols WHERE kind IN ('struct', 'class')",
765            [],
766            |row| row.get(0),
767        )?;
768        let enums: i64 = self.conn.query_row(
769            "SELECT COUNT(*) FROM symbols WHERE kind = 'enum'",
770            [],
771            |row| row.get(0),
772        )?;
773        let traits: i64 = self.conn.query_row(
774            "SELECT COUNT(*) FROM symbols WHERE kind IN ('trait', 'interface')",
775            [],
776            |row| row.get(0),
777        )?;
778
779        Ok(CodebaseStats {
780            files,
781            symbols,
782            edges,
783            functions,
784            structs,
785            enums,
786            traits,
787        })
788    }
789
790    // ========== Shared Complexity Metrics ==========
791    //
792    // These mirror the DuckDB `complexity_analysis` formula exactly:
793    // fan_out = COUNT(*) of 'calls' edges grouped by source_id,
794    // fan_in  = COUNT(*) of 'calls' edges grouped by target_id (resolved only),
795    // complexity = fan_out * 2 + fan_in.
796
797    /// Per-symbol fan-in/fan-out/complexity metrics for functions and methods.
798    ///
799    /// Results are ordered by complexity (highest first).
800    pub fn symbol_metrics(&self) -> Result<Vec<SymbolMetrics>> {
801        let mut stmt = self.conn.prepare(
802            r#"
803            WITH fo AS (
804                SELECT source_id AS id, COUNT(*) AS n
805                FROM edges
806                WHERE kind = 'calls'
807                GROUP BY source_id
808            ),
809            fi AS (
810                SELECT target_id AS id, COUNT(*) AS n
811                FROM edges
812                WHERE kind = 'calls' AND target_id IS NOT NULL
813                GROUP BY target_id
814            )
815            SELECT
816                s.id,
817                s.name,
818                s.qualified_name,
819                s.kind,
820                s.file_path,
821                s.line_start,
822                s.line_end,
823                COALESCE(fi.n, 0) AS fan_in,
824                COALESCE(fo.n, 0) AS fan_out,
825                (COALESCE(fo.n, 0) * 2 + COALESCE(fi.n, 0)) AS complexity
826            FROM symbols s
827            LEFT JOIN fo ON s.id = fo.id
828            LEFT JOIN fi ON s.id = fi.id
829            WHERE s.kind IN ('function', 'method')
830            ORDER BY complexity DESC, s.file_path, s.line_start
831            "#,
832        )?;
833
834        let rows = stmt.query_map([], |row| {
835            Ok(SymbolMetrics {
836                id: row.get(0)?,
837                name: row.get(1)?,
838                qualified_name: row.get(2)?,
839                kind: row.get(3)?,
840                file_path: row.get(4)?,
841                line_start: row.get(5)?,
842                line_end: row.get(6)?,
843                fan_in: row.get(7)?,
844                fan_out: row.get(8)?,
845                complexity: row.get(9)?,
846            })
847        })?;
848
849        rows.collect()
850    }
851
852    /// Per-file aggregated complexity (same formula as [`Self::symbol_metrics`],
853    /// summed over all symbols in the file).
854    ///
855    /// `symbol_count` counts all symbols in the file, not only functions.
856    /// Results are ordered by complexity (highest first).
857    pub fn file_complexity(&self) -> Result<Vec<FileComplexity>> {
858        let mut stmt = self.conn.prepare(
859            r#"
860            WITH fo AS (
861                SELECT source_id AS id, COUNT(*) AS n
862                FROM edges
863                WHERE kind = 'calls'
864                GROUP BY source_id
865            ),
866            fi AS (
867                SELECT target_id AS id, COUNT(*) AS n
868                FROM edges
869                WHERE kind = 'calls' AND target_id IS NOT NULL
870                GROUP BY target_id
871            )
872            SELECT
873                s.file_path,
874                SUM(COALESCE(fo.n, 0) * 2 + COALESCE(fi.n, 0)) AS complexity,
875                SUM(COALESCE(fo.n, 0)) AS fan_out,
876                COUNT(*) AS symbol_count
877            FROM symbols s
878            LEFT JOIN fo ON s.id = fo.id
879            LEFT JOIN fi ON s.id = fi.id
880            GROUP BY s.file_path
881            ORDER BY complexity DESC, s.file_path
882            "#,
883        )?;
884
885        let rows = stmt.query_map([], |row| {
886            Ok(FileComplexity {
887                file_path: row.get(0)?,
888                complexity: row.get(1)?,
889                fan_out: row.get(2)?,
890                symbol_count: row.get(3)?,
891            })
892        })?;
893
894        rows.collect()
895    }
896
897    /// All `calls` edges sourced from symbols in `file_path`, as
898    /// `(source_symbol_id, target_name)` pairs.
899    ///
900    /// Used by `ctx score` to compute per-file complexity restricted to
901    /// changed files (per-changed-file queries keep scoring fast on large
902    /// indexes).
903    pub fn file_call_edges(&self, file_path: &str) -> Result<Vec<(String, String)>> {
904        let mut stmt = self.conn.prepare(
905            r#"
906            SELECT e.source_id, e.target_name
907            FROM edges e
908            JOIN symbols s ON s.id = e.source_id
909            WHERE s.file_path = ?1 AND e.kind = 'calls'
910            ORDER BY e.id
911            "#,
912        )?;
913
914        let rows = stmt.query_map([file_path], |row| Ok((row.get(0)?, row.get(1)?)))?;
915        rows.collect()
916    }
917
918    /// Count resolved incoming 'calls' edges for the given symbol IDs.
919    ///
920    /// Symbols with no incoming calls are absent from the returned map.
921    pub fn fan_in_counts(&self, ids: &[String]) -> Result<std::collections::HashMap<String, i64>> {
922        let mut counts = std::collections::HashMap::new();
923        if ids.is_empty() {
924            return Ok(counts);
925        }
926
927        let placeholders = vec!["?"; ids.len()].join(", ");
928        let sql = format!(
929            "SELECT target_id, COUNT(*) FROM edges \
930             WHERE target_id IN ({}) AND kind = 'calls' \
931             GROUP BY target_id",
932            placeholders
933        );
934
935        let mut stmt = self.conn.prepare(&sql)?;
936        let rows = stmt.query_map(rusqlite::params_from_iter(ids.iter()), |row| {
937            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
938        })?;
939
940        for row in rows {
941            let (id, n) = row?;
942            counts.insert(id, n);
943        }
944
945        Ok(counts)
946    }
947
948    /// Get all indexed file paths.
949    pub fn get_indexed_files(&self) -> Result<Vec<String>> {
950        let mut stmt = self.conn.prepare("SELECT path FROM files ORDER BY path")?;
951        let rows = stmt.query_map([], |row| row.get(0))?;
952        rows.collect()
953    }
954
955    // ========== Symbol Rank Cache (ctx map) ==========
956    //
957    // The `symbol_rank` table caches PageRank scores computed by
958    // `crate::rank`. It is cleared by the indexer whenever the index
959    // changes and lazily repopulated by `ctx map`.
960
961    /// Get all symbol IDs, sorted ascending (stable order for rank computation).
962    pub fn get_all_symbol_ids(&self) -> Result<Vec<String>> {
963        let mut stmt = self.conn.prepare("SELECT id FROM symbols ORDER BY id")?;
964        let rows = stmt.query_map([], |row| row.get(0))?;
965        rows.collect()
966    }
967
968    /// Get deduplicated resolved edges of the kinds used for ranking
969    /// (calls, imports, extends, implements), ordered for determinism.
970    pub fn get_rank_edges(&self) -> Result<Vec<(String, String)>> {
971        let mut stmt = self.conn.prepare(
972            r#"
973            SELECT DISTINCT source_id, target_id
974            FROM edges
975            WHERE target_id IS NOT NULL
976              AND kind IN ('calls', 'imports', 'extends', 'implements')
977            ORDER BY source_id, target_id
978            "#,
979        )?;
980        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
981        rows.collect()
982    }
983
984    /// Delete all cached PageRank scores (called when the index changes).
985    pub fn clear_symbol_rank(&self) -> Result<()> {
986        self.conn.execute("DELETE FROM symbol_rank", [])?;
987        Ok(())
988    }
989
990    /// Bulk-store PageRank scores in a single transaction, replacing any
991    /// existing cache.
992    pub fn store_symbol_ranks(&self, ranks: &[(String, f64)]) -> Result<()> {
993        let tx = self.conn.unchecked_transaction()?;
994        tx.execute("DELETE FROM symbol_rank", [])?;
995        {
996            let mut stmt = tx.prepare("INSERT INTO symbol_rank (symbol_id, rank) VALUES (?, ?)")?;
997            for (id, rank) in ranks {
998                stmt.execute(params![id, rank])?;
999            }
1000        }
1001        tx.commit()
1002    }
1003
1004    /// Load all cached PageRank scores.
1005    pub fn load_symbol_ranks(&self) -> Result<Vec<(String, f64)>> {
1006        let mut stmt = self
1007            .conn
1008            .prepare("SELECT symbol_id, rank FROM symbol_rank ORDER BY symbol_id")?;
1009        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1010        rows.collect()
1011    }
1012
1013    /// Count rows in the symbols table.
1014    pub fn count_symbols(&self) -> Result<i64> {
1015        self.conn
1016            .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
1017    }
1018
1019    /// Count rows in the symbol_rank cache.
1020    pub fn count_symbol_ranks(&self) -> Result<i64> {
1021        self.conn
1022            .query_row("SELECT COUNT(*) FROM symbol_rank", [], |row| row.get(0))
1023    }
1024
1025    /// Get all indexed files with their sizes, ordered by path.
1026    pub fn get_files_with_sizes(&self) -> Result<Vec<(String, i64)>> {
1027        let mut stmt = self
1028            .conn
1029            .prepare("SELECT path, COALESCE(size_bytes, 0) FROM files ORDER BY path")?;
1030        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1031        rows.collect()
1032    }
1033
1034    /// Get the IDs of all symbols defined in a file.
1035    pub fn get_symbol_ids_in_file(&self, file_path: &str) -> Result<Vec<String>> {
1036        let mut stmt = self
1037            .conn
1038            .prepare("SELECT id FROM symbols WHERE file_path = ? ORDER BY id")?;
1039        let rows = stmt.query_map([file_path], |row| row.get(0))?;
1040        rows.collect()
1041    }
1042
1043    /// Get the IDs of all symbols whose name or qualified name matches exactly.
1044    pub fn get_symbol_ids_by_name(&self, name: &str) -> Result<Vec<String>> {
1045        let mut stmt = self
1046            .conn
1047            .prepare("SELECT id FROM symbols WHERE name = ?1 OR qualified_name = ?1 ORDER BY id")?;
1048        let rows = stmt.query_map([name], |row| row.get(0))?;
1049        rows.collect()
1050    }
1051
1052    /// Get the lightweight symbol rows shown by `ctx map` (declaration-level
1053    /// kinds only), in a stable base order.
1054    pub fn get_map_symbols(&self) -> Result<Vec<MapSymbolRow>> {
1055        let mut stmt = self.conn.prepare(
1056            r#"
1057            SELECT id, file_path, name, qualified_name, kind, signature, line_start
1058            FROM symbols
1059            WHERE kind IN ('function', 'method', 'struct', 'class', 'enum', 'trait', 'interface')
1060            ORDER BY id
1061            "#,
1062        )?;
1063        let rows = stmt.query_map([], |row| {
1064            Ok(MapSymbolRow {
1065                id: row.get(0)?,
1066                file_path: row.get(1)?,
1067                name: row.get(2)?,
1068                qualified_name: row.get(3)?,
1069                kind: row.get(4)?,
1070                signature: row.get(5)?,
1071                line_start: row.get::<_, Option<u32>>(6)?.unwrap_or(0),
1072            })
1073        })?;
1074        rows.collect()
1075    }
1076
1077    /// All resolved relationship edges whose endpoints live in different files.
1078    ///
1079    /// Used by `ctx check` to build the file-level dependency graph. Only
1080    /// `calls`/`implements`/`extends`/`uses` edges are included (`imports`
1081    /// edges are file-level and handled separately; `contains` and friends
1082    /// are structural, not dependencies).
1083    pub fn get_cross_file_edges(&self) -> Result<Vec<CrossFileEdge>> {
1084        let mut stmt = self.conn.prepare(
1085            r#"
1086            SELECT
1087                s1.name, s1.qualified_name, s1.kind, s1.file_path, s1.line_start, s1.line_end,
1088                s2.name, s2.qualified_name, s2.kind, s2.file_path, s2.line_start, s2.line_end,
1089                e.kind, e.line
1090            FROM edges e
1091            JOIN symbols s1 ON e.source_id = s1.id
1092            JOIN symbols s2 ON e.target_id = s2.id
1093            WHERE e.target_id IS NOT NULL
1094              AND s1.file_path <> s2.file_path
1095              AND e.kind IN ('calls', 'implements', 'extends', 'uses')
1096            ORDER BY s1.file_path, e.line, s2.file_path
1097            "#,
1098        )?;
1099
1100        let rows = stmt.query_map([], |row| {
1101            Ok(CrossFileEdge {
1102                source: EdgeSymbol {
1103                    name: row.get(0)?,
1104                    qualified_name: row.get(1)?,
1105                    kind: row.get(2)?,
1106                    file_path: row.get(3)?,
1107                    line_start: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
1108                    line_end: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
1109                },
1110                target: EdgeSymbol {
1111                    name: row.get(6)?,
1112                    qualified_name: row.get(7)?,
1113                    kind: row.get(8)?,
1114                    file_path: row.get(9)?,
1115                    line_start: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
1116                    line_end: row.get::<_, Option<i64>>(11)?.unwrap_or(0),
1117                },
1118                kind: row.get(12)?,
1119                line: row.get(13)?,
1120            })
1121        })?;
1122
1123        rows.collect()
1124    }
1125
1126    /// Per-file imports recorded in the `modules` table.
1127    ///
1128    /// The `imports` column stores a JSON array of [`ImportInfo`]; rows whose
1129    /// JSON fails to parse are skipped.
1130    pub fn get_file_imports(&self) -> Result<Vec<(String, Vec<ImportInfo>)>> {
1131        let mut stmt = self.conn.prepare(
1132            "SELECT file_path, imports FROM modules \
1133             WHERE imports IS NOT NULL AND imports <> '' \
1134             ORDER BY file_path",
1135        )?;
1136        let rows = stmt.query_map([], |row| {
1137            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1138        })?;
1139
1140        let mut result = Vec::new();
1141        for row in rows {
1142            let (file_path, json) = row?;
1143            if let Ok(imports) = serde_json::from_str::<Vec<ImportInfo>>(&json) {
1144                if !imports.is_empty() {
1145                    result.push((file_path, imports));
1146                }
1147            }
1148        }
1149        Ok(result)
1150    }
1151
1152    /// File-level `imports` edges from the `edges` table.
1153    ///
1154    /// Some parsers (Go) record imports as edges whose `source_id` is the
1155    /// importing *file path* and whose `target_name` is the import path.
1156    /// Returns `(source, target_name, line)` tuples.
1157    pub fn get_import_edges(&self) -> Result<Vec<(String, String, Option<i64>)>> {
1158        let mut stmt = self.conn.prepare(
1159            "SELECT source_id, target_name, line FROM edges \
1160             WHERE kind = 'imports' ORDER BY source_id, line",
1161        )?;
1162        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
1163        rows.collect()
1164    }
1165
1166    /// Normalize an FTS5 bm25 score into a 0-1 relevance value.
1167    fn bm25_relevance(rank: f64) -> f64 {
1168        if rank.is_finite() {
1169            // Use magnitude to normalize regardless of sign: |rank|/(1+|rank|).
1170            let abs_rank = rank.abs();
1171            (abs_rank / (1.0 + abs_rank)).clamp(0.0, 1.0)
1172        } else {
1173            0.0 // Return 0 for invalid scores
1174        }
1175    }
1176
1177    /// Semantic search using FTS5 full-text search.
1178    /// Searches across name, signature, brief, and docstring fields.
1179    pub fn semantic_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64)>> {
1180        // Preprocess query: split into keywords, handle natural language
1181        let keywords = preprocess_search_query(query);
1182
1183        if keywords.is_empty() {
1184            return Ok(Vec::new());
1185        }
1186
1187        // Build FTS5 query with OR logic for broader matches
1188        let fts_query = keywords.join(" OR ");
1189
1190        let mut stmt = self.conn.prepare(
1191            r#"
1192            SELECT
1193                s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
1194                s.signature, s.brief, s.docstring, s.line_start, s.line_end,
1195                s.col_start, s.col_end, s.parent_id, s.source,
1196                bm25(symbol_fts) as rank
1197            FROM symbol_fts
1198            JOIN symbols s ON symbol_fts.id = s.id
1199            WHERE symbol_fts MATCH ?
1200            ORDER BY rank
1201            LIMIT ?
1202            "#,
1203        )?;
1204
1205        let rows = stmt.query_map(params![fts_query, limit], |row| {
1206            let symbol = symbol_from_row(row);
1207            let rank: f64 = row.get(15)?;
1208            let relevance = Self::bm25_relevance(rank);
1209            Ok((symbol, relevance))
1210        })?;
1211
1212        rows.collect()
1213    }
1214
1215    /// Hybrid search combining exact match with semantic search.
1216    pub fn hybrid_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64, String)>> {
1217        let mut results: std::collections::HashMap<String, (Symbol, f64, String)> =
1218            std::collections::HashMap::new();
1219
1220        // Ensure we get at least 1 result from each source, even for small limits
1221        let half_limit = (limit / 2).max(1);
1222
1223        // 1. Exact name matches (highest priority)
1224        let exact_matches = self.find_symbols(query, half_limit)?;
1225        for symbol in exact_matches {
1226            let score = if symbol.name.eq_ignore_ascii_case(query) {
1227                1.0 // Exact match
1228            } else if symbol
1229                .name
1230                .to_lowercase()
1231                .starts_with(&query.to_lowercase())
1232            {
1233                0.9 // Prefix match
1234            } else {
1235                0.7 // Contains match
1236            };
1237            results.insert(symbol.id.clone(), (symbol, score, "exact".to_string()));
1238        }
1239
1240        // 2. Semantic matches (FTS5)
1241        if let Ok(semantic_matches) = self.semantic_search(query, half_limit) {
1242            for (symbol, relevance) in semantic_matches {
1243                results
1244                    .entry(symbol.id.clone())
1245                    .and_modify(|(_, existing_score, _)| {
1246                        *existing_score = existing_score.max(relevance);
1247                    })
1248                    .or_insert((symbol, relevance, "semantic".to_string()));
1249            }
1250        }
1251
1252        // Sort by score and return
1253        let mut results: Vec<_> = results.into_values().collect();
1254        // Guard against NaN/Inf by treating non-finite values as lower priority
1255        results.sort_by(|a, b| {
1256            match (a.1.is_finite(), b.1.is_finite()) {
1257                (true, true) => b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal),
1258                (true, false) => std::cmp::Ordering::Less, // a (finite) is better than b (non-finite)
1259                (false, true) => std::cmp::Ordering::Greater, // b (finite) is better than a (non-finite)
1260                (false, false) => std::cmp::Ordering::Equal,
1261            }
1262        });
1263        results.truncate(limit as usize);
1264
1265        Ok(results)
1266    }
1267
1268    /// Rebuild the FTS index (useful after schema changes).
1269    #[allow(dead_code)]
1270    pub fn rebuild_fts_index(&self) -> Result<()> {
1271        self.conn.execute_batch(
1272            r#"
1273            INSERT INTO symbol_fts(symbol_fts) VALUES('rebuild');
1274            "#,
1275        )?;
1276        Ok(())
1277    }
1278
1279    // ========== Embedding Operations ==========
1280
1281    /// Store an embedding for a symbol.
1282    ///
1283    /// This stores the embedding in two places:
1284    /// 1. The `embeddings` table (JSON format, for compatibility)
1285    /// 2. The `symbol_vectors` table (binary format, for fast KNN search via sqlite-vec)
1286    pub fn store_embedding(
1287        &self,
1288        symbol_id: &str,
1289        provider: &str,
1290        model: &str,
1291        vector: &[f32],
1292    ) -> Result<()> {
1293        let vector_json = serde_json::to_string(vector)
1294            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
1295
1296        // Store in JSON embeddings table
1297        self.conn.execute(
1298            r#"
1299            INSERT OR REPLACE INTO embeddings (symbol_id, provider, model, dimension, vector)
1300            VALUES (?, ?, ?, ?, ?)
1301            "#,
1302            params![symbol_id, provider, model, vector.len(), vector_json],
1303        )?;
1304
1305        // Also store in vector table for fast KNN search (if available and dimension matches)
1306        if self.has_vector_search() && vector.len() == DEFAULT_EMBEDDING_DIM {
1307            // Convert to bytes for sqlite-vec (f32 little-endian)
1308            let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1309
1310            // Delete existing entry first (vec0 doesn't support REPLACE)
1311            let _ = self.conn.execute(
1312                "DELETE FROM symbol_vectors WHERE symbol_id = ?",
1313                [symbol_id],
1314            );
1315
1316            self.conn.execute(
1317                "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1318                params![vector_bytes, symbol_id],
1319            )?;
1320        }
1321
1322        Ok(())
1323    }
1324
1325    /// Get the embedding for a symbol.
1326    #[allow(dead_code)]
1327    pub fn get_embedding(&self, symbol_id: &str) -> Result<Option<Vec<f32>>> {
1328        let result = self.conn.query_row(
1329            "SELECT vector FROM embeddings WHERE symbol_id = ?",
1330            [symbol_id],
1331            |row| {
1332                let json: String = row.get(0)?;
1333                Ok(json)
1334            },
1335        );
1336
1337        match result {
1338            Ok(json) => {
1339                let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
1340                    rusqlite::Error::FromSqlConversionFailure(
1341                        0,
1342                        rusqlite::types::Type::Text,
1343                        Box::new(e),
1344                    )
1345                })?;
1346                Ok(Some(vector))
1347            }
1348            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1349            Err(e) => Err(e),
1350        }
1351    }
1352
1353    #[allow(clippy::type_complexity)]
1354    /// Get all embeddings with their symbol metadata.
1355    pub fn get_all_embeddings(
1356        &self,
1357    ) -> Result<Vec<(String, String, String, String, u32, Vec<f32>)>> {
1358        let mut stmt = self.conn.prepare(
1359            r#"
1360            SELECT e.symbol_id, s.name, s.kind, s.file_path, s.line_start, e.vector
1361            FROM embeddings e
1362            JOIN symbols s ON e.symbol_id = s.id
1363            "#,
1364        )?;
1365
1366        let rows = stmt.query_map([], |row| {
1367            let symbol_id: String = row.get(0)?;
1368            let name: String = row.get(1)?;
1369            let kind: String = row.get(2)?;
1370            let file_path: String = row.get(3)?;
1371            let line: u32 = row.get(4)?;
1372            let json: String = row.get(5)?;
1373            Ok((symbol_id, name, kind, file_path, line, json))
1374        })?;
1375
1376        let mut results = Vec::new();
1377        for row in rows {
1378            let (symbol_id, name, kind, file_path, line, json) = row?;
1379            let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
1380                rusqlite::Error::FromSqlConversionFailure(
1381                    0,
1382                    rusqlite::types::Type::Text,
1383                    Box::new(e),
1384                )
1385            })?;
1386            results.push((symbol_id, name, kind, file_path, line, vector));
1387        }
1388
1389        Ok(results)
1390    }
1391
1392    /// Count symbols that have embeddings.
1393    pub fn count_embeddings(&self) -> Result<i64> {
1394        self.conn
1395            .query_row("SELECT COUNT(*) FROM embeddings", [], |row| row.get(0))
1396    }
1397
1398    /// Get metadata about stored embeddings (provider, model, dimension, count).
1399    ///
1400    /// Returns a list of (provider, model, dimension, count) tuples for each
1401    /// unique combination in the embeddings table. This is useful for detecting
1402    /// dimension mismatches when querying with a different embedding provider.
1403    pub fn get_embedding_metadata(&self) -> Result<Vec<(String, String, i64, i64)>> {
1404        let mut stmt = self.conn.prepare(
1405            r#"
1406            SELECT provider, model, dimension, COUNT(*) as count
1407            FROM embeddings
1408            GROUP BY provider, model, dimension
1409            ORDER BY count DESC
1410            "#,
1411        )?;
1412
1413        let rows = stmt.query_map([], |row| {
1414            Ok((
1415                row.get::<_, String>(0)?,
1416                row.get::<_, String>(1)?,
1417                row.get::<_, i64>(2)?,
1418                row.get::<_, i64>(3)?,
1419            ))
1420        })?;
1421
1422        rows.collect()
1423    }
1424
1425    /// Get symbols that don't have embeddings yet.
1426    pub fn get_symbols_without_embeddings(&self, limit: i64) -> Result<Vec<Symbol>> {
1427        let mut stmt = self.conn.prepare(
1428            r#"
1429            SELECT s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
1430                   s.signature, s.brief, s.docstring, s.line_start, s.line_end,
1431                   s.col_start, s.col_end, s.parent_id, s.source
1432            FROM symbols s
1433            LEFT JOIN embeddings e ON s.id = e.symbol_id
1434            WHERE e.symbol_id IS NULL
1435            LIMIT ?
1436            "#,
1437        )?;
1438
1439        let rows = stmt.query_map([limit], |row| {
1440            Ok(Symbol {
1441                id: row.get(0)?,
1442                file_path: row.get(1)?,
1443                name: row.get(2)?,
1444                qualified_name: row.get(3)?,
1445                kind: SymbolKind::from_str(&row.get::<_, String>(4)?)
1446                    .unwrap_or(SymbolKind::Function),
1447                visibility: Visibility::from_str(&row.get::<_, String>(5)?).unwrap_or_default(),
1448                signature: row.get(6)?,
1449                brief: row.get(7)?,
1450                docstring: row.get(8)?,
1451                line_start: row.get(9)?,
1452                line_end: row.get(10)?,
1453                col_start: row.get(11)?,
1454                col_end: row.get(12)?,
1455                parent_id: row.get(13)?,
1456                source: row.get(14)?,
1457            })
1458        })?;
1459
1460        rows.collect()
1461    }
1462
1463    /// Migrate existing embeddings from JSON table to vector table for fast KNN search.
1464    ///
1465    /// This copies all embeddings with the correct dimension to the symbol_vectors table.
1466    /// Returns the number of embeddings migrated.
1467    #[allow(dead_code)] // Migration utility for future use
1468    pub fn migrate_embeddings_to_vec(&self) -> Result<usize> {
1469        if !self.has_vector_search() {
1470            return Ok(0);
1471        }
1472
1473        // Get all embeddings with matching dimension
1474        let mut stmt = self.conn.prepare(
1475            r#"
1476            SELECT symbol_id, vector FROM embeddings
1477            WHERE dimension = ?
1478            "#,
1479        )?;
1480
1481        let rows = stmt.query_map([DEFAULT_EMBEDDING_DIM as i64], |row| {
1482            let symbol_id: String = row.get(0)?;
1483            let json: String = row.get(1)?;
1484            Ok((symbol_id, json))
1485        })?;
1486
1487        let mut count = 0;
1488        for row in rows {
1489            let (symbol_id, json) = row?;
1490            let vector: Vec<f32> = match serde_json::from_str(&json) {
1491                Ok(v) => v,
1492                Err(_) => continue,
1493            };
1494
1495            // Convert to bytes for sqlite-vec (f32 little-endian)
1496            let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1497
1498            // Skip if already exists
1499            let exists: bool = self
1500                .conn
1501                .query_row(
1502                    "SELECT 1 FROM symbol_vectors WHERE symbol_id = ?",
1503                    [&symbol_id],
1504                    |_| Ok(true),
1505                )
1506                .unwrap_or(false);
1507
1508            if !exists
1509                && self
1510                    .conn
1511                    .execute(
1512                        "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1513                        params![vector_bytes, symbol_id],
1514                    )
1515                    .is_ok()
1516            {
1517                count += 1;
1518            }
1519        }
1520
1521        Ok(count)
1522    }
1523
1524    /// Get the count of embeddings in the vector table.
1525    pub fn count_vector_embeddings(&self) -> Result<i64> {
1526        if !self.has_vector_search() {
1527            return Ok(0);
1528        }
1529        self.conn
1530            .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
1531    }
1532
1533    /// Fast vector similarity search using sqlite-vec.
1534    ///
1535    /// Returns the top-k most similar symbols to the query embedding.
1536    /// This uses indexed KNN search which is O(log n) instead of O(n).
1537    ///
1538    /// Returns (symbol_id, name, kind, file_path, line, distance) tuples.
1539    #[allow(clippy::type_complexity)]
1540    /// Distance is L2 distance (lower is more similar).
1541    pub fn vector_search(
1542        &self,
1543        query_embedding: &[f32],
1544        limit: usize,
1545    ) -> Result<Vec<(String, String, String, String, u32, f32)>> {
1546        if !self.has_vector_search() {
1547            return Ok(Vec::new());
1548        }
1549
1550        if query_embedding.len() != DEFAULT_EMBEDDING_DIM {
1551            return Ok(Vec::new());
1552        }
1553
1554        // Convert query to bytes for sqlite-vec (f32 little-endian)
1555        let query_bytes: Vec<u8> = query_embedding
1556            .iter()
1557            .flat_map(|f| f.to_le_bytes())
1558            .collect();
1559
1560        // sqlite-vec KNN query - first get matching rowids, then join with symbols
1561        // The vec0 virtual table uses MATCH for KNN queries with LIMIT
1562        let mut stmt = self.conn.prepare(
1563            r#"
1564            SELECT knn.symbol_id, s.name, s.kind, s.file_path, s.line_start, knn.distance
1565            FROM (
1566                SELECT symbol_id, distance
1567                FROM symbol_vectors
1568                WHERE embedding MATCH ?
1569                ORDER BY distance
1570                LIMIT ?
1571            ) knn
1572            JOIN symbols s ON knn.symbol_id = s.id
1573            ORDER BY knn.distance
1574            "#,
1575        )?;
1576
1577        let rows = stmt.query_map(params![query_bytes, limit as i64], |row| {
1578            Ok((
1579                row.get::<_, String>(0)?,
1580                row.get::<_, String>(1)?,
1581                row.get::<_, String>(2)?,
1582                row.get::<_, String>(3)?,
1583                row.get::<_, u32>(4)?,
1584                row.get::<_, f32>(5)?,
1585            ))
1586        })?;
1587
1588        rows.collect()
1589    }
1590
1591    /// Check if the vector table has any embeddings.
1592    pub fn has_vector_embeddings(&self) -> bool {
1593        self.count_vector_embeddings().unwrap_or(0) > 0
1594    }
1595
1596    /// Delete embeddings for a specific provider/model.
1597    pub fn delete_embeddings(&self, provider: &str, model: Option<&str>) -> Result<usize> {
1598        if let Some(model) = model {
1599            self.conn.execute(
1600                "DELETE FROM embeddings WHERE provider = ? AND model = ?",
1601                params![provider, model],
1602            )
1603        } else {
1604            self.conn
1605                .execute("DELETE FROM embeddings WHERE provider = ?", [provider])
1606        }
1607    }
1608
1609    /// Resolve target_id for edges that only have target_name.
1610    ///
1611    /// This performs cross-file symbol resolution by matching target_name to symbols
1612    /// in the database. Resolution priority:
1613    /// 0. Qualified match: the edge `context` equals a symbol's `qualified_name`
1614    ///    exactly (e.g., "ChessPureLib.isKingInCheck"), disambiguating a bare name
1615    ///    shared across files/languages.
1616    /// 1. Context match: the call context contains the type name (e.g., "TypeScriptParser::new()")
1617    /// 2. Unique: only one symbol with that name exists in the codebase
1618    /// 3. Same file unique: only one symbol with that name exists in the same file
1619    ///
1620    /// We intentionally avoid aggressive same-file matching because calls like
1621    /// `Vec::new()` would incorrectly match a local `new` function.
1622    ///
1623    /// Returns the number of edges that were resolved.
1624    pub fn resolve_edge_targets(&self) -> Result<usize> {
1625        // Step 0: Resolve edges whose `context` holds a fully qualified name by an
1626        // EXACT match on a symbol's `qualified_name` (e.g. Solidity qualified calls
1627        // like `ChessPureLib.isKingInCheck`). This must run BEFORE the substring
1628        // LIKE match in Step 1: exact equality disambiguates the intended target
1629        // even when the bare `target_name` collides across files/languages, and it
1630        // sets `target_id` so the later `target_id IS NULL` steps skip the row.
1631        let qualified_resolved = self.conn.execute(
1632            r#"
1633            UPDATE edges
1634            SET target_id = (
1635                SELECT id FROM symbols
1636                WHERE qualified_name = edges.context
1637                  AND kind IN ('function', 'method')
1638                LIMIT 1
1639            )
1640            WHERE target_id IS NULL
1641              AND context IS NOT NULL
1642              AND (
1643                SELECT COUNT(*) FROM symbols
1644                WHERE qualified_name = edges.context
1645                  AND kind IN ('function', 'method')
1646              ) = 1
1647            "#,
1648            [],
1649        )?;
1650
1651        // Step 1: Resolve edges where the context contains the qualified name
1652        // e.g., context "TypeScriptParser::new()" matches symbol with qualified_name "TypeScriptParser::new"
1653        // Only resolve if exactly one symbol matches to avoid ambiguous resolution
1654        let context_resolved = self.conn.execute(
1655            r#"
1656            UPDATE edges
1657            SET target_id = (
1658                SELECT t.id
1659                FROM symbols t
1660                WHERE t.name = edges.target_name
1661                  AND t.kind IN ('function', 'method')
1662                  AND t.qualified_name IS NOT NULL
1663                  AND edges.context LIKE '%' || t.qualified_name || '%'
1664            )
1665            WHERE target_id IS NULL
1666              AND context IS NOT NULL
1667              AND (
1668                SELECT COUNT(*)
1669                FROM symbols t
1670                WHERE t.name = edges.target_name
1671                  AND t.kind IN ('function', 'method')
1672                  AND t.qualified_name IS NOT NULL
1673                  AND edges.context LIKE '%' || t.qualified_name || '%'
1674              ) = 1
1675            "#,
1676            [],
1677        )?;
1678
1679        // Step 2: Resolve edges where target name is unique across the codebase
1680        let unique_resolved = self.conn.execute(
1681            r#"
1682            UPDATE edges
1683            SET target_id = (
1684                SELECT id FROM symbols
1685                WHERE name = edges.target_name
1686                  AND kind IN ('function', 'method')
1687            )
1688            WHERE target_id IS NULL
1689              AND (
1690                SELECT COUNT(*) FROM symbols
1691                WHERE name = edges.target_name
1692                  AND kind IN ('function', 'method')
1693              ) = 1
1694            "#,
1695            [],
1696        )?;
1697
1698        // Step 3: Resolve edges where target is unique in the same file
1699        // Only match if:
1700        // - There's exactly one function with that name in the same file
1701        // - The context doesn't suggest an external type (no :: prefix before the name)
1702        // - The context doesn't suggest an external type/receiver call
1703        //   (no ::, ., or -> before the function name)
1704        let same_file_unique = self.conn.execute(
1705            r#"
1706            UPDATE edges
1707            SET target_id = (
1708                SELECT t.id
1709                FROM symbols t
1710                JOIN symbols s ON s.id = edges.source_id
1711                WHERE t.name = edges.target_name
1712                  AND t.file_path = s.file_path
1713                  AND t.kind IN ('function', 'method')
1714            )
1715            WHERE target_id IS NULL
1716              AND (
1717                SELECT COUNT(*)
1718                FROM symbols t
1719                JOIN symbols s ON s.id = edges.source_id
1720                WHERE t.name = edges.target_name
1721                  AND t.file_path = s.file_path
1722                  AND t.kind IN ('function', 'method')
1723              ) = 1
1724              -- Exclude if context suggests an external type call (has :: before the function name)
1725              -- e.g., "Vec::new()" or "Parser::new()" should NOT match local "new" functions
1726              AND (
1727                context IS NULL
1728                OR (
1729                    context NOT LIKE '%::' || target_name || '(%'
1730                    AND context NOT LIKE '%.' || target_name || '(%'
1731                    AND context NOT LIKE '%->' || target_name || '(%'
1732                )
1733                OR context LIKE '%' || (
1734                        SELECT t.qualified_name
1735                        FROM symbols t
1736                        JOIN symbols s ON s.id = edges.source_id
1737                        WHERE t.name = edges.target_name
1738                          AND t.file_path = s.file_path
1739                          AND t.kind IN ('function', 'method')
1740                        LIMIT 1
1741                    ) || '(%'
1742              )
1743            "#,
1744            [],
1745        )?;
1746
1747        Ok(qualified_resolved + context_resolved + unique_resolved + same_file_unique)
1748    }
1749
1750    /// Insert (or replace) a batch of MinHash fingerprints in one transaction.
1751    pub fn insert_fingerprints_batch(&self, fingerprints: &[Fingerprint]) -> Result<usize> {
1752        if fingerprints.is_empty() {
1753            return Ok(0);
1754        }
1755        let tx = self.conn.unchecked_transaction()?;
1756        {
1757            let mut stmt = tx.prepare(
1758                r#"
1759                INSERT OR REPLACE INTO symbol_fingerprints (symbol_id, file_path, minhash, token_count)
1760                VALUES (?, ?, ?, ?)
1761                "#,
1762            )?;
1763            for fp in fingerprints {
1764                stmt.execute(params![
1765                    fp.symbol_id,
1766                    fp.file_path,
1767                    fp.minhash,
1768                    fp.token_count
1769                ])?;
1770            }
1771        }
1772        tx.commit()?;
1773        Ok(fingerprints.len())
1774    }
1775
1776    /// Load all fingerprints with at least `min_tokens` tokens, ordered by
1777    /// symbol id (so callers get a stable, canonical order).
1778    pub fn get_fingerprints(&self, min_tokens: i64) -> Result<Vec<Fingerprint>> {
1779        let mut stmt = self.conn.prepare(
1780            r#"
1781            SELECT symbol_id, file_path, minhash, token_count
1782            FROM symbol_fingerprints
1783            WHERE token_count >= ?
1784            ORDER BY symbol_id
1785            "#,
1786        )?;
1787        let rows = stmt.query_map([min_tokens], |row| {
1788            Ok(Fingerprint {
1789                symbol_id: row.get(0)?,
1790                file_path: row.get(1)?,
1791                minhash: row.get(2)?,
1792                token_count: row.get(3)?,
1793            })
1794        })?;
1795        rows.collect()
1796    }
1797}
1798
1799/// Escape SQL LIKE special characters in a pattern.
1800///
1801/// SQLite LIKE uses `%` for any sequence and `_` for single character.
1802/// This function escapes these so they match literally.
1803fn escape_like_pattern(pattern: &str) -> String {
1804    pattern
1805        .replace('\\', "\\\\") // Escape backslash first
1806        .replace('%', "\\%")
1807        .replace('_', "\\_")
1808}
1809
1810/// Convert a glob-style pattern to a SQL LIKE pattern.
1811///
1812/// Supports:
1813/// - `*` -> `%` (match any sequence - note: in SQL LIKE this also matches `/`)
1814/// - `**` -> `%` (match any sequence including path separators)
1815/// - `**/` -> consumed, following pattern matches from any depth
1816/// - `?` -> `_` (match single character)
1817/// - Escapes literal `%` and `_` in the pattern
1818///
1819/// Limitations:
1820/// - SQL LIKE `%` matches across path separators, so `src/*.rs` will also match
1821///   `src/foo/bar.rs`. Use substring patterns like `*parser*` for simple filtering,
1822///   or rely on the prefix to narrow results.
1823///
1824/// Examples:
1825/// - `**/*.rs` -> `%.rs` (any .rs file at any depth)
1826/// - `src/**/*.rs` -> `src/%.rs` (any .rs file under src/)
1827/// - `*parser*` -> `%parser%` (any path containing "parser")
1828fn glob_to_like_pattern(pattern: &str) -> String {
1829    let mut result = String::with_capacity(pattern.len() * 2);
1830    let mut chars = pattern.chars().peekable();
1831
1832    while let Some(c) = chars.next() {
1833        match c {
1834            '*' => {
1835                // Check for **
1836                if chars.peek() == Some(&'*') {
1837                    chars.next();
1838                    // Check for **/ - this should match zero or more path segments
1839                    if chars.peek() == Some(&'/') {
1840                        chars.next();
1841                        // **/ means "any path prefix including empty"
1842                        // Don't add % here - the following pattern (likely *.ext) will add it
1843                        // This allows **/*.rs to become %.rs instead of %%.rs
1844                        // But if there's nothing after, or it's not a *, we need the %
1845                        if chars.peek().is_none() || chars.peek() == Some(&'*') {
1846                            // **/ at end or followed by another * - don't add redundant %
1847                            continue;
1848                        }
1849                        // **/ followed by something else - add % to match the path prefix
1850                        result.push('%');
1851                    } else {
1852                        // ** without trailing / - just match anything
1853                        result.push('%');
1854                    }
1855                } else {
1856                    // Single * - match anything (in SQL LIKE, same as %)
1857                    result.push('%');
1858                }
1859            }
1860            '?' => result.push('_'),
1861            '%' => result.push_str("\\%"),
1862            '_' => result.push_str("\\_"),
1863            '\\' => result.push_str("\\\\"),
1864            _ => result.push(c),
1865        }
1866    }
1867
1868    result
1869}
1870
1871/// Preprocess a search query into keywords.
1872fn preprocess_search_query(query: &str) -> Vec<String> {
1873    // Common words to filter out
1874    let stop_words: std::collections::HashSet<&str> = [
1875        "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
1876        "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall",
1877        "can", "need", "dare", "and", "or", "but", "if", "then", "else", "when", "where", "why",
1878        "how", "what", "which", "who", "whom", "this", "that", "these", "those", "i", "you", "he",
1879        "she", "it", "we", "they", "me", "him", "her", "us", "them", "my", "your", "his", "its",
1880        "our", "their", "for", "to", "from", "with", "at", "by", "on", "in", "of", "about", "into",
1881        "through", "during", "before", "after", "above", "below", "find", "get", "search", "look",
1882        "all", "any", "each", "every",
1883    ]
1884    .iter()
1885    .copied()
1886    .collect();
1887
1888    query
1889        .to_lowercase()
1890        .split(|c: char| !c.is_alphanumeric() && c != '_')
1891        .filter(|word| {
1892            let word = word.trim();
1893            !word.is_empty() && word.len() > 1 && !stop_words.contains(word)
1894        })
1895        .map(|s| {
1896            // Add wildcard suffix for prefix matching
1897            format!("{}*", s)
1898        })
1899        .collect()
1900}
1901
1902/// Helper to convert a row to a Symbol.
1903fn symbol_from_row(row: &rusqlite::Row) -> Symbol {
1904    let kind_str: String = row.get(4).unwrap_or_default();
1905    let visibility_str: String = row.get(5).unwrap_or_default();
1906
1907    Symbol {
1908        id: row.get(0).unwrap_or_default(),
1909        file_path: row.get(1).unwrap_or_default(),
1910        name: row.get(2).unwrap_or_default(),
1911        qualified_name: row.get(3).ok(),
1912        kind: SymbolKind::from_str(&kind_str).unwrap_or(SymbolKind::Function),
1913        visibility: Visibility::from_str(&visibility_str).unwrap_or_default(),
1914        signature: row.get(6).ok(),
1915        brief: row.get(7).ok(),
1916        docstring: row.get(8).ok(),
1917        line_start: row.get(9).unwrap_or(0),
1918        line_end: row.get(10).unwrap_or(0),
1919        col_start: row.get(11).unwrap_or(0),
1920        col_end: row.get(12).unwrap_or(0),
1921        parent_id: row.get(13).ok(),
1922        source: row.get(14).ok(),
1923    }
1924}
1925
1926/// Helper to convert a row to an Edge.
1927fn edge_from_row(row: &rusqlite::Row) -> Edge {
1928    let kind_str: String = row.get(3).unwrap_or_default();
1929
1930    Edge {
1931        source_id: row.get(0).unwrap_or_default(),
1932        target_id: row.get(1).ok(),
1933        target_name: row.get(2).unwrap_or_default(),
1934        kind: EdgeKind::from_str(&kind_str).unwrap_or(EdgeKind::Calls),
1935        line: row.get(4).ok(),
1936        col: row.get(5).ok(),
1937        context: row.get(6).ok(),
1938    }
1939}
1940
1941/// A lightweight symbol row for the `ctx map` command
1942/// (see [`Database::get_map_symbols`]).
1943#[derive(Debug, Clone)]
1944pub struct MapSymbolRow {
1945    pub id: String,
1946    pub file_path: String,
1947    pub name: String,
1948    pub qualified_name: Option<String>,
1949    pub kind: String,
1950    pub signature: Option<String>,
1951    pub line_start: u32,
1952}
1953
1954/// Per-symbol complexity metrics (see [`Database::symbol_metrics`]).
1955#[derive(Debug, Clone, serde::Serialize)]
1956pub struct SymbolMetrics {
1957    pub id: String,
1958    pub name: String,
1959    pub qualified_name: Option<String>,
1960    pub kind: String,
1961    pub file_path: String,
1962    pub line_start: i64,
1963    pub line_end: i64,
1964    pub fan_in: i64,
1965    pub fan_out: i64,
1966    pub complexity: i64,
1967}
1968
1969/// A resolved relationship edge whose endpoints live in different files
1970/// (see [`Database::get_cross_file_edges`]).
1971#[derive(Debug, Clone)]
1972pub struct CrossFileEdge {
1973    pub source: EdgeSymbol,
1974    pub target: EdgeSymbol,
1975    /// Edge kind (`calls`, `implements`, `extends`, `uses`).
1976    pub kind: String,
1977    /// Line in the source file where the reference occurs.
1978    pub line: Option<i64>,
1979}
1980
1981/// Lightweight symbol info attached to a [`CrossFileEdge`].
1982#[derive(Debug, Clone)]
1983pub struct EdgeSymbol {
1984    pub name: String,
1985    pub qualified_name: Option<String>,
1986    pub kind: String,
1987    pub file_path: String,
1988    pub line_start: i64,
1989    pub line_end: i64,
1990}
1991
1992/// Per-file aggregated complexity (see [`Database::file_complexity`]).
1993#[derive(Debug, Clone, serde::Serialize)]
1994pub struct FileComplexity {
1995    pub file_path: String,
1996    pub complexity: i64,
1997    pub fan_out: i64,
1998    pub symbol_count: i64,
1999}
2000
2001/// Extension trait for optional query results.
2002trait ResultExt<T> {
2003    fn optional(self) -> Result<Option<T>>;
2004}
2005
2006impl<T> ResultExt<T> for Result<T> {
2007    fn optional(self) -> Result<Option<T>> {
2008        match self {
2009            Ok(v) => Ok(Some(v)),
2010            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2011            Err(e) => Err(e),
2012        }
2013    }
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018    use super::*;
2019
2020    #[test]
2021    fn test_create_database() {
2022        let db = Database::open_in_memory().unwrap();
2023        let stats = db.get_stats().unwrap();
2024        assert_eq!(stats.files, 0);
2025        assert_eq!(stats.symbols, 0);
2026    }
2027
2028    #[test]
2029    fn test_fresh_database_is_stamped_with_schema_version() {
2030        let dir = tempfile::tempdir().unwrap();
2031        let db_path = dir.path().join("codebase.sqlite");
2032
2033        let db = Database::open(&db_path).unwrap();
2034        let version: i64 = db
2035            .conn
2036            .query_row("PRAGMA user_version", [], |row| row.get(0))
2037            .unwrap();
2038        assert_eq!(version, SCHEMA_VERSION);
2039        drop(db);
2040
2041        // Re-opening a stamped database works.
2042        assert!(Database::open(&db_path).is_ok());
2043    }
2044
2045    #[test]
2046    fn test_legacy_v0_database_is_rejected() {
2047        let dir = tempfile::tempdir().unwrap();
2048        let db_path = dir.path().join("codebase.sqlite");
2049
2050        // Create a database, then reset it to the pre-versioning state (v0
2051        // with existing tables). Pre-versioning databases lack the
2052        // symbol_fingerprints table, so they must be rebuilt.
2053        drop(Database::open(&db_path).unwrap());
2054        {
2055            let conn = Connection::open(&db_path).unwrap();
2056            conn.pragma_update(None, "user_version", 0).unwrap();
2057        }
2058
2059        let err = Database::open(&db_path).unwrap_err();
2060        match &err {
2061            crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2062                assert_eq!(*found, 0);
2063                assert_eq!(*expected, SCHEMA_VERSION);
2064            }
2065            other => panic!("expected SchemaVersionMismatch, got: {}", other),
2066        }
2067        assert!(err.to_string().contains("ctx index --force"));
2068    }
2069
2070    #[test]
2071    fn test_schema_version_mismatch_is_rejected() {
2072        let dir = tempfile::tempdir().unwrap();
2073        let db_path = dir.path().join("codebase.sqlite");
2074
2075        // A v1 database (pre-fingerprints) must be rejected with a hint to
2076        // rebuild, as must any other unknown version.
2077        for stale_version in [1i64, 99] {
2078            drop(Database::open(&db_path).unwrap());
2079            {
2080                let conn = Connection::open(&db_path).unwrap();
2081                conn.pragma_update(None, "user_version", stale_version)
2082                    .unwrap();
2083            }
2084
2085            let err = Database::open(&db_path).unwrap_err();
2086            match &err {
2087                crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2088                    assert_eq!(*found, stale_version);
2089                    assert_eq!(*expected, SCHEMA_VERSION);
2090                }
2091                other => panic!("expected SchemaVersionMismatch, got: {}", other),
2092            }
2093            assert!(err.to_string().contains("ctx index --force"));
2094
2095            // Restore the correct version so the next iteration can re-open.
2096            let conn = Connection::open(&db_path).unwrap();
2097            conn.pragma_update(None, "user_version", SCHEMA_VERSION)
2098                .unwrap();
2099        }
2100    }
2101
2102    #[test]
2103    fn test_fingerprint_batch_roundtrip_and_min_tokens_filter() {
2104        let db = Database::open_in_memory().unwrap();
2105        db.upsert_file(
2106            &FileRecord {
2107                path: "src/a.rs".to_string(),
2108                content_hash: "h1".to_string(),
2109                size_bytes: 10,
2110                language: Some("rust".to_string()),
2111                last_indexed: 0,
2112            },
2113            None,
2114        )
2115        .unwrap();
2116        db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2117            .unwrap();
2118        db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2119            .unwrap();
2120
2121        let fps = vec![
2122            Fingerprint {
2123                symbol_id: "src/a.rs::alpha".to_string(),
2124                file_path: "src/a.rs".to_string(),
2125                minhash: vec![1u8; 1024],
2126                token_count: 80,
2127            },
2128            Fingerprint {
2129                symbol_id: "src/a.rs::beta".to_string(),
2130                file_path: "src/a.rs".to_string(),
2131                minhash: vec![2u8; 1024],
2132                token_count: 20,
2133            },
2134        ];
2135        assert_eq!(db.insert_fingerprints_batch(&fps).unwrap(), 2);
2136        assert_eq!(db.insert_fingerprints_batch(&[]).unwrap(), 0);
2137
2138        // No filter: both come back, ordered by symbol_id, bytes intact.
2139        let all = db.get_fingerprints(0).unwrap();
2140        assert_eq!(all.len(), 2);
2141        assert_eq!(all[0].symbol_id, "src/a.rs::alpha");
2142        assert_eq!(all[0].minhash, vec![1u8; 1024]);
2143        assert_eq!(all[1].token_count, 20);
2144
2145        // min_tokens filters out short symbols.
2146        let filtered = db.get_fingerprints(50).unwrap();
2147        assert_eq!(filtered.len(), 1);
2148        assert_eq!(filtered[0].symbol_id, "src/a.rs::alpha");
2149
2150        // Deleting the file's symbols cascades to fingerprints.
2151        db.delete_symbols_for_file("src/a.rs").unwrap();
2152        assert!(db.get_fingerprints(0).unwrap().is_empty());
2153    }
2154
2155    #[test]
2156    fn test_sqlite_vec_extension_loaded() {
2157        // Verify sqlite-vec is properly initialized
2158        let db = Database::open_in_memory().unwrap();
2159
2160        // Check that extension initialization was attempted
2161        // Note: init_vec_extension() is called during Database::open_in_memory()
2162        // so is_vec_extension_available() reflects whether it succeeded
2163        if !is_vec_extension_available() {
2164            eprintln!("Skipping sqlite-vec tests: extension not available on this platform");
2165            return;
2166        }
2167
2168        // Check vec_version() function exists (proves extension loaded)
2169        let version: Result<String, _> = db
2170            .conn
2171            .query_row("SELECT vec_version()", [], |row| row.get(0));
2172
2173        match version {
2174            Ok(v) => {
2175                assert!(
2176                    v.starts_with('v'),
2177                    "vec_version should return version string, got: {}",
2178                    v
2179                );
2180            }
2181            Err(e) => {
2182                // Extension registered but function not available - this shouldn't happen
2183                // if is_vec_extension_available() returned true, but handle gracefully
2184                panic!(
2185                    "sqlite-vec extension reported available but vec_version() failed: {}",
2186                    e
2187                );
2188            }
2189        }
2190
2191        // Verify has_vector_search() works
2192        assert!(db.has_vector_search(), "vector search should be available");
2193
2194        // Verify symbol_vectors table exists and is queryable
2195        let count: i64 = db
2196            .conn
2197            .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
2198            .expect("symbol_vectors table should exist");
2199        assert_eq!(count, 0, "symbol_vectors should be empty initially");
2200    }
2201
2202    /// Build a minimal function symbol for metrics tests.
2203    fn make_fn_symbol(id: &str, name: &str, file: &str, line: u32) -> Symbol {
2204        Symbol {
2205            id: id.to_string(),
2206            file_path: file.to_string(),
2207            name: name.to_string(),
2208            qualified_name: Some(name.to_string()),
2209            kind: SymbolKind::Function,
2210            visibility: Visibility::Public,
2211            signature: None,
2212            brief: None,
2213            docstring: None,
2214            line_start: line,
2215            line_end: line + 5,
2216            col_start: 0,
2217            col_end: 0,
2218            parent_id: None,
2219            source: None,
2220        }
2221    }
2222
2223    /// Build a 'calls' edge for metrics tests.
2224    fn make_call_edge(source_id: &str, target_id: Option<&str>, target_name: &str) -> Edge {
2225        Edge {
2226            source_id: source_id.to_string(),
2227            target_id: target_id.map(|s| s.to_string()),
2228            target_name: target_name.to_string(),
2229            kind: EdgeKind::Calls,
2230            line: Some(1),
2231            col: None,
2232            context: None,
2233        }
2234    }
2235
2236    /// Set up a database with three functions and a small call graph:
2237    /// alpha -> beta (resolved), alpha -> external (unresolved), beta -> alpha (resolved).
2238    fn metrics_fixture() -> Database {
2239        let db = Database::open_in_memory().unwrap();
2240        db.upsert_file(
2241            &FileRecord {
2242                path: "src/a.rs".to_string(),
2243                content_hash: "h1".to_string(),
2244                size_bytes: 10,
2245                language: Some("rust".to_string()),
2246                last_indexed: 0,
2247            },
2248            None,
2249        )
2250        .unwrap();
2251
2252        db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2253            .unwrap();
2254        db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2255            .unwrap();
2256        db.insert_symbol(&make_fn_symbol("src/a.rs::gamma", "gamma", "src/a.rs", 20))
2257            .unwrap();
2258
2259        db.insert_edge(&make_call_edge(
2260            "src/a.rs::alpha",
2261            Some("src/a.rs::beta"),
2262            "beta",
2263        ))
2264        .unwrap();
2265        db.insert_edge(&make_call_edge("src/a.rs::alpha", None, "external"))
2266            .unwrap();
2267        db.insert_edge(&make_call_edge(
2268            "src/a.rs::beta",
2269            Some("src/a.rs::alpha"),
2270            "alpha",
2271        ))
2272        .unwrap();
2273
2274        db
2275    }
2276
2277    #[test]
2278    fn test_symbol_metrics_mirrors_complexity_formula() {
2279        let db = metrics_fixture();
2280        let metrics = db.symbol_metrics().unwrap();
2281        assert_eq!(metrics.len(), 3);
2282
2283        let get = |name: &str| metrics.iter().find(|m| m.name == name).unwrap();
2284
2285        // alpha: fan_out 2 (beta + unresolved external), fan_in 1 (from beta)
2286        let alpha = get("alpha");
2287        assert_eq!(alpha.fan_out, 2);
2288        assert_eq!(alpha.fan_in, 1);
2289        assert_eq!(alpha.complexity, 2 * 2 + 1);
2290        assert_eq!(alpha.file_path, "src/a.rs");
2291        assert_eq!(alpha.line_start, 1);
2292
2293        // beta: fan_out 1, fan_in 1
2294        let beta = get("beta");
2295        assert_eq!(beta.fan_out, 1);
2296        assert_eq!(beta.fan_in, 1);
2297        assert_eq!(beta.complexity, 3); // fan_out(1) * 2 + fan_in(1)
2298
2299        // gamma: no edges at all
2300        let gamma = get("gamma");
2301        assert_eq!(gamma.fan_out, 0);
2302        assert_eq!(gamma.fan_in, 0);
2303        assert_eq!(gamma.complexity, 0);
2304
2305        // Ordered by complexity, highest first
2306        assert_eq!(metrics[0].name, "alpha");
2307    }
2308
2309    #[test]
2310    fn test_file_complexity_aggregates_per_file() {
2311        let db = metrics_fixture();
2312        let files = db.file_complexity().unwrap();
2313        assert_eq!(files.len(), 1);
2314
2315        let f = &files[0];
2316        assert_eq!(f.file_path, "src/a.rs");
2317        assert_eq!(f.symbol_count, 3);
2318        assert_eq!(f.fan_out, 3);
2319        // alpha (5) + beta (3) + gamma (0)
2320        assert_eq!(f.complexity, 8);
2321    }
2322
2323    #[test]
2324    fn test_fan_in_counts() {
2325        let db = metrics_fixture();
2326
2327        let ids = vec![
2328            "src/a.rs::alpha".to_string(),
2329            "src/a.rs::beta".to_string(),
2330            "src/a.rs::gamma".to_string(),
2331        ];
2332        let counts = db.fan_in_counts(&ids).unwrap();
2333        assert_eq!(counts.get("src/a.rs::alpha"), Some(&1));
2334        assert_eq!(counts.get("src/a.rs::beta"), Some(&1));
2335        // gamma has no incoming resolved calls, so it is absent
2336        assert!(!counts.contains_key("src/a.rs::gamma"));
2337
2338        // Empty input short-circuits
2339        assert!(db.fan_in_counts(&[]).unwrap().is_empty());
2340    }
2341
2342    #[test]
2343    fn test_insert_and_find_symbol() {
2344        let db = Database::open_in_memory().unwrap();
2345
2346        // Insert a file first (foreign key)
2347        let file = FileRecord {
2348            path: "src/main.rs".to_string(),
2349            content_hash: "abc123".to_string(),
2350            size_bytes: 100,
2351            language: Some("rust".to_string()),
2352            last_indexed: 0,
2353        };
2354        db.upsert_file(&file, None).unwrap();
2355
2356        // Insert a symbol
2357        let symbol = Symbol {
2358            id: "src/main.rs::main".to_string(),
2359            file_path: "src/main.rs".to_string(),
2360            name: "main".to_string(),
2361            qualified_name: None,
2362            kind: SymbolKind::Function,
2363            visibility: Visibility::Private,
2364            signature: Some("fn main()".to_string()),
2365            brief: Some("Entry point".to_string()),
2366            docstring: None,
2367            line_start: 1,
2368            line_end: 5,
2369            col_start: 0,
2370            col_end: 1,
2371            parent_id: None,
2372            source: Some("fn main() {\n    println!(\"Hello\");\n}".to_string()),
2373        };
2374        db.insert_symbol(&symbol).unwrap();
2375
2376        // Find it
2377        let found = db.get_symbol("src/main.rs::main").unwrap();
2378        assert!(found.is_some());
2379        assert_eq!(found.unwrap().name, "main");
2380
2381        // Search for it
2382        let results = db.find_symbols("main", 10).unwrap();
2383        assert_eq!(results.len(), 1);
2384    }
2385
2386    #[test]
2387    fn test_find_symbols_exact_match_ordering() {
2388        let db = Database::open_in_memory().unwrap();
2389
2390        // Insert a file
2391        let file = FileRecord {
2392            path: "src/test.rs".to_string(),
2393            content_hash: "abc123".to_string(),
2394            size_bytes: 100,
2395            language: Some("rust".to_string()),
2396            last_indexed: 0,
2397        };
2398        db.upsert_file(&file, None).unwrap();
2399
2400        // Insert symbols with similar names - order matters for testing
2401        // We insert in reverse order to ensure ordering comes from query, not insertion
2402        let symbols = vec![
2403            ("new_large", "src/test.rs::new_large"), // substring match
2404            ("new_item", "src/test.rs::new_item"),   // prefix match
2405            ("renew", "src/test.rs::renew"),         // substring match (contains "new")
2406            ("new", "src/test.rs::new"),             // exact match
2407            ("new_thing", "src/test.rs::new_thing"), // prefix match
2408        ];
2409
2410        for (name, id) in &symbols {
2411            let symbol = Symbol {
2412                id: id.to_string(),
2413                file_path: "src/test.rs".to_string(),
2414                name: name.to_string(),
2415                qualified_name: None,
2416                kind: SymbolKind::Function,
2417                visibility: Visibility::Public,
2418                signature: None,
2419                brief: None,
2420                docstring: None,
2421                line_start: 1,
2422                line_end: 5,
2423                col_start: 0,
2424                col_end: 1,
2425                parent_id: None,
2426                source: None,
2427            };
2428            db.insert_symbol(&symbol).unwrap();
2429        }
2430
2431        // Search for "new" - should get exact match first, then prefix matches, then substring
2432        let results = db.find_symbols("new", 10).unwrap();
2433
2434        assert!(results.len() >= 4, "Should find at least 4 symbols");
2435
2436        // First result should be exact match "new"
2437        assert_eq!(results[0].name, "new", "Exact match 'new' should be first");
2438
2439        // Next results should be prefix matches (new_*), alphabetically sorted
2440        // new_item, new_large, new_thing should come before renew
2441        let prefix_matches: Vec<&str> = results[1..4].iter().map(|s| s.name.as_str()).collect();
2442        assert!(
2443            prefix_matches.iter().all(|n| n.starts_with("new")),
2444            "Positions 2-4 should be prefix matches, got: {:?}",
2445            prefix_matches
2446        );
2447
2448        // "renew" should be last (substring but not prefix)
2449        let renew_pos = results.iter().position(|s| s.name == "renew");
2450        assert!(
2451            renew_pos.is_some() && renew_pos.unwrap() >= 4,
2452            "'renew' should be after prefix matches"
2453        );
2454    }
2455
2456    #[test]
2457    fn test_find_symbols_with_file_filter() {
2458        let db = Database::open_in_memory().unwrap();
2459
2460        // Insert two files
2461        for path in &["src/parser/rust.rs", "src/embeddings/local.rs"] {
2462            let file = FileRecord {
2463                path: path.to_string(),
2464                content_hash: "abc123".to_string(),
2465                size_bytes: 100,
2466                language: Some("rust".to_string()),
2467                last_indexed: 0,
2468            };
2469            db.upsert_file(&file, None).unwrap();
2470        }
2471
2472        // Insert "new" in both files
2473        for (path, id) in &[
2474            ("src/parser/rust.rs", "src/parser/rust.rs::new"),
2475            ("src/embeddings/local.rs", "src/embeddings/local.rs::new"),
2476        ] {
2477            let symbol = Symbol {
2478                id: id.to_string(),
2479                file_path: path.to_string(),
2480                name: "new".to_string(),
2481                qualified_name: None,
2482                kind: SymbolKind::Method,
2483                visibility: Visibility::Public,
2484                signature: None,
2485                brief: None,
2486                docstring: None,
2487                line_start: 1,
2488                line_end: 5,
2489                col_start: 0,
2490                col_end: 1,
2491                parent_id: None,
2492                source: None,
2493            };
2494            db.insert_symbol(&symbol).unwrap();
2495        }
2496
2497        // Without filter, should find both
2498        let all_results = db.find_symbols_filtered("new", 10, None, None).unwrap();
2499        assert_eq!(all_results.len(), 2);
2500
2501        // With file filter for parser, should find only one
2502        let filtered = db
2503            .find_symbols_filtered("new", 10, Some("*parser*"), None)
2504            .unwrap();
2505        assert_eq!(filtered.len(), 1);
2506        assert!(filtered[0].file_path.contains("parser"));
2507
2508        // With file filter for embeddings
2509        let filtered = db
2510            .find_symbols_filtered("new", 10, Some("*embeddings*"), None)
2511            .unwrap();
2512        assert_eq!(filtered.len(), 1);
2513        assert!(filtered[0].file_path.contains("embeddings"));
2514    }
2515
2516    #[test]
2517    fn test_find_symbols_with_underscore_name() {
2518        // Regression: escape_like_pattern turns `_` into `\_`, and the LIKE clauses must
2519        // declare `ESCAPE '\'` for that to match. Without it, any snake_case name (the
2520        // dominant style in Rust/Go/Python) was unreachable by exact name via
2521        // find_symbols_filtered — silently returning zero rows for query find/explain/
2522        // source/callers/deps/impact.
2523        let db = Database::open_in_memory().unwrap();
2524
2525        // symbols.file_path is a FK into files.path, so register the file first.
2526        db.upsert_file(
2527            &FileRecord {
2528                path: "src/commands/sql.rs".to_string(),
2529                content_hash: "abc123".to_string(),
2530                size_bytes: 100,
2531                language: Some("rust".to_string()),
2532                last_indexed: 0,
2533            },
2534            None,
2535        )
2536        .unwrap();
2537
2538        for (id, name) in &[
2539            ("a::run_sql", "run_sql"),
2540            ("b::run_sql_duckdb", "run_sql_duckdb"),
2541            ("c::runsql", "runsql"),
2542        ] {
2543            let symbol = Symbol {
2544                id: id.to_string(),
2545                file_path: "src/commands/sql.rs".to_string(),
2546                name: name.to_string(),
2547                qualified_name: None,
2548                kind: SymbolKind::Function,
2549                visibility: Visibility::Public,
2550                signature: None,
2551                brief: None,
2552                docstring: None,
2553                line_start: 1,
2554                line_end: 5,
2555                col_start: 0,
2556                col_end: 1,
2557                parent_id: None,
2558                source: None,
2559            };
2560            db.insert_symbol(&symbol).unwrap();
2561        }
2562
2563        // Exact snake_case name must be found (this returned zero rows before the fix).
2564        let exact = db.find_symbols_filtered("run_sql", 10, None, None).unwrap();
2565        assert!(
2566            exact.iter().any(|s| s.name == "run_sql"),
2567            "exact underscore name 'run_sql' must be found"
2568        );
2569
2570        // The underscore must be treated literally, not as the LIKE single-char wildcard:
2571        // searching "run_sql" must NOT match "runsql".
2572        assert!(
2573            !exact.iter().any(|s| s.name == "runsql"),
2574            "'_' must be literal, not a wildcard matching 'runsql'"
2575        );
2576
2577        // Exact match ranks ahead of the longer prefix match.
2578        assert_eq!(
2579            exact.first().map(|s| s.name.as_str()),
2580            Some("run_sql"),
2581            "exact match should sort before 'run_sql_duckdb'"
2582        );
2583
2584        // Substring search across the underscore still works.
2585        let prefix = db.find_symbols_filtered("run_sql", 10, None, None).unwrap();
2586        assert!(
2587            prefix.iter().any(|s| s.name == "run_sql_duckdb"),
2588            "substring search should still surface 'run_sql_duckdb'"
2589        );
2590    }
2591
2592    #[test]
2593    fn test_escape_like_pattern() {
2594        // Normal patterns should pass through
2595        assert_eq!(escape_like_pattern("new"), "new");
2596        assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
2597
2598        // Special SQL LIKE characters should be escaped
2599        assert_eq!(escape_like_pattern("100%"), "100\\%");
2600        assert_eq!(escape_like_pattern("a_b"), "a\\_b");
2601        assert_eq!(escape_like_pattern("a%b_c"), "a\\%b\\_c");
2602    }
2603
2604    #[test]
2605    fn test_glob_to_like_pattern() {
2606        // * becomes %
2607        assert_eq!(glob_to_like_pattern("*.rs"), "%.rs");
2608        assert_eq!(glob_to_like_pattern("src/*"), "src/%");
2609
2610        // **/ is consumed when followed by *, preventing double %
2611        // This ensures **/*.rs becomes %.rs (not %%.rs)
2612        assert_eq!(glob_to_like_pattern("**/*.rs"), "%.rs");
2613        assert_eq!(glob_to_like_pattern("src/**/*.rs"), "src/%.rs");
2614
2615        // ** without trailing / just becomes %
2616        assert_eq!(glob_to_like_pattern("src/**"), "src/%");
2617
2618        // **/ followed by non-* adds the %
2619        assert_eq!(glob_to_like_pattern("**/foo.rs"), "%foo.rs");
2620
2621        // ? becomes _
2622        assert_eq!(glob_to_like_pattern("file?.txt"), "file_.txt");
2623
2624        // Literal % and _ are escaped
2625        assert_eq!(glob_to_like_pattern("100%"), "100\\%");
2626        assert_eq!(glob_to_like_pattern("a_b"), "a\\_b");
2627    }
2628
2629    #[test]
2630    fn test_glob_pattern_matches_files() {
2631        let db = Database::open_in_memory().unwrap();
2632
2633        // Insert files at different depths
2634        for path in &[
2635            "main.rs",
2636            "src/lib.rs",
2637            "src/parser/mod.rs",
2638            "src/parser/rust.rs",
2639            "tests/test.rs",
2640        ] {
2641            let file = FileRecord {
2642                path: path.to_string(),
2643                content_hash: "abc".to_string(),
2644                size_bytes: 100,
2645                language: Some("rust".to_string()),
2646                last_indexed: 0,
2647            };
2648            db.upsert_file(&file, None).unwrap();
2649
2650            let symbol = Symbol {
2651                id: format!("{}::main", path),
2652                file_path: path.to_string(),
2653                name: "main".to_string(),
2654                qualified_name: None,
2655                kind: SymbolKind::Function,
2656                visibility: Visibility::Public,
2657                signature: None,
2658                brief: None,
2659                docstring: None,
2660                line_start: 1,
2661                line_end: 5,
2662                col_start: 0,
2663                col_end: 1,
2664                parent_id: None,
2665                source: None,
2666            };
2667            db.insert_symbol(&symbol).unwrap();
2668        }
2669
2670        // **/*.rs should match ALL .rs files at any depth
2671        let results = db
2672            .find_symbols_filtered("main", 20, Some("**/*.rs"), None)
2673            .unwrap();
2674        assert_eq!(results.len(), 5, "**/*.rs should match all .rs files");
2675
2676        // src/**/*.rs should match all .rs files under src/
2677        let results = db
2678            .find_symbols_filtered("main", 20, Some("src/**/*.rs"), None)
2679            .unwrap();
2680        assert_eq!(
2681            results.len(),
2682            3,
2683            "src/**/*.rs should match src/lib.rs, src/parser/mod.rs, src/parser/rust.rs"
2684        );
2685
2686        // Note: src/*.rs also matches nested files because SQL LIKE % matches /
2687        // This is a documented limitation. For precise matching, use substring patterns.
2688        let results = db
2689            .find_symbols_filtered("main", 20, Some("src/*.rs"), None)
2690            .unwrap();
2691        assert_eq!(
2692            results.len(),
2693            3,
2694            "src/*.rs matches all under src/ (SQL LIKE limitation)"
2695        );
2696
2697        // *parser* should match files with "parser" in the path
2698        let results = db
2699            .find_symbols_filtered("main", 20, Some("*parser*"), None)
2700            .unwrap();
2701        assert_eq!(
2702            results.len(),
2703            2,
2704            "*parser* should match parser directory files"
2705        );
2706    }
2707
2708    #[test]
2709    fn test_hybrid_search_limit_one() {
2710        // Tests that hybrid_search doesn't panic or return 0 results with limit=1
2711        // Previously, limit/2 = 0 caused no results to be returned
2712        let db = Database::open_in_memory().unwrap();
2713
2714        // Insert a file and symbol
2715        let file = FileRecord {
2716            path: "src/main.rs".to_string(),
2717            content_hash: "abc123".to_string(),
2718            size_bytes: 100,
2719            language: Some("rust".to_string()),
2720            last_indexed: 0,
2721        };
2722        db.upsert_file(&file, None).unwrap();
2723
2724        let symbol = Symbol {
2725            id: "src/main.rs::authenticate".to_string(),
2726            file_path: "src/main.rs".to_string(),
2727            name: "authenticate".to_string(),
2728            qualified_name: None,
2729            kind: SymbolKind::Function,
2730            visibility: Visibility::Public,
2731            signature: Some("fn authenticate(user: &str)".to_string()),
2732            brief: Some("Authenticate a user".to_string()),
2733            docstring: None,
2734            line_start: 1,
2735            line_end: 5,
2736            col_start: 0,
2737            col_end: 1,
2738            parent_id: None,
2739            source: None,
2740        };
2741        db.insert_symbol(&symbol).unwrap();
2742
2743        // hybrid_search with limit=1 should return exactly 1 result
2744        let results = db.hybrid_search("authenticate", 1).unwrap();
2745        assert_eq!(
2746            results.len(),
2747            1,
2748            "hybrid_search with limit=1 should return 1 result"
2749        );
2750        assert_eq!(results[0].0.name, "authenticate");
2751    }
2752
2753    #[test]
2754    fn test_hybrid_search_limit_three() {
2755        // Tests hybrid_search behavior with limit=3 (where limit/2 = 1)
2756        let db = Database::open_in_memory().unwrap();
2757
2758        // Insert a file
2759        let file = FileRecord {
2760            path: "src/test.rs".to_string(),
2761            content_hash: "abc123".to_string(),
2762            size_bytes: 100,
2763            language: Some("rust".to_string()),
2764            last_indexed: 0,
2765        };
2766        db.upsert_file(&file, None).unwrap();
2767
2768        // Insert multiple symbols
2769        for (name, i) in &[("login", 1), ("logout", 2), ("log_error", 3)] {
2770            let symbol = Symbol {
2771                id: format!("src/test.rs::{}", name),
2772                file_path: "src/test.rs".to_string(),
2773                name: name.to_string(),
2774                qualified_name: None,
2775                kind: SymbolKind::Function,
2776                visibility: Visibility::Public,
2777                signature: Some(format!("fn {}()", name)),
2778                brief: Some(format!("{} function", name)),
2779                docstring: None,
2780                line_start: *i,
2781                line_end: *i + 5,
2782                col_start: 0,
2783                col_end: 1,
2784                parent_id: None,
2785                source: None,
2786            };
2787            db.insert_symbol(&symbol).unwrap();
2788        }
2789
2790        // hybrid_search with limit=3 should work correctly
2791        let results = db.hybrid_search("log", 3).unwrap();
2792        assert!(
2793            !results.is_empty(),
2794            "hybrid_search with limit=3 should return results"
2795        );
2796        assert!(results.len() <= 3, "hybrid_search should respect limit");
2797    }
2798
2799    #[test]
2800    fn test_semantic_search_score_range() {
2801        // Tests that semantic search returns scores in valid 0-1 range
2802        let db = Database::open_in_memory().unwrap();
2803
2804        // Insert a file and symbol
2805        let file = FileRecord {
2806            path: "src/main.rs".to_string(),
2807            content_hash: "abc123".to_string(),
2808            size_bytes: 100,
2809            language: Some("rust".to_string()),
2810            last_indexed: 0,
2811        };
2812        db.upsert_file(&file, None).unwrap();
2813
2814        let symbol = Symbol {
2815            id: "src/main.rs::process_data".to_string(),
2816            file_path: "src/main.rs".to_string(),
2817            name: "process_data".to_string(),
2818            qualified_name: None,
2819            kind: SymbolKind::Function,
2820            visibility: Visibility::Public,
2821            signature: Some("fn process_data(input: &str) -> Result<()>".to_string()),
2822            brief: Some("Process incoming data".to_string()),
2823            docstring: Some("Processes the incoming data and returns a result.".to_string()),
2824            line_start: 1,
2825            line_end: 10,
2826            col_start: 0,
2827            col_end: 1,
2828            parent_id: None,
2829            source: None,
2830        };
2831        db.insert_symbol(&symbol).unwrap();
2832
2833        // Semantic search should return scores in 0-1 range
2834        let results = db.semantic_search("process data", 10).unwrap();
2835        for (symbol, score) in &results {
2836            assert!(
2837                *score >= 0.0 && *score <= 1.0,
2838                "Score for '{}' should be in 0-1 range, got {}",
2839                symbol.name,
2840                score
2841            );
2842            assert!(
2843                score.is_finite(),
2844                "Score for '{}' should be finite, got {}",
2845                symbol.name,
2846                score
2847            );
2848        }
2849    }
2850
2851    #[test]
2852    fn test_bm25_relevance_mapping() {
2853        let cases = [
2854            (0.0, 0.0),
2855            (-1.0, 0.5),
2856            (1.0, 0.5),
2857            (-10.0, 10.0 / 11.0),
2858            (10.0, 10.0 / 11.0),
2859        ];
2860
2861        for (rank, expected) in cases {
2862            let actual = Database::bm25_relevance(rank);
2863            assert!(
2864                (actual - expected).abs() < 1e-12,
2865                "rank {} expected {} got {}",
2866                rank,
2867                expected,
2868                actual
2869            );
2870        }
2871
2872        assert_eq!(Database::bm25_relevance(f64::INFINITY), 0.0);
2873        assert_eq!(Database::bm25_relevance(f64::NEG_INFINITY), 0.0);
2874        assert_eq!(Database::bm25_relevance(f64::NAN), 0.0);
2875    }
2876
2877    #[test]
2878    fn test_get_embedding_metadata() {
2879        // Tests the embedding metadata query used for dimension mismatch detection
2880        let db = Database::open_in_memory().unwrap();
2881
2882        // Insert a file first (foreign key)
2883        let file = FileRecord {
2884            path: "src/main.rs".to_string(),
2885            content_hash: "abc123".to_string(),
2886            size_bytes: 100,
2887            language: Some("rust".to_string()),
2888            last_indexed: 0,
2889        };
2890        db.upsert_file(&file, None).unwrap();
2891
2892        // Insert some symbols
2893        for (name, id) in &[("foo", "src/main.rs::foo"), ("bar", "src/main.rs::bar")] {
2894            let symbol = Symbol {
2895                id: id.to_string(),
2896                file_path: "src/main.rs".to_string(),
2897                name: name.to_string(),
2898                qualified_name: None,
2899                kind: SymbolKind::Function,
2900                visibility: Visibility::Public,
2901                signature: None,
2902                brief: None,
2903                docstring: None,
2904                line_start: 1,
2905                line_end: 5,
2906                col_start: 0,
2907                col_end: 1,
2908                parent_id: None,
2909                source: None,
2910            };
2911            db.insert_symbol(&symbol).unwrap();
2912        }
2913
2914        // No embeddings yet
2915        let metadata = db.get_embedding_metadata().unwrap();
2916        assert!(
2917            metadata.is_empty(),
2918            "Should have no embedding metadata initially"
2919        );
2920
2921        // Store embeddings with different dimensions (simulating local vs OpenAI)
2922        let local_vec: Vec<f32> = vec![0.1; 384]; // Local embedding dimension
2923        let openai_vec: Vec<f32> = vec![0.2; 1536]; // OpenAI embedding dimension
2924
2925        db.store_embedding("src/main.rs::foo", "local", "all-MiniLM-L6-v2", &local_vec)
2926            .unwrap();
2927        db.store_embedding(
2928            "src/main.rs::bar",
2929            "openai",
2930            "text-embedding-3-small",
2931            &openai_vec,
2932        )
2933        .unwrap();
2934
2935        // Query metadata
2936        let metadata = db.get_embedding_metadata().unwrap();
2937
2938        // Should have two distinct provider/dimension combinations
2939        assert_eq!(
2940            metadata.len(),
2941            2,
2942            "Should have 2 embedding metadata entries"
2943        );
2944
2945        // Verify dimensions are recorded correctly
2946        let dims: Vec<i64> = metadata.iter().map(|(_, _, dim, _)| *dim).collect();
2947        assert!(
2948            dims.contains(&384),
2949            "Should have local embedding dimension 384"
2950        );
2951        assert!(
2952            dims.contains(&1536),
2953            "Should have OpenAI embedding dimension 1536"
2954        );
2955
2956        // Verify providers are recorded correctly
2957        let providers: Vec<&str> = metadata.iter().map(|(p, _, _, _)| p.as_str()).collect();
2958        assert!(providers.contains(&"local"), "Should have local provider");
2959        assert!(providers.contains(&"openai"), "Should have openai provider");
2960    }
2961
2962    #[test]
2963    fn test_vector_search() {
2964        // Test the fast vector search using sqlite-vec
2965        let db = Database::open_in_memory().unwrap();
2966
2967        if !is_vec_extension_available() {
2968            eprintln!("Skipping vector search test: sqlite-vec not available");
2969            return;
2970        }
2971
2972        // Insert a file first (foreign key)
2973        let file = FileRecord {
2974            path: "src/main.rs".to_string(),
2975            content_hash: "abc123".to_string(),
2976            size_bytes: 100,
2977            language: Some("rust".to_string()),
2978            last_indexed: 0,
2979        };
2980        db.upsert_file(&file, None).unwrap();
2981
2982        // Insert multiple symbols
2983        for (name, id, line) in &[
2984            ("foo", "src/main.rs::foo", 1),
2985            ("bar", "src/main.rs::bar", 10),
2986            ("baz", "src/main.rs::baz", 20),
2987        ] {
2988            let symbol = Symbol {
2989                id: id.to_string(),
2990                file_path: "src/main.rs".to_string(),
2991                name: name.to_string(),
2992                qualified_name: None,
2993                kind: SymbolKind::Function,
2994                visibility: Visibility::Public,
2995                signature: None,
2996                brief: None,
2997                docstring: None,
2998                line_start: *line,
2999                line_end: *line + 5,
3000                col_start: 0,
3001                col_end: 1,
3002                parent_id: None,
3003                source: None,
3004            };
3005            db.insert_symbol(&symbol).unwrap();
3006        }
3007
3008        // Create embeddings with default dimension (1536)
3009        // Make foo and bar similar, baz different
3010        let mut foo_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
3011        foo_vec[0] = 1.0;
3012        foo_vec[1] = 0.5;
3013
3014        let mut bar_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
3015        bar_vec[0] = 0.9;
3016        bar_vec[1] = 0.6;
3017
3018        let mut baz_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
3019        baz_vec[0] = 0.0;
3020        baz_vec[1] = 0.0;
3021        baz_vec[2] = 1.0; // Completely different direction
3022
3023        // Store embeddings (should also insert into symbol_vectors)
3024        db.store_embedding(
3025            "src/main.rs::foo",
3026            "openai",
3027            "text-embedding-3-small",
3028            &foo_vec,
3029        )
3030        .unwrap();
3031        db.store_embedding(
3032            "src/main.rs::bar",
3033            "openai",
3034            "text-embedding-3-small",
3035            &bar_vec,
3036        )
3037        .unwrap();
3038        db.store_embedding(
3039            "src/main.rs::baz",
3040            "openai",
3041            "text-embedding-3-small",
3042            &baz_vec,
3043        )
3044        .unwrap();
3045
3046        // Verify vector embeddings were stored
3047        let vec_count = db.count_vector_embeddings().unwrap();
3048        assert_eq!(vec_count, 3, "Should have 3 vector embeddings");
3049
3050        // Search with a query similar to foo
3051        let query: Vec<f32> = foo_vec.clone();
3052        let results = db.vector_search(&query, 3).unwrap();
3053
3054        assert_eq!(results.len(), 3, "Should return 3 results");
3055
3056        // First result should be foo (exact match)
3057        assert_eq!(
3058            results[0].0, "src/main.rs::foo",
3059            "First result should be foo (exact match)"
3060        );
3061        assert_eq!(results[0].5, 0.0, "Exact match should have distance 0");
3062
3063        // Second result should be bar (similar)
3064        assert_eq!(
3065            results[1].0, "src/main.rs::bar",
3066            "Second result should be bar (similar)"
3067        );
3068
3069        // Third result should be baz (different)
3070        assert_eq!(
3071            results[2].0, "src/main.rs::baz",
3072            "Third result should be baz (different)"
3073        );
3074
3075        // baz should have larger distance than bar
3076        assert!(
3077            results[2].5 > results[1].5,
3078            "baz should have larger distance than bar"
3079        );
3080    }
3081
3082    #[test]
3083    fn test_migrate_embeddings_to_vec() {
3084        // Test migrating existing JSON embeddings to vector table
3085        let db = Database::open_in_memory().unwrap();
3086
3087        if !is_vec_extension_available() {
3088            eprintln!("Skipping migration test: sqlite-vec not available");
3089            return;
3090        }
3091
3092        // Insert a file and symbol
3093        let file = FileRecord {
3094            path: "src/main.rs".to_string(),
3095            content_hash: "abc123".to_string(),
3096            size_bytes: 100,
3097            language: Some("rust".to_string()),
3098            last_indexed: 0,
3099        };
3100        db.upsert_file(&file, None).unwrap();
3101
3102        let symbol = Symbol {
3103            id: "src/main.rs::foo".to_string(),
3104            file_path: "src/main.rs".to_string(),
3105            name: "foo".to_string(),
3106            qualified_name: None,
3107            kind: SymbolKind::Function,
3108            visibility: Visibility::Public,
3109            signature: None,
3110            brief: None,
3111            docstring: None,
3112            line_start: 1,
3113            line_end: 5,
3114            col_start: 0,
3115            col_end: 1,
3116            parent_id: None,
3117            source: None,
3118        };
3119        db.insert_symbol(&symbol).unwrap();
3120
3121        // Manually insert an embedding only in the JSON table (simulating old data)
3122        let vec: Vec<f32> = vec![0.1; DEFAULT_EMBEDDING_DIM];
3123        let vec_json = serde_json::to_string(&vec).unwrap();
3124        db.conn.execute(
3125            "INSERT INTO embeddings (symbol_id, provider, model, dimension, vector) VALUES (?, ?, ?, ?, ?)",
3126            params!["src/main.rs::foo", "openai", "test", DEFAULT_EMBEDDING_DIM as i64, vec_json],
3127        ).unwrap();
3128
3129        // Verify not in vector table yet
3130        let vec_count_before = db.count_vector_embeddings().unwrap();
3131        assert_eq!(
3132            vec_count_before, 0,
3133            "Should have no vector embeddings before migration"
3134        );
3135
3136        // Run migration
3137        let migrated = db.migrate_embeddings_to_vec().unwrap();
3138        assert_eq!(migrated, 1, "Should migrate 1 embedding");
3139
3140        // Verify now in vector table
3141        let vec_count_after = db.count_vector_embeddings().unwrap();
3142        assert_eq!(
3143            vec_count_after, 1,
3144            "Should have 1 vector embedding after migration"
3145        );
3146
3147        // Re-running migration should not duplicate
3148        let migrated_again = db.migrate_embeddings_to_vec().unwrap();
3149        assert_eq!(
3150            migrated_again, 0,
3151            "Should not re-migrate existing embeddings"
3152        );
3153    }
3154}