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 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
314const IMPL_OWNER_KINDS: &str = "'class', 'struct', 'enum', 'union', 'interface', 'type'";
316
317pub 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 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
383fn 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
409pub 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
422pub 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
431pub 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 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 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 {
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 {
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 #[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}