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 fts_index_is_ready(conn) {
169        return Ok(());
170    }
171
172    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
173    if fts_index_is_ready(&tx) {
174        return Ok(());
175    }
176
177    let is_local = local_variable_predicate("s");
178    let new_is_local = local_variable_predicate("new");
179    let child_is_local = local_variable_predicate("c");
180    let already_indexed = "EXISTS (SELECT 1 FROM symbols_fts_docsize d WHERE d.id = old.rowid)";
181    let name_already_indexed =
182        "EXISTS (SELECT 1 FROM symbol_names_tri_docsize d WHERE d.id = old.rowid)";
183
184    tx.execute_batch(&format!(
185        "CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
186            name,
187            signature,
188            doc_comment,
189            content='symbols',
190            content_rowid='rowid',
191            tokenize='porter unicode61'
192        );
193
194        CREATE VIRTUAL TABLE IF NOT EXISTS symbol_names_tri USING fts5(
195            name,
196            content='symbols',
197            content_rowid='rowid',
198            tokenize='trigram'
199        );
200
201        DROP TRIGGER IF EXISTS symbols_ai;
202        DROP TRIGGER IF EXISTS symbols_ad;
203        DROP TRIGGER IF EXISTS symbols_au;
204
205        CREATE TRIGGER symbols_ai AFTER INSERT ON symbols BEGIN
206            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
207            SELECT new.rowid, new.name, new.signature, new.doc_comment
208            WHERE NOT {new_is_local};
209            INSERT INTO symbol_names_tri(rowid, name)
210            SELECT new.rowid, new.name
211            WHERE NOT {new_is_local};
212            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
213            SELECT 'delete', c.rowid, c.name, c.signature, c.doc_comment
214            FROM symbols c
215            WHERE new.kind IN ('function', 'method', 'constructor')
216              AND c.path = new.path
217              AND c.kind = 'variable'
218              AND {child_is_local}
219              AND EXISTS (SELECT 1 FROM symbols_fts_docsize d WHERE d.id = c.rowid);
220            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
221            SELECT 'delete', c.rowid, c.name
222            FROM symbols c
223            WHERE new.kind IN ('function', 'method', 'constructor')
224              AND c.path = new.path
225              AND c.kind = 'variable'
226              AND {child_is_local}
227              AND EXISTS (SELECT 1 FROM symbol_names_tri_docsize d WHERE d.id = c.rowid);
228        END;
229
230        CREATE TRIGGER symbols_ad AFTER DELETE ON symbols BEGIN
231            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
232            SELECT 'delete', old.rowid, old.name, old.signature, old.doc_comment
233            WHERE {already_indexed};
234            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
235            SELECT 'delete', old.rowid, old.name
236            WHERE {name_already_indexed};
237        END;
238
239        CREATE TRIGGER symbols_au AFTER UPDATE ON symbols BEGIN
240            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
241            SELECT 'delete', old.rowid, old.name, old.signature, old.doc_comment
242            WHERE {already_indexed};
243            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
244            SELECT 'delete', old.rowid, old.name
245            WHERE {name_already_indexed};
246            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
247            SELECT new.rowid, new.name, new.signature, new.doc_comment
248            WHERE NOT {new_is_local};
249            INSERT INTO symbol_names_tri(rowid, name)
250            SELECT new.rowid, new.name
251            WHERE NOT {new_is_local};
252        END;
253
254        INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all');
255        INSERT INTO symbol_names_tri(symbol_names_tri) VALUES('delete-all');
256
257        INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
258        SELECT s.rowid, s.name, s.signature, s.doc_comment
259        FROM symbols s WHERE NOT {is_local};
260        INSERT INTO symbol_names_tri(rowid, name)
261        SELECT s.rowid, s.name
262        FROM symbols s WHERE NOT {is_local};
263
264        CREATE TABLE IF NOT EXISTS artifact_metadata (key TEXT PRIMARY KEY, value TEXT);"
265    ))?;
266    tx.execute(
267        "INSERT INTO artifact_metadata (key, value) VALUES ('fts_rule', ?1)
268         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
269        [FTS_RULE],
270    )?;
271    tx.commit()?;
272
273    Ok(())
274}
275
276/// Ensures the FTS5 index on `symbols` exists at the specified database file path. The
277/// connection waits up to 60 s for another process's migration; a large index takes seconds.
278pub fn ensure_fts_index_path(path: &Path) -> Result<(), DbError> {
279    if !path.exists() {
280        return Err(DbError::NotFound(path.display().to_string()));
281    }
282    let conn = open_read_write(path)?;
283    conn.busy_timeout(std::time::Duration::from_secs(60))
284        .map_err(DbError::PragmaFailed)?;
285    ensure_fts_index(&conn).map_err(DbError::FtsMigration)?;
286    Ok(())
287}
288
289/// Retargets the `root_path` key in `artifact_metadata` to a new workspace canonical root.
290/// This is essential when cloning or copying an artifact database (e.g. into a git worktree),
291/// ensuring `julie-extract update`, `delete`, and `scan` recognize the new root without root mismatch errors.
292pub fn retarget_artifact_root(db_path: &Path, new_root: &Path) -> Result<(), DbError> {
293    if !db_path.exists() {
294        return Err(DbError::NotFound(db_path.display().to_string()));
295    }
296    let conn = open_read_write(db_path)?;
297    conn.execute(
298        "CREATE TABLE IF NOT EXISTS artifact_metadata (key TEXT PRIMARY KEY, value TEXT)",
299        [],
300    )?;
301
302    let existing_root: Option<String> = conn
303        .query_row(
304            "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
305            [],
306            |r| r.get(0),
307        )
308        .ok();
309
310    let root_str = if existing_root
311        .as_deref()
312        .is_some_and(|ex| ex.starts_with(r"\\?\") || ex.starts_with(r"\\.\"))
313        || (existing_root.is_none() && cfg!(windows))
314    {
315        std::fs::canonicalize(new_root)
316            .map(|p| p.to_string_lossy().to_string())
317            .unwrap_or_else(|_| {
318                let s = new_root.to_string_lossy();
319                format!(r"\\?\{s}")
320            })
321    } else {
322        new_root.to_string_lossy().to_string()
323    };
324
325    conn.execute(
326        "INSERT INTO artifact_metadata (key, value) VALUES ('root_path', ?1)
327         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
328        rusqlite::params![root_str],
329    )?;
330
331    Ok(())
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_open_read_write_and_read_only() {
340        let dir = crate::safe_tempdir();
341        let db_path = dir.path().join("test.db");
342        let conn_rw = open_read_write(&db_path).unwrap();
343        conn_rw
344            .execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT);", [])
345            .unwrap();
346        conn_rw
347            .execute("INSERT INTO test (name) VALUES ('alpha');", [])
348            .unwrap();
349        drop(conn_rw);
350
351        let conn_ro = open_read_only(&db_path).unwrap();
352        let name: String = conn_ro
353            .query_row("SELECT name FROM test WHERE id = 1", [], |r| r.get(0))
354            .unwrap();
355        assert_eq!(name, "alpha");
356
357        // Verifying query_only prevents writes
358        let write_res = conn_ro.execute("INSERT INTO test (name) VALUES ('beta');", []);
359        assert!(write_res.is_err());
360    }
361
362    #[test]
363    fn test_fts5_support() {
364        let conn = rusqlite::Connection::open_in_memory().unwrap();
365        conn.execute("CREATE VIRTUAL TABLE test_fts USING fts5(content);", [])
366            .unwrap();
367        conn.execute(
368            "INSERT INTO test_fts (content) VALUES ('hello world token search');",
369            [],
370        )
371        .unwrap();
372        let count: i64 = conn
373            .query_row(
374                "SELECT count(*) FROM test_fts WHERE test_fts MATCH 'token'",
375                [],
376                |r| r.get(0),
377            )
378            .unwrap();
379        assert_eq!(count, 1);
380    }
381
382    #[test]
383    fn test_ensure_fts_index_lifecycle() {
384        let dir = crate::safe_tempdir();
385        let db_path = dir.path().join("fts_lifecycle.db");
386        let conn = open_read_write(&db_path).unwrap();
387
388        conn.execute_batch(
389            "CREATE TABLE symbols (
390                symbol_id TEXT PRIMARY KEY,
391                path TEXT,
392                name TEXT,
393                kind TEXT,
394                parent_symbol_id TEXT,
395                signature TEXT,
396                doc_comment TEXT
397            );
398            INSERT INTO symbols VALUES ('1', 'src/pay.rs', 'PaymentGateway', 'trait', NULL, 'pub trait PaymentGateway', 'Core payment provider interface');
399            INSERT INTO symbols VALUES ('2', 'src/pay.rs', 'StripeClient', 'struct', NULL, 'pub struct StripeClient', 'Handles HTTP requests to stripe API');",
400        )
401        .unwrap();
402
403        ensure_fts_index(&conn).unwrap();
404
405        let count: i64 = conn
406            .query_row(
407                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'payment'",
408                [],
409                |r| r.get(0),
410            )
411            .unwrap();
412        assert_eq!(count, 1);
413
414        conn.execute(
415            "INSERT INTO symbols VALUES ('3', 'src/pay.rs', 'RefundHandler', 'function', NULL, 'pub fn handle_refund()', 'Processes transaction refunds');",
416            [],
417        )
418        .unwrap();
419
420        let count: i64 = conn
421            .query_row(
422                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
423                [],
424                |r| r.get(0),
425            )
426            .unwrap();
427        assert_eq!(count, 1);
428
429        conn.execute("DELETE FROM symbols WHERE symbol_id = '3';", [])
430            .unwrap();
431        let count: i64 = conn
432            .query_row(
433                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
434                [],
435                |r| r.get(0),
436            )
437            .unwrap();
438        assert_eq!(count, 0);
439    }
440
441    #[test]
442    fn ensure_fts_index_drops_a_local_indexed_before_its_parent() {
443        let dir = crate::safe_tempdir();
444        let db_path = dir.path().join("fts_rule.db");
445        let conn = open_read_write(&db_path).unwrap();
446        conn.execute_batch(
447            "CREATE TABLE symbols (
448                symbol_id TEXT PRIMARY KEY,
449                path TEXT,
450                name TEXT,
451                kind TEXT,
452                parent_symbol_id TEXT,
453                signature TEXT,
454                doc_comment TEXT
455            );
456            INSERT INTO symbols VALUES ('1', 'src/db.rs', 'open_conn', 'function', NULL, 'fn open_conn()', '');",
457        )
458        .unwrap();
459
460        ensure_fts_index(&conn).unwrap();
461
462        let rule: String = conn
463            .query_row(
464                "SELECT value FROM artifact_metadata WHERE key = 'fts_rule'",
465                [],
466                |r| r.get(0),
467            )
468            .unwrap();
469        assert_eq!(rule, FTS_RULE);
470
471        conn.execute(
472            "INSERT INTO symbols VALUES ('2', 'src/db.rs', 'drifted', 'variable', '3', 'let drifted = 1', '')",
473            [],
474        )
475        .unwrap();
476        conn.execute(
477            "INSERT INTO symbols VALUES ('3', 'src/db.rs', 'later', 'function', NULL, 'fn later()', '')",
478            [],
479        )
480        .unwrap();
481
482        let count: i64 = conn
483            .query_row(
484                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'drifted'",
485                [],
486                |r| r.get(0),
487            )
488            .unwrap();
489        assert_eq!(count, 0);
490        assert!(trigram_names(&conn, "drifted").is_empty());
491        assert_eq!(trigram_names(&conn, "later"), vec!["later"]);
492    }
493
494    #[test]
495    fn ensure_fts_index_repopulates_an_emptied_index() {
496        let dir = crate::safe_tempdir();
497        let db_path = dir.path().join("fts_empty.db");
498        let conn = open_read_write(&db_path).unwrap();
499        conn.execute_batch(
500            "CREATE TABLE symbols (
501                symbol_id TEXT PRIMARY KEY,
502                path TEXT,
503                name TEXT,
504                kind TEXT,
505                parent_symbol_id TEXT,
506                signature TEXT,
507                doc_comment TEXT
508            );
509            INSERT INTO symbols VALUES ('1', 'src/pay.rs', 'RefundHandler', 'function', NULL, 'fn handle_refund()', '');",
510        )
511        .unwrap();
512
513        ensure_fts_index(&conn).unwrap();
514        conn.execute(
515            "INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all')",
516            [],
517        )
518        .unwrap();
519
520        ensure_fts_index(&conn).unwrap();
521
522        let count: i64 = conn
523            .query_row(
524                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
525                [],
526                |r| r.get(0),
527            )
528            .unwrap();
529        assert_eq!(count, 1);
530    }
531
532    fn symbols_db(file: &str) -> (tempfile::TempDir, Connection) {
533        let dir = crate::safe_tempdir();
534        let conn = open_read_write(&dir.path().join(file)).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/sidecar.ts', 'parseSha256Sidecar', 'function', NULL, 'function parseSha256Sidecar()', 'Reads the checksum sidecar');
546            INSERT INTO symbols VALUES ('2', 'src/sidecar.ts', 'digestBuffer', 'variable', '1', 'const digestBuffer', '');",
547        )
548        .unwrap();
549        (dir, conn)
550    }
551
552    fn trigram_names(conn: &Connection, term: &str) -> Vec<String> {
553        let mut stmt = conn
554            .prepare(
555                "SELECT name FROM symbol_names_tri WHERE symbol_names_tri MATCH ?1 ORDER BY name",
556            )
557            .unwrap();
558        stmt.query_map([format!("\"{term}\"")], |r| r.get(0))
559            .unwrap()
560            .map(Result::unwrap)
561            .collect()
562    }
563
564    fn word_names(conn: &Connection, term: &str) -> Vec<String> {
565        let mut stmt = conn
566            .prepare("SELECT name FROM symbols_fts WHERE symbols_fts MATCH ?1 ORDER BY name")
567            .unwrap();
568        stmt.query_map([term], |r| r.get(0))
569            .unwrap()
570            .map(Result::unwrap)
571            .collect()
572    }
573
574    fn docsize_rows(conn: &Connection, table: &str) -> i64 {
575        conn.query_row(&format!("SELECT count(*) FROM {table}_docsize"), [], |r| {
576            r.get(0)
577        })
578        .unwrap()
579    }
580
581    fn schema_version(conn: &Connection) -> i64 {
582        conn.query_row("PRAGMA schema_version", [], |r| r.get(0))
583            .unwrap()
584    }
585
586    #[test]
587    fn fresh_index_populates_both_tables_without_locals() {
588        let (_dir, conn) = symbols_db("fresh.db");
589
590        ensure_fts_index(&conn).unwrap();
591
592        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
593        assert_eq!(word_names(&conn, "sidecar"), vec!["parseSha256Sidecar"]);
594        assert!(trigram_names(&conn, "digest").is_empty());
595        assert!(word_names(&conn, "digestBuffer").is_empty());
596        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
597    }
598
599    fn exclude_locals_v1_layout(conn: &Connection) {
600        conn.execute_batch(
601            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
602                name, signature, doc_comment,
603                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
604            );
605            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
606            SELECT rowid, name, signature, doc_comment FROM symbols WHERE kind != 'variable';
607            CREATE TABLE artifact_metadata (key TEXT PRIMARY KEY, value TEXT);
608            INSERT INTO artifact_metadata VALUES ('fts_rule', 'exclude-locals-v1');",
609        )
610        .unwrap();
611    }
612
613    #[test]
614    fn upgrade_from_exclude_locals_v1_adds_the_trigram_table() {
615        let (_dir, conn) = symbols_db("upgrade.db");
616        exclude_locals_v1_layout(&conn);
617
618        ensure_fts_index(&conn).unwrap();
619
620        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
621        assert_eq!(word_names(&conn, "sidecar"), vec!["parseSha256Sidecar"]);
622        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
623    }
624
625    #[test]
626    fn migration_waits_for_another_writer_and_does_not_repeat_its_work() {
627        let (dir, a) = symbols_db("contended.db");
628        exclude_locals_v1_layout(&a);
629        let b = open_read_write(&dir.path().join("contended.db")).unwrap();
630        b.busy_timeout(std::time::Duration::from_secs(5)).unwrap();
631        let (locked_tx, locked_rx) = std::sync::mpsc::channel();
632
633        let writer = std::thread::spawn(move || {
634            a.execute_batch("BEGIN IMMEDIATE").unwrap();
635            locked_tx.send(()).unwrap();
636            std::thread::sleep(std::time::Duration::from_millis(300));
637            a.execute_batch("COMMIT").unwrap();
638            ensure_fts_index(&a).unwrap();
639            schema_version(&a)
640        });
641        locked_rx.recv().unwrap();
642
643        ensure_fts_index(&b).unwrap();
644
645        assert_eq!(schema_version(&b), writer.join().unwrap());
646        assert_eq!(trigram_names(&b, "sha256"), vec!["parseSha256Sidecar"]);
647        assert_eq!(word_names(&b, "sidecar"), vec!["parseSha256Sidecar"]);
648        assert_eq!(stored_fts_rule(&b).as_deref(), Some(FTS_RULE));
649    }
650
651    #[test]
652    fn interrupted_migration_is_completed_once_and_then_left_alone() {
653        let (_dir, conn) = symbols_db("interrupted.db");
654        ensure_fts_index(&conn).unwrap();
655        conn.execute("DELETE FROM artifact_metadata WHERE key = 'fts_rule'", [])
656            .unwrap();
657        let before_repair = schema_version(&conn);
658
659        ensure_fts_index(&conn).unwrap();
660
661        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
662        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
663        assert_ne!(schema_version(&conn), before_repair);
664        let settled = schema_version(&conn);
665        let rows = (
666            docsize_rows(&conn, "symbols_fts"),
667            docsize_rows(&conn, "symbol_names_tri"),
668        );
669
670        ensure_fts_index(&conn).unwrap();
671
672        assert_eq!(schema_version(&conn), settled);
673        assert_eq!(
674            (
675                docsize_rows(&conn, "symbols_fts"),
676                docsize_rows(&conn, "symbol_names_tri")
677            ),
678            rows
679        );
680    }
681
682    #[test]
683    fn missing_trigram_table_is_recreated_despite_the_marker() {
684        let (_dir, conn) = symbols_db("dropped.db");
685        ensure_fts_index(&conn).unwrap();
686        conn.execute("DROP TABLE symbol_names_tri", []).unwrap();
687
688        ensure_fts_index(&conn).unwrap();
689
690        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
691        assert_eq!(docsize_rows(&conn, "symbol_names_tri"), 1);
692    }
693
694    #[test]
695    fn triggers_keep_the_trigram_table_in_sync() {
696        let (_dir, conn) = symbols_db("triggers.db");
697        ensure_fts_index(&conn).unwrap();
698
699        conn.execute(
700            "INSERT INTO symbols VALUES ('3', 'src/verify.ts', 'verifyChecksum', 'function', NULL, 'function verifyChecksum()', '')",
701            [],
702        )
703        .unwrap();
704        assert_eq!(trigram_names(&conn, "checksum"), vec!["verifyChecksum"]);
705
706        conn.execute(
707            "UPDATE symbols SET name = 'verifyDigest' WHERE symbol_id = '3'",
708            [],
709        )
710        .unwrap();
711        assert!(trigram_names(&conn, "checksum").is_empty());
712        assert_eq!(trigram_names(&conn, "digest"), vec!["verifyDigest"]);
713
714        conn.execute("DELETE FROM symbols WHERE symbol_id = '3'", [])
715            .unwrap();
716        assert!(trigram_names(&conn, "digest").is_empty());
717        assert_eq!(docsize_rows(&conn, "symbol_names_tri"), 1);
718    }
719
720    #[test]
721    fn test_mmap_size_configuration() {
722        let dir = crate::safe_tempdir();
723        let db_path = dir.path().join("mmap_test.db");
724        let conn_rw = open_read_write(&db_path).unwrap();
725        conn_rw.execute("CREATE TABLE t (x INT);", []).unwrap();
726        drop(conn_rw);
727
728        let conn_ro = open_read_only(&db_path).unwrap();
729        let mmap_size: i64 = conn_ro
730            .query_row("PRAGMA mmap_size;", [], |r| r.get(0))
731            .unwrap();
732        #[cfg(windows)]
733        assert_eq!(
734            mmap_size, 0,
735            "mmap_size must be 0 on Windows to prevent file locks"
736        );
737        #[cfg(not(windows))]
738        assert_eq!(
739            mmap_size, 268435456,
740            "mmap_size should be 256MB on non-Windows"
741        );
742    }
743
744    #[test]
745    fn test_checkpoint_truncate() {
746        let dir = crate::safe_tempdir();
747        let db_path = dir.path().join("wal_checkpoint.db");
748        let conn_rw = open_read_write(&db_path).unwrap();
749        conn_rw
750            .execute("CREATE TABLE items (id INTEGER PRIMARY KEY, val TEXT);", [])
751            .unwrap();
752        conn_rw
753            .execute("INSERT INTO items (val) VALUES ('persisted_val');", [])
754            .unwrap();
755        checkpoint_truncate(&conn_rw).expect("checkpoint_truncate should succeed");
756        drop(conn_rw);
757
758        let conn_ro = open_read_only(&db_path).unwrap();
759        let val: String = conn_ro
760            .query_row("SELECT val FROM items WHERE id = 1", [], |r| r.get(0))
761            .unwrap();
762        assert_eq!(val, "persisted_val");
763    }
764
765    #[test]
766    fn test_retarget_artifact_root() {
767        let dir = crate::safe_tempdir();
768        let db_path = dir.path().join("retarget.db");
769
770        // 1. Missing db file -> NotFound error
771        let missing_path = dir.path().join("missing.db");
772        assert!(matches!(
773            retarget_artifact_root(&missing_path, dir.path()),
774            Err(DbError::NotFound(_))
775        ));
776
777        // 2. Db without existing artifact_metadata table -> creates table and sets root_path
778        {
779            let conn = open_read_write(&db_path).unwrap();
780            conn.execute("CREATE TABLE other (x INT);", []).unwrap();
781        }
782        let fresh_root = dir.path().join("fresh_root");
783        std::fs::create_dir_all(&fresh_root).unwrap();
784        retarget_artifact_root(&db_path, &fresh_root).expect("Must create table and succeed");
785        {
786            let conn = open_read_only(&db_path).unwrap();
787            let val: String = conn
788                .query_row(
789                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
790                    [],
791                    |r| r.get(0),
792                )
793                .unwrap();
794            assert!(
795                crate::workspace::paths_equal(Path::new(&val), &fresh_root),
796                "Paths must be equal: got {val}, expected {}",
797                fresh_root.display()
798            );
799        }
800
801        // 3. Db with existing artifact_metadata -> updates root_path
802        {
803            let conn = open_read_write(&db_path).unwrap();
804            conn.execute(
805                "UPDATE artifact_metadata SET value = '/old/root' WHERE key = 'root_path';",
806                [],
807            )
808            .unwrap();
809        }
810
811        let new_root = dir.path().join("new_root");
812        std::fs::create_dir_all(&new_root).unwrap();
813        retarget_artifact_root(&db_path, &new_root).expect("retargeting must succeed");
814
815        {
816            let conn = open_read_only(&db_path).unwrap();
817            let val: String = conn
818                .query_row(
819                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
820                    [],
821                    |r| r.get(0),
822                )
823                .unwrap();
824            assert!(
825                crate::workspace::paths_equal(Path::new(&val), &new_root),
826                "Paths must be equal: got {val}, expected {}",
827                new_root.display()
828            );
829        }
830
831        // 4. Verbatim prefix preservation on Windows when existing root started with \\?\
832        #[cfg(windows)]
833        {
834            {
835                let conn = open_read_write(&db_path).unwrap();
836                conn.execute(
837                    "UPDATE artifact_metadata SET value = '\\\\?\\C:\\old\\root' WHERE key = 'root_path';",
838                    [],
839                )
840                .unwrap();
841            }
842
843            retarget_artifact_root(&db_path, &new_root).expect("retargeting verbatim must succeed");
844
845            let conn = open_read_only(&db_path).unwrap();
846            let val: String = conn
847                .query_row(
848                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
849                    [],
850                    |r| r.get(0),
851                )
852                .unwrap();
853            assert!(
854                val.starts_with(r"\\?\"),
855                "Must preserve \\\\?\\ prefix when existing root had it: got {val}"
856            );
857        }
858    }
859}