Skip to main content

code_kb_core/
db.rs

1pub use rusqlite::Connection;
2use rusqlite::OpenFlags;
3use std::path::Path;
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum DbError {
8    #[error("Failed to open SQLite database at '{0}': {1}")]
9    OpenFailed(String, rusqlite::Error),
10    #[error("Failed to configure connection pragmas: {0}")]
11    PragmaFailed(rusqlite::Error),
12    #[error("Database file does not exist: {0}")]
13    NotFound(String),
14    #[error("FTS index migration failed: {0}")]
15    FtsMigration(rusqlite::Error),
16    #[error("Database error: {0}")]
17    Sqlite(#[from] rusqlite::Error),
18}
19
20/// Opens a read-only SQLite connection configured for low-overhead WAL reads.
21pub fn open_read_only(path: &Path) -> Result<Connection, DbError> {
22    if !path.exists() {
23        return Err(DbError::NotFound(path.display().to_string()));
24    }
25
26    let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;
27    let conn = Connection::open_with_flags(path, flags)
28        .map_err(|e| DbError::OpenFailed(path.display().to_string(), e))?;
29
30    #[cfg(windows)]
31    conn.execute_batch(
32        "PRAGMA busy_timeout = 5000;
33         PRAGMA query_only = ON;
34         PRAGMA cache_size = -4000;
35         PRAGMA mmap_size = 0;",
36    )
37    .map_err(DbError::PragmaFailed)?;
38
39    #[cfg(not(windows))]
40    conn.execute_batch(
41        "PRAGMA busy_timeout = 5000;
42         PRAGMA query_only = ON;
43         PRAGMA cache_size = -4000;
44         PRAGMA mmap_size = 268435456;",
45    )
46    .map_err(DbError::PragmaFailed)?;
47
48    Ok(conn)
49}
50
51/// Safely flushes all committed transactions from the WAL file into the main database file
52/// and truncates the WAL to zero bytes. Returns an error if the database is busy and unable to truncate.
53pub fn checkpoint_truncate(conn: &Connection) -> Result<(), DbError> {
54    let busy: i32 = conn
55        .query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |r| r.get(0))
56        .map_err(DbError::PragmaFailed)?;
57    if busy != 0 {
58        return Err(DbError::PragmaFailed(rusqlite::Error::SqliteFailure(
59            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
60            Some("wal_checkpoint(TRUNCATE) failed: database busy".to_string()),
61        )));
62    }
63    Ok(())
64}
65
66/// Opens a read-write SQLite connection (used when creating fresh or test databases).
67pub fn open_read_write(path: &Path) -> Result<Connection, DbError> {
68    let conn =
69        Connection::open(path).map_err(|e| DbError::OpenFailed(path.display().to_string(), e))?;
70
71    conn.execute_batch(
72        "PRAGMA journal_mode = WAL;
73         PRAGMA busy_timeout = 5000;
74         PRAGMA synchronous = NORMAL;
75         PRAGMA foreign_keys = ON;",
76    )
77    .map_err(DbError::PragmaFailed)?;
78
79    Ok(conn)
80}
81
82/// SQL predicate that is true for a symbols row that is a local variable or a parameter:
83/// a `variable` declared inside a function, method, or constructor, directly or through
84/// enclosing variables such as closures.
85pub fn local_variable_predicate(alias: &str) -> String {
86    format!(
87        "({alias}.kind = 'variable' AND EXISTS (
88            WITH RECURSIVE ancestor(symbol_id, kind, parent_symbol_id) AS (
89                SELECT p.symbol_id, p.kind, p.parent_symbol_id FROM symbols p
90                WHERE p.symbol_id = {alias}.parent_symbol_id
91                UNION
92                SELECT p.symbol_id, p.kind, p.parent_symbol_id FROM symbols p
93                JOIN ancestor a ON p.symbol_id = a.parent_symbol_id
94                WHERE a.kind = 'variable'
95            )
96            SELECT 1 FROM ancestor WHERE kind IN ('function', 'method', 'constructor')))"
97    )
98}
99
100/// Identifies the rule the FTS content was built under. A stored marker that differs from this
101/// value means the index predates the rule and must be repopulated once.
102const FTS_RULE: &str = "exclude-locals-v1+names-trigram-v1";
103
104fn stored_fts_rule(conn: &Connection) -> Option<String> {
105    conn.query_row(
106        "SELECT value FROM artifact_metadata WHERE key = 'fts_rule'",
107        [],
108        |r| r.get(0),
109    )
110    .ok()
111}
112
113fn fts_content_is_missing(conn: &Connection, table: &str) -> bool {
114    let table_exists = conn
115        .query_row(
116            "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
117            [table],
118            |_| Ok(true),
119        )
120        .unwrap_or(false);
121    if !table_exists {
122        return true;
123    }
124    let has_symbols = conn
125        .query_row("SELECT 1 FROM symbols LIMIT 1", [], |_| Ok(true))
126        .unwrap_or(false);
127    if !has_symbols {
128        return false;
129    }
130    !conn
131        .query_row(
132            &format!("SELECT 1 FROM {table}_docsize LIMIT 1"),
133            [],
134            |_| Ok(true),
135        )
136        .unwrap_or(false)
137}
138
139fn fts_index_is_ready(conn: &Connection) -> bool {
140    stored_fts_rule(conn).as_deref() == Some(FTS_RULE)
141        && !fts_content_is_missing(conn, "symbols_fts")
142        && !fts_content_is_missing(conn, "symbol_names_tri")
143}
144
145/// Ensures the `symbols_fts` (words) and `symbol_names_tri` (name trigrams) FTS5 virtual tables
146/// and their synchronization triggers exist in the SQLite database, and that both hold every
147/// symbol except locals and parameters. An index built under an earlier rule, or one left
148/// without content or without one of the tables, is repopulated once inside a single
149/// transaction; the rule marker is written last so an interrupted migration reruns.
150/// The migration takes the write lock up front, so a second process waits for the busy
151/// timeout instead of failing at once, and re-checks readiness under the lock so it never
152/// repeats a migration another process just finished.
153/// julie may write a local before its enclosing function, so the insert trigger also drops any
154/// same-file variable that became local when its parent arrived.
155pub fn ensure_fts_index(conn: &Connection) -> Result<(), rusqlite::Error> {
156    let symbols_table_exists: bool = conn
157        .query_row(
158            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols'",
159            [],
160            |_| Ok(true),
161        )
162        .unwrap_or(false);
163
164    if !symbols_table_exists {
165        return Ok(());
166    }
167
168    if conn.query_row(
169        "SELECT count(*) = 2 FROM pragma_table_info('type_facts')
170         WHERE name IN ('resolved_type', 'symbol_id')",
171        [],
172        |row| row.get::<_, bool>(0),
173    )? {
174        conn.execute_batch(
175            "CREATE INDEX IF NOT EXISTS idx_type_facts_resolved_symbol
176             ON type_facts(resolved_type, symbol_id);",
177        )?;
178    }
179
180    if conn.query_row(
181        "SELECT count(*) = 3 FROM pragma_table_info('pending_relationships')
182         WHERE name IN ('target_terminal_name', 'path', 'start_line')",
183        [],
184        |row| row.get::<_, bool>(0),
185    )? {
186        conn.execute_batch(
187            "CREATE INDEX IF NOT EXISTS idx_pending_name_site
188             ON pending_relationships(target_terminal_name, path, start_line);",
189        )?;
190    }
191
192    if conn.query_row(
193        "SELECT count(*) = 3 FROM pragma_table_info('symbols')
194         WHERE name IN ('path', 'name', 'kind')",
195        [],
196        |row| row.get::<_, bool>(0),
197    )? {
198        conn.execute_batch(
199            "CREATE INDEX IF NOT EXISTS idx_symbols_import_path_name
200             ON symbols(path, name) WHERE kind = 'import';",
201        )?;
202    }
203
204    if fts_index_is_ready(conn) {
205        return Ok(());
206    }
207
208    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
209    if fts_index_is_ready(&tx) {
210        return Ok(());
211    }
212
213    let is_local = local_variable_predicate("s");
214    let new_is_local = local_variable_predicate("new");
215    let child_is_local = local_variable_predicate("c");
216    let already_indexed = "EXISTS (SELECT 1 FROM symbols_fts_docsize d WHERE d.id = old.rowid)";
217    let name_already_indexed =
218        "EXISTS (SELECT 1 FROM symbol_names_tri_docsize d WHERE d.id = old.rowid)";
219
220    tx.execute_batch(&format!(
221        "CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
222            name,
223            signature,
224            doc_comment,
225            content='symbols',
226            content_rowid='rowid',
227            tokenize='porter unicode61'
228        );
229
230        CREATE VIRTUAL TABLE IF NOT EXISTS symbol_names_tri USING fts5(
231            name,
232            content='symbols',
233            content_rowid='rowid',
234            tokenize='trigram'
235        );
236
237        DROP TRIGGER IF EXISTS symbols_ai;
238        DROP TRIGGER IF EXISTS symbols_ad;
239        DROP TRIGGER IF EXISTS symbols_au;
240
241        CREATE TRIGGER symbols_ai AFTER INSERT ON symbols BEGIN
242            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
243            SELECT new.rowid, new.name, new.signature, new.doc_comment
244            WHERE NOT {new_is_local};
245            INSERT INTO symbol_names_tri(rowid, name)
246            SELECT new.rowid, new.name
247            WHERE NOT {new_is_local};
248            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
249            SELECT 'delete', c.rowid, c.name, c.signature, c.doc_comment
250            FROM symbols c
251            WHERE new.kind IN ('function', 'method', 'constructor')
252              AND c.path = new.path
253              AND c.kind = 'variable'
254              AND {child_is_local}
255              AND EXISTS (SELECT 1 FROM symbols_fts_docsize d WHERE d.id = c.rowid);
256            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
257            SELECT 'delete', c.rowid, c.name
258            FROM symbols c
259            WHERE new.kind IN ('function', 'method', 'constructor')
260              AND c.path = new.path
261              AND c.kind = 'variable'
262              AND {child_is_local}
263              AND EXISTS (SELECT 1 FROM symbol_names_tri_docsize d WHERE d.id = c.rowid);
264        END;
265
266        CREATE TRIGGER symbols_ad AFTER DELETE ON symbols BEGIN
267            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
268            SELECT 'delete', old.rowid, old.name, old.signature, old.doc_comment
269            WHERE {already_indexed};
270            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
271            SELECT 'delete', old.rowid, old.name
272            WHERE {name_already_indexed};
273        END;
274
275        CREATE TRIGGER symbols_au AFTER UPDATE ON symbols BEGIN
276            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
277            SELECT 'delete', old.rowid, old.name, old.signature, old.doc_comment
278            WHERE {already_indexed};
279            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
280            SELECT 'delete', old.rowid, old.name
281            WHERE {name_already_indexed};
282            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
283            SELECT new.rowid, new.name, new.signature, new.doc_comment
284            WHERE NOT {new_is_local};
285            INSERT INTO symbol_names_tri(rowid, name)
286            SELECT new.rowid, new.name
287            WHERE NOT {new_is_local};
288        END;
289
290        INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all');
291        INSERT INTO symbol_names_tri(symbol_names_tri) VALUES('delete-all');
292
293        INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
294        SELECT s.rowid, s.name, s.signature, s.doc_comment
295        FROM symbols s WHERE NOT {is_local};
296        INSERT INTO symbol_names_tri(rowid, name)
297        SELECT s.rowid, s.name
298        FROM symbols s WHERE NOT {is_local};
299
300        CREATE TABLE IF NOT EXISTS artifact_metadata (key TEXT PRIMARY KEY, value TEXT);"
301    ))?;
302    tx.execute(
303        "INSERT INTO artifact_metadata (key, value) VALUES ('fts_rule', ?1)
304         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
305        [FTS_RULE],
306    )?;
307    tx.commit()?;
308
309    Ok(())
310}
311
312/// Ensures the FTS5 index on `symbols` exists at the specified database file path. The
313/// connection waits up to 60 s for another process's migration; a large index takes seconds.
314pub fn ensure_fts_index_path(path: &Path) -> Result<(), DbError> {
315    if !path.exists() {
316        return Err(DbError::NotFound(path.display().to_string()));
317    }
318    let conn = open_read_write(path)?;
319    conn.busy_timeout(std::time::Duration::from_secs(60))
320        .map_err(DbError::PragmaFailed)?;
321    ensure_fts_index(&conn).map_err(DbError::FtsMigration)?;
322    Ok(())
323}
324
325/// Retargets the `root_path` key in `artifact_metadata` to a new workspace canonical root.
326/// This is essential when cloning or copying an artifact database (e.g. into a git worktree),
327/// ensuring `julie-extract update`, `delete`, and `scan` recognize the new root without root mismatch errors.
328pub fn retarget_artifact_root(db_path: &Path, new_root: &Path) -> Result<(), DbError> {
329    if !db_path.exists() {
330        return Err(DbError::NotFound(db_path.display().to_string()));
331    }
332    let conn = open_read_write(db_path)?;
333    conn.execute(
334        "CREATE TABLE IF NOT EXISTS artifact_metadata (key TEXT PRIMARY KEY, value TEXT)",
335        [],
336    )?;
337
338    let existing_root: Option<String> = conn
339        .query_row(
340            "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
341            [],
342            |r| r.get(0),
343        )
344        .ok();
345
346    let root_str = if existing_root
347        .as_deref()
348        .is_some_and(|ex| ex.starts_with(r"\\?\") || ex.starts_with(r"\\.\"))
349        || (existing_root.is_none() && cfg!(windows))
350    {
351        std::fs::canonicalize(new_root)
352            .map(|p| p.to_string_lossy().to_string())
353            .unwrap_or_else(|_| {
354                let s = new_root.to_string_lossy();
355                format!(r"\\?\{s}")
356            })
357    } else {
358        new_root.to_string_lossy().to_string()
359    };
360
361    conn.execute(
362        "INSERT INTO artifact_metadata (key, value) VALUES ('root_path', ?1)
363         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
364        rusqlite::params![root_str],
365    )?;
366
367    Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_open_read_write_and_read_only() {
376        let dir = crate::safe_tempdir();
377        let db_path = dir.path().join("test.db");
378        let conn_rw = open_read_write(&db_path).unwrap();
379        conn_rw
380            .execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT);", [])
381            .unwrap();
382        conn_rw
383            .execute("INSERT INTO test (name) VALUES ('alpha');", [])
384            .unwrap();
385        drop(conn_rw);
386
387        let conn_ro = open_read_only(&db_path).unwrap();
388        let name: String = conn_ro
389            .query_row("SELECT name FROM test WHERE id = 1", [], |r| r.get(0))
390            .unwrap();
391        assert_eq!(name, "alpha");
392
393        // Verifying query_only prevents writes
394        let write_res = conn_ro.execute("INSERT INTO test (name) VALUES ('beta');", []);
395        assert!(write_res.is_err());
396    }
397
398    #[test]
399    fn test_fts5_support() {
400        let conn = rusqlite::Connection::open_in_memory().unwrap();
401        conn.execute("CREATE VIRTUAL TABLE test_fts USING fts5(content);", [])
402            .unwrap();
403        conn.execute(
404            "INSERT INTO test_fts (content) VALUES ('hello world token search');",
405            [],
406        )
407        .unwrap();
408        let count: i64 = conn
409            .query_row(
410                "SELECT count(*) FROM test_fts WHERE test_fts MATCH 'token'",
411                [],
412                |r| r.get(0),
413            )
414            .unwrap();
415        assert_eq!(count, 1);
416    }
417
418    #[test]
419    fn test_ensure_fts_index_lifecycle() {
420        let dir = crate::safe_tempdir();
421        let db_path = dir.path().join("fts_lifecycle.db");
422        let conn = open_read_write(&db_path).unwrap();
423
424        conn.execute_batch(
425            "CREATE TABLE symbols (
426                symbol_id TEXT PRIMARY KEY,
427                path TEXT,
428                name TEXT,
429                kind TEXT,
430                parent_symbol_id TEXT,
431                signature TEXT,
432                doc_comment TEXT
433            );
434            INSERT INTO symbols VALUES ('1', 'src/pay.rs', 'PaymentGateway', 'trait', NULL, 'pub trait PaymentGateway', 'Core payment provider interface');
435            INSERT INTO symbols VALUES ('2', 'src/pay.rs', 'StripeClient', 'struct', NULL, 'pub struct StripeClient', 'Handles HTTP requests to stripe API');",
436        )
437        .unwrap();
438
439        ensure_fts_index(&conn).unwrap();
440
441        let count: i64 = conn
442            .query_row(
443                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'payment'",
444                [],
445                |r| r.get(0),
446            )
447            .unwrap();
448        assert_eq!(count, 1);
449
450        conn.execute(
451            "INSERT INTO symbols VALUES ('3', 'src/pay.rs', 'RefundHandler', 'function', NULL, 'pub fn handle_refund()', 'Processes transaction refunds');",
452            [],
453        )
454        .unwrap();
455
456        let count: i64 = conn
457            .query_row(
458                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
459                [],
460                |r| r.get(0),
461            )
462            .unwrap();
463        assert_eq!(count, 1);
464
465        conn.execute("DELETE FROM symbols WHERE symbol_id = '3';", [])
466            .unwrap();
467        let count: i64 = conn
468            .query_row(
469                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
470                [],
471                |r| r.get(0),
472            )
473            .unwrap();
474        assert_eq!(count, 0);
475    }
476
477    #[test]
478    fn ensure_fts_index_drops_a_local_indexed_before_its_parent() {
479        let dir = crate::safe_tempdir();
480        let db_path = dir.path().join("fts_rule.db");
481        let conn = open_read_write(&db_path).unwrap();
482        conn.execute_batch(
483            "CREATE TABLE symbols (
484                symbol_id TEXT PRIMARY KEY,
485                path TEXT,
486                name TEXT,
487                kind TEXT,
488                parent_symbol_id TEXT,
489                signature TEXT,
490                doc_comment TEXT
491            );
492            INSERT INTO symbols VALUES ('1', 'src/db.rs', 'open_conn', 'function', NULL, 'fn open_conn()', '');",
493        )
494        .unwrap();
495
496        ensure_fts_index(&conn).unwrap();
497
498        let rule: String = conn
499            .query_row(
500                "SELECT value FROM artifact_metadata WHERE key = 'fts_rule'",
501                [],
502                |r| r.get(0),
503            )
504            .unwrap();
505        assert_eq!(rule, FTS_RULE);
506
507        conn.execute(
508            "INSERT INTO symbols VALUES ('2', 'src/db.rs', 'drifted', 'variable', '3', 'let drifted = 1', '')",
509            [],
510        )
511        .unwrap();
512        conn.execute(
513            "INSERT INTO symbols VALUES ('3', 'src/db.rs', 'later', 'function', NULL, 'fn later()', '')",
514            [],
515        )
516        .unwrap();
517
518        let count: i64 = conn
519            .query_row(
520                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'drifted'",
521                [],
522                |r| r.get(0),
523            )
524            .unwrap();
525        assert_eq!(count, 0);
526        assert!(trigram_names(&conn, "drifted").is_empty());
527        assert_eq!(trigram_names(&conn, "later"), vec!["later"]);
528    }
529
530    #[test]
531    fn ensure_fts_index_repopulates_an_emptied_index() {
532        let dir = crate::safe_tempdir();
533        let db_path = dir.path().join("fts_empty.db");
534        let conn = open_read_write(&db_path).unwrap();
535        conn.execute_batch(
536            "CREATE TABLE symbols (
537                symbol_id TEXT PRIMARY KEY,
538                path TEXT,
539                name TEXT,
540                kind TEXT,
541                parent_symbol_id TEXT,
542                signature TEXT,
543                doc_comment TEXT
544            );
545            INSERT INTO symbols VALUES ('1', 'src/pay.rs', 'RefundHandler', 'function', NULL, 'fn handle_refund()', '');",
546        )
547        .unwrap();
548
549        ensure_fts_index(&conn).unwrap();
550        conn.execute(
551            "INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all')",
552            [],
553        )
554        .unwrap();
555
556        ensure_fts_index(&conn).unwrap();
557
558        let count: i64 = conn
559            .query_row(
560                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
561                [],
562                |r| r.get(0),
563            )
564            .unwrap();
565        assert_eq!(count, 1);
566    }
567
568    fn symbols_db(file: &str) -> (tempfile::TempDir, Connection) {
569        let dir = crate::safe_tempdir();
570        let conn = open_read_write(&dir.path().join(file)).unwrap();
571        conn.execute_batch(
572            "CREATE TABLE symbols (
573                symbol_id TEXT PRIMARY KEY,
574                path TEXT,
575                name TEXT,
576                kind TEXT,
577                parent_symbol_id TEXT,
578                signature TEXT,
579                doc_comment TEXT
580            );
581            INSERT INTO symbols VALUES ('1', 'src/sidecar.ts', 'parseSha256Sidecar', 'function', NULL, 'function parseSha256Sidecar()', 'Reads the checksum sidecar');
582            INSERT INTO symbols VALUES ('2', 'src/sidecar.ts', 'digestBuffer', 'variable', '1', 'const digestBuffer', '');",
583        )
584        .unwrap();
585        (dir, conn)
586    }
587
588    fn trigram_names(conn: &Connection, term: &str) -> Vec<String> {
589        let mut stmt = conn
590            .prepare(
591                "SELECT name FROM symbol_names_tri WHERE symbol_names_tri MATCH ?1 ORDER BY name",
592            )
593            .unwrap();
594        stmt.query_map([format!("\"{term}\"")], |r| r.get(0))
595            .unwrap()
596            .map(Result::unwrap)
597            .collect()
598    }
599
600    fn word_names(conn: &Connection, term: &str) -> Vec<String> {
601        let mut stmt = conn
602            .prepare("SELECT name FROM symbols_fts WHERE symbols_fts MATCH ?1 ORDER BY name")
603            .unwrap();
604        stmt.query_map([term], |r| r.get(0))
605            .unwrap()
606            .map(Result::unwrap)
607            .collect()
608    }
609
610    fn docsize_rows(conn: &Connection, table: &str) -> i64 {
611        conn.query_row(&format!("SELECT count(*) FROM {table}_docsize"), [], |r| {
612            r.get(0)
613        })
614        .unwrap()
615    }
616
617    fn schema_version(conn: &Connection) -> i64 {
618        conn.query_row("PRAGMA schema_version", [], |r| r.get(0))
619            .unwrap()
620    }
621
622    #[test]
623    fn fresh_index_populates_both_tables_without_locals() {
624        let (_dir, conn) = symbols_db("fresh.db");
625
626        ensure_fts_index(&conn).unwrap();
627
628        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
629        assert_eq!(word_names(&conn, "sidecar"), vec!["parseSha256Sidecar"]);
630        assert!(trigram_names(&conn, "digest").is_empty());
631        assert!(word_names(&conn, "digestBuffer").is_empty());
632        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
633    }
634
635    #[test]
636    fn ready_search_indexes_gain_bounded_reference_lookups() {
637        let (_dir, conn) = symbols_db("reference-lookups.db");
638        ensure_fts_index(&conn).unwrap();
639        conn.execute_batch(
640            "CREATE TABLE type_facts (symbol_id TEXT, resolved_type TEXT);
641             CREATE INDEX idx_type_facts_symbol ON type_facts(symbol_id);
642             WITH RECURSIVE n(value) AS (
643                 VALUES(1) UNION ALL SELECT value + 1 FROM n WHERE value < 2000
644             )
645             INSERT INTO type_facts SELECT 'noise-' || value, 'Other' FROM n;
646             INSERT INTO type_facts VALUES ('receiver', 'Selected');
647
648             CREATE TABLE pending_relationships (
649                 target_terminal_name TEXT, path TEXT, start_line INTEGER
650             );
651             CREATE INDEX idx_pending_terminal ON pending_relationships(target_terminal_name);
652             WITH RECURSIVE n(value) AS (
653                 VALUES(1) UNION ALL SELECT value + 1 FROM n WHERE value < 2000
654             )
655             INSERT INTO pending_relationships SELECT 'get', 'noise/' || value || '.py', 42 FROM n;
656             INSERT INTO pending_relationships VALUES ('get', 'main.py', 42);",
657        )
658        .unwrap();
659
660        ensure_fts_index(&conn).unwrap();
661        ensure_fts_index(&conn).unwrap();
662
663        for (sql, expected) in [
664            (
665                "SELECT symbol_id FROM type_facts WHERE resolved_type = 'Selected'",
666                "receiver",
667            ),
668            (
669                "SELECT target_terminal_name FROM pending_relationships
670                 WHERE target_terminal_name = 'get' AND path = 'main.py' AND start_line = 42",
671                "get",
672            ),
673        ] {
674            let mut statement = conn.prepare(sql).unwrap();
675            let rows = statement
676                .query_map([], |row| row.get::<_, String>(0))
677                .unwrap()
678                .collect::<Result<Vec<_>, _>>()
679                .unwrap();
680            assert_eq!(rows, [expected]);
681            let steps = statement.get_status(rusqlite::StatementStatus::VmStep);
682            assert!(steps < 100, "{steps} SQLite steps for {sql}");
683        }
684    }
685
686    #[test]
687    fn ready_search_indexes_gain_bounded_import_lookups() {
688        let (_dir, conn) = symbols_db("import-lookups.db");
689        ensure_fts_index(&conn).unwrap();
690        conn.execute_batch(
691            "DROP INDEX IF EXISTS idx_symbols_import_path_name;
692             CREATE INDEX idx_symbols_path ON symbols(path);
693             CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
694             WITH RECURSIVE n(value) AS (
695                 VALUES(1) UNION ALL SELECT value + 1 FROM n WHERE value < 2000
696             )
697             INSERT INTO symbols(symbol_id, path, name, kind)
698             SELECT 'local-' || value, 'main.py', 'other_' || value, 'function' FROM n;
699             WITH RECURSIVE n(value) AS (
700                 VALUES(1) UNION ALL SELECT value + 1 FROM n WHERE value < 2000
701             )
702             INSERT INTO symbols(symbol_id, path, name, kind)
703             SELECT 'import-' || value, 'noise/' || value || '.py', 'Factory', 'import' FROM n;
704             INSERT INTO symbols(symbol_id, path, name, kind)
705             VALUES ('selected-import', 'main.py', 'Factory', 'import');",
706        )
707        .unwrap();
708
709        ensure_fts_index(&conn).unwrap();
710        ensure_fts_index(&conn).unwrap();
711
712        for sql in [
713            "SELECT name FROM symbols WHERE kind = 'import' AND path = 'main.py'",
714            "SELECT name FROM symbols
715             WHERE kind = 'import' AND path = 'main.py' AND name = 'Factory'",
716        ] {
717            let mut statement = conn.prepare(sql).unwrap();
718            let rows = statement
719                .query_map([], |row| row.get::<_, String>(0))
720                .unwrap()
721                .collect::<Result<Vec<_>, _>>()
722                .unwrap();
723            assert_eq!(rows, ["Factory"]);
724            let steps = statement.get_status(rusqlite::StatementStatus::VmStep);
725            assert!(steps < 100, "{steps} SQLite steps for {sql}");
726        }
727    }
728
729    fn exclude_locals_v1_layout(conn: &Connection) {
730        conn.execute_batch(
731            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
732                name, signature, doc_comment,
733                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
734            );
735            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
736            SELECT rowid, name, signature, doc_comment FROM symbols WHERE kind != 'variable';
737            CREATE TABLE artifact_metadata (key TEXT PRIMARY KEY, value TEXT);
738            INSERT INTO artifact_metadata VALUES ('fts_rule', 'exclude-locals-v1');",
739        )
740        .unwrap();
741    }
742
743    #[test]
744    fn upgrade_from_exclude_locals_v1_adds_the_trigram_table() {
745        let (_dir, conn) = symbols_db("upgrade.db");
746        exclude_locals_v1_layout(&conn);
747
748        ensure_fts_index(&conn).unwrap();
749
750        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
751        assert_eq!(word_names(&conn, "sidecar"), vec!["parseSha256Sidecar"]);
752        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
753    }
754
755    #[test]
756    fn migration_waits_for_another_writer_and_does_not_repeat_its_work() {
757        let (dir, a) = symbols_db("contended.db");
758        exclude_locals_v1_layout(&a);
759        let b = open_read_write(&dir.path().join("contended.db")).unwrap();
760        b.busy_timeout(std::time::Duration::from_secs(5)).unwrap();
761        let (locked_tx, locked_rx) = std::sync::mpsc::channel();
762
763        let writer = std::thread::spawn(move || {
764            a.execute_batch("BEGIN IMMEDIATE").unwrap();
765            locked_tx.send(()).unwrap();
766            std::thread::sleep(std::time::Duration::from_millis(300));
767            a.execute_batch("COMMIT").unwrap();
768            ensure_fts_index(&a).unwrap();
769            schema_version(&a)
770        });
771        locked_rx.recv().unwrap();
772
773        ensure_fts_index(&b).unwrap();
774
775        assert_eq!(schema_version(&b), writer.join().unwrap());
776        assert_eq!(trigram_names(&b, "sha256"), vec!["parseSha256Sidecar"]);
777        assert_eq!(word_names(&b, "sidecar"), vec!["parseSha256Sidecar"]);
778        assert_eq!(stored_fts_rule(&b).as_deref(), Some(FTS_RULE));
779    }
780
781    #[test]
782    fn interrupted_migration_is_completed_once_and_then_left_alone() {
783        let (_dir, conn) = symbols_db("interrupted.db");
784        ensure_fts_index(&conn).unwrap();
785        conn.execute("DELETE FROM artifact_metadata WHERE key = 'fts_rule'", [])
786            .unwrap();
787        let before_repair = schema_version(&conn);
788
789        ensure_fts_index(&conn).unwrap();
790
791        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
792        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
793        assert_ne!(schema_version(&conn), before_repair);
794        let settled = schema_version(&conn);
795        let rows = (
796            docsize_rows(&conn, "symbols_fts"),
797            docsize_rows(&conn, "symbol_names_tri"),
798        );
799
800        ensure_fts_index(&conn).unwrap();
801
802        assert_eq!(schema_version(&conn), settled);
803        assert_eq!(
804            (
805                docsize_rows(&conn, "symbols_fts"),
806                docsize_rows(&conn, "symbol_names_tri")
807            ),
808            rows
809        );
810    }
811
812    #[test]
813    fn missing_trigram_table_is_recreated_despite_the_marker() {
814        let (_dir, conn) = symbols_db("dropped.db");
815        ensure_fts_index(&conn).unwrap();
816        conn.execute("DROP TABLE symbol_names_tri", []).unwrap();
817
818        ensure_fts_index(&conn).unwrap();
819
820        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
821        assert_eq!(docsize_rows(&conn, "symbol_names_tri"), 1);
822    }
823
824    #[test]
825    fn triggers_keep_the_trigram_table_in_sync() {
826        let (_dir, conn) = symbols_db("triggers.db");
827        ensure_fts_index(&conn).unwrap();
828
829        conn.execute(
830            "INSERT INTO symbols VALUES ('3', 'src/verify.ts', 'verifyChecksum', 'function', NULL, 'function verifyChecksum()', '')",
831            [],
832        )
833        .unwrap();
834        assert_eq!(trigram_names(&conn, "checksum"), vec!["verifyChecksum"]);
835
836        conn.execute(
837            "UPDATE symbols SET name = 'verifyDigest' WHERE symbol_id = '3'",
838            [],
839        )
840        .unwrap();
841        assert!(trigram_names(&conn, "checksum").is_empty());
842        assert_eq!(trigram_names(&conn, "digest"), vec!["verifyDigest"]);
843
844        conn.execute("DELETE FROM symbols WHERE symbol_id = '3'", [])
845            .unwrap();
846        assert!(trigram_names(&conn, "digest").is_empty());
847        assert_eq!(docsize_rows(&conn, "symbol_names_tri"), 1);
848    }
849
850    #[test]
851    fn test_mmap_size_configuration() {
852        let dir = crate::safe_tempdir();
853        let db_path = dir.path().join("mmap_test.db");
854        let conn_rw = open_read_write(&db_path).unwrap();
855        conn_rw.execute("CREATE TABLE t (x INT);", []).unwrap();
856        drop(conn_rw);
857
858        let conn_ro = open_read_only(&db_path).unwrap();
859        let mmap_size: i64 = conn_ro
860            .query_row("PRAGMA mmap_size;", [], |r| r.get(0))
861            .unwrap();
862        #[cfg(windows)]
863        assert_eq!(
864            mmap_size, 0,
865            "mmap_size must be 0 on Windows to prevent file locks"
866        );
867        #[cfg(not(windows))]
868        assert_eq!(
869            mmap_size, 268435456,
870            "mmap_size should be 256MB on non-Windows"
871        );
872    }
873
874    #[test]
875    fn test_checkpoint_truncate() {
876        let dir = crate::safe_tempdir();
877        let db_path = dir.path().join("wal_checkpoint.db");
878        let conn_rw = open_read_write(&db_path).unwrap();
879        conn_rw
880            .execute("CREATE TABLE items (id INTEGER PRIMARY KEY, val TEXT);", [])
881            .unwrap();
882        conn_rw
883            .execute("INSERT INTO items (val) VALUES ('persisted_val');", [])
884            .unwrap();
885        checkpoint_truncate(&conn_rw).expect("checkpoint_truncate should succeed");
886        drop(conn_rw);
887
888        let conn_ro = open_read_only(&db_path).unwrap();
889        let val: String = conn_ro
890            .query_row("SELECT val FROM items WHERE id = 1", [], |r| r.get(0))
891            .unwrap();
892        assert_eq!(val, "persisted_val");
893    }
894
895    #[test]
896    fn test_retarget_artifact_root() {
897        let dir = crate::safe_tempdir();
898        let db_path = dir.path().join("retarget.db");
899
900        // 1. Missing db file -> NotFound error
901        let missing_path = dir.path().join("missing.db");
902        assert!(matches!(
903            retarget_artifact_root(&missing_path, dir.path()),
904            Err(DbError::NotFound(_))
905        ));
906
907        // 2. Db without existing artifact_metadata table -> creates table and sets root_path
908        {
909            let conn = open_read_write(&db_path).unwrap();
910            conn.execute("CREATE TABLE other (x INT);", []).unwrap();
911        }
912        let fresh_root = dir.path().join("fresh_root");
913        std::fs::create_dir_all(&fresh_root).unwrap();
914        retarget_artifact_root(&db_path, &fresh_root).expect("Must create table and succeed");
915        {
916            let conn = open_read_only(&db_path).unwrap();
917            let val: String = conn
918                .query_row(
919                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
920                    [],
921                    |r| r.get(0),
922                )
923                .unwrap();
924            assert!(
925                crate::workspace::paths_equal(Path::new(&val), &fresh_root),
926                "Paths must be equal: got {val}, expected {}",
927                fresh_root.display()
928            );
929        }
930
931        // 3. Db with existing artifact_metadata -> updates root_path
932        {
933            let conn = open_read_write(&db_path).unwrap();
934            conn.execute(
935                "UPDATE artifact_metadata SET value = '/old/root' WHERE key = 'root_path';",
936                [],
937            )
938            .unwrap();
939        }
940
941        let new_root = dir.path().join("new_root");
942        std::fs::create_dir_all(&new_root).unwrap();
943        retarget_artifact_root(&db_path, &new_root).expect("retargeting must succeed");
944
945        {
946            let conn = open_read_only(&db_path).unwrap();
947            let val: String = conn
948                .query_row(
949                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
950                    [],
951                    |r| r.get(0),
952                )
953                .unwrap();
954            assert!(
955                crate::workspace::paths_equal(Path::new(&val), &new_root),
956                "Paths must be equal: got {val}, expected {}",
957                new_root.display()
958            );
959        }
960
961        // 4. Verbatim prefix preservation on Windows when existing root started with \\?\
962        #[cfg(windows)]
963        {
964            {
965                let conn = open_read_write(&db_path).unwrap();
966                conn.execute(
967                    "UPDATE artifact_metadata SET value = '\\\\?\\C:\\old\\root' WHERE key = 'root_path';",
968                    [],
969                )
970                .unwrap();
971            }
972
973            retarget_artifact_root(&db_path, &new_root).expect("retargeting verbatim must succeed");
974
975            let conn = open_read_only(&db_path).unwrap();
976            let val: String = conn
977                .query_row(
978                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
979                    [],
980                    |r| r.get(0),
981                )
982                .unwrap();
983            assert!(
984                val.starts_with(r"\\?\"),
985                "Must preserve \\\\?\\ prefix when existing root had it: got {val}"
986            );
987        }
988    }
989}