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
23pub type FileSymbolList = Vec<(String, Vec<(String, String)>)>;
25
26pub trait CacheStore {
27 fn init(&self) -> rusqlite::Result<()>;
29
30 fn upsert_file(&self, path: &str, hash: &str, content: &[u8]) -> rusqlite::Result<i64>;
32
33 fn get_file_hash(&self, path: &str) -> rusqlite::Result<Option<String>>;
35
36 fn insert_symbol(&self, symbol: &Symbol) -> rusqlite::Result<()>;
38
39 fn get_symbol_content(&self, symbol_id: &str) -> rusqlite::Result<Option<Vec<u8>>>;
41
42 fn delete_file(&self, path: &str) -> rusqlite::Result<()>;
44
45 fn get_file_symbols(&self, path: &str) -> rusqlite::Result<Vec<(String, String)>>;
47
48 fn get_file_content(&self, path: &str) -> rusqlite::Result<Option<Vec<u8>>>;
50
51 fn list_file_paths(&self) -> rusqlite::Result<Vec<String>>;
53
54 fn list_files_with_symbols(&self) -> rusqlite::Result<FileSymbolList>;
56
57 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 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 #[test]
376 fn test_symbol_name_with_single_quote_roundtrips() {
377 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 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 match result {
448 Ok(()) => {
449 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 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 #[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 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 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 assert_eq!(id1, id2);
584
585 let hash = cache.get_file_hash(path).unwrap();
587 assert_eq!(hash, Some("h_updated".to_string()));
588
589 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 #[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 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 let after = cache.get_file_symbols("src/doomed.rs").unwrap();
660 assert!(after.is_empty(), "Expected no symbols after file deletion");
661
662 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 #[test]
674 fn test_new_database_creates_schema_correctly() {
675 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 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 {
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 {
723 let cache = SqliteCache::new(path_str).unwrap();
724 cache.init().unwrap(); 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}