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    /// 1. Context match: the call context contains the type name (e.g., "TypeScriptParser::new()")
1614    /// 2. Unique: only one symbol with that name exists in the codebase
1615    /// 3. Same file unique: only one symbol with that name exists in the same file
1616    ///
1617    /// We intentionally avoid aggressive same-file matching because calls like
1618    /// `Vec::new()` would incorrectly match a local `new` function.
1619    ///
1620    /// Returns the number of edges that were resolved.
1621    pub fn resolve_edge_targets(&self) -> Result<usize> {
1622        // Step 1: Resolve edges where the context contains the qualified name
1623        // e.g., context "TypeScriptParser::new()" matches symbol with qualified_name "TypeScriptParser::new"
1624        // Only resolve if exactly one symbol matches to avoid ambiguous resolution
1625        let context_resolved = self.conn.execute(
1626            r#"
1627            UPDATE edges
1628            SET target_id = (
1629                SELECT t.id
1630                FROM symbols t
1631                WHERE t.name = edges.target_name
1632                  AND t.kind IN ('function', 'method')
1633                  AND t.qualified_name IS NOT NULL
1634                  AND edges.context LIKE '%' || t.qualified_name || '%'
1635            )
1636            WHERE target_id IS NULL
1637              AND context IS NOT NULL
1638              AND (
1639                SELECT COUNT(*)
1640                FROM symbols t
1641                WHERE t.name = edges.target_name
1642                  AND t.kind IN ('function', 'method')
1643                  AND t.qualified_name IS NOT NULL
1644                  AND edges.context LIKE '%' || t.qualified_name || '%'
1645              ) = 1
1646            "#,
1647            [],
1648        )?;
1649
1650        // Step 2: Resolve edges where target name is unique across the codebase
1651        let unique_resolved = self.conn.execute(
1652            r#"
1653            UPDATE edges
1654            SET target_id = (
1655                SELECT id FROM symbols
1656                WHERE name = edges.target_name
1657                  AND kind IN ('function', 'method')
1658            )
1659            WHERE target_id IS NULL
1660              AND (
1661                SELECT COUNT(*) FROM symbols
1662                WHERE name = edges.target_name
1663                  AND kind IN ('function', 'method')
1664              ) = 1
1665            "#,
1666            [],
1667        )?;
1668
1669        // Step 3: Resolve edges where target is unique in the same file
1670        // Only match if:
1671        // - There's exactly one function with that name in the same file
1672        // - The context doesn't suggest an external type (no :: prefix before the name)
1673        // - The context doesn't suggest an external type/receiver call
1674        //   (no ::, ., or -> before the function name)
1675        let same_file_unique = self.conn.execute(
1676            r#"
1677            UPDATE edges
1678            SET target_id = (
1679                SELECT t.id
1680                FROM symbols t
1681                JOIN symbols s ON s.id = edges.source_id
1682                WHERE t.name = edges.target_name
1683                  AND t.file_path = s.file_path
1684                  AND t.kind IN ('function', 'method')
1685            )
1686            WHERE target_id IS NULL
1687              AND (
1688                SELECT COUNT(*)
1689                FROM symbols t
1690                JOIN symbols s ON s.id = edges.source_id
1691                WHERE t.name = edges.target_name
1692                  AND t.file_path = s.file_path
1693                  AND t.kind IN ('function', 'method')
1694              ) = 1
1695              -- Exclude if context suggests an external type call (has :: before the function name)
1696              -- e.g., "Vec::new()" or "Parser::new()" should NOT match local "new" functions
1697              AND (
1698                context IS NULL
1699                OR (
1700                    context NOT LIKE '%::' || target_name || '(%'
1701                    AND context NOT LIKE '%.' || target_name || '(%'
1702                    AND context NOT LIKE '%->' || target_name || '(%'
1703                )
1704                OR context LIKE '%' || (
1705                        SELECT t.qualified_name
1706                        FROM symbols t
1707                        JOIN symbols s ON s.id = edges.source_id
1708                        WHERE t.name = edges.target_name
1709                          AND t.file_path = s.file_path
1710                          AND t.kind IN ('function', 'method')
1711                        LIMIT 1
1712                    ) || '(%'
1713              )
1714            "#,
1715            [],
1716        )?;
1717
1718        Ok(context_resolved + unique_resolved + same_file_unique)
1719    }
1720
1721    /// Insert (or replace) a batch of MinHash fingerprints in one transaction.
1722    pub fn insert_fingerprints_batch(&self, fingerprints: &[Fingerprint]) -> Result<usize> {
1723        if fingerprints.is_empty() {
1724            return Ok(0);
1725        }
1726        let tx = self.conn.unchecked_transaction()?;
1727        {
1728            let mut stmt = tx.prepare(
1729                r#"
1730                INSERT OR REPLACE INTO symbol_fingerprints (symbol_id, file_path, minhash, token_count)
1731                VALUES (?, ?, ?, ?)
1732                "#,
1733            )?;
1734            for fp in fingerprints {
1735                stmt.execute(params![
1736                    fp.symbol_id,
1737                    fp.file_path,
1738                    fp.minhash,
1739                    fp.token_count
1740                ])?;
1741            }
1742        }
1743        tx.commit()?;
1744        Ok(fingerprints.len())
1745    }
1746
1747    /// Load all fingerprints with at least `min_tokens` tokens, ordered by
1748    /// symbol id (so callers get a stable, canonical order).
1749    pub fn get_fingerprints(&self, min_tokens: i64) -> Result<Vec<Fingerprint>> {
1750        let mut stmt = self.conn.prepare(
1751            r#"
1752            SELECT symbol_id, file_path, minhash, token_count
1753            FROM symbol_fingerprints
1754            WHERE token_count >= ?
1755            ORDER BY symbol_id
1756            "#,
1757        )?;
1758        let rows = stmt.query_map([min_tokens], |row| {
1759            Ok(Fingerprint {
1760                symbol_id: row.get(0)?,
1761                file_path: row.get(1)?,
1762                minhash: row.get(2)?,
1763                token_count: row.get(3)?,
1764            })
1765        })?;
1766        rows.collect()
1767    }
1768}
1769
1770/// Escape SQL LIKE special characters in a pattern.
1771///
1772/// SQLite LIKE uses `%` for any sequence and `_` for single character.
1773/// This function escapes these so they match literally.
1774fn escape_like_pattern(pattern: &str) -> String {
1775    pattern
1776        .replace('\\', "\\\\") // Escape backslash first
1777        .replace('%', "\\%")
1778        .replace('_', "\\_")
1779}
1780
1781/// Convert a glob-style pattern to a SQL LIKE pattern.
1782///
1783/// Supports:
1784/// - `*` -> `%` (match any sequence - note: in SQL LIKE this also matches `/`)
1785/// - `**` -> `%` (match any sequence including path separators)
1786/// - `**/` -> consumed, following pattern matches from any depth
1787/// - `?` -> `_` (match single character)
1788/// - Escapes literal `%` and `_` in the pattern
1789///
1790/// Limitations:
1791/// - SQL LIKE `%` matches across path separators, so `src/*.rs` will also match
1792///   `src/foo/bar.rs`. Use substring patterns like `*parser*` for simple filtering,
1793///   or rely on the prefix to narrow results.
1794///
1795/// Examples:
1796/// - `**/*.rs` -> `%.rs` (any .rs file at any depth)
1797/// - `src/**/*.rs` -> `src/%.rs` (any .rs file under src/)
1798/// - `*parser*` -> `%parser%` (any path containing "parser")
1799fn glob_to_like_pattern(pattern: &str) -> String {
1800    let mut result = String::with_capacity(pattern.len() * 2);
1801    let mut chars = pattern.chars().peekable();
1802
1803    while let Some(c) = chars.next() {
1804        match c {
1805            '*' => {
1806                // Check for **
1807                if chars.peek() == Some(&'*') {
1808                    chars.next();
1809                    // Check for **/ - this should match zero or more path segments
1810                    if chars.peek() == Some(&'/') {
1811                        chars.next();
1812                        // **/ means "any path prefix including empty"
1813                        // Don't add % here - the following pattern (likely *.ext) will add it
1814                        // This allows **/*.rs to become %.rs instead of %%.rs
1815                        // But if there's nothing after, or it's not a *, we need the %
1816                        if chars.peek().is_none() || chars.peek() == Some(&'*') {
1817                            // **/ at end or followed by another * - don't add redundant %
1818                            continue;
1819                        }
1820                        // **/ followed by something else - add % to match the path prefix
1821                        result.push('%');
1822                    } else {
1823                        // ** without trailing / - just match anything
1824                        result.push('%');
1825                    }
1826                } else {
1827                    // Single * - match anything (in SQL LIKE, same as %)
1828                    result.push('%');
1829                }
1830            }
1831            '?' => result.push('_'),
1832            '%' => result.push_str("\\%"),
1833            '_' => result.push_str("\\_"),
1834            '\\' => result.push_str("\\\\"),
1835            _ => result.push(c),
1836        }
1837    }
1838
1839    result
1840}
1841
1842/// Preprocess a search query into keywords.
1843fn preprocess_search_query(query: &str) -> Vec<String> {
1844    // Common words to filter out
1845    let stop_words: std::collections::HashSet<&str> = [
1846        "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
1847        "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall",
1848        "can", "need", "dare", "and", "or", "but", "if", "then", "else", "when", "where", "why",
1849        "how", "what", "which", "who", "whom", "this", "that", "these", "those", "i", "you", "he",
1850        "she", "it", "we", "they", "me", "him", "her", "us", "them", "my", "your", "his", "its",
1851        "our", "their", "for", "to", "from", "with", "at", "by", "on", "in", "of", "about", "into",
1852        "through", "during", "before", "after", "above", "below", "find", "get", "search", "look",
1853        "all", "any", "each", "every",
1854    ]
1855    .iter()
1856    .copied()
1857    .collect();
1858
1859    query
1860        .to_lowercase()
1861        .split(|c: char| !c.is_alphanumeric() && c != '_')
1862        .filter(|word| {
1863            let word = word.trim();
1864            !word.is_empty() && word.len() > 1 && !stop_words.contains(word)
1865        })
1866        .map(|s| {
1867            // Add wildcard suffix for prefix matching
1868            format!("{}*", s)
1869        })
1870        .collect()
1871}
1872
1873/// Helper to convert a row to a Symbol.
1874fn symbol_from_row(row: &rusqlite::Row) -> Symbol {
1875    let kind_str: String = row.get(4).unwrap_or_default();
1876    let visibility_str: String = row.get(5).unwrap_or_default();
1877
1878    Symbol {
1879        id: row.get(0).unwrap_or_default(),
1880        file_path: row.get(1).unwrap_or_default(),
1881        name: row.get(2).unwrap_or_default(),
1882        qualified_name: row.get(3).ok(),
1883        kind: SymbolKind::from_str(&kind_str).unwrap_or(SymbolKind::Function),
1884        visibility: Visibility::from_str(&visibility_str).unwrap_or_default(),
1885        signature: row.get(6).ok(),
1886        brief: row.get(7).ok(),
1887        docstring: row.get(8).ok(),
1888        line_start: row.get(9).unwrap_or(0),
1889        line_end: row.get(10).unwrap_or(0),
1890        col_start: row.get(11).unwrap_or(0),
1891        col_end: row.get(12).unwrap_or(0),
1892        parent_id: row.get(13).ok(),
1893        source: row.get(14).ok(),
1894    }
1895}
1896
1897/// Helper to convert a row to an Edge.
1898fn edge_from_row(row: &rusqlite::Row) -> Edge {
1899    let kind_str: String = row.get(3).unwrap_or_default();
1900
1901    Edge {
1902        source_id: row.get(0).unwrap_or_default(),
1903        target_id: row.get(1).ok(),
1904        target_name: row.get(2).unwrap_or_default(),
1905        kind: EdgeKind::from_str(&kind_str).unwrap_or(EdgeKind::Calls),
1906        line: row.get(4).ok(),
1907        col: row.get(5).ok(),
1908        context: row.get(6).ok(),
1909    }
1910}
1911
1912/// A lightweight symbol row for the `ctx map` command
1913/// (see [`Database::get_map_symbols`]).
1914#[derive(Debug, Clone)]
1915pub struct MapSymbolRow {
1916    pub id: String,
1917    pub file_path: String,
1918    pub name: String,
1919    pub qualified_name: Option<String>,
1920    pub kind: String,
1921    pub signature: Option<String>,
1922    pub line_start: u32,
1923}
1924
1925/// Per-symbol complexity metrics (see [`Database::symbol_metrics`]).
1926#[derive(Debug, Clone, serde::Serialize)]
1927pub struct SymbolMetrics {
1928    pub id: String,
1929    pub name: String,
1930    pub qualified_name: Option<String>,
1931    pub kind: String,
1932    pub file_path: String,
1933    pub line_start: i64,
1934    pub line_end: i64,
1935    pub fan_in: i64,
1936    pub fan_out: i64,
1937    pub complexity: i64,
1938}
1939
1940/// A resolved relationship edge whose endpoints live in different files
1941/// (see [`Database::get_cross_file_edges`]).
1942#[derive(Debug, Clone)]
1943pub struct CrossFileEdge {
1944    pub source: EdgeSymbol,
1945    pub target: EdgeSymbol,
1946    /// Edge kind (`calls`, `implements`, `extends`, `uses`).
1947    pub kind: String,
1948    /// Line in the source file where the reference occurs.
1949    pub line: Option<i64>,
1950}
1951
1952/// Lightweight symbol info attached to a [`CrossFileEdge`].
1953#[derive(Debug, Clone)]
1954pub struct EdgeSymbol {
1955    pub name: String,
1956    pub qualified_name: Option<String>,
1957    pub kind: String,
1958    pub file_path: String,
1959    pub line_start: i64,
1960    pub line_end: i64,
1961}
1962
1963/// Per-file aggregated complexity (see [`Database::file_complexity`]).
1964#[derive(Debug, Clone, serde::Serialize)]
1965pub struct FileComplexity {
1966    pub file_path: String,
1967    pub complexity: i64,
1968    pub fan_out: i64,
1969    pub symbol_count: i64,
1970}
1971
1972/// Extension trait for optional query results.
1973trait ResultExt<T> {
1974    fn optional(self) -> Result<Option<T>>;
1975}
1976
1977impl<T> ResultExt<T> for Result<T> {
1978    fn optional(self) -> Result<Option<T>> {
1979        match self {
1980            Ok(v) => Ok(Some(v)),
1981            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1982            Err(e) => Err(e),
1983        }
1984    }
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989    use super::*;
1990
1991    #[test]
1992    fn test_create_database() {
1993        let db = Database::open_in_memory().unwrap();
1994        let stats = db.get_stats().unwrap();
1995        assert_eq!(stats.files, 0);
1996        assert_eq!(stats.symbols, 0);
1997    }
1998
1999    #[test]
2000    fn test_fresh_database_is_stamped_with_schema_version() {
2001        let dir = tempfile::tempdir().unwrap();
2002        let db_path = dir.path().join("codebase.sqlite");
2003
2004        let db = Database::open(&db_path).unwrap();
2005        let version: i64 = db
2006            .conn
2007            .query_row("PRAGMA user_version", [], |row| row.get(0))
2008            .unwrap();
2009        assert_eq!(version, SCHEMA_VERSION);
2010        drop(db);
2011
2012        // Re-opening a stamped database works.
2013        assert!(Database::open(&db_path).is_ok());
2014    }
2015
2016    #[test]
2017    fn test_legacy_v0_database_is_rejected() {
2018        let dir = tempfile::tempdir().unwrap();
2019        let db_path = dir.path().join("codebase.sqlite");
2020
2021        // Create a database, then reset it to the pre-versioning state (v0
2022        // with existing tables). Pre-versioning databases lack the
2023        // symbol_fingerprints table, so they must be rebuilt.
2024        drop(Database::open(&db_path).unwrap());
2025        {
2026            let conn = Connection::open(&db_path).unwrap();
2027            conn.pragma_update(None, "user_version", 0).unwrap();
2028        }
2029
2030        let err = Database::open(&db_path).unwrap_err();
2031        match &err {
2032            crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2033                assert_eq!(*found, 0);
2034                assert_eq!(*expected, SCHEMA_VERSION);
2035            }
2036            other => panic!("expected SchemaVersionMismatch, got: {}", other),
2037        }
2038        assert!(err.to_string().contains("ctx index --force"));
2039    }
2040
2041    #[test]
2042    fn test_schema_version_mismatch_is_rejected() {
2043        let dir = tempfile::tempdir().unwrap();
2044        let db_path = dir.path().join("codebase.sqlite");
2045
2046        // A v1 database (pre-fingerprints) must be rejected with a hint to
2047        // rebuild, as must any other unknown version.
2048        for stale_version in [1i64, 99] {
2049            drop(Database::open(&db_path).unwrap());
2050            {
2051                let conn = Connection::open(&db_path).unwrap();
2052                conn.pragma_update(None, "user_version", stale_version)
2053                    .unwrap();
2054            }
2055
2056            let err = Database::open(&db_path).unwrap_err();
2057            match &err {
2058                crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2059                    assert_eq!(*found, stale_version);
2060                    assert_eq!(*expected, SCHEMA_VERSION);
2061                }
2062                other => panic!("expected SchemaVersionMismatch, got: {}", other),
2063            }
2064            assert!(err.to_string().contains("ctx index --force"));
2065
2066            // Restore the correct version so the next iteration can re-open.
2067            let conn = Connection::open(&db_path).unwrap();
2068            conn.pragma_update(None, "user_version", SCHEMA_VERSION)
2069                .unwrap();
2070        }
2071    }
2072
2073    #[test]
2074    fn test_fingerprint_batch_roundtrip_and_min_tokens_filter() {
2075        let db = Database::open_in_memory().unwrap();
2076        db.upsert_file(
2077            &FileRecord {
2078                path: "src/a.rs".to_string(),
2079                content_hash: "h1".to_string(),
2080                size_bytes: 10,
2081                language: Some("rust".to_string()),
2082                last_indexed: 0,
2083            },
2084            None,
2085        )
2086        .unwrap();
2087        db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2088            .unwrap();
2089        db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2090            .unwrap();
2091
2092        let fps = vec![
2093            Fingerprint {
2094                symbol_id: "src/a.rs::alpha".to_string(),
2095                file_path: "src/a.rs".to_string(),
2096                minhash: vec![1u8; 1024],
2097                token_count: 80,
2098            },
2099            Fingerprint {
2100                symbol_id: "src/a.rs::beta".to_string(),
2101                file_path: "src/a.rs".to_string(),
2102                minhash: vec![2u8; 1024],
2103                token_count: 20,
2104            },
2105        ];
2106        assert_eq!(db.insert_fingerprints_batch(&fps).unwrap(), 2);
2107        assert_eq!(db.insert_fingerprints_batch(&[]).unwrap(), 0);
2108
2109        // No filter: both come back, ordered by symbol_id, bytes intact.
2110        let all = db.get_fingerprints(0).unwrap();
2111        assert_eq!(all.len(), 2);
2112        assert_eq!(all[0].symbol_id, "src/a.rs::alpha");
2113        assert_eq!(all[0].minhash, vec![1u8; 1024]);
2114        assert_eq!(all[1].token_count, 20);
2115
2116        // min_tokens filters out short symbols.
2117        let filtered = db.get_fingerprints(50).unwrap();
2118        assert_eq!(filtered.len(), 1);
2119        assert_eq!(filtered[0].symbol_id, "src/a.rs::alpha");
2120
2121        // Deleting the file's symbols cascades to fingerprints.
2122        db.delete_symbols_for_file("src/a.rs").unwrap();
2123        assert!(db.get_fingerprints(0).unwrap().is_empty());
2124    }
2125
2126    #[test]
2127    fn test_sqlite_vec_extension_loaded() {
2128        // Verify sqlite-vec is properly initialized
2129        let db = Database::open_in_memory().unwrap();
2130
2131        // Check that extension initialization was attempted
2132        // Note: init_vec_extension() is called during Database::open_in_memory()
2133        // so is_vec_extension_available() reflects whether it succeeded
2134        if !is_vec_extension_available() {
2135            eprintln!("Skipping sqlite-vec tests: extension not available on this platform");
2136            return;
2137        }
2138
2139        // Check vec_version() function exists (proves extension loaded)
2140        let version: Result<String, _> = db
2141            .conn
2142            .query_row("SELECT vec_version()", [], |row| row.get(0));
2143
2144        match version {
2145            Ok(v) => {
2146                assert!(
2147                    v.starts_with('v'),
2148                    "vec_version should return version string, got: {}",
2149                    v
2150                );
2151            }
2152            Err(e) => {
2153                // Extension registered but function not available - this shouldn't happen
2154                // if is_vec_extension_available() returned true, but handle gracefully
2155                panic!(
2156                    "sqlite-vec extension reported available but vec_version() failed: {}",
2157                    e
2158                );
2159            }
2160        }
2161
2162        // Verify has_vector_search() works
2163        assert!(db.has_vector_search(), "vector search should be available");
2164
2165        // Verify symbol_vectors table exists and is queryable
2166        let count: i64 = db
2167            .conn
2168            .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
2169            .expect("symbol_vectors table should exist");
2170        assert_eq!(count, 0, "symbol_vectors should be empty initially");
2171    }
2172
2173    /// Build a minimal function symbol for metrics tests.
2174    fn make_fn_symbol(id: &str, name: &str, file: &str, line: u32) -> Symbol {
2175        Symbol {
2176            id: id.to_string(),
2177            file_path: file.to_string(),
2178            name: name.to_string(),
2179            qualified_name: Some(name.to_string()),
2180            kind: SymbolKind::Function,
2181            visibility: Visibility::Public,
2182            signature: None,
2183            brief: None,
2184            docstring: None,
2185            line_start: line,
2186            line_end: line + 5,
2187            col_start: 0,
2188            col_end: 0,
2189            parent_id: None,
2190            source: None,
2191        }
2192    }
2193
2194    /// Build a 'calls' edge for metrics tests.
2195    fn make_call_edge(source_id: &str, target_id: Option<&str>, target_name: &str) -> Edge {
2196        Edge {
2197            source_id: source_id.to_string(),
2198            target_id: target_id.map(|s| s.to_string()),
2199            target_name: target_name.to_string(),
2200            kind: EdgeKind::Calls,
2201            line: Some(1),
2202            col: None,
2203            context: None,
2204        }
2205    }
2206
2207    /// Set up a database with three functions and a small call graph:
2208    /// alpha -> beta (resolved), alpha -> external (unresolved), beta -> alpha (resolved).
2209    fn metrics_fixture() -> Database {
2210        let db = Database::open_in_memory().unwrap();
2211        db.upsert_file(
2212            &FileRecord {
2213                path: "src/a.rs".to_string(),
2214                content_hash: "h1".to_string(),
2215                size_bytes: 10,
2216                language: Some("rust".to_string()),
2217                last_indexed: 0,
2218            },
2219            None,
2220        )
2221        .unwrap();
2222
2223        db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2224            .unwrap();
2225        db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2226            .unwrap();
2227        db.insert_symbol(&make_fn_symbol("src/a.rs::gamma", "gamma", "src/a.rs", 20))
2228            .unwrap();
2229
2230        db.insert_edge(&make_call_edge(
2231            "src/a.rs::alpha",
2232            Some("src/a.rs::beta"),
2233            "beta",
2234        ))
2235        .unwrap();
2236        db.insert_edge(&make_call_edge("src/a.rs::alpha", None, "external"))
2237            .unwrap();
2238        db.insert_edge(&make_call_edge(
2239            "src/a.rs::beta",
2240            Some("src/a.rs::alpha"),
2241            "alpha",
2242        ))
2243        .unwrap();
2244
2245        db
2246    }
2247
2248    #[test]
2249    fn test_symbol_metrics_mirrors_complexity_formula() {
2250        let db = metrics_fixture();
2251        let metrics = db.symbol_metrics().unwrap();
2252        assert_eq!(metrics.len(), 3);
2253
2254        let get = |name: &str| metrics.iter().find(|m| m.name == name).unwrap();
2255
2256        // alpha: fan_out 2 (beta + unresolved external), fan_in 1 (from beta)
2257        let alpha = get("alpha");
2258        assert_eq!(alpha.fan_out, 2);
2259        assert_eq!(alpha.fan_in, 1);
2260        assert_eq!(alpha.complexity, 2 * 2 + 1);
2261        assert_eq!(alpha.file_path, "src/a.rs");
2262        assert_eq!(alpha.line_start, 1);
2263
2264        // beta: fan_out 1, fan_in 1
2265        let beta = get("beta");
2266        assert_eq!(beta.fan_out, 1);
2267        assert_eq!(beta.fan_in, 1);
2268        assert_eq!(beta.complexity, 3); // fan_out(1) * 2 + fan_in(1)
2269
2270        // gamma: no edges at all
2271        let gamma = get("gamma");
2272        assert_eq!(gamma.fan_out, 0);
2273        assert_eq!(gamma.fan_in, 0);
2274        assert_eq!(gamma.complexity, 0);
2275
2276        // Ordered by complexity, highest first
2277        assert_eq!(metrics[0].name, "alpha");
2278    }
2279
2280    #[test]
2281    fn test_file_complexity_aggregates_per_file() {
2282        let db = metrics_fixture();
2283        let files = db.file_complexity().unwrap();
2284        assert_eq!(files.len(), 1);
2285
2286        let f = &files[0];
2287        assert_eq!(f.file_path, "src/a.rs");
2288        assert_eq!(f.symbol_count, 3);
2289        assert_eq!(f.fan_out, 3);
2290        // alpha (5) + beta (3) + gamma (0)
2291        assert_eq!(f.complexity, 8);
2292    }
2293
2294    #[test]
2295    fn test_fan_in_counts() {
2296        let db = metrics_fixture();
2297
2298        let ids = vec![
2299            "src/a.rs::alpha".to_string(),
2300            "src/a.rs::beta".to_string(),
2301            "src/a.rs::gamma".to_string(),
2302        ];
2303        let counts = db.fan_in_counts(&ids).unwrap();
2304        assert_eq!(counts.get("src/a.rs::alpha"), Some(&1));
2305        assert_eq!(counts.get("src/a.rs::beta"), Some(&1));
2306        // gamma has no incoming resolved calls, so it is absent
2307        assert!(!counts.contains_key("src/a.rs::gamma"));
2308
2309        // Empty input short-circuits
2310        assert!(db.fan_in_counts(&[]).unwrap().is_empty());
2311    }
2312
2313    #[test]
2314    fn test_insert_and_find_symbol() {
2315        let db = Database::open_in_memory().unwrap();
2316
2317        // Insert a file first (foreign key)
2318        let file = FileRecord {
2319            path: "src/main.rs".to_string(),
2320            content_hash: "abc123".to_string(),
2321            size_bytes: 100,
2322            language: Some("rust".to_string()),
2323            last_indexed: 0,
2324        };
2325        db.upsert_file(&file, None).unwrap();
2326
2327        // Insert a symbol
2328        let symbol = Symbol {
2329            id: "src/main.rs::main".to_string(),
2330            file_path: "src/main.rs".to_string(),
2331            name: "main".to_string(),
2332            qualified_name: None,
2333            kind: SymbolKind::Function,
2334            visibility: Visibility::Private,
2335            signature: Some("fn main()".to_string()),
2336            brief: Some("Entry point".to_string()),
2337            docstring: None,
2338            line_start: 1,
2339            line_end: 5,
2340            col_start: 0,
2341            col_end: 1,
2342            parent_id: None,
2343            source: Some("fn main() {\n    println!(\"Hello\");\n}".to_string()),
2344        };
2345        db.insert_symbol(&symbol).unwrap();
2346
2347        // Find it
2348        let found = db.get_symbol("src/main.rs::main").unwrap();
2349        assert!(found.is_some());
2350        assert_eq!(found.unwrap().name, "main");
2351
2352        // Search for it
2353        let results = db.find_symbols("main", 10).unwrap();
2354        assert_eq!(results.len(), 1);
2355    }
2356
2357    #[test]
2358    fn test_find_symbols_exact_match_ordering() {
2359        let db = Database::open_in_memory().unwrap();
2360
2361        // Insert a file
2362        let file = FileRecord {
2363            path: "src/test.rs".to_string(),
2364            content_hash: "abc123".to_string(),
2365            size_bytes: 100,
2366            language: Some("rust".to_string()),
2367            last_indexed: 0,
2368        };
2369        db.upsert_file(&file, None).unwrap();
2370
2371        // Insert symbols with similar names - order matters for testing
2372        // We insert in reverse order to ensure ordering comes from query, not insertion
2373        let symbols = vec![
2374            ("new_large", "src/test.rs::new_large"), // substring match
2375            ("new_item", "src/test.rs::new_item"),   // prefix match
2376            ("renew", "src/test.rs::renew"),         // substring match (contains "new")
2377            ("new", "src/test.rs::new"),             // exact match
2378            ("new_thing", "src/test.rs::new_thing"), // prefix match
2379        ];
2380
2381        for (name, id) in &symbols {
2382            let symbol = Symbol {
2383                id: id.to_string(),
2384                file_path: "src/test.rs".to_string(),
2385                name: name.to_string(),
2386                qualified_name: None,
2387                kind: SymbolKind::Function,
2388                visibility: Visibility::Public,
2389                signature: None,
2390                brief: None,
2391                docstring: None,
2392                line_start: 1,
2393                line_end: 5,
2394                col_start: 0,
2395                col_end: 1,
2396                parent_id: None,
2397                source: None,
2398            };
2399            db.insert_symbol(&symbol).unwrap();
2400        }
2401
2402        // Search for "new" - should get exact match first, then prefix matches, then substring
2403        let results = db.find_symbols("new", 10).unwrap();
2404
2405        assert!(results.len() >= 4, "Should find at least 4 symbols");
2406
2407        // First result should be exact match "new"
2408        assert_eq!(results[0].name, "new", "Exact match 'new' should be first");
2409
2410        // Next results should be prefix matches (new_*), alphabetically sorted
2411        // new_item, new_large, new_thing should come before renew
2412        let prefix_matches: Vec<&str> = results[1..4].iter().map(|s| s.name.as_str()).collect();
2413        assert!(
2414            prefix_matches.iter().all(|n| n.starts_with("new")),
2415            "Positions 2-4 should be prefix matches, got: {:?}",
2416            prefix_matches
2417        );
2418
2419        // "renew" should be last (substring but not prefix)
2420        let renew_pos = results.iter().position(|s| s.name == "renew");
2421        assert!(
2422            renew_pos.is_some() && renew_pos.unwrap() >= 4,
2423            "'renew' should be after prefix matches"
2424        );
2425    }
2426
2427    #[test]
2428    fn test_find_symbols_with_file_filter() {
2429        let db = Database::open_in_memory().unwrap();
2430
2431        // Insert two files
2432        for path in &["src/parser/rust.rs", "src/embeddings/local.rs"] {
2433            let file = FileRecord {
2434                path: path.to_string(),
2435                content_hash: "abc123".to_string(),
2436                size_bytes: 100,
2437                language: Some("rust".to_string()),
2438                last_indexed: 0,
2439            };
2440            db.upsert_file(&file, None).unwrap();
2441        }
2442
2443        // Insert "new" in both files
2444        for (path, id) in &[
2445            ("src/parser/rust.rs", "src/parser/rust.rs::new"),
2446            ("src/embeddings/local.rs", "src/embeddings/local.rs::new"),
2447        ] {
2448            let symbol = Symbol {
2449                id: id.to_string(),
2450                file_path: path.to_string(),
2451                name: "new".to_string(),
2452                qualified_name: None,
2453                kind: SymbolKind::Method,
2454                visibility: Visibility::Public,
2455                signature: None,
2456                brief: None,
2457                docstring: None,
2458                line_start: 1,
2459                line_end: 5,
2460                col_start: 0,
2461                col_end: 1,
2462                parent_id: None,
2463                source: None,
2464            };
2465            db.insert_symbol(&symbol).unwrap();
2466        }
2467
2468        // Without filter, should find both
2469        let all_results = db.find_symbols_filtered("new", 10, None, None).unwrap();
2470        assert_eq!(all_results.len(), 2);
2471
2472        // With file filter for parser, should find only one
2473        let filtered = db
2474            .find_symbols_filtered("new", 10, Some("*parser*"), None)
2475            .unwrap();
2476        assert_eq!(filtered.len(), 1);
2477        assert!(filtered[0].file_path.contains("parser"));
2478
2479        // With file filter for embeddings
2480        let filtered = db
2481            .find_symbols_filtered("new", 10, Some("*embeddings*"), None)
2482            .unwrap();
2483        assert_eq!(filtered.len(), 1);
2484        assert!(filtered[0].file_path.contains("embeddings"));
2485    }
2486
2487    #[test]
2488    fn test_find_symbols_with_underscore_name() {
2489        // Regression: escape_like_pattern turns `_` into `\_`, and the LIKE clauses must
2490        // declare `ESCAPE '\'` for that to match. Without it, any snake_case name (the
2491        // dominant style in Rust/Go/Python) was unreachable by exact name via
2492        // find_symbols_filtered — silently returning zero rows for query find/explain/
2493        // source/callers/deps/impact.
2494        let db = Database::open_in_memory().unwrap();
2495
2496        // symbols.file_path is a FK into files.path, so register the file first.
2497        db.upsert_file(
2498            &FileRecord {
2499                path: "src/commands/sql.rs".to_string(),
2500                content_hash: "abc123".to_string(),
2501                size_bytes: 100,
2502                language: Some("rust".to_string()),
2503                last_indexed: 0,
2504            },
2505            None,
2506        )
2507        .unwrap();
2508
2509        for (id, name) in &[
2510            ("a::run_sql", "run_sql"),
2511            ("b::run_sql_duckdb", "run_sql_duckdb"),
2512            ("c::runsql", "runsql"),
2513        ] {
2514            let symbol = Symbol {
2515                id: id.to_string(),
2516                file_path: "src/commands/sql.rs".to_string(),
2517                name: name.to_string(),
2518                qualified_name: None,
2519                kind: SymbolKind::Function,
2520                visibility: Visibility::Public,
2521                signature: None,
2522                brief: None,
2523                docstring: None,
2524                line_start: 1,
2525                line_end: 5,
2526                col_start: 0,
2527                col_end: 1,
2528                parent_id: None,
2529                source: None,
2530            };
2531            db.insert_symbol(&symbol).unwrap();
2532        }
2533
2534        // Exact snake_case name must be found (this returned zero rows before the fix).
2535        let exact = db.find_symbols_filtered("run_sql", 10, None, None).unwrap();
2536        assert!(
2537            exact.iter().any(|s| s.name == "run_sql"),
2538            "exact underscore name 'run_sql' must be found"
2539        );
2540
2541        // The underscore must be treated literally, not as the LIKE single-char wildcard:
2542        // searching "run_sql" must NOT match "runsql".
2543        assert!(
2544            !exact.iter().any(|s| s.name == "runsql"),
2545            "'_' must be literal, not a wildcard matching 'runsql'"
2546        );
2547
2548        // Exact match ranks ahead of the longer prefix match.
2549        assert_eq!(
2550            exact.first().map(|s| s.name.as_str()),
2551            Some("run_sql"),
2552            "exact match should sort before 'run_sql_duckdb'"
2553        );
2554
2555        // Substring search across the underscore still works.
2556        let prefix = db.find_symbols_filtered("run_sql", 10, None, None).unwrap();
2557        assert!(
2558            prefix.iter().any(|s| s.name == "run_sql_duckdb"),
2559            "substring search should still surface 'run_sql_duckdb'"
2560        );
2561    }
2562
2563    #[test]
2564    fn test_escape_like_pattern() {
2565        // Normal patterns should pass through
2566        assert_eq!(escape_like_pattern("new"), "new");
2567        assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
2568
2569        // Special SQL LIKE characters should be escaped
2570        assert_eq!(escape_like_pattern("100%"), "100\\%");
2571        assert_eq!(escape_like_pattern("a_b"), "a\\_b");
2572        assert_eq!(escape_like_pattern("a%b_c"), "a\\%b\\_c");
2573    }
2574
2575    #[test]
2576    fn test_glob_to_like_pattern() {
2577        // * becomes %
2578        assert_eq!(glob_to_like_pattern("*.rs"), "%.rs");
2579        assert_eq!(glob_to_like_pattern("src/*"), "src/%");
2580
2581        // **/ is consumed when followed by *, preventing double %
2582        // This ensures **/*.rs becomes %.rs (not %%.rs)
2583        assert_eq!(glob_to_like_pattern("**/*.rs"), "%.rs");
2584        assert_eq!(glob_to_like_pattern("src/**/*.rs"), "src/%.rs");
2585
2586        // ** without trailing / just becomes %
2587        assert_eq!(glob_to_like_pattern("src/**"), "src/%");
2588
2589        // **/ followed by non-* adds the %
2590        assert_eq!(glob_to_like_pattern("**/foo.rs"), "%foo.rs");
2591
2592        // ? becomes _
2593        assert_eq!(glob_to_like_pattern("file?.txt"), "file_.txt");
2594
2595        // Literal % and _ are escaped
2596        assert_eq!(glob_to_like_pattern("100%"), "100\\%");
2597        assert_eq!(glob_to_like_pattern("a_b"), "a\\_b");
2598    }
2599
2600    #[test]
2601    fn test_glob_pattern_matches_files() {
2602        let db = Database::open_in_memory().unwrap();
2603
2604        // Insert files at different depths
2605        for path in &[
2606            "main.rs",
2607            "src/lib.rs",
2608            "src/parser/mod.rs",
2609            "src/parser/rust.rs",
2610            "tests/test.rs",
2611        ] {
2612            let file = FileRecord {
2613                path: path.to_string(),
2614                content_hash: "abc".to_string(),
2615                size_bytes: 100,
2616                language: Some("rust".to_string()),
2617                last_indexed: 0,
2618            };
2619            db.upsert_file(&file, None).unwrap();
2620
2621            let symbol = Symbol {
2622                id: format!("{}::main", path),
2623                file_path: path.to_string(),
2624                name: "main".to_string(),
2625                qualified_name: None,
2626                kind: SymbolKind::Function,
2627                visibility: Visibility::Public,
2628                signature: None,
2629                brief: None,
2630                docstring: None,
2631                line_start: 1,
2632                line_end: 5,
2633                col_start: 0,
2634                col_end: 1,
2635                parent_id: None,
2636                source: None,
2637            };
2638            db.insert_symbol(&symbol).unwrap();
2639        }
2640
2641        // **/*.rs should match ALL .rs files at any depth
2642        let results = db
2643            .find_symbols_filtered("main", 20, Some("**/*.rs"), None)
2644            .unwrap();
2645        assert_eq!(results.len(), 5, "**/*.rs should match all .rs files");
2646
2647        // src/**/*.rs should match all .rs files under src/
2648        let results = db
2649            .find_symbols_filtered("main", 20, Some("src/**/*.rs"), None)
2650            .unwrap();
2651        assert_eq!(
2652            results.len(),
2653            3,
2654            "src/**/*.rs should match src/lib.rs, src/parser/mod.rs, src/parser/rust.rs"
2655        );
2656
2657        // Note: src/*.rs also matches nested files because SQL LIKE % matches /
2658        // This is a documented limitation. For precise matching, use substring patterns.
2659        let results = db
2660            .find_symbols_filtered("main", 20, Some("src/*.rs"), None)
2661            .unwrap();
2662        assert_eq!(
2663            results.len(),
2664            3,
2665            "src/*.rs matches all under src/ (SQL LIKE limitation)"
2666        );
2667
2668        // *parser* should match files with "parser" in the path
2669        let results = db
2670            .find_symbols_filtered("main", 20, Some("*parser*"), None)
2671            .unwrap();
2672        assert_eq!(
2673            results.len(),
2674            2,
2675            "*parser* should match parser directory files"
2676        );
2677    }
2678
2679    #[test]
2680    fn test_hybrid_search_limit_one() {
2681        // Tests that hybrid_search doesn't panic or return 0 results with limit=1
2682        // Previously, limit/2 = 0 caused no results to be returned
2683        let db = Database::open_in_memory().unwrap();
2684
2685        // Insert a file and symbol
2686        let file = FileRecord {
2687            path: "src/main.rs".to_string(),
2688            content_hash: "abc123".to_string(),
2689            size_bytes: 100,
2690            language: Some("rust".to_string()),
2691            last_indexed: 0,
2692        };
2693        db.upsert_file(&file, None).unwrap();
2694
2695        let symbol = Symbol {
2696            id: "src/main.rs::authenticate".to_string(),
2697            file_path: "src/main.rs".to_string(),
2698            name: "authenticate".to_string(),
2699            qualified_name: None,
2700            kind: SymbolKind::Function,
2701            visibility: Visibility::Public,
2702            signature: Some("fn authenticate(user: &str)".to_string()),
2703            brief: Some("Authenticate a user".to_string()),
2704            docstring: None,
2705            line_start: 1,
2706            line_end: 5,
2707            col_start: 0,
2708            col_end: 1,
2709            parent_id: None,
2710            source: None,
2711        };
2712        db.insert_symbol(&symbol).unwrap();
2713
2714        // hybrid_search with limit=1 should return exactly 1 result
2715        let results = db.hybrid_search("authenticate", 1).unwrap();
2716        assert_eq!(
2717            results.len(),
2718            1,
2719            "hybrid_search with limit=1 should return 1 result"
2720        );
2721        assert_eq!(results[0].0.name, "authenticate");
2722    }
2723
2724    #[test]
2725    fn test_hybrid_search_limit_three() {
2726        // Tests hybrid_search behavior with limit=3 (where limit/2 = 1)
2727        let db = Database::open_in_memory().unwrap();
2728
2729        // Insert a file
2730        let file = FileRecord {
2731            path: "src/test.rs".to_string(),
2732            content_hash: "abc123".to_string(),
2733            size_bytes: 100,
2734            language: Some("rust".to_string()),
2735            last_indexed: 0,
2736        };
2737        db.upsert_file(&file, None).unwrap();
2738
2739        // Insert multiple symbols
2740        for (name, i) in &[("login", 1), ("logout", 2), ("log_error", 3)] {
2741            let symbol = Symbol {
2742                id: format!("src/test.rs::{}", name),
2743                file_path: "src/test.rs".to_string(),
2744                name: name.to_string(),
2745                qualified_name: None,
2746                kind: SymbolKind::Function,
2747                visibility: Visibility::Public,
2748                signature: Some(format!("fn {}()", name)),
2749                brief: Some(format!("{} function", name)),
2750                docstring: None,
2751                line_start: *i,
2752                line_end: *i + 5,
2753                col_start: 0,
2754                col_end: 1,
2755                parent_id: None,
2756                source: None,
2757            };
2758            db.insert_symbol(&symbol).unwrap();
2759        }
2760
2761        // hybrid_search with limit=3 should work correctly
2762        let results = db.hybrid_search("log", 3).unwrap();
2763        assert!(
2764            !results.is_empty(),
2765            "hybrid_search with limit=3 should return results"
2766        );
2767        assert!(results.len() <= 3, "hybrid_search should respect limit");
2768    }
2769
2770    #[test]
2771    fn test_semantic_search_score_range() {
2772        // Tests that semantic search returns scores in valid 0-1 range
2773        let db = Database::open_in_memory().unwrap();
2774
2775        // Insert a file and symbol
2776        let file = FileRecord {
2777            path: "src/main.rs".to_string(),
2778            content_hash: "abc123".to_string(),
2779            size_bytes: 100,
2780            language: Some("rust".to_string()),
2781            last_indexed: 0,
2782        };
2783        db.upsert_file(&file, None).unwrap();
2784
2785        let symbol = Symbol {
2786            id: "src/main.rs::process_data".to_string(),
2787            file_path: "src/main.rs".to_string(),
2788            name: "process_data".to_string(),
2789            qualified_name: None,
2790            kind: SymbolKind::Function,
2791            visibility: Visibility::Public,
2792            signature: Some("fn process_data(input: &str) -> Result<()>".to_string()),
2793            brief: Some("Process incoming data".to_string()),
2794            docstring: Some("Processes the incoming data and returns a result.".to_string()),
2795            line_start: 1,
2796            line_end: 10,
2797            col_start: 0,
2798            col_end: 1,
2799            parent_id: None,
2800            source: None,
2801        };
2802        db.insert_symbol(&symbol).unwrap();
2803
2804        // Semantic search should return scores in 0-1 range
2805        let results = db.semantic_search("process data", 10).unwrap();
2806        for (symbol, score) in &results {
2807            assert!(
2808                *score >= 0.0 && *score <= 1.0,
2809                "Score for '{}' should be in 0-1 range, got {}",
2810                symbol.name,
2811                score
2812            );
2813            assert!(
2814                score.is_finite(),
2815                "Score for '{}' should be finite, got {}",
2816                symbol.name,
2817                score
2818            );
2819        }
2820    }
2821
2822    #[test]
2823    fn test_bm25_relevance_mapping() {
2824        let cases = [
2825            (0.0, 0.0),
2826            (-1.0, 0.5),
2827            (1.0, 0.5),
2828            (-10.0, 10.0 / 11.0),
2829            (10.0, 10.0 / 11.0),
2830        ];
2831
2832        for (rank, expected) in cases {
2833            let actual = Database::bm25_relevance(rank);
2834            assert!(
2835                (actual - expected).abs() < 1e-12,
2836                "rank {} expected {} got {}",
2837                rank,
2838                expected,
2839                actual
2840            );
2841        }
2842
2843        assert_eq!(Database::bm25_relevance(f64::INFINITY), 0.0);
2844        assert_eq!(Database::bm25_relevance(f64::NEG_INFINITY), 0.0);
2845        assert_eq!(Database::bm25_relevance(f64::NAN), 0.0);
2846    }
2847
2848    #[test]
2849    fn test_get_embedding_metadata() {
2850        // Tests the embedding metadata query used for dimension mismatch detection
2851        let db = Database::open_in_memory().unwrap();
2852
2853        // Insert a file first (foreign key)
2854        let file = FileRecord {
2855            path: "src/main.rs".to_string(),
2856            content_hash: "abc123".to_string(),
2857            size_bytes: 100,
2858            language: Some("rust".to_string()),
2859            last_indexed: 0,
2860        };
2861        db.upsert_file(&file, None).unwrap();
2862
2863        // Insert some symbols
2864        for (name, id) in &[("foo", "src/main.rs::foo"), ("bar", "src/main.rs::bar")] {
2865            let symbol = Symbol {
2866                id: id.to_string(),
2867                file_path: "src/main.rs".to_string(),
2868                name: name.to_string(),
2869                qualified_name: None,
2870                kind: SymbolKind::Function,
2871                visibility: Visibility::Public,
2872                signature: None,
2873                brief: None,
2874                docstring: None,
2875                line_start: 1,
2876                line_end: 5,
2877                col_start: 0,
2878                col_end: 1,
2879                parent_id: None,
2880                source: None,
2881            };
2882            db.insert_symbol(&symbol).unwrap();
2883        }
2884
2885        // No embeddings yet
2886        let metadata = db.get_embedding_metadata().unwrap();
2887        assert!(
2888            metadata.is_empty(),
2889            "Should have no embedding metadata initially"
2890        );
2891
2892        // Store embeddings with different dimensions (simulating local vs OpenAI)
2893        let local_vec: Vec<f32> = vec![0.1; 384]; // Local embedding dimension
2894        let openai_vec: Vec<f32> = vec![0.2; 1536]; // OpenAI embedding dimension
2895
2896        db.store_embedding("src/main.rs::foo", "local", "all-MiniLM-L6-v2", &local_vec)
2897            .unwrap();
2898        db.store_embedding(
2899            "src/main.rs::bar",
2900            "openai",
2901            "text-embedding-3-small",
2902            &openai_vec,
2903        )
2904        .unwrap();
2905
2906        // Query metadata
2907        let metadata = db.get_embedding_metadata().unwrap();
2908
2909        // Should have two distinct provider/dimension combinations
2910        assert_eq!(
2911            metadata.len(),
2912            2,
2913            "Should have 2 embedding metadata entries"
2914        );
2915
2916        // Verify dimensions are recorded correctly
2917        let dims: Vec<i64> = metadata.iter().map(|(_, _, dim, _)| *dim).collect();
2918        assert!(
2919            dims.contains(&384),
2920            "Should have local embedding dimension 384"
2921        );
2922        assert!(
2923            dims.contains(&1536),
2924            "Should have OpenAI embedding dimension 1536"
2925        );
2926
2927        // Verify providers are recorded correctly
2928        let providers: Vec<&str> = metadata.iter().map(|(p, _, _, _)| p.as_str()).collect();
2929        assert!(providers.contains(&"local"), "Should have local provider");
2930        assert!(providers.contains(&"openai"), "Should have openai provider");
2931    }
2932
2933    #[test]
2934    fn test_vector_search() {
2935        // Test the fast vector search using sqlite-vec
2936        let db = Database::open_in_memory().unwrap();
2937
2938        if !is_vec_extension_available() {
2939            eprintln!("Skipping vector search test: sqlite-vec not available");
2940            return;
2941        }
2942
2943        // Insert a file first (foreign key)
2944        let file = FileRecord {
2945            path: "src/main.rs".to_string(),
2946            content_hash: "abc123".to_string(),
2947            size_bytes: 100,
2948            language: Some("rust".to_string()),
2949            last_indexed: 0,
2950        };
2951        db.upsert_file(&file, None).unwrap();
2952
2953        // Insert multiple symbols
2954        for (name, id, line) in &[
2955            ("foo", "src/main.rs::foo", 1),
2956            ("bar", "src/main.rs::bar", 10),
2957            ("baz", "src/main.rs::baz", 20),
2958        ] {
2959            let symbol = Symbol {
2960                id: id.to_string(),
2961                file_path: "src/main.rs".to_string(),
2962                name: name.to_string(),
2963                qualified_name: None,
2964                kind: SymbolKind::Function,
2965                visibility: Visibility::Public,
2966                signature: None,
2967                brief: None,
2968                docstring: None,
2969                line_start: *line,
2970                line_end: *line + 5,
2971                col_start: 0,
2972                col_end: 1,
2973                parent_id: None,
2974                source: None,
2975            };
2976            db.insert_symbol(&symbol).unwrap();
2977        }
2978
2979        // Create embeddings with default dimension (1536)
2980        // Make foo and bar similar, baz different
2981        let mut foo_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2982        foo_vec[0] = 1.0;
2983        foo_vec[1] = 0.5;
2984
2985        let mut bar_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2986        bar_vec[0] = 0.9;
2987        bar_vec[1] = 0.6;
2988
2989        let mut baz_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2990        baz_vec[0] = 0.0;
2991        baz_vec[1] = 0.0;
2992        baz_vec[2] = 1.0; // Completely different direction
2993
2994        // Store embeddings (should also insert into symbol_vectors)
2995        db.store_embedding(
2996            "src/main.rs::foo",
2997            "openai",
2998            "text-embedding-3-small",
2999            &foo_vec,
3000        )
3001        .unwrap();
3002        db.store_embedding(
3003            "src/main.rs::bar",
3004            "openai",
3005            "text-embedding-3-small",
3006            &bar_vec,
3007        )
3008        .unwrap();
3009        db.store_embedding(
3010            "src/main.rs::baz",
3011            "openai",
3012            "text-embedding-3-small",
3013            &baz_vec,
3014        )
3015        .unwrap();
3016
3017        // Verify vector embeddings were stored
3018        let vec_count = db.count_vector_embeddings().unwrap();
3019        assert_eq!(vec_count, 3, "Should have 3 vector embeddings");
3020
3021        // Search with a query similar to foo
3022        let query: Vec<f32> = foo_vec.clone();
3023        let results = db.vector_search(&query, 3).unwrap();
3024
3025        assert_eq!(results.len(), 3, "Should return 3 results");
3026
3027        // First result should be foo (exact match)
3028        assert_eq!(
3029            results[0].0, "src/main.rs::foo",
3030            "First result should be foo (exact match)"
3031        );
3032        assert_eq!(results[0].5, 0.0, "Exact match should have distance 0");
3033
3034        // Second result should be bar (similar)
3035        assert_eq!(
3036            results[1].0, "src/main.rs::bar",
3037            "Second result should be bar (similar)"
3038        );
3039
3040        // Third result should be baz (different)
3041        assert_eq!(
3042            results[2].0, "src/main.rs::baz",
3043            "Third result should be baz (different)"
3044        );
3045
3046        // baz should have larger distance than bar
3047        assert!(
3048            results[2].5 > results[1].5,
3049            "baz should have larger distance than bar"
3050        );
3051    }
3052
3053    #[test]
3054    fn test_migrate_embeddings_to_vec() {
3055        // Test migrating existing JSON embeddings to vector table
3056        let db = Database::open_in_memory().unwrap();
3057
3058        if !is_vec_extension_available() {
3059            eprintln!("Skipping migration test: sqlite-vec not available");
3060            return;
3061        }
3062
3063        // Insert a file and symbol
3064        let file = FileRecord {
3065            path: "src/main.rs".to_string(),
3066            content_hash: "abc123".to_string(),
3067            size_bytes: 100,
3068            language: Some("rust".to_string()),
3069            last_indexed: 0,
3070        };
3071        db.upsert_file(&file, None).unwrap();
3072
3073        let symbol = Symbol {
3074            id: "src/main.rs::foo".to_string(),
3075            file_path: "src/main.rs".to_string(),
3076            name: "foo".to_string(),
3077            qualified_name: None,
3078            kind: SymbolKind::Function,
3079            visibility: Visibility::Public,
3080            signature: None,
3081            brief: None,
3082            docstring: None,
3083            line_start: 1,
3084            line_end: 5,
3085            col_start: 0,
3086            col_end: 1,
3087            parent_id: None,
3088            source: None,
3089        };
3090        db.insert_symbol(&symbol).unwrap();
3091
3092        // Manually insert an embedding only in the JSON table (simulating old data)
3093        let vec: Vec<f32> = vec![0.1; DEFAULT_EMBEDDING_DIM];
3094        let vec_json = serde_json::to_string(&vec).unwrap();
3095        db.conn.execute(
3096            "INSERT INTO embeddings (symbol_id, provider, model, dimension, vector) VALUES (?, ?, ?, ?, ?)",
3097            params!["src/main.rs::foo", "openai", "test", DEFAULT_EMBEDDING_DIM as i64, vec_json],
3098        ).unwrap();
3099
3100        // Verify not in vector table yet
3101        let vec_count_before = db.count_vector_embeddings().unwrap();
3102        assert_eq!(
3103            vec_count_before, 0,
3104            "Should have no vector embeddings before migration"
3105        );
3106
3107        // Run migration
3108        let migrated = db.migrate_embeddings_to_vec().unwrap();
3109        assert_eq!(migrated, 1, "Should migrate 1 embedding");
3110
3111        // Verify now in vector table
3112        let vec_count_after = db.count_vector_embeddings().unwrap();
3113        assert_eq!(
3114            vec_count_after, 1,
3115            "Should have 1 vector embedding after migration"
3116        );
3117
3118        // Re-running migration should not duplicate
3119        let migrated_again = db.migrate_embeddings_to_vec().unwrap();
3120        assert_eq!(
3121            migrated_again, 0,
3122            "Should not re-migrate existing embeddings"
3123        );
3124    }
3125}