1use anyhow::{Context, Result};
9use rusqlite::{Connection, OptionalExtension};
10use std::path::Path;
11
12use crate::models::SearchResult;
13
14#[cfg(test)]
15use crate::models::{Language, Span, SymbolKind};
16
17pub struct SymbolCache {
19 db_path: std::path::PathBuf,
20}
21
22impl SymbolCache {
23 pub fn open(cache_dir: &Path) -> Result<Self> {
25 let db_path = cache_dir.join("meta.db");
26
27 if !db_path.exists() {
28 anyhow::bail!("Cache not initialized - run 'rfx index' first");
29 }
30
31 let cache = Self { db_path };
32 cache.init_schema()?;
33
34 Ok(cache)
35 }
36
37 fn init_schema(&self) -> Result<()> {
39 let conn = Connection::open(&self.db_path)
40 .context("Failed to open meta.db")?;
41
42 let uses_file_id: bool = conn
44 .query_row(
45 "SELECT COUNT(*) FROM pragma_table_info('symbols') WHERE name='file_id'",
46 [],
47 |row| row.get::<_, i64>(0),
48 )
49 .unwrap_or(0) > 0;
50
51 if !uses_file_id {
52 let table_exists: bool = conn
54 .query_row(
55 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='symbols'",
56 [],
57 |row| row.get::<_, i64>(0),
58 )
59 .unwrap_or(0) > 0;
60
61 if table_exists {
62 log::warn!("Symbol cache schema outdated - migrating to file_id-based schema");
63 conn.execute("DROP TABLE IF EXISTS symbols", [])?;
64 }
65 }
66
67 conn.execute(
69 "CREATE TABLE IF NOT EXISTS symbols (
70 file_id INTEGER NOT NULL,
71 file_hash TEXT NOT NULL,
72 symbols_json TEXT NOT NULL,
73 last_cached INTEGER NOT NULL,
74 PRIMARY KEY (file_id, file_hash),
75 FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
76 )",
77 [],
78 )?;
79
80 conn.execute(
81 "CREATE INDEX IF NOT EXISTS idx_symbols_file_id ON symbols(file_id)",
82 [],
83 )?;
84
85 conn.execute(
86 "CREATE INDEX IF NOT EXISTS idx_symbols_hash ON symbols(file_hash)",
87 [],
88 )?;
89
90 log::debug!("Symbol cache schema initialized (file_id-based)");
91 Ok(())
92 }
93
94 pub fn get(&self, file_path: &str, file_hash: &str) -> Result<Option<Vec<SearchResult>>> {
96 let conn = Connection::open(&self.db_path)?;
97
98 let file_id: Option<i64> = conn
100 .query_row(
101 "SELECT id FROM files WHERE path = ?",
102 [file_path],
103 |row| row.get(0),
104 )
105 .optional()?;
106
107 let Some(file_id) = file_id else {
108 log::debug!("Symbol cache MISS: {} (file not in index)", file_path);
109 return Ok(None);
110 };
111
112 let symbols_json: Option<String> = conn
113 .query_row(
114 "SELECT symbols_json FROM symbols WHERE file_id = ? AND file_hash = ?",
115 [&file_id.to_string(), file_hash],
116 |row| row.get(0),
117 )
118 .optional()?;
119
120 match symbols_json {
121 Some(json) => {
122 let mut symbols: Vec<SearchResult> = serde_json::from_str(&json)
123 .context("Failed to deserialize cached symbols")?;
124
125 for symbol in &mut symbols {
127 symbol.path = file_path.to_string();
128 }
129
130 log::debug!("Symbol cache HIT: {} ({} symbols)", file_path, symbols.len());
131 Ok(Some(symbols))
132 }
133 None => {
134 log::debug!("Symbol cache MISS: {}", file_path);
135 Ok(None)
136 }
137 }
138 }
139
140 pub fn batch_get(&self, files: &[(String, String)]) -> Result<Vec<(String, Option<Vec<SearchResult>>)>> {
149 if files.is_empty() {
150 return Ok(Vec::new());
151 }
152
153 let conn = Connection::open(&self.db_path)?;
154
155 let mut file_id_stmt = conn.prepare("SELECT id FROM files WHERE path = ?")?;
157 let mut symbols_stmt = conn.prepare(
158 "SELECT symbols_json FROM symbols WHERE file_id = ? AND file_hash = ?"
159 )?;
160
161 let mut results = Vec::with_capacity(files.len());
162 let mut hits = 0;
163 let mut misses = 0;
164
165 for (file_path, file_hash) in files {
166 let file_id: Option<i64> = file_id_stmt
168 .query_row([file_path.as_str()], |row| row.get(0))
169 .optional()?;
170
171 let symbols = if let Some(file_id) = file_id {
172 let symbols_json: Option<String> = symbols_stmt
173 .query_row([&file_id.to_string(), file_hash.as_str()], |row| row.get(0))
174 .optional()?;
175
176 match symbols_json {
177 Some(json) => {
178 match serde_json::from_str::<Vec<SearchResult>>(&json) {
179 Ok(mut symbols) => {
180 for symbol in &mut symbols {
182 symbol.path = file_path.clone();
183 }
184 hits += 1;
185 Some(symbols)
186 }
187 Err(e) => {
188 log::warn!("Failed to deserialize cached symbols for {}: {}", file_path, e);
189 misses += 1;
190 None
191 }
192 }
193 }
194 None => {
195 misses += 1;
196 None
197 }
198 }
199 } else {
200 misses += 1;
201 None
202 };
203
204 results.push((file_path.clone(), symbols));
205 }
206
207 log::debug!("Batch symbol cache: {} hits, {} misses ({} total)", hits, misses, files.len());
208 Ok(results)
209 }
210
211 pub fn batch_get_with_kind(
224 &self,
225 file_ids: &[(i64, String, String)], kind_filter: Option<crate::models::SymbolKind>
227 ) -> Result<std::collections::HashMap<i64, Vec<SearchResult>>> {
228 use std::collections::HashMap;
229
230 if file_ids.is_empty() {
231 return Ok(HashMap::new());
232 }
233
234 let conn = Connection::open(&self.db_path)?;
235
236 const BATCH_SIZE: usize = 900;
239
240 let file_info: HashMap<i64, (String, String)> = file_ids.iter()
242 .map(|(id, hash, path)| (*id, (hash.clone(), path.clone())))
243 .collect();
244
245 let kind_for_filtering = kind_filter.clone();
247
248 let mut cache_map: HashMap<i64, Vec<SearchResult>> = HashMap::new();
250 let mut hits = 0;
251
252 for chunk in file_ids.chunks(BATCH_SIZE) {
253 let id_placeholders = chunk.iter()
255 .map(|_| "?")
256 .collect::<Vec<_>>()
257 .join(", ");
258
259 let query = format!(
261 "SELECT file_id, symbols_json
262 FROM symbols
263 WHERE file_id IN ({})",
264 id_placeholders
265 );
266
267 let params: Vec<Box<dyn rusqlite::ToSql>> = chunk.iter()
269 .map(|(id, _, _)| Box::new(*id) as Box<dyn rusqlite::ToSql>)
270 .collect();
271
272 let mut stmt = conn.prepare(&query)?;
274 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
275 let rows = stmt.query_map(param_refs.as_slice(), |row| {
276 Ok((
277 row.get::<_, i64>(0)?,
278 row.get::<_, String>(1)?
279 ))
280 })?;
281
282 for row_result in rows {
283 let (file_id, symbols_json) = row_result?;
284
285 if let Some((_hash, file_path)) = file_info.get(&file_id) {
287 match serde_json::from_str::<Vec<SearchResult>>(&symbols_json) {
290 Ok(mut symbols) => {
291 for symbol in &mut symbols {
293 symbol.path = file_path.clone();
294 }
295
296 if let Some(ref filter_kind) = kind_for_filtering {
300 symbols.retain(|s| &s.kind == filter_kind);
301 }
302
303 cache_map.insert(file_id, symbols);
304 hits += 1;
305 }
306 Err(e) => {
307 log::warn!("Failed to deserialize cached symbols for file_id {}: {}", file_id, e);
308 }
309 }
310 }
311 }
312 }
313
314 let misses = file_ids.len() - hits;
315
316 if kind_for_filtering.is_some() {
317 log::debug!(
318 "Batch symbol cache with Rust-side kind filter: {} hits, {} misses ({} total, {} chunks)",
319 hits, misses, file_ids.len(), (file_ids.len() + BATCH_SIZE - 1) / BATCH_SIZE
320 );
321 } else {
322 log::debug!(
323 "Batch symbol cache: {} hits, {} misses ({} total, {} chunks)",
324 hits, misses, file_ids.len(), (file_ids.len() + BATCH_SIZE - 1) / BATCH_SIZE
325 );
326 }
327
328 Ok(cache_map)
329 }
330
331 pub fn set(&self, file_path: &str, file_hash: &str, symbols: &[SearchResult]) -> Result<()> {
333 let conn = Connection::open(&self.db_path)?;
334
335 let file_id: i64 = conn.query_row(
337 "SELECT id FROM files WHERE path = ?",
338 [file_path],
339 |row| row.get(0)
340 ).context(format!("File not found in index: {}", file_path))?;
341
342 let symbols_without_path: Vec<_> = symbols
344 .iter()
345 .map(|s| {
346 let mut s = s.clone();
347 s.path = String::new(); s
349 })
350 .collect();
351
352 let symbols_json = serde_json::to_string(&symbols_without_path)
353 .context("Failed to serialize symbols")?;
354
355 let now = chrono::Utc::now().timestamp();
356
357 conn.execute(
358 "INSERT OR REPLACE INTO symbols (file_id, file_hash, symbols_json, last_cached)
359 VALUES (?, ?, ?, ?)",
360 [&file_id.to_string(), file_hash, &symbols_json, &now.to_string()],
361 )?;
362
363 log::debug!("Cached {} symbols for {}", symbols.len(), file_path);
364 Ok(())
365 }
366
367 pub fn batch_set(&self, entries: &[(String, String, Vec<SearchResult>)]) -> Result<()> {
369 let mut conn = Connection::open(&self.db_path)?;
370 let tx = conn.transaction()?;
371
372 let now = chrono::Utc::now().timestamp();
373 let now_str = now.to_string();
374
375 for (file_path, file_hash, symbols) in entries {
376 let file_id: i64 = tx.query_row(
378 "SELECT id FROM files WHERE path = ?",
379 [file_path.as_str()],
380 |row| row.get(0)
381 ).context(format!("File not found in index: {}", file_path))?;
382
383 let symbols_without_path: Vec<_> = symbols
385 .iter()
386 .map(|s| {
387 let mut s = s.clone();
388 s.path = String::new();
389 s
390 })
391 .collect();
392
393 let symbols_json = serde_json::to_string(&symbols_without_path)
394 .context("Failed to serialize symbols")?;
395
396 tx.execute(
398 "INSERT OR REPLACE INTO symbols (file_id, file_hash, symbols_json, last_cached)
399 VALUES (?, ?, ?, ?)",
400 [&file_id.to_string(), file_hash.as_str(), &symbols_json, &now_str],
401 )?;
402 }
403
404 tx.commit()?;
405 log::debug!("Batch cached symbols for {} files", entries.len());
406 Ok(())
407 }
408
409 pub fn clear(&self) -> Result<()> {
411 let conn = Connection::open(&self.db_path)?;
412 conn.execute("DELETE FROM symbols", [])?;
413 log::info!("Cleared symbol cache");
414 Ok(())
415 }
416
417 pub fn stats(&self) -> Result<SymbolCacheStats> {
419 let conn = Connection::open(&self.db_path)?;
420
421 let total_files: usize = conn
422 .query_row("SELECT COUNT(DISTINCT file_id) FROM symbols", [], |row| {
423 row.get(0)
424 })
425 .unwrap_or(0);
426
427 let total_entries: usize = conn
428 .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
429 .unwrap_or(0);
430
431 let cache_size_bytes: u64 = conn
433 .query_row(
434 "SELECT SUM(LENGTH(symbols_json)) FROM symbols",
435 [],
436 |row| row.get(0),
437 )
438 .unwrap_or(0);
439
440 Ok(SymbolCacheStats {
441 total_files,
442 total_entries,
443 cache_size_bytes,
444 })
445 }
446
447 pub fn cleanup_stale(&self) -> Result<usize> {
455 let conn = Connection::open(&self.db_path)?;
456
457 let removed = conn.execute(
458 "DELETE FROM symbols WHERE file_id NOT IN (SELECT id FROM files)",
459 [],
460 )?;
461
462 if removed > 0 {
463 log::info!("Removed {} stale symbol cache entries", removed);
464 }
465
466 Ok(removed)
467 }
468}
469
470#[derive(Debug, Clone)]
472pub struct SymbolCacheStats {
473 pub total_files: usize,
474 pub total_entries: usize,
475 pub cache_size_bytes: u64,
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::cache::CacheManager;
482 use tempfile::TempDir;
483
484 #[test]
485 fn test_symbol_cache_init() {
486 let temp = TempDir::new().unwrap();
487 let cache_mgr = CacheManager::new(temp.path());
488 cache_mgr.init().unwrap();
489
490 let symbol_cache = SymbolCache::open(cache_mgr.path()).unwrap();
491 let stats = symbol_cache.stats().unwrap();
492 assert_eq!(stats.total_files, 0);
493 }
494
495 #[test]
496 fn test_symbol_cache_set_get() {
497 let temp = TempDir::new().unwrap();
498 let cache_mgr = CacheManager::new(temp.path());
499 cache_mgr.init().unwrap();
500
501 cache_mgr.update_file("test.rs", "rust", 100).unwrap();
503
504 let symbol_cache = SymbolCache::open(cache_mgr.path()).unwrap();
505
506 let symbols = vec![
507 SearchResult::new(
508 "test.rs".to_string(),
509 Language::Rust,
510 SymbolKind::Function,
511 Some("test_fn".to_string()),
512 Span::new(1, 0, 5, 0),
513 None,
514 "fn test_fn() {}".to_string(),
515 ),
516 ];
517
518 symbol_cache
520 .set("test.rs", "hash123", &symbols)
521 .unwrap();
522
523 let cached = symbol_cache.get("test.rs", "hash123").unwrap();
525 assert!(cached.is_some());
526 assert_eq!(cached.as_ref().unwrap().len(), 1);
527 assert_eq!(
528 cached.unwrap()[0].symbol.as_deref(),
529 Some("test_fn")
530 );
531 }
532
533 #[test]
534 fn test_symbol_cache_hash_mismatch() {
535 let temp = TempDir::new().unwrap();
536 let cache_mgr = CacheManager::new(temp.path());
537 cache_mgr.init().unwrap();
538
539 cache_mgr.update_file("test.rs", "rust", 100).unwrap();
541
542 let symbol_cache = SymbolCache::open(cache_mgr.path()).unwrap();
543
544 let symbols = vec![SearchResult::new(
545 "test.rs".to_string(),
546 Language::Rust,
547 SymbolKind::Function,
548 Some("test_fn".to_string()),
549 Span::new(1, 0, 5, 0),
550 None,
551 "fn test_fn() {}".to_string(),
552 )];
553
554 symbol_cache
556 .set("test.rs", "hash123", &symbols)
557 .unwrap();
558
559 let cached = symbol_cache.get("test.rs", "hash456").unwrap();
561 assert!(cached.is_none());
562 }
563
564 #[test]
565 fn test_symbol_cache_batch_set() {
566 let temp = TempDir::new().unwrap();
567 let cache_mgr = CacheManager::new(temp.path());
568 cache_mgr.init().unwrap();
569
570 cache_mgr.update_file("file1.rs", "rust", 100).unwrap();
572 cache_mgr.update_file("file2.rs", "rust", 200).unwrap();
573
574 let symbol_cache = SymbolCache::open(cache_mgr.path()).unwrap();
575
576 let entries = vec![
577 (
578 "file1.rs".to_string(),
579 "hash1".to_string(),
580 vec![SearchResult::new(
581 "file1.rs".to_string(),
582 Language::Rust,
583 SymbolKind::Function,
584 Some("fn1".to_string()),
585 Span::new(1, 0, 5, 0),
586 None,
587 "fn fn1() {}".to_string(),
588 )],
589 ),
590 (
591 "file2.rs".to_string(),
592 "hash2".to_string(),
593 vec![SearchResult::new(
594 "file2.rs".to_string(),
595 Language::Rust,
596 SymbolKind::Function,
597 Some("fn2".to_string()),
598 Span::new(1, 0, 5, 0),
599 None,
600 "fn fn2() {}".to_string(),
601 )],
602 ),
603 ];
604
605 symbol_cache.batch_set(&entries).unwrap();
606
607 let stats = symbol_cache.stats().unwrap();
608 assert_eq!(stats.total_files, 2);
609
610 let cached1 = symbol_cache.get("file1.rs", "hash1").unwrap();
611 assert!(cached1.is_some());
612
613 let cached2 = symbol_cache.get("file2.rs", "hash2").unwrap();
614 assert!(cached2.is_some());
615 }
616
617 #[test]
618 fn test_symbol_cache_batch_get() {
619 let temp = TempDir::new().unwrap();
620 let cache_mgr = CacheManager::new(temp.path());
621 cache_mgr.init().unwrap();
622
623 cache_mgr.update_file("file1.rs", "rust", 100).unwrap();
625 cache_mgr.update_file("file2.rs", "rust", 200).unwrap();
626 cache_mgr.update_file("file3.rs", "rust", 300).unwrap();
627
628 let symbol_cache = SymbolCache::open(cache_mgr.path()).unwrap();
629
630 let entries = vec![
632 (
633 "file1.rs".to_string(),
634 "hash1".to_string(),
635 vec![SearchResult::new(
636 "file1.rs".to_string(),
637 Language::Rust,
638 SymbolKind::Function,
639 Some("fn1".to_string()),
640 Span::new(1, 0, 5, 0),
641 None,
642 "fn fn1() {}".to_string(),
643 )],
644 ),
645 (
646 "file2.rs".to_string(),
647 "hash2".to_string(),
648 vec![SearchResult::new(
649 "file2.rs".to_string(),
650 Language::Rust,
651 SymbolKind::Struct,
652 Some("Struct2".to_string()),
653 Span::new(1, 0, 5, 0),
654 None,
655 "struct Struct2 {}".to_string(),
656 )],
657 ),
658 (
659 "file3.rs".to_string(),
660 "hash3".to_string(),
661 vec![SearchResult::new(
662 "file3.rs".to_string(),
663 Language::Rust,
664 SymbolKind::Enum,
665 Some("Enum3".to_string()),
666 Span::new(1, 0, 5, 0),
667 None,
668 "enum Enum3 {}".to_string(),
669 )],
670 ),
671 ];
672
673 symbol_cache.batch_set(&entries).unwrap();
674
675 let lookup = vec![
677 ("file1.rs".to_string(), "hash1".to_string()),
678 ("file2.rs".to_string(), "hash2".to_string()),
679 ("file3.rs".to_string(), "hash3".to_string()),
680 ];
681
682 let results = symbol_cache.batch_get(&lookup).unwrap();
683 assert_eq!(results.len(), 3);
684
685 assert!(results[0].1.is_some());
687 assert_eq!(results[0].1.as_ref().unwrap()[0].symbol.as_deref(), Some("fn1"));
688
689 assert!(results[1].1.is_some());
690 assert_eq!(results[1].1.as_ref().unwrap()[0].symbol.as_deref(), Some("Struct2"));
691
692 assert!(results[2].1.is_some());
693 assert_eq!(results[2].1.as_ref().unwrap()[0].symbol.as_deref(), Some("Enum3"));
694
695 let mixed_lookup = vec![
697 ("file1.rs".to_string(), "hash1".to_string()), ("nonexistent.rs".to_string(), "hash999".to_string()), ("file2.rs".to_string(), "wrong_hash".to_string()), ("file3.rs".to_string(), "hash3".to_string()), ];
702
703 let mixed_results = symbol_cache.batch_get(&mixed_lookup).unwrap();
704 assert_eq!(mixed_results.len(), 4);
705
706 assert!(mixed_results[0].1.is_some()); assert!(mixed_results[1].1.is_none()); assert!(mixed_results[2].1.is_none()); assert!(mixed_results[3].1.is_some()); let empty_results = symbol_cache.batch_get(&[]).unwrap();
713 assert_eq!(empty_results.len(), 0);
714 }
715
716 #[test]
717 fn test_symbol_cache_clear() {
718 let temp = TempDir::new().unwrap();
719 let cache_mgr = CacheManager::new(temp.path());
720 cache_mgr.init().unwrap();
721
722 cache_mgr.update_file("test.rs", "rust", 100).unwrap();
724
725 let symbol_cache = SymbolCache::open(cache_mgr.path()).unwrap();
726
727 let symbols = vec![SearchResult::new(
728 "test.rs".to_string(),
729 Language::Rust,
730 SymbolKind::Function,
731 Some("test_fn".to_string()),
732 Span::new(1, 0, 5, 0),
733 None,
734 "fn test_fn() {}".to_string(),
735 )];
736
737 symbol_cache.set("test.rs", "hash123", &symbols).unwrap();
738
739 let stats_before = symbol_cache.stats().unwrap();
740 assert_eq!(stats_before.total_files, 1);
741
742 symbol_cache.clear().unwrap();
743
744 let stats_after = symbol_cache.stats().unwrap();
745 assert_eq!(stats_after.total_files, 0);
746 }
747
748 #[test]
749 fn test_symbol_cache_cleanup_stale() {
750 let temp = TempDir::new().unwrap();
751 let cache_mgr = CacheManager::new(temp.path());
752 cache_mgr.init().unwrap();
753
754 cache_mgr.update_file("exists.rs", "rust", 100).unwrap();
756 cache_mgr.record_branch_file("exists.rs", "main", "hash1", None).unwrap();
757
758 cache_mgr.update_file("deleted.rs", "rust", 200).unwrap();
760
761 let symbol_cache = SymbolCache::open(cache_mgr.path()).unwrap();
762
763 let symbols = vec![SearchResult::new(
765 "test.rs".to_string(),
766 Language::Rust,
767 SymbolKind::Function,
768 Some("test_fn".to_string()),
769 Span::new(1, 0, 5, 0),
770 None,
771 "fn test_fn() {}".to_string(),
772 )];
773
774 symbol_cache.set("exists.rs", "hash1", &symbols).unwrap();
775 symbol_cache
776 .set("deleted.rs", "hash2", &symbols)
777 .unwrap();
778
779 let stats_before = symbol_cache.stats().unwrap();
780 assert_eq!(stats_before.total_files, 2);
781
782 let conn = rusqlite::Connection::open(cache_mgr.path().join("meta.db")).unwrap();
786 conn.execute("DELETE FROM files WHERE path = 'deleted.rs'", []).unwrap();
787
788 let removed = symbol_cache.cleanup_stale().unwrap();
790 assert_eq!(removed, 0); let stats_after = symbol_cache.stats().unwrap();
793 assert_eq!(stats_after.total_files, 1);
794
795 let cached = symbol_cache.get("exists.rs", "hash1").unwrap();
797 assert!(cached.is_some());
798
799 let cached2 = symbol_cache.get("deleted.rs", "hash2").unwrap();
801 assert!(cached2.is_none());
802 }
803}