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        let mut sql = String::from(
591            r#"
592            SELECT id, file_path, name, qualified_name, kind, visibility,
593                   signature, brief, docstring, line_start, line_end,
594                   col_start, col_end, parent_id, source
595            FROM symbols
596            WHERE (name LIKE ?2 OR qualified_name LIKE ?2)
597            "#,
598        );
599
600        // Track next parameter position
601        let mut next_param = 4; // ?1=pattern, ?2=like_pattern, ?3=starts_with
602
603        // Add file pattern filter if provided
604        let file_param_pos = if file_pattern.is_some() {
605            let pos = next_param;
606            sql.push_str(&format!(" AND file_path LIKE ?{}", pos));
607            next_param += 1;
608            Some(pos)
609        } else {
610            None
611        };
612
613        // Add kind filter if provided
614        let kind_param_pos = if kind_filter.is_some() {
615            let pos = next_param;
616            sql.push_str(&format!(" AND kind = ?{}", pos));
617            next_param += 1;
618            Some(pos)
619        } else {
620            None
621        };
622
623        // Add ORDER BY with proper exact match detection
624        // ?1 is the original pattern (for exact match)
625        // ?3 is the starts_with pattern (for prefix match)
626        sql.push_str(&format!(
627            r#"
628            ORDER BY
629                CASE WHEN name = ?1 THEN 0
630                     WHEN name LIKE ?3 THEN 1
631                     ELSE 2 END,
632                name
633            LIMIT ?{}
634            "#,
635            next_param
636        ));
637
638        let mut stmt = self.conn.prepare(&sql)?;
639
640        // Execute with appropriate parameters based on which filters are active
641        let rows: Vec<Result<Symbol>> = match (
642            file_like.as_deref(),
643            kind_filter,
644            file_param_pos,
645            kind_param_pos,
646        ) {
647            (Some(fp), Some(kf), Some(_), Some(_)) => stmt
648                .query_map(
649                    params![pattern, like_pattern, starts_with_pattern, fp, kf, limit],
650                    |row| Ok(symbol_from_row(row)),
651                )?
652                .collect(),
653            (Some(fp), None, Some(_), None) => stmt
654                .query_map(
655                    params![pattern, like_pattern, starts_with_pattern, fp, limit],
656                    |row| Ok(symbol_from_row(row)),
657                )?
658                .collect(),
659            (None, Some(kf), None, Some(_)) => stmt
660                .query_map(
661                    params![pattern, like_pattern, starts_with_pattern, kf, limit],
662                    |row| Ok(symbol_from_row(row)),
663                )?
664                .collect(),
665            (None, None, None, None) => stmt
666                .query_map(
667                    params![pattern, like_pattern, starts_with_pattern, limit],
668                    |row| Ok(symbol_from_row(row)),
669                )?
670                .collect(),
671            // Handle impossible cases (filter provided but no param pos)
672            _ => unreachable!("Filter and param position should match"),
673        };
674
675        rows.into_iter().collect()
676    }
677
678    /// Get the source code for a symbol.
679    pub fn get_source(&self, symbol_id: &str) -> Result<Option<String>> {
680        self.conn
681            .query_row(
682                "SELECT source FROM symbols WHERE id = ?",
683                [symbol_id],
684                |row| row.get(0),
685            )
686            .optional()
687    }
688
689    /// Get all symbols in a file.
690    pub fn get_file_symbols(&self, file_path: &str) -> Result<Vec<Symbol>> {
691        let mut stmt = self.conn.prepare(
692            r#"
693            SELECT id, file_path, name, qualified_name, kind, visibility,
694                   signature, brief, docstring, line_start, line_end,
695                   col_start, col_end, parent_id, source
696            FROM symbols
697            WHERE file_path = ?
698            ORDER BY line_start
699            "#,
700        )?;
701
702        let rows = stmt.query_map([file_path], |row| Ok(symbol_from_row(row)))?;
703        rows.collect()
704    }
705
706    /// Find symbols in a specific file (alias for get_file_symbols).
707    pub fn find_symbols_in_file(&self, file_path: &str) -> Result<Vec<Symbol>> {
708        self.get_file_symbols(file_path)
709    }
710
711    /// Get edges from a symbol.
712    pub fn get_outgoing_edges(&self, symbol_id: &str) -> Result<Vec<Edge>> {
713        let mut stmt = self.conn.prepare(
714            r#"
715            SELECT source_id, target_id, target_name, kind, line, col, context
716            FROM edges
717            WHERE source_id = ?
718            ORDER BY line
719            "#,
720        )?;
721
722        let rows = stmt.query_map([symbol_id], |row| Ok(edge_from_row(row)))?;
723        rows.collect()
724    }
725
726    /// Get edges to a symbol (callers).
727    pub fn get_incoming_edges(&self, target_name: &str) -> Result<Vec<Edge>> {
728        let mut stmt = self.conn.prepare(
729            r#"
730            SELECT source_id, target_id, target_name, kind, line, col, context
731            FROM edges
732            WHERE target_name = ? OR target_id = ?
733            ORDER BY source_id
734            "#,
735        )?;
736
737        let rows = stmt.query_map([target_name, target_name], |row| Ok(edge_from_row(row)))?;
738        rows.collect()
739    }
740
741    /// Get codebase statistics.
742    pub fn get_stats(&self) -> Result<CodebaseStats> {
743        let files: i64 = self
744            .conn
745            .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
746        let symbols: i64 = self
747            .conn
748            .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))?;
749        let edges: i64 = self
750            .conn
751            .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?;
752        let functions: i64 = self.conn.query_row(
753            "SELECT COUNT(*) FROM symbols WHERE kind IN ('function', 'method')",
754            [],
755            |row| row.get(0),
756        )?;
757        let structs: i64 = self.conn.query_row(
758            "SELECT COUNT(*) FROM symbols WHERE kind IN ('struct', 'class')",
759            [],
760            |row| row.get(0),
761        )?;
762        let enums: i64 = self.conn.query_row(
763            "SELECT COUNT(*) FROM symbols WHERE kind = 'enum'",
764            [],
765            |row| row.get(0),
766        )?;
767        let traits: i64 = self.conn.query_row(
768            "SELECT COUNT(*) FROM symbols WHERE kind IN ('trait', 'interface')",
769            [],
770            |row| row.get(0),
771        )?;
772
773        Ok(CodebaseStats {
774            files,
775            symbols,
776            edges,
777            functions,
778            structs,
779            enums,
780            traits,
781        })
782    }
783
784    // ========== Shared Complexity Metrics ==========
785    //
786    // These mirror the DuckDB `complexity_analysis` formula exactly:
787    // fan_out = COUNT(*) of 'calls' edges grouped by source_id,
788    // fan_in  = COUNT(*) of 'calls' edges grouped by target_id (resolved only),
789    // complexity = fan_out * 2 + fan_in.
790
791    /// Per-symbol fan-in/fan-out/complexity metrics for functions and methods.
792    ///
793    /// Results are ordered by complexity (highest first).
794    pub fn symbol_metrics(&self) -> Result<Vec<SymbolMetrics>> {
795        let mut stmt = self.conn.prepare(
796            r#"
797            WITH fo AS (
798                SELECT source_id AS id, COUNT(*) AS n
799                FROM edges
800                WHERE kind = 'calls'
801                GROUP BY source_id
802            ),
803            fi AS (
804                SELECT target_id AS id, COUNT(*) AS n
805                FROM edges
806                WHERE kind = 'calls' AND target_id IS NOT NULL
807                GROUP BY target_id
808            )
809            SELECT
810                s.id,
811                s.name,
812                s.qualified_name,
813                s.kind,
814                s.file_path,
815                s.line_start,
816                s.line_end,
817                COALESCE(fi.n, 0) AS fan_in,
818                COALESCE(fo.n, 0) AS fan_out,
819                (COALESCE(fo.n, 0) * 2 + COALESCE(fi.n, 0)) AS complexity
820            FROM symbols s
821            LEFT JOIN fo ON s.id = fo.id
822            LEFT JOIN fi ON s.id = fi.id
823            WHERE s.kind IN ('function', 'method')
824            ORDER BY complexity DESC, s.file_path, s.line_start
825            "#,
826        )?;
827
828        let rows = stmt.query_map([], |row| {
829            Ok(SymbolMetrics {
830                id: row.get(0)?,
831                name: row.get(1)?,
832                qualified_name: row.get(2)?,
833                kind: row.get(3)?,
834                file_path: row.get(4)?,
835                line_start: row.get(5)?,
836                line_end: row.get(6)?,
837                fan_in: row.get(7)?,
838                fan_out: row.get(8)?,
839                complexity: row.get(9)?,
840            })
841        })?;
842
843        rows.collect()
844    }
845
846    /// Per-file aggregated complexity (same formula as [`Self::symbol_metrics`],
847    /// summed over all symbols in the file).
848    ///
849    /// `symbol_count` counts all symbols in the file, not only functions.
850    /// Results are ordered by complexity (highest first).
851    pub fn file_complexity(&self) -> Result<Vec<FileComplexity>> {
852        let mut stmt = self.conn.prepare(
853            r#"
854            WITH fo AS (
855                SELECT source_id AS id, COUNT(*) AS n
856                FROM edges
857                WHERE kind = 'calls'
858                GROUP BY source_id
859            ),
860            fi AS (
861                SELECT target_id AS id, COUNT(*) AS n
862                FROM edges
863                WHERE kind = 'calls' AND target_id IS NOT NULL
864                GROUP BY target_id
865            )
866            SELECT
867                s.file_path,
868                SUM(COALESCE(fo.n, 0) * 2 + COALESCE(fi.n, 0)) AS complexity,
869                SUM(COALESCE(fo.n, 0)) AS fan_out,
870                COUNT(*) AS symbol_count
871            FROM symbols s
872            LEFT JOIN fo ON s.id = fo.id
873            LEFT JOIN fi ON s.id = fi.id
874            GROUP BY s.file_path
875            ORDER BY complexity DESC, s.file_path
876            "#,
877        )?;
878
879        let rows = stmt.query_map([], |row| {
880            Ok(FileComplexity {
881                file_path: row.get(0)?,
882                complexity: row.get(1)?,
883                fan_out: row.get(2)?,
884                symbol_count: row.get(3)?,
885            })
886        })?;
887
888        rows.collect()
889    }
890
891    /// All `calls` edges sourced from symbols in `file_path`, as
892    /// `(source_symbol_id, target_name)` pairs.
893    ///
894    /// Used by `ctx score` to compute per-file complexity restricted to
895    /// changed files (per-changed-file queries keep scoring fast on large
896    /// indexes).
897    pub fn file_call_edges(&self, file_path: &str) -> Result<Vec<(String, String)>> {
898        let mut stmt = self.conn.prepare(
899            r#"
900            SELECT e.source_id, e.target_name
901            FROM edges e
902            JOIN symbols s ON s.id = e.source_id
903            WHERE s.file_path = ?1 AND e.kind = 'calls'
904            ORDER BY e.id
905            "#,
906        )?;
907
908        let rows = stmt.query_map([file_path], |row| Ok((row.get(0)?, row.get(1)?)))?;
909        rows.collect()
910    }
911
912    /// Count resolved incoming 'calls' edges for the given symbol IDs.
913    ///
914    /// Symbols with no incoming calls are absent from the returned map.
915    pub fn fan_in_counts(&self, ids: &[String]) -> Result<std::collections::HashMap<String, i64>> {
916        let mut counts = std::collections::HashMap::new();
917        if ids.is_empty() {
918            return Ok(counts);
919        }
920
921        let placeholders = vec!["?"; ids.len()].join(", ");
922        let sql = format!(
923            "SELECT target_id, COUNT(*) FROM edges \
924             WHERE target_id IN ({}) AND kind = 'calls' \
925             GROUP BY target_id",
926            placeholders
927        );
928
929        let mut stmt = self.conn.prepare(&sql)?;
930        let rows = stmt.query_map(rusqlite::params_from_iter(ids.iter()), |row| {
931            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
932        })?;
933
934        for row in rows {
935            let (id, n) = row?;
936            counts.insert(id, n);
937        }
938
939        Ok(counts)
940    }
941
942    /// Get all indexed file paths.
943    pub fn get_indexed_files(&self) -> Result<Vec<String>> {
944        let mut stmt = self.conn.prepare("SELECT path FROM files ORDER BY path")?;
945        let rows = stmt.query_map([], |row| row.get(0))?;
946        rows.collect()
947    }
948
949    // ========== Symbol Rank Cache (ctx map) ==========
950    //
951    // The `symbol_rank` table caches PageRank scores computed by
952    // `crate::rank`. It is cleared by the indexer whenever the index
953    // changes and lazily repopulated by `ctx map`.
954
955    /// Get all symbol IDs, sorted ascending (stable order for rank computation).
956    pub fn get_all_symbol_ids(&self) -> Result<Vec<String>> {
957        let mut stmt = self.conn.prepare("SELECT id FROM symbols ORDER BY id")?;
958        let rows = stmt.query_map([], |row| row.get(0))?;
959        rows.collect()
960    }
961
962    /// Get deduplicated resolved edges of the kinds used for ranking
963    /// (calls, imports, extends, implements), ordered for determinism.
964    pub fn get_rank_edges(&self) -> Result<Vec<(String, String)>> {
965        let mut stmt = self.conn.prepare(
966            r#"
967            SELECT DISTINCT source_id, target_id
968            FROM edges
969            WHERE target_id IS NOT NULL
970              AND kind IN ('calls', 'imports', 'extends', 'implements')
971            ORDER BY source_id, target_id
972            "#,
973        )?;
974        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
975        rows.collect()
976    }
977
978    /// Delete all cached PageRank scores (called when the index changes).
979    pub fn clear_symbol_rank(&self) -> Result<()> {
980        self.conn.execute("DELETE FROM symbol_rank", [])?;
981        Ok(())
982    }
983
984    /// Bulk-store PageRank scores in a single transaction, replacing any
985    /// existing cache.
986    pub fn store_symbol_ranks(&self, ranks: &[(String, f64)]) -> Result<()> {
987        let tx = self.conn.unchecked_transaction()?;
988        tx.execute("DELETE FROM symbol_rank", [])?;
989        {
990            let mut stmt = tx.prepare("INSERT INTO symbol_rank (symbol_id, rank) VALUES (?, ?)")?;
991            for (id, rank) in ranks {
992                stmt.execute(params![id, rank])?;
993            }
994        }
995        tx.commit()
996    }
997
998    /// Load all cached PageRank scores.
999    pub fn load_symbol_ranks(&self) -> Result<Vec<(String, f64)>> {
1000        let mut stmt = self
1001            .conn
1002            .prepare("SELECT symbol_id, rank FROM symbol_rank ORDER BY symbol_id")?;
1003        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1004        rows.collect()
1005    }
1006
1007    /// Count rows in the symbols table.
1008    pub fn count_symbols(&self) -> Result<i64> {
1009        self.conn
1010            .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
1011    }
1012
1013    /// Count rows in the symbol_rank cache.
1014    pub fn count_symbol_ranks(&self) -> Result<i64> {
1015        self.conn
1016            .query_row("SELECT COUNT(*) FROM symbol_rank", [], |row| row.get(0))
1017    }
1018
1019    /// Get all indexed files with their sizes, ordered by path.
1020    pub fn get_files_with_sizes(&self) -> Result<Vec<(String, i64)>> {
1021        let mut stmt = self
1022            .conn
1023            .prepare("SELECT path, COALESCE(size_bytes, 0) FROM files ORDER BY path")?;
1024        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1025        rows.collect()
1026    }
1027
1028    /// Get the IDs of all symbols defined in a file.
1029    pub fn get_symbol_ids_in_file(&self, file_path: &str) -> Result<Vec<String>> {
1030        let mut stmt = self
1031            .conn
1032            .prepare("SELECT id FROM symbols WHERE file_path = ? ORDER BY id")?;
1033        let rows = stmt.query_map([file_path], |row| row.get(0))?;
1034        rows.collect()
1035    }
1036
1037    /// Get the IDs of all symbols whose name or qualified name matches exactly.
1038    pub fn get_symbol_ids_by_name(&self, name: &str) -> Result<Vec<String>> {
1039        let mut stmt = self
1040            .conn
1041            .prepare("SELECT id FROM symbols WHERE name = ?1 OR qualified_name = ?1 ORDER BY id")?;
1042        let rows = stmt.query_map([name], |row| row.get(0))?;
1043        rows.collect()
1044    }
1045
1046    /// Get the lightweight symbol rows shown by `ctx map` (declaration-level
1047    /// kinds only), in a stable base order.
1048    pub fn get_map_symbols(&self) -> Result<Vec<MapSymbolRow>> {
1049        let mut stmt = self.conn.prepare(
1050            r#"
1051            SELECT id, file_path, name, qualified_name, kind, signature, line_start
1052            FROM symbols
1053            WHERE kind IN ('function', 'method', 'struct', 'class', 'enum', 'trait', 'interface')
1054            ORDER BY id
1055            "#,
1056        )?;
1057        let rows = stmt.query_map([], |row| {
1058            Ok(MapSymbolRow {
1059                id: row.get(0)?,
1060                file_path: row.get(1)?,
1061                name: row.get(2)?,
1062                qualified_name: row.get(3)?,
1063                kind: row.get(4)?,
1064                signature: row.get(5)?,
1065                line_start: row.get::<_, Option<u32>>(6)?.unwrap_or(0),
1066            })
1067        })?;
1068        rows.collect()
1069    }
1070
1071    /// All resolved relationship edges whose endpoints live in different files.
1072    ///
1073    /// Used by `ctx check` to build the file-level dependency graph. Only
1074    /// `calls`/`implements`/`extends`/`uses` edges are included (`imports`
1075    /// edges are file-level and handled separately; `contains` and friends
1076    /// are structural, not dependencies).
1077    pub fn get_cross_file_edges(&self) -> Result<Vec<CrossFileEdge>> {
1078        let mut stmt = self.conn.prepare(
1079            r#"
1080            SELECT
1081                s1.name, s1.qualified_name, s1.kind, s1.file_path, s1.line_start, s1.line_end,
1082                s2.name, s2.qualified_name, s2.kind, s2.file_path, s2.line_start, s2.line_end,
1083                e.kind, e.line
1084            FROM edges e
1085            JOIN symbols s1 ON e.source_id = s1.id
1086            JOIN symbols s2 ON e.target_id = s2.id
1087            WHERE e.target_id IS NOT NULL
1088              AND s1.file_path <> s2.file_path
1089              AND e.kind IN ('calls', 'implements', 'extends', 'uses')
1090            ORDER BY s1.file_path, e.line, s2.file_path
1091            "#,
1092        )?;
1093
1094        let rows = stmt.query_map([], |row| {
1095            Ok(CrossFileEdge {
1096                source: EdgeSymbol {
1097                    name: row.get(0)?,
1098                    qualified_name: row.get(1)?,
1099                    kind: row.get(2)?,
1100                    file_path: row.get(3)?,
1101                    line_start: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
1102                    line_end: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
1103                },
1104                target: EdgeSymbol {
1105                    name: row.get(6)?,
1106                    qualified_name: row.get(7)?,
1107                    kind: row.get(8)?,
1108                    file_path: row.get(9)?,
1109                    line_start: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
1110                    line_end: row.get::<_, Option<i64>>(11)?.unwrap_or(0),
1111                },
1112                kind: row.get(12)?,
1113                line: row.get(13)?,
1114            })
1115        })?;
1116
1117        rows.collect()
1118    }
1119
1120    /// Per-file imports recorded in the `modules` table.
1121    ///
1122    /// The `imports` column stores a JSON array of [`ImportInfo`]; rows whose
1123    /// JSON fails to parse are skipped.
1124    pub fn get_file_imports(&self) -> Result<Vec<(String, Vec<ImportInfo>)>> {
1125        let mut stmt = self.conn.prepare(
1126            "SELECT file_path, imports FROM modules \
1127             WHERE imports IS NOT NULL AND imports <> '' \
1128             ORDER BY file_path",
1129        )?;
1130        let rows = stmt.query_map([], |row| {
1131            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1132        })?;
1133
1134        let mut result = Vec::new();
1135        for row in rows {
1136            let (file_path, json) = row?;
1137            if let Ok(imports) = serde_json::from_str::<Vec<ImportInfo>>(&json) {
1138                if !imports.is_empty() {
1139                    result.push((file_path, imports));
1140                }
1141            }
1142        }
1143        Ok(result)
1144    }
1145
1146    /// File-level `imports` edges from the `edges` table.
1147    ///
1148    /// Some parsers (Go) record imports as edges whose `source_id` is the
1149    /// importing *file path* and whose `target_name` is the import path.
1150    /// Returns `(source, target_name, line)` tuples.
1151    pub fn get_import_edges(&self) -> Result<Vec<(String, String, Option<i64>)>> {
1152        let mut stmt = self.conn.prepare(
1153            "SELECT source_id, target_name, line FROM edges \
1154             WHERE kind = 'imports' ORDER BY source_id, line",
1155        )?;
1156        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
1157        rows.collect()
1158    }
1159
1160    /// Normalize an FTS5 bm25 score into a 0-1 relevance value.
1161    fn bm25_relevance(rank: f64) -> f64 {
1162        if rank.is_finite() {
1163            // Use magnitude to normalize regardless of sign: |rank|/(1+|rank|).
1164            let abs_rank = rank.abs();
1165            (abs_rank / (1.0 + abs_rank)).clamp(0.0, 1.0)
1166        } else {
1167            0.0 // Return 0 for invalid scores
1168        }
1169    }
1170
1171    /// Semantic search using FTS5 full-text search.
1172    /// Searches across name, signature, brief, and docstring fields.
1173    pub fn semantic_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64)>> {
1174        // Preprocess query: split into keywords, handle natural language
1175        let keywords = preprocess_search_query(query);
1176
1177        if keywords.is_empty() {
1178            return Ok(Vec::new());
1179        }
1180
1181        // Build FTS5 query with OR logic for broader matches
1182        let fts_query = keywords.join(" OR ");
1183
1184        let mut stmt = self.conn.prepare(
1185            r#"
1186            SELECT
1187                s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
1188                s.signature, s.brief, s.docstring, s.line_start, s.line_end,
1189                s.col_start, s.col_end, s.parent_id, s.source,
1190                bm25(symbol_fts) as rank
1191            FROM symbol_fts
1192            JOIN symbols s ON symbol_fts.id = s.id
1193            WHERE symbol_fts MATCH ?
1194            ORDER BY rank
1195            LIMIT ?
1196            "#,
1197        )?;
1198
1199        let rows = stmt.query_map(params![fts_query, limit], |row| {
1200            let symbol = symbol_from_row(row);
1201            let rank: f64 = row.get(15)?;
1202            let relevance = Self::bm25_relevance(rank);
1203            Ok((symbol, relevance))
1204        })?;
1205
1206        rows.collect()
1207    }
1208
1209    /// Hybrid search combining exact match with semantic search.
1210    pub fn hybrid_search(&self, query: &str, limit: i32) -> Result<Vec<(Symbol, f64, String)>> {
1211        let mut results: std::collections::HashMap<String, (Symbol, f64, String)> =
1212            std::collections::HashMap::new();
1213
1214        // Ensure we get at least 1 result from each source, even for small limits
1215        let half_limit = (limit / 2).max(1);
1216
1217        // 1. Exact name matches (highest priority)
1218        let exact_matches = self.find_symbols(query, half_limit)?;
1219        for symbol in exact_matches {
1220            let score = if symbol.name.eq_ignore_ascii_case(query) {
1221                1.0 // Exact match
1222            } else if symbol
1223                .name
1224                .to_lowercase()
1225                .starts_with(&query.to_lowercase())
1226            {
1227                0.9 // Prefix match
1228            } else {
1229                0.7 // Contains match
1230            };
1231            results.insert(symbol.id.clone(), (symbol, score, "exact".to_string()));
1232        }
1233
1234        // 2. Semantic matches (FTS5)
1235        if let Ok(semantic_matches) = self.semantic_search(query, half_limit) {
1236            for (symbol, relevance) in semantic_matches {
1237                results
1238                    .entry(symbol.id.clone())
1239                    .and_modify(|(_, existing_score, _)| {
1240                        *existing_score = existing_score.max(relevance);
1241                    })
1242                    .or_insert((symbol, relevance, "semantic".to_string()));
1243            }
1244        }
1245
1246        // Sort by score and return
1247        let mut results: Vec<_> = results.into_values().collect();
1248        // Guard against NaN/Inf by treating non-finite values as lower priority
1249        results.sort_by(|a, b| {
1250            match (a.1.is_finite(), b.1.is_finite()) {
1251                (true, true) => b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal),
1252                (true, false) => std::cmp::Ordering::Less, // a (finite) is better than b (non-finite)
1253                (false, true) => std::cmp::Ordering::Greater, // b (finite) is better than a (non-finite)
1254                (false, false) => std::cmp::Ordering::Equal,
1255            }
1256        });
1257        results.truncate(limit as usize);
1258
1259        Ok(results)
1260    }
1261
1262    /// Rebuild the FTS index (useful after schema changes).
1263    #[allow(dead_code)]
1264    pub fn rebuild_fts_index(&self) -> Result<()> {
1265        self.conn.execute_batch(
1266            r#"
1267            INSERT INTO symbol_fts(symbol_fts) VALUES('rebuild');
1268            "#,
1269        )?;
1270        Ok(())
1271    }
1272
1273    // ========== Embedding Operations ==========
1274
1275    /// Store an embedding for a symbol.
1276    ///
1277    /// This stores the embedding in two places:
1278    /// 1. The `embeddings` table (JSON format, for compatibility)
1279    /// 2. The `symbol_vectors` table (binary format, for fast KNN search via sqlite-vec)
1280    pub fn store_embedding(
1281        &self,
1282        symbol_id: &str,
1283        provider: &str,
1284        model: &str,
1285        vector: &[f32],
1286    ) -> Result<()> {
1287        let vector_json = serde_json::to_string(vector)
1288            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
1289
1290        // Store in JSON embeddings table
1291        self.conn.execute(
1292            r#"
1293            INSERT OR REPLACE INTO embeddings (symbol_id, provider, model, dimension, vector)
1294            VALUES (?, ?, ?, ?, ?)
1295            "#,
1296            params![symbol_id, provider, model, vector.len(), vector_json],
1297        )?;
1298
1299        // Also store in vector table for fast KNN search (if available and dimension matches)
1300        if self.has_vector_search() && vector.len() == DEFAULT_EMBEDDING_DIM {
1301            // Convert to bytes for sqlite-vec (f32 little-endian)
1302            let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1303
1304            // Delete existing entry first (vec0 doesn't support REPLACE)
1305            let _ = self.conn.execute(
1306                "DELETE FROM symbol_vectors WHERE symbol_id = ?",
1307                [symbol_id],
1308            );
1309
1310            self.conn.execute(
1311                "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1312                params![vector_bytes, symbol_id],
1313            )?;
1314        }
1315
1316        Ok(())
1317    }
1318
1319    /// Get the embedding for a symbol.
1320    #[allow(dead_code)]
1321    pub fn get_embedding(&self, symbol_id: &str) -> Result<Option<Vec<f32>>> {
1322        let result = self.conn.query_row(
1323            "SELECT vector FROM embeddings WHERE symbol_id = ?",
1324            [symbol_id],
1325            |row| {
1326                let json: String = row.get(0)?;
1327                Ok(json)
1328            },
1329        );
1330
1331        match result {
1332            Ok(json) => {
1333                let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
1334                    rusqlite::Error::FromSqlConversionFailure(
1335                        0,
1336                        rusqlite::types::Type::Text,
1337                        Box::new(e),
1338                    )
1339                })?;
1340                Ok(Some(vector))
1341            }
1342            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1343            Err(e) => Err(e),
1344        }
1345    }
1346
1347    #[allow(clippy::type_complexity)]
1348    /// Get all embeddings with their symbol metadata.
1349    pub fn get_all_embeddings(
1350        &self,
1351    ) -> Result<Vec<(String, String, String, String, u32, Vec<f32>)>> {
1352        let mut stmt = self.conn.prepare(
1353            r#"
1354            SELECT e.symbol_id, s.name, s.kind, s.file_path, s.line_start, e.vector
1355            FROM embeddings e
1356            JOIN symbols s ON e.symbol_id = s.id
1357            "#,
1358        )?;
1359
1360        let rows = stmt.query_map([], |row| {
1361            let symbol_id: String = row.get(0)?;
1362            let name: String = row.get(1)?;
1363            let kind: String = row.get(2)?;
1364            let file_path: String = row.get(3)?;
1365            let line: u32 = row.get(4)?;
1366            let json: String = row.get(5)?;
1367            Ok((symbol_id, name, kind, file_path, line, json))
1368        })?;
1369
1370        let mut results = Vec::new();
1371        for row in rows {
1372            let (symbol_id, name, kind, file_path, line, json) = row?;
1373            let vector: Vec<f32> = serde_json::from_str(&json).map_err(|e| {
1374                rusqlite::Error::FromSqlConversionFailure(
1375                    0,
1376                    rusqlite::types::Type::Text,
1377                    Box::new(e),
1378                )
1379            })?;
1380            results.push((symbol_id, name, kind, file_path, line, vector));
1381        }
1382
1383        Ok(results)
1384    }
1385
1386    /// Count symbols that have embeddings.
1387    pub fn count_embeddings(&self) -> Result<i64> {
1388        self.conn
1389            .query_row("SELECT COUNT(*) FROM embeddings", [], |row| row.get(0))
1390    }
1391
1392    /// Get metadata about stored embeddings (provider, model, dimension, count).
1393    ///
1394    /// Returns a list of (provider, model, dimension, count) tuples for each
1395    /// unique combination in the embeddings table. This is useful for detecting
1396    /// dimension mismatches when querying with a different embedding provider.
1397    pub fn get_embedding_metadata(&self) -> Result<Vec<(String, String, i64, i64)>> {
1398        let mut stmt = self.conn.prepare(
1399            r#"
1400            SELECT provider, model, dimension, COUNT(*) as count
1401            FROM embeddings
1402            GROUP BY provider, model, dimension
1403            ORDER BY count DESC
1404            "#,
1405        )?;
1406
1407        let rows = stmt.query_map([], |row| {
1408            Ok((
1409                row.get::<_, String>(0)?,
1410                row.get::<_, String>(1)?,
1411                row.get::<_, i64>(2)?,
1412                row.get::<_, i64>(3)?,
1413            ))
1414        })?;
1415
1416        rows.collect()
1417    }
1418
1419    /// Get symbols that don't have embeddings yet.
1420    pub fn get_symbols_without_embeddings(&self, limit: i64) -> Result<Vec<Symbol>> {
1421        let mut stmt = self.conn.prepare(
1422            r#"
1423            SELECT s.id, s.file_path, s.name, s.qualified_name, s.kind, s.visibility,
1424                   s.signature, s.brief, s.docstring, s.line_start, s.line_end,
1425                   s.col_start, s.col_end, s.parent_id, s.source
1426            FROM symbols s
1427            LEFT JOIN embeddings e ON s.id = e.symbol_id
1428            WHERE e.symbol_id IS NULL
1429            LIMIT ?
1430            "#,
1431        )?;
1432
1433        let rows = stmt.query_map([limit], |row| {
1434            Ok(Symbol {
1435                id: row.get(0)?,
1436                file_path: row.get(1)?,
1437                name: row.get(2)?,
1438                qualified_name: row.get(3)?,
1439                kind: SymbolKind::from_str(&row.get::<_, String>(4)?)
1440                    .unwrap_or(SymbolKind::Function),
1441                visibility: Visibility::from_str(&row.get::<_, String>(5)?).unwrap_or_default(),
1442                signature: row.get(6)?,
1443                brief: row.get(7)?,
1444                docstring: row.get(8)?,
1445                line_start: row.get(9)?,
1446                line_end: row.get(10)?,
1447                col_start: row.get(11)?,
1448                col_end: row.get(12)?,
1449                parent_id: row.get(13)?,
1450                source: row.get(14)?,
1451            })
1452        })?;
1453
1454        rows.collect()
1455    }
1456
1457    /// Migrate existing embeddings from JSON table to vector table for fast KNN search.
1458    ///
1459    /// This copies all embeddings with the correct dimension to the symbol_vectors table.
1460    /// Returns the number of embeddings migrated.
1461    #[allow(dead_code)] // Migration utility for future use
1462    pub fn migrate_embeddings_to_vec(&self) -> Result<usize> {
1463        if !self.has_vector_search() {
1464            return Ok(0);
1465        }
1466
1467        // Get all embeddings with matching dimension
1468        let mut stmt = self.conn.prepare(
1469            r#"
1470            SELECT symbol_id, vector FROM embeddings
1471            WHERE dimension = ?
1472            "#,
1473        )?;
1474
1475        let rows = stmt.query_map([DEFAULT_EMBEDDING_DIM as i64], |row| {
1476            let symbol_id: String = row.get(0)?;
1477            let json: String = row.get(1)?;
1478            Ok((symbol_id, json))
1479        })?;
1480
1481        let mut count = 0;
1482        for row in rows {
1483            let (symbol_id, json) = row?;
1484            let vector: Vec<f32> = match serde_json::from_str(&json) {
1485                Ok(v) => v,
1486                Err(_) => continue,
1487            };
1488
1489            // Convert to bytes for sqlite-vec (f32 little-endian)
1490            let vector_bytes: Vec<u8> = vector.iter().flat_map(|f| f.to_le_bytes()).collect();
1491
1492            // Skip if already exists
1493            let exists: bool = self
1494                .conn
1495                .query_row(
1496                    "SELECT 1 FROM symbol_vectors WHERE symbol_id = ?",
1497                    [&symbol_id],
1498                    |_| Ok(true),
1499                )
1500                .unwrap_or(false);
1501
1502            if !exists
1503                && self
1504                    .conn
1505                    .execute(
1506                        "INSERT INTO symbol_vectors (embedding, symbol_id) VALUES (?, ?)",
1507                        params![vector_bytes, symbol_id],
1508                    )
1509                    .is_ok()
1510            {
1511                count += 1;
1512            }
1513        }
1514
1515        Ok(count)
1516    }
1517
1518    /// Get the count of embeddings in the vector table.
1519    pub fn count_vector_embeddings(&self) -> Result<i64> {
1520        if !self.has_vector_search() {
1521            return Ok(0);
1522        }
1523        self.conn
1524            .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
1525    }
1526
1527    /// Fast vector similarity search using sqlite-vec.
1528    ///
1529    /// Returns the top-k most similar symbols to the query embedding.
1530    /// This uses indexed KNN search which is O(log n) instead of O(n).
1531    ///
1532    /// Returns (symbol_id, name, kind, file_path, line, distance) tuples.
1533    #[allow(clippy::type_complexity)]
1534    /// Distance is L2 distance (lower is more similar).
1535    pub fn vector_search(
1536        &self,
1537        query_embedding: &[f32],
1538        limit: usize,
1539    ) -> Result<Vec<(String, String, String, String, u32, f32)>> {
1540        if !self.has_vector_search() {
1541            return Ok(Vec::new());
1542        }
1543
1544        if query_embedding.len() != DEFAULT_EMBEDDING_DIM {
1545            return Ok(Vec::new());
1546        }
1547
1548        // Convert query to bytes for sqlite-vec (f32 little-endian)
1549        let query_bytes: Vec<u8> = query_embedding
1550            .iter()
1551            .flat_map(|f| f.to_le_bytes())
1552            .collect();
1553
1554        // sqlite-vec KNN query - first get matching rowids, then join with symbols
1555        // The vec0 virtual table uses MATCH for KNN queries with LIMIT
1556        let mut stmt = self.conn.prepare(
1557            r#"
1558            SELECT knn.symbol_id, s.name, s.kind, s.file_path, s.line_start, knn.distance
1559            FROM (
1560                SELECT symbol_id, distance
1561                FROM symbol_vectors
1562                WHERE embedding MATCH ?
1563                ORDER BY distance
1564                LIMIT ?
1565            ) knn
1566            JOIN symbols s ON knn.symbol_id = s.id
1567            ORDER BY knn.distance
1568            "#,
1569        )?;
1570
1571        let rows = stmt.query_map(params![query_bytes, limit as i64], |row| {
1572            Ok((
1573                row.get::<_, String>(0)?,
1574                row.get::<_, String>(1)?,
1575                row.get::<_, String>(2)?,
1576                row.get::<_, String>(3)?,
1577                row.get::<_, u32>(4)?,
1578                row.get::<_, f32>(5)?,
1579            ))
1580        })?;
1581
1582        rows.collect()
1583    }
1584
1585    /// Check if the vector table has any embeddings.
1586    pub fn has_vector_embeddings(&self) -> bool {
1587        self.count_vector_embeddings().unwrap_or(0) > 0
1588    }
1589
1590    /// Delete embeddings for a specific provider/model.
1591    pub fn delete_embeddings(&self, provider: &str, model: Option<&str>) -> Result<usize> {
1592        if let Some(model) = model {
1593            self.conn.execute(
1594                "DELETE FROM embeddings WHERE provider = ? AND model = ?",
1595                params![provider, model],
1596            )
1597        } else {
1598            self.conn
1599                .execute("DELETE FROM embeddings WHERE provider = ?", [provider])
1600        }
1601    }
1602
1603    /// Resolve target_id for edges that only have target_name.
1604    ///
1605    /// This performs cross-file symbol resolution by matching target_name to symbols
1606    /// in the database. Resolution priority:
1607    /// 1. Context match: the call context contains the type name (e.g., "TypeScriptParser::new()")
1608    /// 2. Unique: only one symbol with that name exists in the codebase
1609    /// 3. Same file unique: only one symbol with that name exists in the same file
1610    ///
1611    /// We intentionally avoid aggressive same-file matching because calls like
1612    /// `Vec::new()` would incorrectly match a local `new` function.
1613    ///
1614    /// Returns the number of edges that were resolved.
1615    pub fn resolve_edge_targets(&self) -> Result<usize> {
1616        // Step 1: Resolve edges where the context contains the qualified name
1617        // e.g., context "TypeScriptParser::new()" matches symbol with qualified_name "TypeScriptParser::new"
1618        // Only resolve if exactly one symbol matches to avoid ambiguous resolution
1619        let context_resolved = self.conn.execute(
1620            r#"
1621            UPDATE edges
1622            SET target_id = (
1623                SELECT t.id
1624                FROM symbols t
1625                WHERE t.name = edges.target_name
1626                  AND t.kind IN ('function', 'method')
1627                  AND t.qualified_name IS NOT NULL
1628                  AND edges.context LIKE '%' || t.qualified_name || '%'
1629            )
1630            WHERE target_id IS NULL
1631              AND context IS NOT NULL
1632              AND (
1633                SELECT COUNT(*)
1634                FROM symbols t
1635                WHERE t.name = edges.target_name
1636                  AND t.kind IN ('function', 'method')
1637                  AND t.qualified_name IS NOT NULL
1638                  AND edges.context LIKE '%' || t.qualified_name || '%'
1639              ) = 1
1640            "#,
1641            [],
1642        )?;
1643
1644        // Step 2: Resolve edges where target name is unique across the codebase
1645        let unique_resolved = self.conn.execute(
1646            r#"
1647            UPDATE edges
1648            SET target_id = (
1649                SELECT id FROM symbols
1650                WHERE name = edges.target_name
1651                  AND kind IN ('function', 'method')
1652            )
1653            WHERE target_id IS NULL
1654              AND (
1655                SELECT COUNT(*) FROM symbols
1656                WHERE name = edges.target_name
1657                  AND kind IN ('function', 'method')
1658              ) = 1
1659            "#,
1660            [],
1661        )?;
1662
1663        // Step 3: Resolve edges where target is unique in the same file
1664        // Only match if:
1665        // - There's exactly one function with that name in the same file
1666        // - The context doesn't suggest an external type (no :: prefix before the name)
1667        // - The context doesn't suggest an external type/receiver call
1668        //   (no ::, ., or -> before the function name)
1669        let same_file_unique = self.conn.execute(
1670            r#"
1671            UPDATE edges
1672            SET target_id = (
1673                SELECT t.id
1674                FROM symbols t
1675                JOIN symbols s ON s.id = edges.source_id
1676                WHERE t.name = edges.target_name
1677                  AND t.file_path = s.file_path
1678                  AND t.kind IN ('function', 'method')
1679            )
1680            WHERE target_id IS NULL
1681              AND (
1682                SELECT COUNT(*)
1683                FROM symbols t
1684                JOIN symbols s ON s.id = edges.source_id
1685                WHERE t.name = edges.target_name
1686                  AND t.file_path = s.file_path
1687                  AND t.kind IN ('function', 'method')
1688              ) = 1
1689              -- Exclude if context suggests an external type call (has :: before the function name)
1690              -- e.g., "Vec::new()" or "Parser::new()" should NOT match local "new" functions
1691              AND (
1692                context IS NULL
1693                OR (
1694                    context NOT LIKE '%::' || target_name || '(%'
1695                    AND context NOT LIKE '%.' || target_name || '(%'
1696                    AND context NOT LIKE '%->' || target_name || '(%'
1697                )
1698                OR context LIKE '%' || (
1699                        SELECT t.qualified_name
1700                        FROM symbols t
1701                        JOIN symbols s ON s.id = edges.source_id
1702                        WHERE t.name = edges.target_name
1703                          AND t.file_path = s.file_path
1704                          AND t.kind IN ('function', 'method')
1705                        LIMIT 1
1706                    ) || '(%'
1707              )
1708            "#,
1709            [],
1710        )?;
1711
1712        Ok(context_resolved + unique_resolved + same_file_unique)
1713    }
1714
1715    /// Insert (or replace) a batch of MinHash fingerprints in one transaction.
1716    pub fn insert_fingerprints_batch(&self, fingerprints: &[Fingerprint]) -> Result<usize> {
1717        if fingerprints.is_empty() {
1718            return Ok(0);
1719        }
1720        let tx = self.conn.unchecked_transaction()?;
1721        {
1722            let mut stmt = tx.prepare(
1723                r#"
1724                INSERT OR REPLACE INTO symbol_fingerprints (symbol_id, file_path, minhash, token_count)
1725                VALUES (?, ?, ?, ?)
1726                "#,
1727            )?;
1728            for fp in fingerprints {
1729                stmt.execute(params![
1730                    fp.symbol_id,
1731                    fp.file_path,
1732                    fp.minhash,
1733                    fp.token_count
1734                ])?;
1735            }
1736        }
1737        tx.commit()?;
1738        Ok(fingerprints.len())
1739    }
1740
1741    /// Load all fingerprints with at least `min_tokens` tokens, ordered by
1742    /// symbol id (so callers get a stable, canonical order).
1743    pub fn get_fingerprints(&self, min_tokens: i64) -> Result<Vec<Fingerprint>> {
1744        let mut stmt = self.conn.prepare(
1745            r#"
1746            SELECT symbol_id, file_path, minhash, token_count
1747            FROM symbol_fingerprints
1748            WHERE token_count >= ?
1749            ORDER BY symbol_id
1750            "#,
1751        )?;
1752        let rows = stmt.query_map([min_tokens], |row| {
1753            Ok(Fingerprint {
1754                symbol_id: row.get(0)?,
1755                file_path: row.get(1)?,
1756                minhash: row.get(2)?,
1757                token_count: row.get(3)?,
1758            })
1759        })?;
1760        rows.collect()
1761    }
1762}
1763
1764/// Escape SQL LIKE special characters in a pattern.
1765///
1766/// SQLite LIKE uses `%` for any sequence and `_` for single character.
1767/// This function escapes these so they match literally.
1768fn escape_like_pattern(pattern: &str) -> String {
1769    pattern
1770        .replace('\\', "\\\\") // Escape backslash first
1771        .replace('%', "\\%")
1772        .replace('_', "\\_")
1773}
1774
1775/// Convert a glob-style pattern to a SQL LIKE pattern.
1776///
1777/// Supports:
1778/// - `*` -> `%` (match any sequence - note: in SQL LIKE this also matches `/`)
1779/// - `**` -> `%` (match any sequence including path separators)
1780/// - `**/` -> consumed, following pattern matches from any depth
1781/// - `?` -> `_` (match single character)
1782/// - Escapes literal `%` and `_` in the pattern
1783///
1784/// Limitations:
1785/// - SQL LIKE `%` matches across path separators, so `src/*.rs` will also match
1786///   `src/foo/bar.rs`. Use substring patterns like `*parser*` for simple filtering,
1787///   or rely on the prefix to narrow results.
1788///
1789/// Examples:
1790/// - `**/*.rs` -> `%.rs` (any .rs file at any depth)
1791/// - `src/**/*.rs` -> `src/%.rs` (any .rs file under src/)
1792/// - `*parser*` -> `%parser%` (any path containing "parser")
1793fn glob_to_like_pattern(pattern: &str) -> String {
1794    let mut result = String::with_capacity(pattern.len() * 2);
1795    let mut chars = pattern.chars().peekable();
1796
1797    while let Some(c) = chars.next() {
1798        match c {
1799            '*' => {
1800                // Check for **
1801                if chars.peek() == Some(&'*') {
1802                    chars.next();
1803                    // Check for **/ - this should match zero or more path segments
1804                    if chars.peek() == Some(&'/') {
1805                        chars.next();
1806                        // **/ means "any path prefix including empty"
1807                        // Don't add % here - the following pattern (likely *.ext) will add it
1808                        // This allows **/*.rs to become %.rs instead of %%.rs
1809                        // But if there's nothing after, or it's not a *, we need the %
1810                        if chars.peek().is_none() || chars.peek() == Some(&'*') {
1811                            // **/ at end or followed by another * - don't add redundant %
1812                            continue;
1813                        }
1814                        // **/ followed by something else - add % to match the path prefix
1815                        result.push('%');
1816                    } else {
1817                        // ** without trailing / - just match anything
1818                        result.push('%');
1819                    }
1820                } else {
1821                    // Single * - match anything (in SQL LIKE, same as %)
1822                    result.push('%');
1823                }
1824            }
1825            '?' => result.push('_'),
1826            '%' => result.push_str("\\%"),
1827            '_' => result.push_str("\\_"),
1828            '\\' => result.push_str("\\\\"),
1829            _ => result.push(c),
1830        }
1831    }
1832
1833    result
1834}
1835
1836/// Preprocess a search query into keywords.
1837fn preprocess_search_query(query: &str) -> Vec<String> {
1838    // Common words to filter out
1839    let stop_words: std::collections::HashSet<&str> = [
1840        "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
1841        "do", "does", "did", "will", "would", "could", "should", "may", "might", "must", "shall",
1842        "can", "need", "dare", "and", "or", "but", "if", "then", "else", "when", "where", "why",
1843        "how", "what", "which", "who", "whom", "this", "that", "these", "those", "i", "you", "he",
1844        "she", "it", "we", "they", "me", "him", "her", "us", "them", "my", "your", "his", "its",
1845        "our", "their", "for", "to", "from", "with", "at", "by", "on", "in", "of", "about", "into",
1846        "through", "during", "before", "after", "above", "below", "find", "get", "search", "look",
1847        "all", "any", "each", "every",
1848    ]
1849    .iter()
1850    .copied()
1851    .collect();
1852
1853    query
1854        .to_lowercase()
1855        .split(|c: char| !c.is_alphanumeric() && c != '_')
1856        .filter(|word| {
1857            let word = word.trim();
1858            !word.is_empty() && word.len() > 1 && !stop_words.contains(word)
1859        })
1860        .map(|s| {
1861            // Add wildcard suffix for prefix matching
1862            format!("{}*", s)
1863        })
1864        .collect()
1865}
1866
1867/// Helper to convert a row to a Symbol.
1868fn symbol_from_row(row: &rusqlite::Row) -> Symbol {
1869    let kind_str: String = row.get(4).unwrap_or_default();
1870    let visibility_str: String = row.get(5).unwrap_or_default();
1871
1872    Symbol {
1873        id: row.get(0).unwrap_or_default(),
1874        file_path: row.get(1).unwrap_or_default(),
1875        name: row.get(2).unwrap_or_default(),
1876        qualified_name: row.get(3).ok(),
1877        kind: SymbolKind::from_str(&kind_str).unwrap_or(SymbolKind::Function),
1878        visibility: Visibility::from_str(&visibility_str).unwrap_or_default(),
1879        signature: row.get(6).ok(),
1880        brief: row.get(7).ok(),
1881        docstring: row.get(8).ok(),
1882        line_start: row.get(9).unwrap_or(0),
1883        line_end: row.get(10).unwrap_or(0),
1884        col_start: row.get(11).unwrap_or(0),
1885        col_end: row.get(12).unwrap_or(0),
1886        parent_id: row.get(13).ok(),
1887        source: row.get(14).ok(),
1888    }
1889}
1890
1891/// Helper to convert a row to an Edge.
1892fn edge_from_row(row: &rusqlite::Row) -> Edge {
1893    let kind_str: String = row.get(3).unwrap_or_default();
1894
1895    Edge {
1896        source_id: row.get(0).unwrap_or_default(),
1897        target_id: row.get(1).ok(),
1898        target_name: row.get(2).unwrap_or_default(),
1899        kind: EdgeKind::from_str(&kind_str).unwrap_or(EdgeKind::Calls),
1900        line: row.get(4).ok(),
1901        col: row.get(5).ok(),
1902        context: row.get(6).ok(),
1903    }
1904}
1905
1906/// A lightweight symbol row for the `ctx map` command
1907/// (see [`Database::get_map_symbols`]).
1908#[derive(Debug, Clone)]
1909pub struct MapSymbolRow {
1910    pub id: String,
1911    pub file_path: String,
1912    pub name: String,
1913    pub qualified_name: Option<String>,
1914    pub kind: String,
1915    pub signature: Option<String>,
1916    pub line_start: u32,
1917}
1918
1919/// Per-symbol complexity metrics (see [`Database::symbol_metrics`]).
1920#[derive(Debug, Clone, serde::Serialize)]
1921pub struct SymbolMetrics {
1922    pub id: String,
1923    pub name: String,
1924    pub qualified_name: Option<String>,
1925    pub kind: String,
1926    pub file_path: String,
1927    pub line_start: i64,
1928    pub line_end: i64,
1929    pub fan_in: i64,
1930    pub fan_out: i64,
1931    pub complexity: i64,
1932}
1933
1934/// A resolved relationship edge whose endpoints live in different files
1935/// (see [`Database::get_cross_file_edges`]).
1936#[derive(Debug, Clone)]
1937pub struct CrossFileEdge {
1938    pub source: EdgeSymbol,
1939    pub target: EdgeSymbol,
1940    /// Edge kind (`calls`, `implements`, `extends`, `uses`).
1941    pub kind: String,
1942    /// Line in the source file where the reference occurs.
1943    pub line: Option<i64>,
1944}
1945
1946/// Lightweight symbol info attached to a [`CrossFileEdge`].
1947#[derive(Debug, Clone)]
1948pub struct EdgeSymbol {
1949    pub name: String,
1950    pub qualified_name: Option<String>,
1951    pub kind: String,
1952    pub file_path: String,
1953    pub line_start: i64,
1954    pub line_end: i64,
1955}
1956
1957/// Per-file aggregated complexity (see [`Database::file_complexity`]).
1958#[derive(Debug, Clone, serde::Serialize)]
1959pub struct FileComplexity {
1960    pub file_path: String,
1961    pub complexity: i64,
1962    pub fan_out: i64,
1963    pub symbol_count: i64,
1964}
1965
1966/// Extension trait for optional query results.
1967trait ResultExt<T> {
1968    fn optional(self) -> Result<Option<T>>;
1969}
1970
1971impl<T> ResultExt<T> for Result<T> {
1972    fn optional(self) -> Result<Option<T>> {
1973        match self {
1974            Ok(v) => Ok(Some(v)),
1975            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1976            Err(e) => Err(e),
1977        }
1978    }
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983    use super::*;
1984
1985    #[test]
1986    fn test_create_database() {
1987        let db = Database::open_in_memory().unwrap();
1988        let stats = db.get_stats().unwrap();
1989        assert_eq!(stats.files, 0);
1990        assert_eq!(stats.symbols, 0);
1991    }
1992
1993    #[test]
1994    fn test_fresh_database_is_stamped_with_schema_version() {
1995        let dir = tempfile::tempdir().unwrap();
1996        let db_path = dir.path().join("codebase.sqlite");
1997
1998        let db = Database::open(&db_path).unwrap();
1999        let version: i64 = db
2000            .conn
2001            .query_row("PRAGMA user_version", [], |row| row.get(0))
2002            .unwrap();
2003        assert_eq!(version, SCHEMA_VERSION);
2004        drop(db);
2005
2006        // Re-opening a stamped database works.
2007        assert!(Database::open(&db_path).is_ok());
2008    }
2009
2010    #[test]
2011    fn test_legacy_v0_database_is_rejected() {
2012        let dir = tempfile::tempdir().unwrap();
2013        let db_path = dir.path().join("codebase.sqlite");
2014
2015        // Create a database, then reset it to the pre-versioning state (v0
2016        // with existing tables). Pre-versioning databases lack the
2017        // symbol_fingerprints table, so they must be rebuilt.
2018        drop(Database::open(&db_path).unwrap());
2019        {
2020            let conn = Connection::open(&db_path).unwrap();
2021            conn.pragma_update(None, "user_version", 0).unwrap();
2022        }
2023
2024        let err = Database::open(&db_path).unwrap_err();
2025        match &err {
2026            crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2027                assert_eq!(*found, 0);
2028                assert_eq!(*expected, SCHEMA_VERSION);
2029            }
2030            other => panic!("expected SchemaVersionMismatch, got: {}", other),
2031        }
2032        assert!(err.to_string().contains("ctx index --force"));
2033    }
2034
2035    #[test]
2036    fn test_schema_version_mismatch_is_rejected() {
2037        let dir = tempfile::tempdir().unwrap();
2038        let db_path = dir.path().join("codebase.sqlite");
2039
2040        // A v1 database (pre-fingerprints) must be rejected with a hint to
2041        // rebuild, as must any other unknown version.
2042        for stale_version in [1i64, 99] {
2043            drop(Database::open(&db_path).unwrap());
2044            {
2045                let conn = Connection::open(&db_path).unwrap();
2046                conn.pragma_update(None, "user_version", stale_version)
2047                    .unwrap();
2048            }
2049
2050            let err = Database::open(&db_path).unwrap_err();
2051            match &err {
2052                crate::error::CtxError::SchemaVersionMismatch { found, expected } => {
2053                    assert_eq!(*found, stale_version);
2054                    assert_eq!(*expected, SCHEMA_VERSION);
2055                }
2056                other => panic!("expected SchemaVersionMismatch, got: {}", other),
2057            }
2058            assert!(err.to_string().contains("ctx index --force"));
2059
2060            // Restore the correct version so the next iteration can re-open.
2061            let conn = Connection::open(&db_path).unwrap();
2062            conn.pragma_update(None, "user_version", SCHEMA_VERSION)
2063                .unwrap();
2064        }
2065    }
2066
2067    #[test]
2068    fn test_fingerprint_batch_roundtrip_and_min_tokens_filter() {
2069        let db = Database::open_in_memory().unwrap();
2070        db.upsert_file(
2071            &FileRecord {
2072                path: "src/a.rs".to_string(),
2073                content_hash: "h1".to_string(),
2074                size_bytes: 10,
2075                language: Some("rust".to_string()),
2076                last_indexed: 0,
2077            },
2078            None,
2079        )
2080        .unwrap();
2081        db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2082            .unwrap();
2083        db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2084            .unwrap();
2085
2086        let fps = vec![
2087            Fingerprint {
2088                symbol_id: "src/a.rs::alpha".to_string(),
2089                file_path: "src/a.rs".to_string(),
2090                minhash: vec![1u8; 1024],
2091                token_count: 80,
2092            },
2093            Fingerprint {
2094                symbol_id: "src/a.rs::beta".to_string(),
2095                file_path: "src/a.rs".to_string(),
2096                minhash: vec![2u8; 1024],
2097                token_count: 20,
2098            },
2099        ];
2100        assert_eq!(db.insert_fingerprints_batch(&fps).unwrap(), 2);
2101        assert_eq!(db.insert_fingerprints_batch(&[]).unwrap(), 0);
2102
2103        // No filter: both come back, ordered by symbol_id, bytes intact.
2104        let all = db.get_fingerprints(0).unwrap();
2105        assert_eq!(all.len(), 2);
2106        assert_eq!(all[0].symbol_id, "src/a.rs::alpha");
2107        assert_eq!(all[0].minhash, vec![1u8; 1024]);
2108        assert_eq!(all[1].token_count, 20);
2109
2110        // min_tokens filters out short symbols.
2111        let filtered = db.get_fingerprints(50).unwrap();
2112        assert_eq!(filtered.len(), 1);
2113        assert_eq!(filtered[0].symbol_id, "src/a.rs::alpha");
2114
2115        // Deleting the file's symbols cascades to fingerprints.
2116        db.delete_symbols_for_file("src/a.rs").unwrap();
2117        assert!(db.get_fingerprints(0).unwrap().is_empty());
2118    }
2119
2120    #[test]
2121    fn test_sqlite_vec_extension_loaded() {
2122        // Verify sqlite-vec is properly initialized
2123        let db = Database::open_in_memory().unwrap();
2124
2125        // Check that extension initialization was attempted
2126        // Note: init_vec_extension() is called during Database::open_in_memory()
2127        // so is_vec_extension_available() reflects whether it succeeded
2128        if !is_vec_extension_available() {
2129            eprintln!("Skipping sqlite-vec tests: extension not available on this platform");
2130            return;
2131        }
2132
2133        // Check vec_version() function exists (proves extension loaded)
2134        let version: Result<String, _> = db
2135            .conn
2136            .query_row("SELECT vec_version()", [], |row| row.get(0));
2137
2138        match version {
2139            Ok(v) => {
2140                assert!(
2141                    v.starts_with('v'),
2142                    "vec_version should return version string, got: {}",
2143                    v
2144                );
2145            }
2146            Err(e) => {
2147                // Extension registered but function not available - this shouldn't happen
2148                // if is_vec_extension_available() returned true, but handle gracefully
2149                panic!(
2150                    "sqlite-vec extension reported available but vec_version() failed: {}",
2151                    e
2152                );
2153            }
2154        }
2155
2156        // Verify has_vector_search() works
2157        assert!(db.has_vector_search(), "vector search should be available");
2158
2159        // Verify symbol_vectors table exists and is queryable
2160        let count: i64 = db
2161            .conn
2162            .query_row("SELECT COUNT(*) FROM symbol_vectors", [], |row| row.get(0))
2163            .expect("symbol_vectors table should exist");
2164        assert_eq!(count, 0, "symbol_vectors should be empty initially");
2165    }
2166
2167    /// Build a minimal function symbol for metrics tests.
2168    fn make_fn_symbol(id: &str, name: &str, file: &str, line: u32) -> Symbol {
2169        Symbol {
2170            id: id.to_string(),
2171            file_path: file.to_string(),
2172            name: name.to_string(),
2173            qualified_name: Some(name.to_string()),
2174            kind: SymbolKind::Function,
2175            visibility: Visibility::Public,
2176            signature: None,
2177            brief: None,
2178            docstring: None,
2179            line_start: line,
2180            line_end: line + 5,
2181            col_start: 0,
2182            col_end: 0,
2183            parent_id: None,
2184            source: None,
2185        }
2186    }
2187
2188    /// Build a 'calls' edge for metrics tests.
2189    fn make_call_edge(source_id: &str, target_id: Option<&str>, target_name: &str) -> Edge {
2190        Edge {
2191            source_id: source_id.to_string(),
2192            target_id: target_id.map(|s| s.to_string()),
2193            target_name: target_name.to_string(),
2194            kind: EdgeKind::Calls,
2195            line: Some(1),
2196            col: None,
2197            context: None,
2198        }
2199    }
2200
2201    /// Set up a database with three functions and a small call graph:
2202    /// alpha -> beta (resolved), alpha -> external (unresolved), beta -> alpha (resolved).
2203    fn metrics_fixture() -> Database {
2204        let db = Database::open_in_memory().unwrap();
2205        db.upsert_file(
2206            &FileRecord {
2207                path: "src/a.rs".to_string(),
2208                content_hash: "h1".to_string(),
2209                size_bytes: 10,
2210                language: Some("rust".to_string()),
2211                last_indexed: 0,
2212            },
2213            None,
2214        )
2215        .unwrap();
2216
2217        db.insert_symbol(&make_fn_symbol("src/a.rs::alpha", "alpha", "src/a.rs", 1))
2218            .unwrap();
2219        db.insert_symbol(&make_fn_symbol("src/a.rs::beta", "beta", "src/a.rs", 10))
2220            .unwrap();
2221        db.insert_symbol(&make_fn_symbol("src/a.rs::gamma", "gamma", "src/a.rs", 20))
2222            .unwrap();
2223
2224        db.insert_edge(&make_call_edge(
2225            "src/a.rs::alpha",
2226            Some("src/a.rs::beta"),
2227            "beta",
2228        ))
2229        .unwrap();
2230        db.insert_edge(&make_call_edge("src/a.rs::alpha", None, "external"))
2231            .unwrap();
2232        db.insert_edge(&make_call_edge(
2233            "src/a.rs::beta",
2234            Some("src/a.rs::alpha"),
2235            "alpha",
2236        ))
2237        .unwrap();
2238
2239        db
2240    }
2241
2242    #[test]
2243    fn test_symbol_metrics_mirrors_complexity_formula() {
2244        let db = metrics_fixture();
2245        let metrics = db.symbol_metrics().unwrap();
2246        assert_eq!(metrics.len(), 3);
2247
2248        let get = |name: &str| metrics.iter().find(|m| m.name == name).unwrap();
2249
2250        // alpha: fan_out 2 (beta + unresolved external), fan_in 1 (from beta)
2251        let alpha = get("alpha");
2252        assert_eq!(alpha.fan_out, 2);
2253        assert_eq!(alpha.fan_in, 1);
2254        assert_eq!(alpha.complexity, 2 * 2 + 1);
2255        assert_eq!(alpha.file_path, "src/a.rs");
2256        assert_eq!(alpha.line_start, 1);
2257
2258        // beta: fan_out 1, fan_in 1
2259        let beta = get("beta");
2260        assert_eq!(beta.fan_out, 1);
2261        assert_eq!(beta.fan_in, 1);
2262        assert_eq!(beta.complexity, 3); // fan_out(1) * 2 + fan_in(1)
2263
2264        // gamma: no edges at all
2265        let gamma = get("gamma");
2266        assert_eq!(gamma.fan_out, 0);
2267        assert_eq!(gamma.fan_in, 0);
2268        assert_eq!(gamma.complexity, 0);
2269
2270        // Ordered by complexity, highest first
2271        assert_eq!(metrics[0].name, "alpha");
2272    }
2273
2274    #[test]
2275    fn test_file_complexity_aggregates_per_file() {
2276        let db = metrics_fixture();
2277        let files = db.file_complexity().unwrap();
2278        assert_eq!(files.len(), 1);
2279
2280        let f = &files[0];
2281        assert_eq!(f.file_path, "src/a.rs");
2282        assert_eq!(f.symbol_count, 3);
2283        assert_eq!(f.fan_out, 3);
2284        // alpha (5) + beta (3) + gamma (0)
2285        assert_eq!(f.complexity, 8);
2286    }
2287
2288    #[test]
2289    fn test_fan_in_counts() {
2290        let db = metrics_fixture();
2291
2292        let ids = vec![
2293            "src/a.rs::alpha".to_string(),
2294            "src/a.rs::beta".to_string(),
2295            "src/a.rs::gamma".to_string(),
2296        ];
2297        let counts = db.fan_in_counts(&ids).unwrap();
2298        assert_eq!(counts.get("src/a.rs::alpha"), Some(&1));
2299        assert_eq!(counts.get("src/a.rs::beta"), Some(&1));
2300        // gamma has no incoming resolved calls, so it is absent
2301        assert!(!counts.contains_key("src/a.rs::gamma"));
2302
2303        // Empty input short-circuits
2304        assert!(db.fan_in_counts(&[]).unwrap().is_empty());
2305    }
2306
2307    #[test]
2308    fn test_insert_and_find_symbol() {
2309        let db = Database::open_in_memory().unwrap();
2310
2311        // Insert a file first (foreign key)
2312        let file = FileRecord {
2313            path: "src/main.rs".to_string(),
2314            content_hash: "abc123".to_string(),
2315            size_bytes: 100,
2316            language: Some("rust".to_string()),
2317            last_indexed: 0,
2318        };
2319        db.upsert_file(&file, None).unwrap();
2320
2321        // Insert a symbol
2322        let symbol = Symbol {
2323            id: "src/main.rs::main".to_string(),
2324            file_path: "src/main.rs".to_string(),
2325            name: "main".to_string(),
2326            qualified_name: None,
2327            kind: SymbolKind::Function,
2328            visibility: Visibility::Private,
2329            signature: Some("fn main()".to_string()),
2330            brief: Some("Entry point".to_string()),
2331            docstring: None,
2332            line_start: 1,
2333            line_end: 5,
2334            col_start: 0,
2335            col_end: 1,
2336            parent_id: None,
2337            source: Some("fn main() {\n    println!(\"Hello\");\n}".to_string()),
2338        };
2339        db.insert_symbol(&symbol).unwrap();
2340
2341        // Find it
2342        let found = db.get_symbol("src/main.rs::main").unwrap();
2343        assert!(found.is_some());
2344        assert_eq!(found.unwrap().name, "main");
2345
2346        // Search for it
2347        let results = db.find_symbols("main", 10).unwrap();
2348        assert_eq!(results.len(), 1);
2349    }
2350
2351    #[test]
2352    fn test_find_symbols_exact_match_ordering() {
2353        let db = Database::open_in_memory().unwrap();
2354
2355        // Insert a file
2356        let file = FileRecord {
2357            path: "src/test.rs".to_string(),
2358            content_hash: "abc123".to_string(),
2359            size_bytes: 100,
2360            language: Some("rust".to_string()),
2361            last_indexed: 0,
2362        };
2363        db.upsert_file(&file, None).unwrap();
2364
2365        // Insert symbols with similar names - order matters for testing
2366        // We insert in reverse order to ensure ordering comes from query, not insertion
2367        let symbols = vec![
2368            ("new_large", "src/test.rs::new_large"), // substring match
2369            ("new_item", "src/test.rs::new_item"),   // prefix match
2370            ("renew", "src/test.rs::renew"),         // substring match (contains "new")
2371            ("new", "src/test.rs::new"),             // exact match
2372            ("new_thing", "src/test.rs::new_thing"), // prefix match
2373        ];
2374
2375        for (name, id) in &symbols {
2376            let symbol = Symbol {
2377                id: id.to_string(),
2378                file_path: "src/test.rs".to_string(),
2379                name: name.to_string(),
2380                qualified_name: None,
2381                kind: SymbolKind::Function,
2382                visibility: Visibility::Public,
2383                signature: None,
2384                brief: None,
2385                docstring: None,
2386                line_start: 1,
2387                line_end: 5,
2388                col_start: 0,
2389                col_end: 1,
2390                parent_id: None,
2391                source: None,
2392            };
2393            db.insert_symbol(&symbol).unwrap();
2394        }
2395
2396        // Search for "new" - should get exact match first, then prefix matches, then substring
2397        let results = db.find_symbols("new", 10).unwrap();
2398
2399        assert!(results.len() >= 4, "Should find at least 4 symbols");
2400
2401        // First result should be exact match "new"
2402        assert_eq!(results[0].name, "new", "Exact match 'new' should be first");
2403
2404        // Next results should be prefix matches (new_*), alphabetically sorted
2405        // new_item, new_large, new_thing should come before renew
2406        let prefix_matches: Vec<&str> = results[1..4].iter().map(|s| s.name.as_str()).collect();
2407        assert!(
2408            prefix_matches.iter().all(|n| n.starts_with("new")),
2409            "Positions 2-4 should be prefix matches, got: {:?}",
2410            prefix_matches
2411        );
2412
2413        // "renew" should be last (substring but not prefix)
2414        let renew_pos = results.iter().position(|s| s.name == "renew");
2415        assert!(
2416            renew_pos.is_some() && renew_pos.unwrap() >= 4,
2417            "'renew' should be after prefix matches"
2418        );
2419    }
2420
2421    #[test]
2422    fn test_find_symbols_with_file_filter() {
2423        let db = Database::open_in_memory().unwrap();
2424
2425        // Insert two files
2426        for path in &["src/parser/rust.rs", "src/embeddings/local.rs"] {
2427            let file = FileRecord {
2428                path: path.to_string(),
2429                content_hash: "abc123".to_string(),
2430                size_bytes: 100,
2431                language: Some("rust".to_string()),
2432                last_indexed: 0,
2433            };
2434            db.upsert_file(&file, None).unwrap();
2435        }
2436
2437        // Insert "new" in both files
2438        for (path, id) in &[
2439            ("src/parser/rust.rs", "src/parser/rust.rs::new"),
2440            ("src/embeddings/local.rs", "src/embeddings/local.rs::new"),
2441        ] {
2442            let symbol = Symbol {
2443                id: id.to_string(),
2444                file_path: path.to_string(),
2445                name: "new".to_string(),
2446                qualified_name: None,
2447                kind: SymbolKind::Method,
2448                visibility: Visibility::Public,
2449                signature: None,
2450                brief: None,
2451                docstring: None,
2452                line_start: 1,
2453                line_end: 5,
2454                col_start: 0,
2455                col_end: 1,
2456                parent_id: None,
2457                source: None,
2458            };
2459            db.insert_symbol(&symbol).unwrap();
2460        }
2461
2462        // Without filter, should find both
2463        let all_results = db.find_symbols_filtered("new", 10, None, None).unwrap();
2464        assert_eq!(all_results.len(), 2);
2465
2466        // With file filter for parser, should find only one
2467        let filtered = db
2468            .find_symbols_filtered("new", 10, Some("*parser*"), None)
2469            .unwrap();
2470        assert_eq!(filtered.len(), 1);
2471        assert!(filtered[0].file_path.contains("parser"));
2472
2473        // With file filter for embeddings
2474        let filtered = db
2475            .find_symbols_filtered("new", 10, Some("*embeddings*"), None)
2476            .unwrap();
2477        assert_eq!(filtered.len(), 1);
2478        assert!(filtered[0].file_path.contains("embeddings"));
2479    }
2480
2481    #[test]
2482    fn test_escape_like_pattern() {
2483        // Normal patterns should pass through
2484        assert_eq!(escape_like_pattern("new"), "new");
2485        assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
2486
2487        // Special SQL LIKE characters should be escaped
2488        assert_eq!(escape_like_pattern("100%"), "100\\%");
2489        assert_eq!(escape_like_pattern("a_b"), "a\\_b");
2490        assert_eq!(escape_like_pattern("a%b_c"), "a\\%b\\_c");
2491    }
2492
2493    #[test]
2494    fn test_glob_to_like_pattern() {
2495        // * becomes %
2496        assert_eq!(glob_to_like_pattern("*.rs"), "%.rs");
2497        assert_eq!(glob_to_like_pattern("src/*"), "src/%");
2498
2499        // **/ is consumed when followed by *, preventing double %
2500        // This ensures **/*.rs becomes %.rs (not %%.rs)
2501        assert_eq!(glob_to_like_pattern("**/*.rs"), "%.rs");
2502        assert_eq!(glob_to_like_pattern("src/**/*.rs"), "src/%.rs");
2503
2504        // ** without trailing / just becomes %
2505        assert_eq!(glob_to_like_pattern("src/**"), "src/%");
2506
2507        // **/ followed by non-* adds the %
2508        assert_eq!(glob_to_like_pattern("**/foo.rs"), "%foo.rs");
2509
2510        // ? becomes _
2511        assert_eq!(glob_to_like_pattern("file?.txt"), "file_.txt");
2512
2513        // Literal % and _ are escaped
2514        assert_eq!(glob_to_like_pattern("100%"), "100\\%");
2515        assert_eq!(glob_to_like_pattern("a_b"), "a\\_b");
2516    }
2517
2518    #[test]
2519    fn test_glob_pattern_matches_files() {
2520        let db = Database::open_in_memory().unwrap();
2521
2522        // Insert files at different depths
2523        for path in &[
2524            "main.rs",
2525            "src/lib.rs",
2526            "src/parser/mod.rs",
2527            "src/parser/rust.rs",
2528            "tests/test.rs",
2529        ] {
2530            let file = FileRecord {
2531                path: path.to_string(),
2532                content_hash: "abc".to_string(),
2533                size_bytes: 100,
2534                language: Some("rust".to_string()),
2535                last_indexed: 0,
2536            };
2537            db.upsert_file(&file, None).unwrap();
2538
2539            let symbol = Symbol {
2540                id: format!("{}::main", path),
2541                file_path: path.to_string(),
2542                name: "main".to_string(),
2543                qualified_name: None,
2544                kind: SymbolKind::Function,
2545                visibility: Visibility::Public,
2546                signature: None,
2547                brief: None,
2548                docstring: None,
2549                line_start: 1,
2550                line_end: 5,
2551                col_start: 0,
2552                col_end: 1,
2553                parent_id: None,
2554                source: None,
2555            };
2556            db.insert_symbol(&symbol).unwrap();
2557        }
2558
2559        // **/*.rs should match ALL .rs files at any depth
2560        let results = db
2561            .find_symbols_filtered("main", 20, Some("**/*.rs"), None)
2562            .unwrap();
2563        assert_eq!(results.len(), 5, "**/*.rs should match all .rs files");
2564
2565        // src/**/*.rs should match all .rs files under src/
2566        let results = db
2567            .find_symbols_filtered("main", 20, Some("src/**/*.rs"), None)
2568            .unwrap();
2569        assert_eq!(
2570            results.len(),
2571            3,
2572            "src/**/*.rs should match src/lib.rs, src/parser/mod.rs, src/parser/rust.rs"
2573        );
2574
2575        // Note: src/*.rs also matches nested files because SQL LIKE % matches /
2576        // This is a documented limitation. For precise matching, use substring patterns.
2577        let results = db
2578            .find_symbols_filtered("main", 20, Some("src/*.rs"), None)
2579            .unwrap();
2580        assert_eq!(
2581            results.len(),
2582            3,
2583            "src/*.rs matches all under src/ (SQL LIKE limitation)"
2584        );
2585
2586        // *parser* should match files with "parser" in the path
2587        let results = db
2588            .find_symbols_filtered("main", 20, Some("*parser*"), None)
2589            .unwrap();
2590        assert_eq!(
2591            results.len(),
2592            2,
2593            "*parser* should match parser directory files"
2594        );
2595    }
2596
2597    #[test]
2598    fn test_hybrid_search_limit_one() {
2599        // Tests that hybrid_search doesn't panic or return 0 results with limit=1
2600        // Previously, limit/2 = 0 caused no results to be returned
2601        let db = Database::open_in_memory().unwrap();
2602
2603        // Insert a file and symbol
2604        let file = FileRecord {
2605            path: "src/main.rs".to_string(),
2606            content_hash: "abc123".to_string(),
2607            size_bytes: 100,
2608            language: Some("rust".to_string()),
2609            last_indexed: 0,
2610        };
2611        db.upsert_file(&file, None).unwrap();
2612
2613        let symbol = Symbol {
2614            id: "src/main.rs::authenticate".to_string(),
2615            file_path: "src/main.rs".to_string(),
2616            name: "authenticate".to_string(),
2617            qualified_name: None,
2618            kind: SymbolKind::Function,
2619            visibility: Visibility::Public,
2620            signature: Some("fn authenticate(user: &str)".to_string()),
2621            brief: Some("Authenticate a user".to_string()),
2622            docstring: None,
2623            line_start: 1,
2624            line_end: 5,
2625            col_start: 0,
2626            col_end: 1,
2627            parent_id: None,
2628            source: None,
2629        };
2630        db.insert_symbol(&symbol).unwrap();
2631
2632        // hybrid_search with limit=1 should return exactly 1 result
2633        let results = db.hybrid_search("authenticate", 1).unwrap();
2634        assert_eq!(
2635            results.len(),
2636            1,
2637            "hybrid_search with limit=1 should return 1 result"
2638        );
2639        assert_eq!(results[0].0.name, "authenticate");
2640    }
2641
2642    #[test]
2643    fn test_hybrid_search_limit_three() {
2644        // Tests hybrid_search behavior with limit=3 (where limit/2 = 1)
2645        let db = Database::open_in_memory().unwrap();
2646
2647        // Insert a file
2648        let file = FileRecord {
2649            path: "src/test.rs".to_string(),
2650            content_hash: "abc123".to_string(),
2651            size_bytes: 100,
2652            language: Some("rust".to_string()),
2653            last_indexed: 0,
2654        };
2655        db.upsert_file(&file, None).unwrap();
2656
2657        // Insert multiple symbols
2658        for (name, i) in &[("login", 1), ("logout", 2), ("log_error", 3)] {
2659            let symbol = Symbol {
2660                id: format!("src/test.rs::{}", name),
2661                file_path: "src/test.rs".to_string(),
2662                name: name.to_string(),
2663                qualified_name: None,
2664                kind: SymbolKind::Function,
2665                visibility: Visibility::Public,
2666                signature: Some(format!("fn {}()", name)),
2667                brief: Some(format!("{} function", name)),
2668                docstring: None,
2669                line_start: *i,
2670                line_end: *i + 5,
2671                col_start: 0,
2672                col_end: 1,
2673                parent_id: None,
2674                source: None,
2675            };
2676            db.insert_symbol(&symbol).unwrap();
2677        }
2678
2679        // hybrid_search with limit=3 should work correctly
2680        let results = db.hybrid_search("log", 3).unwrap();
2681        assert!(
2682            !results.is_empty(),
2683            "hybrid_search with limit=3 should return results"
2684        );
2685        assert!(results.len() <= 3, "hybrid_search should respect limit");
2686    }
2687
2688    #[test]
2689    fn test_semantic_search_score_range() {
2690        // Tests that semantic search returns scores in valid 0-1 range
2691        let db = Database::open_in_memory().unwrap();
2692
2693        // Insert a file and symbol
2694        let file = FileRecord {
2695            path: "src/main.rs".to_string(),
2696            content_hash: "abc123".to_string(),
2697            size_bytes: 100,
2698            language: Some("rust".to_string()),
2699            last_indexed: 0,
2700        };
2701        db.upsert_file(&file, None).unwrap();
2702
2703        let symbol = Symbol {
2704            id: "src/main.rs::process_data".to_string(),
2705            file_path: "src/main.rs".to_string(),
2706            name: "process_data".to_string(),
2707            qualified_name: None,
2708            kind: SymbolKind::Function,
2709            visibility: Visibility::Public,
2710            signature: Some("fn process_data(input: &str) -> Result<()>".to_string()),
2711            brief: Some("Process incoming data".to_string()),
2712            docstring: Some("Processes the incoming data and returns a result.".to_string()),
2713            line_start: 1,
2714            line_end: 10,
2715            col_start: 0,
2716            col_end: 1,
2717            parent_id: None,
2718            source: None,
2719        };
2720        db.insert_symbol(&symbol).unwrap();
2721
2722        // Semantic search should return scores in 0-1 range
2723        let results = db.semantic_search("process data", 10).unwrap();
2724        for (symbol, score) in &results {
2725            assert!(
2726                *score >= 0.0 && *score <= 1.0,
2727                "Score for '{}' should be in 0-1 range, got {}",
2728                symbol.name,
2729                score
2730            );
2731            assert!(
2732                score.is_finite(),
2733                "Score for '{}' should be finite, got {}",
2734                symbol.name,
2735                score
2736            );
2737        }
2738    }
2739
2740    #[test]
2741    fn test_bm25_relevance_mapping() {
2742        let cases = [
2743            (0.0, 0.0),
2744            (-1.0, 0.5),
2745            (1.0, 0.5),
2746            (-10.0, 10.0 / 11.0),
2747            (10.0, 10.0 / 11.0),
2748        ];
2749
2750        for (rank, expected) in cases {
2751            let actual = Database::bm25_relevance(rank);
2752            assert!(
2753                (actual - expected).abs() < 1e-12,
2754                "rank {} expected {} got {}",
2755                rank,
2756                expected,
2757                actual
2758            );
2759        }
2760
2761        assert_eq!(Database::bm25_relevance(f64::INFINITY), 0.0);
2762        assert_eq!(Database::bm25_relevance(f64::NEG_INFINITY), 0.0);
2763        assert_eq!(Database::bm25_relevance(f64::NAN), 0.0);
2764    }
2765
2766    #[test]
2767    fn test_get_embedding_metadata() {
2768        // Tests the embedding metadata query used for dimension mismatch detection
2769        let db = Database::open_in_memory().unwrap();
2770
2771        // Insert a file first (foreign key)
2772        let file = FileRecord {
2773            path: "src/main.rs".to_string(),
2774            content_hash: "abc123".to_string(),
2775            size_bytes: 100,
2776            language: Some("rust".to_string()),
2777            last_indexed: 0,
2778        };
2779        db.upsert_file(&file, None).unwrap();
2780
2781        // Insert some symbols
2782        for (name, id) in &[("foo", "src/main.rs::foo"), ("bar", "src/main.rs::bar")] {
2783            let symbol = Symbol {
2784                id: id.to_string(),
2785                file_path: "src/main.rs".to_string(),
2786                name: name.to_string(),
2787                qualified_name: None,
2788                kind: SymbolKind::Function,
2789                visibility: Visibility::Public,
2790                signature: None,
2791                brief: None,
2792                docstring: None,
2793                line_start: 1,
2794                line_end: 5,
2795                col_start: 0,
2796                col_end: 1,
2797                parent_id: None,
2798                source: None,
2799            };
2800            db.insert_symbol(&symbol).unwrap();
2801        }
2802
2803        // No embeddings yet
2804        let metadata = db.get_embedding_metadata().unwrap();
2805        assert!(
2806            metadata.is_empty(),
2807            "Should have no embedding metadata initially"
2808        );
2809
2810        // Store embeddings with different dimensions (simulating local vs OpenAI)
2811        let local_vec: Vec<f32> = vec![0.1; 384]; // Local embedding dimension
2812        let openai_vec: Vec<f32> = vec![0.2; 1536]; // OpenAI embedding dimension
2813
2814        db.store_embedding("src/main.rs::foo", "local", "all-MiniLM-L6-v2", &local_vec)
2815            .unwrap();
2816        db.store_embedding(
2817            "src/main.rs::bar",
2818            "openai",
2819            "text-embedding-3-small",
2820            &openai_vec,
2821        )
2822        .unwrap();
2823
2824        // Query metadata
2825        let metadata = db.get_embedding_metadata().unwrap();
2826
2827        // Should have two distinct provider/dimension combinations
2828        assert_eq!(
2829            metadata.len(),
2830            2,
2831            "Should have 2 embedding metadata entries"
2832        );
2833
2834        // Verify dimensions are recorded correctly
2835        let dims: Vec<i64> = metadata.iter().map(|(_, _, dim, _)| *dim).collect();
2836        assert!(
2837            dims.contains(&384),
2838            "Should have local embedding dimension 384"
2839        );
2840        assert!(
2841            dims.contains(&1536),
2842            "Should have OpenAI embedding dimension 1536"
2843        );
2844
2845        // Verify providers are recorded correctly
2846        let providers: Vec<&str> = metadata.iter().map(|(p, _, _, _)| p.as_str()).collect();
2847        assert!(providers.contains(&"local"), "Should have local provider");
2848        assert!(providers.contains(&"openai"), "Should have openai provider");
2849    }
2850
2851    #[test]
2852    fn test_vector_search() {
2853        // Test the fast vector search using sqlite-vec
2854        let db = Database::open_in_memory().unwrap();
2855
2856        if !is_vec_extension_available() {
2857            eprintln!("Skipping vector search test: sqlite-vec not available");
2858            return;
2859        }
2860
2861        // Insert a file first (foreign key)
2862        let file = FileRecord {
2863            path: "src/main.rs".to_string(),
2864            content_hash: "abc123".to_string(),
2865            size_bytes: 100,
2866            language: Some("rust".to_string()),
2867            last_indexed: 0,
2868        };
2869        db.upsert_file(&file, None).unwrap();
2870
2871        // Insert multiple symbols
2872        for (name, id, line) in &[
2873            ("foo", "src/main.rs::foo", 1),
2874            ("bar", "src/main.rs::bar", 10),
2875            ("baz", "src/main.rs::baz", 20),
2876        ] {
2877            let symbol = Symbol {
2878                id: id.to_string(),
2879                file_path: "src/main.rs".to_string(),
2880                name: name.to_string(),
2881                qualified_name: None,
2882                kind: SymbolKind::Function,
2883                visibility: Visibility::Public,
2884                signature: None,
2885                brief: None,
2886                docstring: None,
2887                line_start: *line,
2888                line_end: *line + 5,
2889                col_start: 0,
2890                col_end: 1,
2891                parent_id: None,
2892                source: None,
2893            };
2894            db.insert_symbol(&symbol).unwrap();
2895        }
2896
2897        // Create embeddings with default dimension (1536)
2898        // Make foo and bar similar, baz different
2899        let mut foo_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2900        foo_vec[0] = 1.0;
2901        foo_vec[1] = 0.5;
2902
2903        let mut bar_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2904        bar_vec[0] = 0.9;
2905        bar_vec[1] = 0.6;
2906
2907        let mut baz_vec: Vec<f32> = vec![0.0; DEFAULT_EMBEDDING_DIM];
2908        baz_vec[0] = 0.0;
2909        baz_vec[1] = 0.0;
2910        baz_vec[2] = 1.0; // Completely different direction
2911
2912        // Store embeddings (should also insert into symbol_vectors)
2913        db.store_embedding(
2914            "src/main.rs::foo",
2915            "openai",
2916            "text-embedding-3-small",
2917            &foo_vec,
2918        )
2919        .unwrap();
2920        db.store_embedding(
2921            "src/main.rs::bar",
2922            "openai",
2923            "text-embedding-3-small",
2924            &bar_vec,
2925        )
2926        .unwrap();
2927        db.store_embedding(
2928            "src/main.rs::baz",
2929            "openai",
2930            "text-embedding-3-small",
2931            &baz_vec,
2932        )
2933        .unwrap();
2934
2935        // Verify vector embeddings were stored
2936        let vec_count = db.count_vector_embeddings().unwrap();
2937        assert_eq!(vec_count, 3, "Should have 3 vector embeddings");
2938
2939        // Search with a query similar to foo
2940        let query: Vec<f32> = foo_vec.clone();
2941        let results = db.vector_search(&query, 3).unwrap();
2942
2943        assert_eq!(results.len(), 3, "Should return 3 results");
2944
2945        // First result should be foo (exact match)
2946        assert_eq!(
2947            results[0].0, "src/main.rs::foo",
2948            "First result should be foo (exact match)"
2949        );
2950        assert_eq!(results[0].5, 0.0, "Exact match should have distance 0");
2951
2952        // Second result should be bar (similar)
2953        assert_eq!(
2954            results[1].0, "src/main.rs::bar",
2955            "Second result should be bar (similar)"
2956        );
2957
2958        // Third result should be baz (different)
2959        assert_eq!(
2960            results[2].0, "src/main.rs::baz",
2961            "Third result should be baz (different)"
2962        );
2963
2964        // baz should have larger distance than bar
2965        assert!(
2966            results[2].5 > results[1].5,
2967            "baz should have larger distance than bar"
2968        );
2969    }
2970
2971    #[test]
2972    fn test_migrate_embeddings_to_vec() {
2973        // Test migrating existing JSON embeddings to vector table
2974        let db = Database::open_in_memory().unwrap();
2975
2976        if !is_vec_extension_available() {
2977            eprintln!("Skipping migration test: sqlite-vec not available");
2978            return;
2979        }
2980
2981        // Insert a file and symbol
2982        let file = FileRecord {
2983            path: "src/main.rs".to_string(),
2984            content_hash: "abc123".to_string(),
2985            size_bytes: 100,
2986            language: Some("rust".to_string()),
2987            last_indexed: 0,
2988        };
2989        db.upsert_file(&file, None).unwrap();
2990
2991        let symbol = Symbol {
2992            id: "src/main.rs::foo".to_string(),
2993            file_path: "src/main.rs".to_string(),
2994            name: "foo".to_string(),
2995            qualified_name: None,
2996            kind: SymbolKind::Function,
2997            visibility: Visibility::Public,
2998            signature: None,
2999            brief: None,
3000            docstring: None,
3001            line_start: 1,
3002            line_end: 5,
3003            col_start: 0,
3004            col_end: 1,
3005            parent_id: None,
3006            source: None,
3007        };
3008        db.insert_symbol(&symbol).unwrap();
3009
3010        // Manually insert an embedding only in the JSON table (simulating old data)
3011        let vec: Vec<f32> = vec![0.1; DEFAULT_EMBEDDING_DIM];
3012        let vec_json = serde_json::to_string(&vec).unwrap();
3013        db.conn.execute(
3014            "INSERT INTO embeddings (symbol_id, provider, model, dimension, vector) VALUES (?, ?, ?, ?, ?)",
3015            params!["src/main.rs::foo", "openai", "test", DEFAULT_EMBEDDING_DIM as i64, vec_json],
3016        ).unwrap();
3017
3018        // Verify not in vector table yet
3019        let vec_count_before = db.count_vector_embeddings().unwrap();
3020        assert_eq!(
3021            vec_count_before, 0,
3022            "Should have no vector embeddings before migration"
3023        );
3024
3025        // Run migration
3026        let migrated = db.migrate_embeddings_to_vec().unwrap();
3027        assert_eq!(migrated, 1, "Should migrate 1 embedding");
3028
3029        // Verify now in vector table
3030        let vec_count_after = db.count_vector_embeddings().unwrap();
3031        assert_eq!(
3032            vec_count_after, 1,
3033            "Should have 1 vector embedding after migration"
3034        );
3035
3036        // Re-running migration should not duplicate
3037        let migrated_again = db.migrate_embeddings_to_vec().unwrap();
3038        assert_eq!(
3039            migrated_again, 0,
3040            "Should not re-migrate existing embeddings"
3041        );
3042    }
3043}