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    link_impl_owners(conn)?;
205
206    if fts_index_is_ready(conn) {
207        return Ok(());
208    }
209
210    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
211    if fts_index_is_ready(&tx) {
212        return Ok(());
213    }
214
215    let is_local = local_variable_predicate("s");
216    let new_is_local = local_variable_predicate("new");
217    let child_is_local = local_variable_predicate("c");
218    let already_indexed = "EXISTS (SELECT 1 FROM symbols_fts_docsize d WHERE d.id = old.rowid)";
219    let name_already_indexed =
220        "EXISTS (SELECT 1 FROM symbol_names_tri_docsize d WHERE d.id = old.rowid)";
221
222    tx.execute_batch(&format!(
223        "CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
224            name,
225            signature,
226            doc_comment,
227            content='symbols',
228            content_rowid='rowid',
229            tokenize='porter unicode61'
230        );
231
232        CREATE VIRTUAL TABLE IF NOT EXISTS symbol_names_tri USING fts5(
233            name,
234            content='symbols',
235            content_rowid='rowid',
236            tokenize='trigram'
237        );
238
239        DROP TRIGGER IF EXISTS symbols_ai;
240        DROP TRIGGER IF EXISTS symbols_ad;
241        DROP TRIGGER IF EXISTS symbols_au;
242
243        CREATE TRIGGER symbols_ai AFTER INSERT ON symbols BEGIN
244            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
245            SELECT new.rowid, new.name, new.signature, new.doc_comment
246            WHERE NOT {new_is_local};
247            INSERT INTO symbol_names_tri(rowid, name)
248            SELECT new.rowid, new.name
249            WHERE NOT {new_is_local};
250            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
251            SELECT 'delete', c.rowid, c.name, c.signature, c.doc_comment
252            FROM symbols c
253            WHERE new.kind IN ('function', 'method', 'constructor')
254              AND c.path = new.path
255              AND c.kind = 'variable'
256              AND {child_is_local}
257              AND EXISTS (SELECT 1 FROM symbols_fts_docsize d WHERE d.id = c.rowid);
258            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
259            SELECT 'delete', c.rowid, c.name
260            FROM symbols c
261            WHERE new.kind IN ('function', 'method', 'constructor')
262              AND c.path = new.path
263              AND c.kind = 'variable'
264              AND {child_is_local}
265              AND EXISTS (SELECT 1 FROM symbol_names_tri_docsize d WHERE d.id = c.rowid);
266        END;
267
268        CREATE TRIGGER symbols_ad AFTER DELETE ON symbols BEGIN
269            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
270            SELECT 'delete', old.rowid, old.name, old.signature, old.doc_comment
271            WHERE {already_indexed};
272            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
273            SELECT 'delete', old.rowid, old.name
274            WHERE {name_already_indexed};
275        END;
276
277        CREATE TRIGGER symbols_au AFTER UPDATE ON symbols BEGIN
278            INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc_comment)
279            SELECT 'delete', old.rowid, old.name, old.signature, old.doc_comment
280            WHERE {already_indexed};
281            INSERT INTO symbol_names_tri(symbol_names_tri, rowid, name)
282            SELECT 'delete', old.rowid, old.name
283            WHERE {name_already_indexed};
284            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
285            SELECT new.rowid, new.name, new.signature, new.doc_comment
286            WHERE NOT {new_is_local};
287            INSERT INTO symbol_names_tri(rowid, name)
288            SELECT new.rowid, new.name
289            WHERE NOT {new_is_local};
290        END;
291
292        INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all');
293        INSERT INTO symbol_names_tri(symbol_names_tri) VALUES('delete-all');
294
295        INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
296        SELECT s.rowid, s.name, s.signature, s.doc_comment
297        FROM symbols s WHERE NOT {is_local};
298        INSERT INTO symbol_names_tri(rowid, name)
299        SELECT s.rowid, s.name
300        FROM symbols s WHERE NOT {is_local};
301
302        CREATE TABLE IF NOT EXISTS artifact_metadata (key TEXT PRIMARY KEY, value TEXT);"
303    ))?;
304    tx.execute(
305        "INSERT INTO artifact_metadata (key, value) VALUES ('fts_rule', ?1)
306         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
307        [FTS_RULE],
308    )?;
309    tx.commit()?;
310
311    Ok(())
312}
313
314/// The kinds of type an `impl` block can name.
315const IMPL_OWNER_KINDS: &str = "'class', 'struct', 'enum', 'union', 'interface', 'type'";
316
317/// julie leaves `parent_symbol_id` empty on a Rust method whose `impl` block is in another file
318/// than its type, and names the type as `impl_type_name` in `metadata_json`. Links each such
319/// method to the type with that name nearest to its file, so every query that reads the parent
320/// sees the owner. Equally near types leave the method unlinked. julie's `ON DELETE SET NULL`
321/// clears a link when it rewrites the type's file, and the next call links the method again.
322pub fn link_impl_owners(conn: &Connection) -> rusqlite::Result<()> {
323    let has_metadata: bool = conn.query_row(
324        "SELECT count(*) = 1 FROM pragma_table_info('symbols') WHERE name = 'metadata_json'",
325        [],
326        |row| row.get(0),
327    )?;
328    if !has_metadata {
329        return Ok(());
330    }
331    const UNLINKED: &str = "parent_symbol_id IS NULL
332         AND metadata_json LIKE '%\"impl_parent_id_resolved\":false%'";
333    conn.execute_batch(&format!(
334        "CREATE INDEX IF NOT EXISTS idx_symbols_unlinked_impl ON symbols(symbol_id) WHERE {UNLINKED};"
335    ))?;
336    let unlinked: Vec<(String, String, String, String)> = conn
337        .prepare(&format!(
338            "SELECT symbol_id, path, language, json_extract(metadata_json, '$.impl_type_name')
339             FROM symbols
340             WHERE {UNLINKED} AND json_valid(metadata_json)
341               AND json_extract(metadata_json, '$.impl_type_name') IS NOT NULL"
342        ))?
343        .query_map([], |row| {
344            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
345        })?
346        .collect::<rusqlite::Result<_>>()?;
347    if unlinked.is_empty() {
348        return Ok(());
349    }
350
351    let mut owners = conn.prepare(&format!(
352        "SELECT symbol_id, path FROM symbols
353         WHERE name = ?1 AND language = ?2 AND kind IN ({IMPL_OWNER_KINDS})"
354    ))?;
355    let mut links = Vec::new();
356    for (method_id, method_path, language, type_name) in &unlinked {
357        let candidates: Vec<(String, String)> = owners
358            .query_map([type_name, language], |row| Ok((row.get(0)?, row.get(1)?)))?
359            .collect::<rusqlite::Result<_>>()?;
360        if let Some(owner_id) = nearest_owner(method_path, &candidates) {
361            links.push((owner_id.to_string(), method_id));
362        }
363    }
364    if links.is_empty() {
365        return Ok(());
366    }
367
368    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
369    {
370        // The owner must still exist: julie checks every foreign key on its next write.
371        let mut link = tx.prepare(
372            "UPDATE symbols SET parent_symbol_id = ?1
373             WHERE symbol_id = ?2 AND parent_symbol_id IS NULL
374               AND EXISTS (SELECT 1 FROM symbols WHERE symbol_id = ?1)",
375        )?;
376        for (owner_id, method_id) in &links {
377            link.execute([owner_id, *method_id])?;
378        }
379    }
380    tx.commit()
381}
382
383/// The candidate whose folder shares the most leading folders with `path`, unless two tie.
384fn nearest_owner<'a>(path: &str, candidates: &'a [(String, String)]) -> Option<&'a str> {
385    fn folders(path: &str) -> impl Iterator<Item = &str> {
386        let mut segments: Vec<&str> = path.split(['/', '\\']).collect();
387        segments.pop();
388        segments.into_iter()
389    }
390    let mut best: Option<(&str, usize)> = None;
391    let mut tied = false;
392    for (id, candidate_path) in candidates {
393        let score = folders(path)
394            .zip(folders(candidate_path))
395            .take_while(|(a, b)| a == b)
396            .count();
397        match best {
398            Some((_, top)) if score < top => {}
399            Some((_, top)) if score == top => tied = true,
400            _ => {
401                best = Some((id, score));
402                tied = false;
403            }
404        }
405    }
406    best.filter(|_| !tied).map(|(id, _)| id)
407}
408
409/// Ensures the FTS5 index on `symbols` exists at the specified database file path. The
410/// connection waits up to 60 s for another process's migration; a large index takes seconds.
411pub fn ensure_fts_index_path(path: &Path) -> Result<(), DbError> {
412    if !path.exists() {
413        return Err(DbError::NotFound(path.display().to_string()));
414    }
415    let conn = open_read_write(path)?;
416    conn.busy_timeout(std::time::Duration::from_secs(60))
417        .map_err(DbError::PragmaFailed)?;
418    ensure_fts_index(&conn).map_err(DbError::FtsMigration)?;
419    Ok(())
420}
421
422/// Runs [`link_impl_owners`] on the database file after an incremental write.
423pub fn link_impl_owners_path(path: &Path) -> Result<(), DbError> {
424    let conn = open_read_write(path)?;
425    conn.busy_timeout(std::time::Duration::from_secs(60))
426        .map_err(DbError::PragmaFailed)?;
427    link_impl_owners(&conn)?;
428    Ok(())
429}
430
431/// Retargets the `root_path` key in `artifact_metadata` to a new workspace canonical root.
432/// This is essential when cloning or copying an artifact database (e.g. into a git worktree),
433/// ensuring `julie-extract update`, `delete`, and `scan` recognize the new root without root mismatch errors.
434pub fn retarget_artifact_root(db_path: &Path, new_root: &Path) -> Result<(), DbError> {
435    if !db_path.exists() {
436        return Err(DbError::NotFound(db_path.display().to_string()));
437    }
438    let conn = open_read_write(db_path)?;
439    conn.execute(
440        "CREATE TABLE IF NOT EXISTS artifact_metadata (key TEXT PRIMARY KEY, value TEXT)",
441        [],
442    )?;
443
444    let existing_root: Option<String> = conn
445        .query_row(
446            "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
447            [],
448            |r| r.get(0),
449        )
450        .ok();
451
452    let root_str = if existing_root
453        .as_deref()
454        .is_some_and(|ex| ex.starts_with(r"\\?\") || ex.starts_with(r"\\.\"))
455        || (existing_root.is_none() && cfg!(windows))
456    {
457        std::fs::canonicalize(new_root)
458            .map(|p| p.to_string_lossy().to_string())
459            .unwrap_or_else(|_| {
460                let s = new_root.to_string_lossy();
461                format!(r"\\?\{s}")
462            })
463    } else {
464        new_root.to_string_lossy().to_string()
465    };
466
467    conn.execute(
468        "INSERT INTO artifact_metadata (key, value) VALUES ('root_path', ?1)
469         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
470        rusqlite::params![root_str],
471    )?;
472
473    Ok(())
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn nearest_owner_prefers_the_closest_folder_and_refuses_a_tie() {
482        let owners = |paths: &[&str]| -> Vec<(String, String)> {
483            paths
484                .iter()
485                .map(|path| (format!("id:{path}"), path.to_string()))
486                .collect()
487        };
488        let near = owners(&["crates/a/src/runtime.rs", "crates/b/src/runtime.rs"]);
489        assert_eq!(
490            nearest_owner("crates/a/src/runtime/processing.rs", &near),
491            Some("id:crates/a/src/runtime.rs")
492        );
493        let tied = owners(&["crates/a/src/x.rs", "crates/a/src/y.rs"]);
494        assert_eq!(nearest_owner("crates/a/src/z/w.rs", &tied), None);
495        assert_eq!(nearest_owner("crates/a/src/z/w.rs", &[]), None);
496    }
497
498    #[test]
499    fn test_open_read_write_and_read_only() {
500        let dir = crate::safe_tempdir();
501        let db_path = dir.path().join("test.db");
502        let conn_rw = open_read_write(&db_path).unwrap();
503        conn_rw
504            .execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT);", [])
505            .unwrap();
506        conn_rw
507            .execute("INSERT INTO test (name) VALUES ('alpha');", [])
508            .unwrap();
509        drop(conn_rw);
510
511        let conn_ro = open_read_only(&db_path).unwrap();
512        let name: String = conn_ro
513            .query_row("SELECT name FROM test WHERE id = 1", [], |r| r.get(0))
514            .unwrap();
515        assert_eq!(name, "alpha");
516
517        // Verifying query_only prevents writes
518        let write_res = conn_ro.execute("INSERT INTO test (name) VALUES ('beta');", []);
519        assert!(write_res.is_err());
520    }
521
522    #[test]
523    fn test_fts5_support() {
524        let conn = rusqlite::Connection::open_in_memory().unwrap();
525        conn.execute("CREATE VIRTUAL TABLE test_fts USING fts5(content);", [])
526            .unwrap();
527        conn.execute(
528            "INSERT INTO test_fts (content) VALUES ('hello world token search');",
529            [],
530        )
531        .unwrap();
532        let count: i64 = conn
533            .query_row(
534                "SELECT count(*) FROM test_fts WHERE test_fts MATCH 'token'",
535                [],
536                |r| r.get(0),
537            )
538            .unwrap();
539        assert_eq!(count, 1);
540    }
541
542    #[test]
543    fn test_ensure_fts_index_lifecycle() {
544        let dir = crate::safe_tempdir();
545        let db_path = dir.path().join("fts_lifecycle.db");
546        let conn = open_read_write(&db_path).unwrap();
547
548        conn.execute_batch(
549            "CREATE TABLE symbols (
550                symbol_id TEXT PRIMARY KEY,
551                path TEXT,
552                name TEXT,
553                kind TEXT,
554                parent_symbol_id TEXT,
555                signature TEXT,
556                doc_comment TEXT
557            );
558            INSERT INTO symbols VALUES ('1', 'src/pay.rs', 'PaymentGateway', 'trait', NULL, 'pub trait PaymentGateway', 'Core payment provider interface');
559            INSERT INTO symbols VALUES ('2', 'src/pay.rs', 'StripeClient', 'struct', NULL, 'pub struct StripeClient', 'Handles HTTP requests to stripe API');",
560        )
561        .unwrap();
562
563        ensure_fts_index(&conn).unwrap();
564
565        let count: i64 = conn
566            .query_row(
567                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'payment'",
568                [],
569                |r| r.get(0),
570            )
571            .unwrap();
572        assert_eq!(count, 1);
573
574        conn.execute(
575            "INSERT INTO symbols VALUES ('3', 'src/pay.rs', 'RefundHandler', 'function', NULL, 'pub fn handle_refund()', 'Processes transaction refunds');",
576            [],
577        )
578        .unwrap();
579
580        let count: i64 = conn
581            .query_row(
582                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
583                [],
584                |r| r.get(0),
585            )
586            .unwrap();
587        assert_eq!(count, 1);
588
589        conn.execute("DELETE FROM symbols WHERE symbol_id = '3';", [])
590            .unwrap();
591        let count: i64 = conn
592            .query_row(
593                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
594                [],
595                |r| r.get(0),
596            )
597            .unwrap();
598        assert_eq!(count, 0);
599    }
600
601    #[test]
602    fn ensure_fts_index_drops_a_local_indexed_before_its_parent() {
603        let dir = crate::safe_tempdir();
604        let db_path = dir.path().join("fts_rule.db");
605        let conn = open_read_write(&db_path).unwrap();
606        conn.execute_batch(
607            "CREATE TABLE symbols (
608                symbol_id TEXT PRIMARY KEY,
609                path TEXT,
610                name TEXT,
611                kind TEXT,
612                parent_symbol_id TEXT,
613                signature TEXT,
614                doc_comment TEXT
615            );
616            INSERT INTO symbols VALUES ('1', 'src/db.rs', 'open_conn', 'function', NULL, 'fn open_conn()', '');",
617        )
618        .unwrap();
619
620        ensure_fts_index(&conn).unwrap();
621
622        let rule: String = conn
623            .query_row(
624                "SELECT value FROM artifact_metadata WHERE key = 'fts_rule'",
625                [],
626                |r| r.get(0),
627            )
628            .unwrap();
629        assert_eq!(rule, FTS_RULE);
630
631        conn.execute(
632            "INSERT INTO symbols VALUES ('2', 'src/db.rs', 'drifted', 'variable', '3', 'let drifted = 1', '')",
633            [],
634        )
635        .unwrap();
636        conn.execute(
637            "INSERT INTO symbols VALUES ('3', 'src/db.rs', 'later', 'function', NULL, 'fn later()', '')",
638            [],
639        )
640        .unwrap();
641
642        let count: i64 = conn
643            .query_row(
644                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'drifted'",
645                [],
646                |r| r.get(0),
647            )
648            .unwrap();
649        assert_eq!(count, 0);
650        assert!(trigram_names(&conn, "drifted").is_empty());
651        assert_eq!(trigram_names(&conn, "later"), vec!["later"]);
652    }
653
654    #[test]
655    fn ensure_fts_index_repopulates_an_emptied_index() {
656        let dir = crate::safe_tempdir();
657        let db_path = dir.path().join("fts_empty.db");
658        let conn = open_read_write(&db_path).unwrap();
659        conn.execute_batch(
660            "CREATE TABLE symbols (
661                symbol_id TEXT PRIMARY KEY,
662                path TEXT,
663                name TEXT,
664                kind TEXT,
665                parent_symbol_id TEXT,
666                signature TEXT,
667                doc_comment TEXT
668            );
669            INSERT INTO symbols VALUES ('1', 'src/pay.rs', 'RefundHandler', 'function', NULL, 'fn handle_refund()', '');",
670        )
671        .unwrap();
672
673        ensure_fts_index(&conn).unwrap();
674        conn.execute(
675            "INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all')",
676            [],
677        )
678        .unwrap();
679
680        ensure_fts_index(&conn).unwrap();
681
682        let count: i64 = conn
683            .query_row(
684                "SELECT count(*) FROM symbols_fts WHERE symbols_fts MATCH 'refund'",
685                [],
686                |r| r.get(0),
687            )
688            .unwrap();
689        assert_eq!(count, 1);
690    }
691
692    fn symbols_db(file: &str) -> (tempfile::TempDir, Connection) {
693        let dir = crate::safe_tempdir();
694        let conn = open_read_write(&dir.path().join(file)).unwrap();
695        conn.execute_batch(
696            "CREATE TABLE symbols (
697                symbol_id TEXT PRIMARY KEY,
698                path TEXT,
699                name TEXT,
700                kind TEXT,
701                parent_symbol_id TEXT,
702                signature TEXT,
703                doc_comment TEXT
704            );
705            INSERT INTO symbols VALUES ('1', 'src/sidecar.ts', 'parseSha256Sidecar', 'function', NULL, 'function parseSha256Sidecar()', 'Reads the checksum sidecar');
706            INSERT INTO symbols VALUES ('2', 'src/sidecar.ts', 'digestBuffer', 'variable', '1', 'const digestBuffer', '');",
707        )
708        .unwrap();
709        (dir, conn)
710    }
711
712    fn trigram_names(conn: &Connection, term: &str) -> Vec<String> {
713        let mut stmt = conn
714            .prepare(
715                "SELECT name FROM symbol_names_tri WHERE symbol_names_tri MATCH ?1 ORDER BY name",
716            )
717            .unwrap();
718        stmt.query_map([format!("\"{term}\"")], |r| r.get(0))
719            .unwrap()
720            .map(Result::unwrap)
721            .collect()
722    }
723
724    fn word_names(conn: &Connection, term: &str) -> Vec<String> {
725        let mut stmt = conn
726            .prepare("SELECT name FROM symbols_fts WHERE symbols_fts MATCH ?1 ORDER BY name")
727            .unwrap();
728        stmt.query_map([term], |r| r.get(0))
729            .unwrap()
730            .map(Result::unwrap)
731            .collect()
732    }
733
734    fn docsize_rows(conn: &Connection, table: &str) -> i64 {
735        conn.query_row(&format!("SELECT count(*) FROM {table}_docsize"), [], |r| {
736            r.get(0)
737        })
738        .unwrap()
739    }
740
741    fn schema_version(conn: &Connection) -> i64 {
742        conn.query_row("PRAGMA schema_version", [], |r| r.get(0))
743            .unwrap()
744    }
745
746    #[test]
747    fn fresh_index_populates_both_tables_without_locals() {
748        let (_dir, conn) = symbols_db("fresh.db");
749
750        ensure_fts_index(&conn).unwrap();
751
752        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
753        assert_eq!(word_names(&conn, "sidecar"), vec!["parseSha256Sidecar"]);
754        assert!(trigram_names(&conn, "digest").is_empty());
755        assert!(word_names(&conn, "digestBuffer").is_empty());
756        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
757    }
758
759    #[test]
760    fn ready_search_indexes_gain_bounded_reference_lookups() {
761        let (_dir, conn) = symbols_db("reference-lookups.db");
762        ensure_fts_index(&conn).unwrap();
763        conn.execute_batch(
764            "CREATE TABLE type_facts (symbol_id TEXT, resolved_type TEXT);
765             CREATE INDEX idx_type_facts_symbol ON type_facts(symbol_id);
766             WITH RECURSIVE n(value) AS (
767                 VALUES(1) UNION ALL SELECT value + 1 FROM n WHERE value < 2000
768             )
769             INSERT INTO type_facts SELECT 'noise-' || value, 'Other' FROM n;
770             INSERT INTO type_facts VALUES ('receiver', 'Selected');
771
772             CREATE TABLE pending_relationships (
773                 target_terminal_name TEXT, path TEXT, start_line INTEGER
774             );
775             CREATE INDEX idx_pending_terminal ON pending_relationships(target_terminal_name);
776             WITH RECURSIVE n(value) AS (
777                 VALUES(1) UNION ALL SELECT value + 1 FROM n WHERE value < 2000
778             )
779             INSERT INTO pending_relationships SELECT 'get', 'noise/' || value || '.py', 42 FROM n;
780             INSERT INTO pending_relationships VALUES ('get', 'main.py', 42);",
781        )
782        .unwrap();
783
784        ensure_fts_index(&conn).unwrap();
785        ensure_fts_index(&conn).unwrap();
786
787        for (sql, expected) in [
788            (
789                "SELECT symbol_id FROM type_facts WHERE resolved_type = 'Selected'",
790                "receiver",
791            ),
792            (
793                "SELECT target_terminal_name FROM pending_relationships
794                 WHERE target_terminal_name = 'get' AND path = 'main.py' AND start_line = 42",
795                "get",
796            ),
797        ] {
798            let mut statement = conn.prepare(sql).unwrap();
799            let rows = statement
800                .query_map([], |row| row.get::<_, String>(0))
801                .unwrap()
802                .collect::<Result<Vec<_>, _>>()
803                .unwrap();
804            assert_eq!(rows, [expected]);
805            let steps = statement.get_status(rusqlite::StatementStatus::VmStep);
806            assert!(steps < 100, "{steps} SQLite steps for {sql}");
807        }
808    }
809
810    #[test]
811    fn ready_search_indexes_gain_bounded_import_lookups() {
812        let (_dir, conn) = symbols_db("import-lookups.db");
813        ensure_fts_index(&conn).unwrap();
814        conn.execute_batch(
815            "DROP INDEX IF EXISTS idx_symbols_import_path_name;
816             CREATE INDEX idx_symbols_path ON symbols(path);
817             CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
818             WITH RECURSIVE n(value) AS (
819                 VALUES(1) UNION ALL SELECT value + 1 FROM n WHERE value < 2000
820             )
821             INSERT INTO symbols(symbol_id, path, name, kind)
822             SELECT 'local-' || value, 'main.py', 'other_' || value, 'function' FROM n;
823             WITH RECURSIVE n(value) AS (
824                 VALUES(1) UNION ALL SELECT value + 1 FROM n WHERE value < 2000
825             )
826             INSERT INTO symbols(symbol_id, path, name, kind)
827             SELECT 'import-' || value, 'noise/' || value || '.py', 'Factory', 'import' FROM n;
828             INSERT INTO symbols(symbol_id, path, name, kind)
829             VALUES ('selected-import', 'main.py', 'Factory', 'import');",
830        )
831        .unwrap();
832
833        ensure_fts_index(&conn).unwrap();
834        ensure_fts_index(&conn).unwrap();
835
836        for sql in [
837            "SELECT name FROM symbols WHERE kind = 'import' AND path = 'main.py'",
838            "SELECT name FROM symbols
839             WHERE kind = 'import' AND path = 'main.py' AND name = 'Factory'",
840        ] {
841            let mut statement = conn.prepare(sql).unwrap();
842            let rows = statement
843                .query_map([], |row| row.get::<_, String>(0))
844                .unwrap()
845                .collect::<Result<Vec<_>, _>>()
846                .unwrap();
847            assert_eq!(rows, ["Factory"]);
848            let steps = statement.get_status(rusqlite::StatementStatus::VmStep);
849            assert!(steps < 100, "{steps} SQLite steps for {sql}");
850        }
851    }
852
853    fn exclude_locals_v1_layout(conn: &Connection) {
854        conn.execute_batch(
855            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
856                name, signature, doc_comment,
857                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
858            );
859            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
860            SELECT rowid, name, signature, doc_comment FROM symbols WHERE kind != 'variable';
861            CREATE TABLE artifact_metadata (key TEXT PRIMARY KEY, value TEXT);
862            INSERT INTO artifact_metadata VALUES ('fts_rule', 'exclude-locals-v1');",
863        )
864        .unwrap();
865    }
866
867    #[test]
868    fn upgrade_from_exclude_locals_v1_adds_the_trigram_table() {
869        let (_dir, conn) = symbols_db("upgrade.db");
870        exclude_locals_v1_layout(&conn);
871
872        ensure_fts_index(&conn).unwrap();
873
874        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
875        assert_eq!(word_names(&conn, "sidecar"), vec!["parseSha256Sidecar"]);
876        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
877    }
878
879    #[test]
880    fn migration_waits_for_another_writer_and_does_not_repeat_its_work() {
881        let (dir, a) = symbols_db("contended.db");
882        exclude_locals_v1_layout(&a);
883        let b = open_read_write(&dir.path().join("contended.db")).unwrap();
884        b.busy_timeout(std::time::Duration::from_secs(5)).unwrap();
885        let (locked_tx, locked_rx) = std::sync::mpsc::channel();
886
887        let writer = std::thread::spawn(move || {
888            a.execute_batch("BEGIN IMMEDIATE").unwrap();
889            locked_tx.send(()).unwrap();
890            std::thread::sleep(std::time::Duration::from_millis(300));
891            a.execute_batch("COMMIT").unwrap();
892            ensure_fts_index(&a).unwrap();
893            schema_version(&a)
894        });
895        locked_rx.recv().unwrap();
896
897        ensure_fts_index(&b).unwrap();
898
899        assert_eq!(schema_version(&b), writer.join().unwrap());
900        assert_eq!(trigram_names(&b, "sha256"), vec!["parseSha256Sidecar"]);
901        assert_eq!(word_names(&b, "sidecar"), vec!["parseSha256Sidecar"]);
902        assert_eq!(stored_fts_rule(&b).as_deref(), Some(FTS_RULE));
903    }
904
905    #[test]
906    fn interrupted_migration_is_completed_once_and_then_left_alone() {
907        let (_dir, conn) = symbols_db("interrupted.db");
908        ensure_fts_index(&conn).unwrap();
909        conn.execute("DELETE FROM artifact_metadata WHERE key = 'fts_rule'", [])
910            .unwrap();
911        let before_repair = schema_version(&conn);
912
913        ensure_fts_index(&conn).unwrap();
914
915        assert_eq!(stored_fts_rule(&conn).as_deref(), Some(FTS_RULE));
916        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
917        assert_ne!(schema_version(&conn), before_repair);
918        let settled = schema_version(&conn);
919        let rows = (
920            docsize_rows(&conn, "symbols_fts"),
921            docsize_rows(&conn, "symbol_names_tri"),
922        );
923
924        ensure_fts_index(&conn).unwrap();
925
926        assert_eq!(schema_version(&conn), settled);
927        assert_eq!(
928            (
929                docsize_rows(&conn, "symbols_fts"),
930                docsize_rows(&conn, "symbol_names_tri")
931            ),
932            rows
933        );
934    }
935
936    #[test]
937    fn missing_trigram_table_is_recreated_despite_the_marker() {
938        let (_dir, conn) = symbols_db("dropped.db");
939        ensure_fts_index(&conn).unwrap();
940        conn.execute("DROP TABLE symbol_names_tri", []).unwrap();
941
942        ensure_fts_index(&conn).unwrap();
943
944        assert_eq!(trigram_names(&conn, "sha256"), vec!["parseSha256Sidecar"]);
945        assert_eq!(docsize_rows(&conn, "symbol_names_tri"), 1);
946    }
947
948    #[test]
949    fn triggers_keep_the_trigram_table_in_sync() {
950        let (_dir, conn) = symbols_db("triggers.db");
951        ensure_fts_index(&conn).unwrap();
952
953        conn.execute(
954            "INSERT INTO symbols VALUES ('3', 'src/verify.ts', 'verifyChecksum', 'function', NULL, 'function verifyChecksum()', '')",
955            [],
956        )
957        .unwrap();
958        assert_eq!(trigram_names(&conn, "checksum"), vec!["verifyChecksum"]);
959
960        conn.execute(
961            "UPDATE symbols SET name = 'verifyDigest' WHERE symbol_id = '3'",
962            [],
963        )
964        .unwrap();
965        assert!(trigram_names(&conn, "checksum").is_empty());
966        assert_eq!(trigram_names(&conn, "digest"), vec!["verifyDigest"]);
967
968        conn.execute("DELETE FROM symbols WHERE symbol_id = '3'", [])
969            .unwrap();
970        assert!(trigram_names(&conn, "digest").is_empty());
971        assert_eq!(docsize_rows(&conn, "symbol_names_tri"), 1);
972    }
973
974    #[test]
975    fn test_mmap_size_configuration() {
976        let dir = crate::safe_tempdir();
977        let db_path = dir.path().join("mmap_test.db");
978        let conn_rw = open_read_write(&db_path).unwrap();
979        conn_rw.execute("CREATE TABLE t (x INT);", []).unwrap();
980        drop(conn_rw);
981
982        let conn_ro = open_read_only(&db_path).unwrap();
983        let mmap_size: i64 = conn_ro
984            .query_row("PRAGMA mmap_size;", [], |r| r.get(0))
985            .unwrap();
986        #[cfg(windows)]
987        assert_eq!(
988            mmap_size, 0,
989            "mmap_size must be 0 on Windows to prevent file locks"
990        );
991        #[cfg(not(windows))]
992        assert_eq!(
993            mmap_size, 268435456,
994            "mmap_size should be 256MB on non-Windows"
995        );
996    }
997
998    #[test]
999    fn test_checkpoint_truncate() {
1000        let dir = crate::safe_tempdir();
1001        let db_path = dir.path().join("wal_checkpoint.db");
1002        let conn_rw = open_read_write(&db_path).unwrap();
1003        conn_rw
1004            .execute("CREATE TABLE items (id INTEGER PRIMARY KEY, val TEXT);", [])
1005            .unwrap();
1006        conn_rw
1007            .execute("INSERT INTO items (val) VALUES ('persisted_val');", [])
1008            .unwrap();
1009        checkpoint_truncate(&conn_rw).expect("checkpoint_truncate should succeed");
1010        drop(conn_rw);
1011
1012        let conn_ro = open_read_only(&db_path).unwrap();
1013        let val: String = conn_ro
1014            .query_row("SELECT val FROM items WHERE id = 1", [], |r| r.get(0))
1015            .unwrap();
1016        assert_eq!(val, "persisted_val");
1017    }
1018
1019    #[test]
1020    fn test_retarget_artifact_root() {
1021        let dir = crate::safe_tempdir();
1022        let db_path = dir.path().join("retarget.db");
1023
1024        // 1. Missing db file -> NotFound error
1025        let missing_path = dir.path().join("missing.db");
1026        assert!(matches!(
1027            retarget_artifact_root(&missing_path, dir.path()),
1028            Err(DbError::NotFound(_))
1029        ));
1030
1031        // 2. Db without existing artifact_metadata table -> creates table and sets root_path
1032        {
1033            let conn = open_read_write(&db_path).unwrap();
1034            conn.execute("CREATE TABLE other (x INT);", []).unwrap();
1035        }
1036        let fresh_root = dir.path().join("fresh_root");
1037        std::fs::create_dir_all(&fresh_root).unwrap();
1038        retarget_artifact_root(&db_path, &fresh_root).expect("Must create table and succeed");
1039        {
1040            let conn = open_read_only(&db_path).unwrap();
1041            let val: String = conn
1042                .query_row(
1043                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
1044                    [],
1045                    |r| r.get(0),
1046                )
1047                .unwrap();
1048            assert!(
1049                crate::workspace::paths_equal(Path::new(&val), &fresh_root),
1050                "Paths must be equal: got {val}, expected {}",
1051                fresh_root.display()
1052            );
1053        }
1054
1055        // 3. Db with existing artifact_metadata -> updates root_path
1056        {
1057            let conn = open_read_write(&db_path).unwrap();
1058            conn.execute(
1059                "UPDATE artifact_metadata SET value = '/old/root' WHERE key = 'root_path';",
1060                [],
1061            )
1062            .unwrap();
1063        }
1064
1065        let new_root = dir.path().join("new_root");
1066        std::fs::create_dir_all(&new_root).unwrap();
1067        retarget_artifact_root(&db_path, &new_root).expect("retargeting must succeed");
1068
1069        {
1070            let conn = open_read_only(&db_path).unwrap();
1071            let val: String = conn
1072                .query_row(
1073                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
1074                    [],
1075                    |r| r.get(0),
1076                )
1077                .unwrap();
1078            assert!(
1079                crate::workspace::paths_equal(Path::new(&val), &new_root),
1080                "Paths must be equal: got {val}, expected {}",
1081                new_root.display()
1082            );
1083        }
1084
1085        // 4. Verbatim prefix preservation on Windows when existing root started with \\?\
1086        #[cfg(windows)]
1087        {
1088            {
1089                let conn = open_read_write(&db_path).unwrap();
1090                conn.execute(
1091                    "UPDATE artifact_metadata SET value = '\\\\?\\C:\\old\\root' WHERE key = 'root_path';",
1092                    [],
1093                )
1094                .unwrap();
1095            }
1096
1097            retarget_artifact_root(&db_path, &new_root).expect("retargeting verbatim must succeed");
1098
1099            let conn = open_read_only(&db_path).unwrap();
1100            let val: String = conn
1101                .query_row(
1102                    "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
1103                    [],
1104                    |r| r.get(0),
1105                )
1106                .unwrap();
1107            assert!(
1108                val.starts_with(r"\\?\"),
1109                "Must preserve \\\\?\\ prefix when existing root had it: got {val}"
1110            );
1111        }
1112    }
1113}