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
20pub 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
51pub 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
66pub 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
82pub 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
100const 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
145pub 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
312pub 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
325pub 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 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 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 {
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 {
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 #[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}