Skip to main content

codebones_core/
cache.rs

1use rusqlite::Connection;
2
3pub struct Cache {}
4
5#[derive(Debug, Clone)]
6pub struct FileRecord {
7    pub id: i64,
8    pub path: String,
9    pub hash: String,
10    pub content: Vec<u8>,
11}
12
13#[derive(Debug, Clone)]
14pub struct Symbol {
15    pub id: String,
16    pub file_id: i64,
17    pub name: String,
18    pub kind: String,
19    pub byte_offset: usize,
20    pub byte_length: usize,
21}
22
23/// File path with its associated (kind, name) symbol pairs.
24pub type FileSymbolList = Vec<(String, Vec<(String, String)>)>;
25
26pub trait CacheStore {
27    /// Initialize the database schema
28    fn init(&self) -> rusqlite::Result<()>;
29
30    /// Insert or update a file and its content. Returns the file_id.
31    fn upsert_file(&self, path: &str, hash: &str, content: &[u8]) -> rusqlite::Result<i64>;
32
33    /// Get a file's hash to check if it has changed
34    fn get_file_hash(&self, path: &str) -> rusqlite::Result<Option<String>>;
35
36    /// Insert a symbol
37    fn insert_symbol(&self, symbol: &Symbol) -> rusqlite::Result<()>;
38
39    /// Retrieve the raw bytes of a symbol using SQLite substr()
40    fn get_symbol_content(&self, symbol_id: &str) -> rusqlite::Result<Option<Vec<u8>>>;
41
42    /// Delete a file and cascade delete its symbols
43    fn delete_file(&self, path: &str) -> rusqlite::Result<()>;
44
45    /// Get symbols for a file
46    fn get_file_symbols(&self, path: &str) -> rusqlite::Result<Vec<(String, String)>>;
47
48    /// Get the raw content bytes of a file by path
49    fn get_file_content(&self, path: &str) -> rusqlite::Result<Option<Vec<u8>>>;
50
51    /// List all file paths stored in the database
52    fn list_file_paths(&self) -> rusqlite::Result<Vec<String>>;
53
54    /// List all files with their associated symbols (kind, name), ordered by byte_offset
55    fn list_files_with_symbols(&self) -> rusqlite::Result<FileSymbolList>;
56
57    /// Search symbol IDs whose name matches a SQL LIKE pattern
58    fn search_symbol_ids(&self, like_pattern: &str) -> rusqlite::Result<Vec<String>>;
59}
60
61pub struct SqliteCache {
62    conn: Connection,
63}
64
65impl SqliteCache {
66    pub fn new(db_path: &str) -> rusqlite::Result<Self> {
67        let conn = Connection::open(db_path)?;
68        // Enable foreign keys for cascading deletes
69        conn.execute("PRAGMA foreign_keys = ON", [])?;
70        Ok(Self { conn })
71    }
72
73    pub fn new_in_memory() -> rusqlite::Result<Self> {
74        let conn = Connection::open_in_memory()?;
75        conn.execute("PRAGMA foreign_keys = ON", [])?;
76        Ok(Self { conn })
77    }
78
79    fn get_file_symbols_by_id(&self, file_id: i64) -> rusqlite::Result<Vec<(String, String)>> {
80        let mut stmt = self.conn.prepare(
81            "SELECT kind, name FROM symbols WHERE file_id = ?1 ORDER BY byte_offset ASC",
82        )?;
83        let mut rows = stmt.query(rusqlite::params![file_id])?;
84        let mut symbols = Vec::new();
85        while let Some(row) = rows.next()? {
86            let kind: String = row.get(0)?;
87            let name: String = row.get(1)?;
88            symbols.push((kind, name));
89        }
90        Ok(symbols)
91    }
92}
93
94impl CacheStore for SqliteCache {
95    fn init(&self) -> rusqlite::Result<()> {
96        self.conn.execute_batch(
97            "CREATE TABLE IF NOT EXISTS files (
98                id INTEGER PRIMARY KEY AUTOINCREMENT,
99                path TEXT NOT NULL UNIQUE,
100                hash TEXT NOT NULL,
101                content BLOB NOT NULL
102            );
103            CREATE TABLE IF NOT EXISTS symbols (
104                id TEXT PRIMARY KEY,
105                file_id INTEGER NOT NULL,
106                name TEXT NOT NULL,
107                kind TEXT NOT NULL,
108                byte_offset INTEGER NOT NULL,
109                byte_length INTEGER NOT NULL,
110                FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE
111            );
112            CREATE INDEX IF NOT EXISTS idx_symbols_file_id ON symbols(file_id);
113            CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);",
114        )?;
115        Ok(())
116    }
117
118    fn upsert_file(&self, path: &str, hash: &str, content: &[u8]) -> rusqlite::Result<i64> {
119        self.conn.query_row(
120            "INSERT INTO files (path, hash, content) VALUES (?1, ?2, ?3)
121             ON CONFLICT(path) DO UPDATE SET hash=excluded.hash, content=excluded.content
122             RETURNING id",
123            rusqlite::params![path, hash, content],
124            |row| row.get(0),
125        )
126    }
127
128    fn get_file_hash(&self, path: &str) -> rusqlite::Result<Option<String>> {
129        let mut stmt = self
130            .conn
131            .prepare("SELECT hash FROM files WHERE path = ?1")?;
132        let mut rows = stmt.query(rusqlite::params![path])?;
133        if let Some(row) = rows.next()? {
134            Ok(Some(row.get(0)?))
135        } else {
136            Ok(None)
137        }
138    }
139
140    fn insert_symbol(&self, symbol: &Symbol) -> rusqlite::Result<()> {
141        self.conn.execute(
142            "INSERT INTO symbols (id, file_id, name, kind, byte_offset, byte_length)
143             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
144             ON CONFLICT(id) DO UPDATE SET
145                file_id=excluded.file_id,
146                name=excluded.name,
147                kind=excluded.kind,
148                byte_offset=excluded.byte_offset,
149                byte_length=excluded.byte_length",
150            rusqlite::params![
151                symbol.id,
152                symbol.file_id,
153                symbol.name,
154                symbol.kind,
155                symbol.byte_offset as i64,
156                symbol.byte_length as i64,
157            ],
158        )?;
159        Ok(())
160    }
161
162    fn get_symbol_content(&self, symbol_id: &str) -> rusqlite::Result<Option<Vec<u8>>> {
163        let mut stmt = self.conn.prepare(
164            "SELECT substr(f.content, s.byte_offset + 1, s.byte_length) 
165             FROM symbols s
166             JOIN files f ON s.file_id = f.id
167             WHERE s.id = ?1",
168        )?;
169        let mut rows = stmt.query(rusqlite::params![symbol_id])?;
170        if let Some(row) = rows.next()? {
171            Ok(Some(row.get(0)?))
172        } else {
173            Ok(None)
174        }
175    }
176
177    fn delete_file(&self, path: &str) -> rusqlite::Result<()> {
178        self.conn
179            .execute("DELETE FROM files WHERE path = ?1", rusqlite::params![path])?;
180        Ok(())
181    }
182
183    fn get_file_symbols(&self, path: &str) -> rusqlite::Result<Vec<(String, String)>> {
184        let mut stmt = self.conn.prepare(
185            "SELECT s.kind, s.name FROM symbols s
186             JOIN files f ON s.file_id = f.id
187             WHERE f.path = ?1
188             ORDER BY s.byte_offset ASC",
189        )?;
190        let mut rows = stmt.query(rusqlite::params![path])?;
191        let mut symbols = Vec::new();
192        while let Some(row) = rows.next()? {
193            let kind: String = row.get(0)?;
194            let name: String = row.get(1)?;
195            symbols.push((kind, name));
196        }
197        Ok(symbols)
198    }
199
200    fn get_file_content(&self, path: &str) -> rusqlite::Result<Option<Vec<u8>>> {
201        let mut stmt = self
202            .conn
203            .prepare("SELECT content FROM files WHERE path = ?1")?;
204        let mut rows = stmt.query(rusqlite::params![path])?;
205        if let Some(row) = rows.next()? {
206            Ok(Some(row.get(0)?))
207        } else {
208            Ok(None)
209        }
210    }
211
212    fn list_file_paths(&self) -> rusqlite::Result<Vec<String>> {
213        let mut stmt = self.conn.prepare("SELECT path FROM files")?;
214        let rows = stmt.query_map([], |row| row.get(0))?;
215        let mut paths = Vec::new();
216        for row in rows {
217            paths.push(row?);
218        }
219        Ok(paths)
220    }
221
222    fn list_files_with_symbols(&self) -> rusqlite::Result<FileSymbolList> {
223        let mut file_stmt = self.conn.prepare("SELECT id, path FROM files")?;
224        let mut file_rows = file_stmt.query([])?;
225        let mut result = Vec::new();
226        while let Some(row) = file_rows.next()? {
227            let id: i64 = row.get(0)?;
228            let path: String = row.get(1)?;
229            let symbols = self.get_file_symbols_by_id(id)?;
230            result.push((path, symbols));
231        }
232        Ok(result)
233    }
234
235    fn search_symbol_ids(&self, like_pattern: &str) -> rusqlite::Result<Vec<String>> {
236        let mut stmt = self
237            .conn
238            .prepare("SELECT id FROM symbols WHERE name LIKE ?1 ESCAPE '\\'")?;
239        let rows = stmt.query_map(rusqlite::params![like_pattern], |row| row.get(0))?;
240        let mut ids = Vec::new();
241        for row in rows {
242            ids.push(row?);
243        }
244        Ok(ids)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_should_initialize_schema_successfully() {
254        let cache = SqliteCache::new_in_memory().unwrap();
255        cache.init().unwrap();
256
257        let mut stmt = cache.conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('files', 'symbols')").unwrap();
258        let tables: Vec<String> = stmt
259            .query_map([], |row| row.get(0))
260            .unwrap()
261            .collect::<Result<_, _>>()
262            .unwrap();
263
264        assert!(tables.contains(&"files".to_string()));
265        assert!(tables.contains(&"symbols".to_string()));
266    }
267
268    #[test]
269    fn test_should_upsert_a_file_and_return_its_id() {
270        let cache = SqliteCache::new_in_memory().unwrap();
271        cache.init().unwrap();
272
273        let id = cache
274            .upsert_file("src/main.rs", "hash123", b"fn main() {}")
275            .unwrap();
276        assert!(id > 0);
277
278        let mut stmt = cache
279            .conn
280            .prepare("SELECT path, hash FROM files WHERE id = ?")
281            .unwrap();
282        let (path, hash): (String, String) = stmt
283            .query_row([id], |row| Ok((row.get(0)?, row.get(1)?)))
284            .unwrap();
285
286        assert_eq!(path, "src/main.rs");
287        assert_eq!(hash, "hash123");
288    }
289
290    #[test]
291    fn test_should_return_correct_file_hash_for_existing_file() {
292        let cache = SqliteCache::new_in_memory().unwrap();
293        cache.init().unwrap();
294
295        cache
296            .upsert_file("src/main.rs", "hash123", b"fn main() {}")
297            .unwrap();
298
299        let hash = cache.get_file_hash("src/main.rs").unwrap();
300        assert_eq!(hash, Some("hash123".to_string()));
301    }
302
303    #[test]
304    fn test_should_return_none_for_missing_file_hash() {
305        let cache = SqliteCache::new_in_memory().unwrap();
306        cache.init().unwrap();
307
308        let hash = cache.get_file_hash("missing.rs").unwrap();
309        assert_eq!(hash, None);
310    }
311
312    #[test]
313    fn test_should_insert_a_symbol_and_retrieve_its_content_via_substr() {
314        let cache = SqliteCache::new_in_memory().unwrap();
315        cache.init().unwrap();
316
317        let content = b"pub fn foo() {}\npub fn bar() {}";
318        let file_id = cache.upsert_file("src/lib.rs", "hash456", content).unwrap();
319
320        let symbol = Symbol {
321            id: "sym_bar".to_string(),
322            file_id,
323            name: "bar".to_string(),
324            kind: "function".to_string(),
325            byte_offset: 16,
326            byte_length: 15,
327        };
328        cache.insert_symbol(&symbol).unwrap();
329
330        let retrieved = cache.get_symbol_content("sym_bar").unwrap();
331        assert_eq!(retrieved, Some(b"pub fn bar() {}".to_vec()));
332    }
333
334    #[test]
335    fn test_should_return_none_for_missing_symbol_content() {
336        let cache = SqliteCache::new_in_memory().unwrap();
337        cache.init().unwrap();
338
339        let retrieved = cache.get_symbol_content("missing_id").unwrap();
340        assert_eq!(retrieved, None);
341    }
342
343    #[test]
344    fn test_should_cascade_delete_symbols_when_file_is_deleted() {
345        let cache = SqliteCache::new_in_memory().unwrap();
346        cache.init().unwrap();
347
348        let file_id = cache
349            .upsert_file("src/temp.rs", "hash789", b"fn temp() {}")
350            .unwrap();
351
352        let symbol = Symbol {
353            id: "sym_temp".to_string(),
354            file_id,
355            name: "temp".to_string(),
356            kind: "function".to_string(),
357            byte_offset: 0,
358            byte_length: 12,
359        };
360        cache.insert_symbol(&symbol).unwrap();
361
362        cache.delete_file("src/temp.rs").unwrap();
363
364        let mut stmt = cache
365            .conn
366            .prepare("SELECT COUNT(*) FROM symbols WHERE file_id = ?")
367            .unwrap();
368        let count: i64 = stmt.query_row([file_id], |row| row.get(0)).unwrap();
369
370        assert_eq!(count, 0);
371    }
372
373    // --- Symbol name edge cases ---
374
375    #[test]
376    fn test_symbol_name_with_single_quote_roundtrips() {
377        // SQL injection defense: a name containing a single quote must survive
378        // the parameterized INSERT and come back intact.
379        let cache = SqliteCache::new_in_memory().unwrap();
380        cache.init().unwrap();
381
382        let file_id = cache
383            .upsert_file("src/q.rs", "hq1", b"fn it's() {}")
384            .unwrap();
385
386        let name = "it's a function".to_string();
387        let symbol = Symbol {
388            id: "sym_sq".to_string(),
389            file_id,
390            name: name.clone(),
391            kind: "function".to_string(),
392            byte_offset: 0,
393            byte_length: 12,
394        };
395        cache.insert_symbol(&symbol).unwrap();
396
397        let symbols = cache.get_file_symbols("src/q.rs").unwrap();
398        assert_eq!(symbols.len(), 1);
399        assert_eq!(symbols[0].1, name);
400    }
401
402    #[test]
403    fn test_symbol_name_with_double_quote_roundtrips() {
404        let cache = SqliteCache::new_in_memory().unwrap();
405        cache.init().unwrap();
406
407        let file_id = cache
408            .upsert_file("src/dq.rs", "hdq1", b"fn main() {}")
409            .unwrap();
410
411        let name = r#"say "hello" world"#.to_string();
412        let symbol = Symbol {
413            id: "sym_dq".to_string(),
414            file_id,
415            name: name.clone(),
416            kind: "function".to_string(),
417            byte_offset: 0,
418            byte_length: 12,
419        };
420        cache.insert_symbol(&symbol).unwrap();
421
422        let symbols = cache.get_file_symbols("src/dq.rs").unwrap();
423        assert_eq!(symbols.len(), 1);
424        assert_eq!(symbols[0].1, name);
425    }
426
427    #[test]
428    fn test_symbol_name_empty_string_does_not_panic() {
429        // The schema has no NOT NULL constraint on name beyond TEXT, so an empty
430        // string should be stored gracefully. We accept either success or a
431        // well-typed rusqlite error — the key invariant is no panic.
432        let cache = SqliteCache::new_in_memory().unwrap();
433        cache.init().unwrap();
434
435        let file_id = cache.upsert_file("src/empty.rs", "hempty", b"").unwrap();
436
437        let symbol = Symbol {
438            id: "sym_empty_name".to_string(),
439            file_id,
440            name: "".to_string(),
441            kind: "function".to_string(),
442            byte_offset: 0,
443            byte_length: 0,
444        };
445        let result = cache.insert_symbol(&symbol);
446        // Must not panic; an empty name stored successfully is also acceptable.
447        match result {
448            Ok(()) => {
449                // Round-trip: the empty name must be retrievable and correct.
450                let symbols = cache
451                    .get_file_symbols("src/empty.rs")
452                    .expect("get_file_symbols must not error after successful insert");
453                assert_eq!(
454                    symbols.len(),
455                    1,
456                    "exactly one symbol should be stored; got: {:?}",
457                    symbols
458                );
459                assert_eq!(
460                    symbols[0].1, "",
461                    "retrieved symbol name must be the empty string; got: {:?}",
462                    symbols[0].1
463                );
464            }
465            Err(e) => {
466                // Graceful rejection is also fine, but must be a rusqlite constraint or
467                // type error — not a panic.  Assert the error is a recognizable rusqlite
468                // error so we know the code path is intentional, not an unexpected crash.
469                let msg = e.to_string();
470                assert!(
471                    !msg.is_empty(),
472                    "rejection error message must be non-empty; got empty string"
473                );
474            }
475        }
476    }
477
478    #[test]
479    fn test_symbol_name_very_long_no_truncation() {
480        let cache = SqliteCache::new_in_memory().unwrap();
481        cache.init().unwrap();
482
483        let long_name: String = "a".repeat(1000);
484        let content = vec![b'x'; 1000];
485        let file_id = cache.upsert_file("src/long.rs", "hlong", &content).unwrap();
486
487        let symbol = Symbol {
488            id: "sym_long_name".to_string(),
489            file_id,
490            name: long_name.clone(),
491            kind: "function".to_string(),
492            byte_offset: 0,
493            byte_length: 1000,
494        };
495        cache.insert_symbol(&symbol).unwrap();
496
497        let symbols = cache.get_file_symbols("src/long.rs").unwrap();
498        assert_eq!(symbols.len(), 1);
499        assert_eq!(symbols[0].1.len(), 1000);
500        assert_eq!(symbols[0].1, long_name);
501    }
502
503    #[test]
504    fn test_symbol_name_with_newlines_and_tabs_roundtrips() {
505        let cache = SqliteCache::new_in_memory().unwrap();
506        cache.init().unwrap();
507
508        let file_id = cache
509            .upsert_file("src/ws.rs", "hws", b"fn foo() {}")
510            .unwrap();
511
512        let name = "line1\nline2\ttabbed".to_string();
513        let symbol = Symbol {
514            id: "sym_whitespace".to_string(),
515            file_id,
516            name: name.clone(),
517            kind: "function".to_string(),
518            byte_offset: 0,
519            byte_length: 11,
520        };
521        cache.insert_symbol(&symbol).unwrap();
522
523        let symbols = cache.get_file_symbols("src/ws.rs").unwrap();
524        assert_eq!(symbols.len(), 1);
525        assert_eq!(symbols[0].1, name);
526    }
527
528    // --- File content edge cases ---
529
530    #[test]
531    fn test_file_content_with_unicode_and_emoji_roundtrips() {
532        let cache = SqliteCache::new_in_memory().unwrap();
533        cache.init().unwrap();
534
535        let content = "🦀 Rust 中文 العربية".as_bytes().to_vec();
536        let file_id = cache
537            .upsert_file("src/unicode.rs", "hunicode", &content)
538            .unwrap();
539
540        // Retrieve via symbol content spanning the whole file
541        let sym = Symbol {
542            id: "sym_unicode_all".to_string(),
543            file_id,
544            name: "unicode_fn".to_string(),
545            kind: "function".to_string(),
546            byte_offset: 0,
547            byte_length: content.len(),
548        };
549        cache.insert_symbol(&sym).unwrap();
550
551        let retrieved = cache.get_symbol_content("sym_unicode_all").unwrap();
552        assert_eq!(retrieved, Some(content));
553    }
554
555    #[test]
556    fn test_file_path_with_spaces_and_special_chars() {
557        let cache = SqliteCache::new_in_memory().unwrap();
558        cache.init().unwrap();
559
560        let path = "src/my project/file (v2) [draft].rs";
561        let id = cache.upsert_file(path, "hspecial", b"fn x() {}").unwrap();
562        assert!(id > 0);
563
564        let hash = cache.get_file_hash(path).unwrap();
565        assert_eq!(hash, Some("hspecial".to_string()));
566    }
567
568    #[test]
569    fn test_upsert_file_twice_returns_updated_content() {
570        // Re-inserting the same path with new content must return the latest
571        // content, not the stale original.
572        let cache = SqliteCache::new_in_memory().unwrap();
573        cache.init().unwrap();
574
575        let path = "src/changed.rs";
576        let original = b"fn original() {}";
577        let updated = b"fn updated() {}";
578
579        let id1 = cache.upsert_file(path, "h_original", original).unwrap();
580        let id2 = cache.upsert_file(path, "h_updated", updated).unwrap();
581
582        // Same row, same id
583        assert_eq!(id1, id2);
584
585        // Hash must reflect the update
586        let hash = cache.get_file_hash(path).unwrap();
587        assert_eq!(hash, Some("h_updated".to_string()));
588
589        // Verify content via a symbol covering the full updated file
590        let sym = Symbol {
591            id: "sym_changed".to_string(),
592            file_id: id2,
593            name: "updated".to_string(),
594            kind: "function".to_string(),
595            byte_offset: 0,
596            byte_length: updated.len(),
597        };
598        cache.insert_symbol(&sym).unwrap();
599        let retrieved = cache.get_symbol_content("sym_changed").unwrap();
600        assert_eq!(retrieved, Some(updated.to_vec()));
601    }
602
603    // --- Lookup correctness ---
604
605    #[test]
606    fn test_get_file_hash_nonexistent_returns_none_not_error() {
607        let cache = SqliteCache::new_in_memory().unwrap();
608        cache.init().unwrap();
609
610        let result = cache.get_file_hash("/nonexistent/path/that/does/not/exist.rs");
611        assert!(
612            matches!(result, Ok(None)),
613            "Expected Ok(None), got {:?}",
614            result
615        );
616    }
617
618    #[test]
619    fn test_get_symbol_content_nonexistent_returns_none_not_error() {
620        let cache = SqliteCache::new_in_memory().unwrap();
621        cache.init().unwrap();
622
623        let result = cache.get_symbol_content("sym_id_that_does_not_exist");
624        assert!(
625            matches!(result, Ok(None)),
626            "Expected Ok(None), got {:?}",
627            result
628        );
629    }
630
631    #[test]
632    fn test_delete_file_also_removes_its_symbols() {
633        let cache = SqliteCache::new_in_memory().unwrap();
634        cache.init().unwrap();
635
636        let file_id = cache
637            .upsert_file("src/doomed.rs", "hdoomed", b"fn doomed() {}")
638            .unwrap();
639
640        for i in 0..3 {
641            let sym = Symbol {
642                id: format!("sym_doomed_{i}"),
643                file_id,
644                name: format!("doomed_{i}"),
645                kind: "function".to_string(),
646                byte_offset: i * 5,
647                byte_length: 5,
648            };
649            cache.insert_symbol(&sym).unwrap();
650        }
651
652        // Confirm symbols exist before deletion
653        let before = cache.get_file_symbols("src/doomed.rs").unwrap();
654        assert_eq!(before.len(), 3);
655
656        cache.delete_file("src/doomed.rs").unwrap();
657
658        // get_file_symbols should now return empty vec (file is gone)
659        let after = cache.get_file_symbols("src/doomed.rs").unwrap();
660        assert!(after.is_empty(), "Expected no symbols after file deletion");
661
662        // Double-check via direct count
663        let mut stmt = cache
664            .conn
665            .prepare("SELECT COUNT(*) FROM symbols WHERE file_id = ?")
666            .unwrap();
667        let count: i64 = stmt.query_row([file_id], |row| row.get(0)).unwrap();
668        assert_eq!(count, 0);
669    }
670
671    // --- Database state ---
672
673    #[test]
674    fn test_new_database_creates_schema_correctly() {
675        // A fresh on-disk database (no tables yet) should have its schema
676        // initialized by SqliteCache::new + init() without error.
677        use tempfile::TempDir;
678
679        let dir = TempDir::new().unwrap();
680        let db_path = dir.path().join("fresh.db");
681        let path_str = db_path.to_str().unwrap();
682
683        let cache = SqliteCache::new(path_str).unwrap();
684        cache.init().unwrap();
685
686        let mut stmt = cache
687            .conn
688            .prepare(
689                "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('files', 'symbols')",
690            )
691            .unwrap();
692        let tables: Vec<String> = stmt
693            .query_map([], |row| row.get(0))
694            .unwrap()
695            .collect::<Result<_, _>>()
696            .unwrap();
697
698        assert!(tables.contains(&"files".to_string()));
699        assert!(tables.contains(&"symbols".to_string()));
700    }
701
702    #[test]
703    fn test_two_consecutive_opens_on_same_db_path_do_not_corrupt() {
704        // Opening a database twice (sequentially) and calling init() both times
705        // must not corrupt existing data or return an error.
706        use tempfile::TempDir;
707
708        let dir = TempDir::new().unwrap();
709        let db_path = dir.path().join("shared.db");
710        let path_str = db_path.to_str().unwrap();
711
712        // First open: create schema and insert data
713        {
714            let cache = SqliteCache::new(path_str).unwrap();
715            cache.init().unwrap();
716            cache
717                .upsert_file("src/lib.rs", "hash_first_open", b"fn lib() {}")
718                .unwrap();
719        }
720
721        // Second open: init() again (idempotent via IF NOT EXISTS), then read back
722        {
723            let cache = SqliteCache::new(path_str).unwrap();
724            cache.init().unwrap(); // Must not drop existing tables or error
725
726            let hash = cache.get_file_hash("src/lib.rs").unwrap();
727            assert_eq!(
728                hash,
729                Some("hash_first_open".to_string()),
730                "Data inserted in first open should survive second open"
731            );
732        }
733    }
734}