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 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
276pub 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
289pub 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 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 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 {
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 {
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 #[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}