Skip to main content

reflex/
symbol_cache.rs

1//! Symbol cache for storing parsed symbols
2//!
3//! This module provides transparent caching of parsed symbols to avoid
4//! re-parsing files during symbol queries. Symbols are stored in SQLite
5//! and keyed by (file_path, blake3_hash) for automatic invalidation when
6//! files change.
7
8use 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
17/// Symbol cache for storing and retrieving parsed symbols
18pub struct SymbolCache {
19    db_path: std::path::PathBuf,
20}
21
22impl SymbolCache {
23    /// Open a symbol cache at the given cache directory
24    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    /// Initialize the symbols table schema if it doesn't exist
38    fn init_schema(&self) -> Result<()> {
39        let conn = Connection::open(&self.db_path)
40            .context("Failed to open meta.db")?;
41
42        // Check if we need to migrate to file_id-based schema
43        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            // Old schema detected - drop and recreate with new schema
53            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        // Create symbols table with file_id instead of file_path
68        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    /// Get cached symbols for a file (returns None if not cached or hash mismatch)
95    pub fn get(&self, file_path: &str, file_hash: &str) -> Result<Option<Vec<SearchResult>>> {
96        let conn = Connection::open(&self.db_path)?;
97
98        // Lookup file_id
99        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                // Restore file_path (it was removed during serialization to save space)
126                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    /// Get cached symbols for multiple files in one transaction (batch read)
141    ///
142    /// This is significantly faster than calling `get()` repeatedly because:
143    /// - Opens only ONE database connection instead of N
144    /// - Reuses ONE prepared statement instead of creating N
145    /// - Executes in ONE transaction instead of N
146    ///
147    /// Returns results in the same order as input. None means cache miss or hash mismatch.
148    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        // Prepare statements for file_id lookup and symbol retrieval
156        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            // Lookup file_id
167            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                                // Restore file_path (it was removed during serialization to save space)
181                                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    /// Get cached symbols for multiple files with optional kind filtering
212    ///
213    /// Uses integer file_ids for fast batch retrieval, then filters by kind in Rust.
214    /// This avoids the cache miss detection bug that occurs with SQL-level filtering.
215    ///
216    /// Automatically chunks large batches to avoid SQLite parameter limits (999 max).
217    ///
218    /// Parameters:
219    /// - file_ids: Vec of (file_id, file_hash, file_path) tuples
220    /// - kind_filter: Optional symbol kind to filter by (applied in Rust after retrieval)
221    ///
222    /// Returns HashMap of file_id → symbols for cache hits.
223    pub fn batch_get_with_kind(
224        &self,
225        file_ids: &[(i64, String, String)],  // (file_id, hash, path)
226        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        // SQLite has a limit of 999 parameters by default
237        // Chunk requests to stay well under that limit
238        const BATCH_SIZE: usize = 900;
239
240        // Build lookup map for file_ids → (hash, path)
241        let file_info: HashMap<i64, (String, String)> = file_ids.iter()
242            .map(|(id, hash, path)| (*id, (hash.clone(), path.clone())))
243            .collect();
244
245        // Capture kind filter for Rust-side filtering
246        let kind_for_filtering = kind_filter.clone();
247
248        // Collect results across all chunks
249        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            // Build placeholders for IN clause for this chunk
254            let id_placeholders = chunk.iter()
255                .map(|_| "?")
256                .collect::<Vec<_>>()
257                .join(", ");
258
259            // Always use simple query - filter by kind in Rust to avoid cache miss detection bug
260            let query = format!(
261                "SELECT file_id, symbols_json
262                 FROM symbols
263                 WHERE file_id IN ({})",
264                id_placeholders
265            );
266
267            // Prepare parameters for this chunk
268            let params: Vec<Box<dyn rusqlite::ToSql>> = chunk.iter()
269                .map(|(id, _, _)| Box::new(*id) as Box<dyn rusqlite::ToSql>)
270                .collect();
271
272            // Execute query
273            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                // Verify hash matches
286                if let Some((_hash, file_path)) = file_info.get(&file_id) {
287                    // Note: We can't verify hash here since symbols table doesn't include hash in result
288                    // This is OK - we'll verify by checking file_hash in a separate query if needed
289                    match serde_json::from_str::<Vec<SearchResult>>(&symbols_json) {
290                        Ok(mut symbols) => {
291                            // Restore file_path (it was removed during serialization)
292                            for symbol in &mut symbols {
293                                symbol.path = file_path.clone();
294                            }
295
296                            // Filter symbols by kind if needed (Rust-side filtering)
297                            // Note: We do this in Rust rather than SQL to avoid cache miss detection bugs
298                            // SQL filtering would exclude files without the kind, making QueryEngine think they're uncached
299                            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    /// Store symbols for a file using file_id
332    pub fn set(&self, file_path: &str, file_hash: &str, symbols: &[SearchResult]) -> Result<()> {
333        let conn = Connection::open(&self.db_path)?;
334
335        // Lookup file_id from file_path
336        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        // Serialize symbols WITHOUT path (we'll restore it on read to save ~90MB)
343        let symbols_without_path: Vec<_> = symbols
344            .iter()
345            .map(|s| {
346                let mut s = s.clone();
347                s.path = String::new();  // Clear path to avoid duplication
348                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    /// Batch store symbols for multiple files in a single transaction
368    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            // Lookup file_id
377            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            // Serialize symbols WITHOUT path
384            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            // Insert into symbols table
397            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    /// Clear all cached symbols
410    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    /// Get cache statistics
418    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        // Estimate cache size by summing length of symbols_json
432        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    /// Remove symbols for files that are no longer in the index
448    ///
449    /// This cleanup operation removes stale symbol cache entries for files
450    /// that have been deleted or are no longer indexed.
451    ///
452    /// Note: With foreign key constraints (CASCADE DELETE), this should rarely
453    /// find anything to clean up, but it's useful for manual verification.
454    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/// Statistics about the symbol cache
471#[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        // Add file to index first (required for symbol_cache.set())
502        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        // Store symbols
519        symbol_cache
520            .set("test.rs", "hash123", &symbols)
521            .unwrap();
522
523        // Retrieve symbols
524        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        // Add file to index first
540        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        // Store with hash123
555        symbol_cache
556            .set("test.rs", "hash123", &symbols)
557            .unwrap();
558
559        // Try to retrieve with different hash - should return None
560        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        // Add files to index first
571        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        // Add files to index first
624        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        // Populate cache with multiple files
631        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        // Test batch_get with all cached files
676        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        // Verify all hits
686        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        // Test batch_get with mixed hits and misses
696        let mixed_lookup = vec![
697            ("file1.rs".to_string(), "hash1".to_string()),      // Hit
698            ("nonexistent.rs".to_string(), "hash999".to_string()), // Miss (file doesn't exist)
699            ("file2.rs".to_string(), "wrong_hash".to_string()),  // Miss (hash mismatch)
700            ("file3.rs".to_string(), "hash3".to_string()),      // Hit
701        ];
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()); // file1.rs - hit
707        assert!(mixed_results[1].1.is_none());  // nonexistent.rs - miss
708        assert!(mixed_results[2].1.is_none());  // file2.rs wrong hash - miss
709        assert!(mixed_results[3].1.is_some()); // file3.rs - hit
710
711        // Test batch_get with empty input
712        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        // Add file to index first
723        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        // Add a file to the index
755        cache_mgr.update_file("exists.rs", "rust", 100).unwrap();
756        cache_mgr.record_branch_file("exists.rs", "main", "hash1", None).unwrap();
757
758        // Add deleted.rs to index temporarily
759        cache_mgr.update_file("deleted.rs", "rust", 200).unwrap();
760
761        let symbol_cache = SymbolCache::open(cache_mgr.path()).unwrap();
762
763        // Cache symbols for both existing and non-existing files
764        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        // Now remove "deleted.rs" from files table to make its symbol cache entry stale
783        // Note: With CASCADE DELETE foreign key constraint, the symbol entry is automatically
784        // removed when the file is deleted, so cleanup_stale() won't find anything to remove.
785        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        // Cleanup stale entries (should find 0 because CASCADE DELETE already cleaned it up)
789        let removed = symbol_cache.cleanup_stale().unwrap();
790        assert_eq!(removed, 0); // CASCADE DELETE already removed it
791
792        let stats_after = symbol_cache.stats().unwrap();
793        assert_eq!(stats_after.total_files, 1);
794
795        // exists.rs should still be cached
796        let cached = symbol_cache.get("exists.rs", "hash1").unwrap();
797        assert!(cached.is_some());
798
799        // deleted.rs should be gone
800        let cached2 = symbol_cache.get("deleted.rs", "hash2").unwrap();
801        assert!(cached2.is_none());
802    }
803}