Skip to main content

sqry_core/cache/
mod.rs

1//! AST and symbol caching for performance.
2//!
3//! This module provides an in-memory LRU cache with optional persistence to disk,
4//! designed to avoid redundant tree-sitter parsing on repeat queries.
5//!
6//! # Architecture
7//!
8//! The cache has two layers:
9//! - **In-memory**: Fast LRU cache using `DashMap` for concurrent access
10//! - **Persistent**: Optional `.sqry-cache/` directory for cross-process reuse
11//!
12//! # Features
13//!
14//! - **Fast hashing**: BLAKE3 for content-based cache keys
15//! - **Atomic writes**: Temp + rename pattern for crash safety
16//! - **Concurrency**: Lock-free reads, per-entry write locks
17//! - **Eviction**: LRU with configurable size cap (default 50 MB)
18//! - **Validation**: Version + ABI checks for cache invalidation
19//! - **TTL support**: Per-plugin time-to-live configuration
20//!
21//! # Usage
22//!
23//! ```rust,ignore
24//! use sqry_core::cache::{CacheManager, CacheConfig};
25//!
26//! // Create cache with default config (50 MB, persistence enabled)
27//! let cache = CacheManager::new(CacheConfig::default());
28//!
29//! // Query cache before parsing
30//! if let Some(summary) = cache.get(&key) {
31//!     // Cache hit - reuse parsed symbols
32//!     return Ok(summary);
33//! }
34//!
35//! // Cache miss - parse and store
36//! let summary = parse_file(&path)?;
37//! cache.insert(key, summary.clone());
38//! ```
39//!
40//! # Configuration
41//!
42//! The cache can be configured via:
43//! - [`CacheConfig`](crate::cache::config::CacheConfig) struct (programmatic)
44//! - Environment variables (`SQRY_CACHE_MAX_BYTES`, `SQRY_CACHE_DISABLE_PERSIST`, `SQRY_CACHE_POLICY`, `SQRY_CACHE_POLICY_WINDOW`)
45//! - Plugin policies ([`CachePolicy`](crate::cache::config::CachePolicy))
46//!
47//! # Persistence
48//!
49//! Cache entries are stored in `.sqry-cache/` with this layout:
50//! ```text
51//! .sqry-cache/
52//! ├── <user_namespace_id>/              # Per-user namespace (hash of $USER)
53//! │   ├── rust/                         # Language-specific directories
54//! │   │   ├── <content_hash>/           # BLAKE3 of file content (64 hex chars)
55//! │   │   │   ├── <path_hash>/          # BLAKE3 of canonical path (16 hex chars)
56//! │   │   │   │   ├── file.rs.bin       # Cached symbol summary
57//! │   │   │   │   └── file.rs.bin.lock  # Write lock file
58//! │   │   │   └── <another_path>/
59//! │   │   │       └── file.rs.bin
60//! │   ├── python/
61//! │   └── ...
62//! └── manifest.json                     # Size tracking (Phase 3)
63//! ```
64//!
65//! # Thread Safety
66//!
67//! All cache operations are thread-safe and can be called concurrently from
68//! multiple threads (e.g., Rayon parallel queries or MCP server requests).
69//!
70//! # Performance Targets
71//!
72//! From [`01_SPEC.md`](../../../docs/development/cache/01_SPEC.md):
73//! - **Latency reduction**: ≥40% on warm cache (vs cold parse)
74//! - **Hit rate**: ≥70% on repeat queries
75//! - **Memory footprint**: ≤50 MB default (configurable)
76//!
77//! # Implementation Status
78//!
79//! ✅ **Phase 0 Complete** - Foundations (6/6 tasks)
80//! - ✅ Hash utility (BLAKE3)
81//! - ✅ Module scaffolding
82//! - ✅ CacheKey (path + language + content hash)
83//! - ✅ GraphNodeSummary (lightweight cached representation)
84//! - ✅ Design decisions resolved
85//!
86//! ✅ **Phase 1 Complete** - In-memory cache (2/2 tasks)
87//! - ✅ CacheStorage (DashMap + LRU eviction)
88//! - ✅ QueryExecutor integration
89//!
90//! ✅ **Phase 2 Complete** - Disk persistence (1/1 tasks)
91//! - ✅ PersistManager (atomic writes, multi-process locks)
92//! - ✅ CacheManager integration
93//! - ✅ Graceful degradation on disk errors
94//! - ✅ User-namespaced cache directories
95//!
96//! ⏳ **Phase 3 Planned** - Cache validation & manifest management
97//! - ⏳ Version checks (sqry version mismatch detection)
98//! - ⏳ ABI checks (postcard schema change detection)
99//! - ⏳ Manifest tracking (disk size enforcement)
100//! - ⏳ Multi-process integration tests
101
102pub mod config;
103pub mod key;
104pub mod persist;
105pub mod policy;
106pub mod prune;
107pub mod storage;
108pub mod summary;
109
110// Re-export commonly used types
111pub use config::{CacheConfig, CachePolicy};
112pub use key::CacheKey;
113pub use persist::PersistManager;
114pub use policy::{CachePolicyConfig, CachePolicyKind, CachePolicyMetrics};
115pub use prune::{
116    PruneEngine, PruneOperation, PruneOptions, PruneOutputMode, PruneReason, PruneReport,
117};
118pub use storage::{CacheStats, CacheStorage};
119pub use summary::GraphNodeSummary;
120
121// CacheManager is defined inline below (see line ~128)
122
123use crate::hash::Blake3Hash;
124use std::path::Path;
125use std::sync::Arc;
126
127/// Cache manager (main entry point).
128///
129/// Provides a high-level API for caching parsed AST symbols to avoid
130/// redundant tree-sitter parsing on repeat queries.
131///
132/// # Thread Safety
133///
134/// All operations are thread-safe and can be called concurrently.
135///
136/// # Examples
137///
138/// ```rust,ignore
139/// use sqry_core::cache::{CacheManager, CacheConfig};
140/// use sqry_core::hash::hash_file;
141///
142/// let cache = CacheManager::new(CacheConfig::default());
143///
144/// // Check cache before parsing
145/// let content_hash = hash_file(path)?;
146/// if let Some(summaries) = cache.get(path, "rust", content_hash) {
147///     // Cache hit - reuse symbols
148///     return Ok(summaries);
149/// }
150///
151/// // Cache miss - parse and store
152/// let summaries = parse_file(path)?;
153/// cache.insert(path, "rust", content_hash, summaries.clone());
154/// ```
155pub struct CacheManager {
156    config: CacheConfig,
157    storage: Arc<CacheStorage>,
158    persist: Option<Arc<PersistManager>>,
159}
160
161impl CacheManager {
162    /// Create a new cache manager with the given configuration.
163    ///
164    /// # Arguments
165    ///
166    /// * `config` - Cache configuration (size limits, persistence, etc.)
167    ///
168    /// # Examples
169    ///
170    /// ```rust
171    /// use sqry_core::cache::{CacheManager, CacheConfig};
172    ///
173    /// let cache = CacheManager::new(CacheConfig::default());
174    /// ```
175    #[must_use]
176    pub fn new(config: CacheConfig) -> Self {
177        let max_bytes = config.max_bytes();
178
179        // Initialize persistence if enabled
180        let persist = if config.is_persistence_enabled() {
181            match PersistManager::new(config.cache_root()) {
182                Ok(manager) => {
183                    log::debug!("Persistence enabled at: {}", config.cache_root().display());
184                    Some(Arc::new(manager))
185                }
186                Err(e) => {
187                    log::warn!(
188                        "Failed to initialize persistence: {e}. Cache will operate in-memory only."
189                    );
190                    None
191                }
192            }
193        } else {
194            log::debug!("Persistence disabled by configuration");
195            None
196        };
197
198        let policy_config = CachePolicyConfig::new(
199            config.policy_kind(),
200            max_bytes,
201            config.policy_window_ratio(),
202        );
203
204        Self {
205            storage: Arc::new(CacheStorage::with_policy(&policy_config)),
206            config,
207            persist,
208        }
209    }
210
211    /// Get cached symbols for a file.
212    ///
213    /// Returns `None` if the file is not in cache or the content hash doesn't match.
214    ///
215    /// # Arguments
216    ///
217    /// * `path` - File path (will be canonicalized)
218    /// * `language` - Language identifier (e.g., "rust", "python")
219    /// * `content_hash` - BLAKE3 hash of file contents
220    ///
221    /// # Returns
222    ///
223    /// `Some(Arc<[GraphNodeSummary]>)` on cache hit, `None` on miss.
224    ///
225    /// # Examples
226    ///
227    /// ```rust,ignore
228    /// use sqry_core::cache::CacheManager;
229    /// use sqry_core::hash::hash_file;
230    ///
231    /// let cache = CacheManager::default();
232    /// let hash = hash_file("example.rs")?;
233    ///
234    /// if let Some(summaries) = cache.get("example.rs", "rust", hash) {
235    ///     println!("Cache hit! {} symbols", summaries.len());
236    /// }
237    /// ```
238    pub fn get(
239        &self,
240        path: impl AsRef<Path>,
241        language: impl AsRef<str>,
242        content_hash: Blake3Hash,
243    ) -> Option<Arc<[GraphNodeSummary]>> {
244        let key = CacheKey::new(path.as_ref(), language.as_ref(), content_hash);
245
246        // Try memory cache first
247        if let Some(summaries) = self.storage.get(&key) {
248            return Some(summaries);
249        }
250
251        // Try disk cache if persistence is enabled
252        if let Some(persist) = &self.persist
253            && let Ok(Some(summaries)) = persist.read_entry(&key)
254        {
255            log::debug!("Disk cache hit for: {}", key.path().display());
256
257            // Populate memory cache for future hits
258            self.storage.insert(key, summaries.clone());
259
260            // Convert to Arc for return
261            return Some(Arc::from(summaries.into_boxed_slice()));
262        }
263
264        None
265    }
266
267    /// Insert symbols into cache.
268    ///
269    /// Triggers eviction if the cache exceeds the configured size limit.
270    ///
271    /// # Arguments
272    ///
273    /// * `path` - File path (will be canonicalized)
274    /// * `language` - Language identifier (e.g., "rust", "python")
275    /// * `content_hash` - BLAKE3 hash of file contents
276    /// * `summaries` - Node summaries to cache
277    ///
278    /// # Examples
279    ///
280    /// ```rust,ignore
281    /// use sqry_core::cache::{CacheManager, GraphNodeSummary};
282    /// use sqry_core::hash::hash_file;
283    ///
284    /// let cache = CacheManager::default();
285    /// let hash = hash_file("example.rs")?;
286    /// let summaries = vec![/* ... */];
287    ///
288    /// cache.insert("example.rs", "rust", hash, summaries);
289    /// ```
290    pub fn insert(
291        &self,
292        path: impl AsRef<Path>,
293        language: impl AsRef<str>,
294        content_hash: Blake3Hash,
295        summaries: Vec<GraphNodeSummary>,
296    ) {
297        let key = CacheKey::new(path.as_ref(), language.as_ref(), content_hash);
298
299        // Write to disk if persistence is enabled
300        if let Some(persist) = &self.persist
301            && let Err(e) = persist.write_entry(&key, &summaries)
302        {
303            log::warn!(
304                "Failed to persist cache entry for {}: {}",
305                key.path().display(),
306                e
307            );
308            // Continue with memory cache even if disk write fails
309        }
310
311        // Insert into memory cache
312        self.storage.insert(key, summaries);
313    }
314
315    /// Get cache statistics.
316    ///
317    /// Returns metrics including hit rate, miss rate, total size, and evictions.
318    ///
319    /// # Examples
320    ///
321    /// ```rust
322    /// use sqry_core::cache::CacheManager;
323    ///
324    /// let cache = CacheManager::default();
325    /// let stats = cache.stats();
326    ///
327    /// println!("Hit rate: {:.1}%", stats.hit_rate() * 100.0);
328    /// println!("Total size: {} bytes", stats.total_bytes);
329    /// ```
330    #[must_use]
331    pub fn stats(&self) -> CacheStats {
332        self.storage.stats()
333    }
334
335    /// Clear all cache entries.
336    ///
337    /// Removes all cached symbols and resets statistics.
338    /// Also clears disk cache if persistence is enabled.
339    ///
340    /// # Examples
341    ///
342    /// ```rust
343    /// use sqry_core::cache::CacheManager;
344    ///
345    /// let cache = CacheManager::default();
346    /// cache.clear();
347    /// assert_eq!(cache.stats().entry_count, 0);
348    /// ```
349    pub fn clear(&self) {
350        // Clear memory cache
351        self.storage.clear();
352
353        // Also clear disk cache if persistence is enabled
354        if let Some(persist) = &self.persist
355            && let Err(e) = persist.clear_all()
356        {
357            log::warn!("Failed to clear disk cache: {e}");
358        }
359    }
360
361    /// Get the cache configuration.
362    ///
363    /// Returns a reference to the configuration used to create this manager.
364    #[must_use]
365    pub fn config(&self) -> &CacheConfig {
366        &self.config
367    }
368
369    /// Prune the cache based on retention policies.
370    ///
371    /// Removes old or excessive cache entries according to the provided options.
372    /// Can operate in dry-run mode to preview deletions without modifying the cache.
373    ///
374    /// # Arguments
375    ///
376    /// * `options` - Pruning options (age limit, size limit, dry-run mode, etc.)
377    ///
378    /// # Returns
379    ///
380    /// `PruneReport` containing statistics about the prune operation.
381    ///
382    /// # Errors
383    ///
384    /// Returns an error if:
385    /// - No retention policy is specified (neither `max_age` nor `max_size`)
386    /// - Persistence is disabled and no target directory is provided
387    /// - IO errors occur during cache traversal or deletion
388    ///
389    /// # Examples
390    ///
391    /// ```rust,ignore
392    /// use sqry_core::cache::{CacheManager, PruneOptions};
393    /// use std::time::Duration;
394    ///
395    /// let cache = CacheManager::default();
396    ///
397    /// // Remove entries older than 7 days
398    /// let options = PruneOptions::new()
399    ///     .with_max_age(Duration::from_secs(7 * 24 * 3600));
400    /// let report = cache.prune(&options)?;
401    ///
402    /// println!("Removed {} entries ({} bytes)",
403    ///          report.entries_removed, report.bytes_removed);
404    /// ```
405    pub fn prune(&self, options: &PruneOptions) -> anyhow::Result<PruneReport> {
406        // Validate options
407        options.validate()?;
408
409        // Determine target directory
410        let cache_dir = if let Some(ref dir) = options.target_dir {
411            dir.clone()
412        } else if let Some(ref persist) = self.persist {
413            persist.user_cache_dir()
414        } else {
415            anyhow::bail!(
416                "Cannot prune cache: persistence is disabled and no --path specified. \
417                 Enable persistence or provide --path to target cache directory."
418            );
419        };
420
421        // Execute prune operation
422        let engine = PruneEngine::new(options.clone())?;
423        engine.execute(&cache_dir)
424    }
425}
426
427impl Default for CacheManager {
428    fn default() -> Self {
429        Self::new(CacheConfig::default())
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::graph::unified::node::NodeKind;
437    use crate::hash::hash_bytes;
438    use approx::assert_abs_diff_eq;
439    use std::path::{Path, PathBuf};
440    use std::sync::Arc;
441    use tempfile::TempDir;
442
443    fn make_test_hash(byte: u8) -> Blake3Hash {
444        hash_bytes(&[byte; 32])
445    }
446
447    fn make_test_summary(name: &str) -> GraphNodeSummary {
448        GraphNodeSummary::new(
449            Arc::from(name),
450            NodeKind::Function,
451            Arc::from(Path::new("test.rs")),
452            1,
453            0,
454            1,
455            10,
456        )
457    }
458
459    /// Create a cache manager with a unique temporary directory for isolation
460    fn make_test_cache() -> (CacheManager, TempDir) {
461        let tmp_cache_dir = TempDir::new().unwrap();
462        let config = CacheConfig::default().with_cache_root(tmp_cache_dir.path().to_path_buf());
463        let cache = CacheManager::new(config);
464        (cache, tmp_cache_dir)
465    }
466
467    #[test]
468    fn test_cache_manager_new() {
469        let config = CacheConfig::default();
470        let cache = CacheManager::new(config);
471
472        // Verify initial stats
473        let stats = cache.stats();
474        assert_eq!(stats.entry_count, 0);
475        assert_eq!(stats.total_bytes, 0);
476        assert_eq!(stats.hits, 0);
477        assert_eq!(stats.misses, 0);
478    }
479
480    #[test]
481    fn test_cache_manager_default() {
482        let cache = CacheManager::default();
483
484        // Verify default configuration
485        assert_eq!(cache.config().max_bytes(), CacheConfig::DEFAULT_MAX_BYTES);
486        assert!(cache.config().is_persistence_enabled());
487    }
488
489    #[test]
490    fn test_cache_manager_get_miss() {
491        let (cache, _tmp_cache_dir) = make_test_cache();
492        let hash = make_test_hash(0x42);
493
494        // Get on empty cache should miss
495        let result = cache.get("test.rs", "rust", hash);
496        assert!(result.is_none());
497
498        // Stats should show one miss
499        let stats = cache.stats();
500        assert_eq!(stats.hits, 0);
501        assert_eq!(stats.misses, 1);
502    }
503
504    #[test]
505    fn test_cache_manager_insert_and_get() {
506        let (cache, _tmp_cache_dir) = make_test_cache();
507        let hash = make_test_hash(0x42);
508
509        // Create test data
510        let summaries = vec![make_test_summary("test_fn")];
511
512        // Insert into cache
513        cache.insert("test.rs", "rust", hash, summaries.clone());
514
515        // Get should return cached data
516        let retrieved = cache
517            .get("test.rs", "rust", hash)
518            .expect("Should be cached");
519        assert_eq!(retrieved.len(), 1);
520        assert_eq!(retrieved[0].name.as_ref(), "test_fn");
521
522        // Stats should show one hit
523        let stats = cache.stats();
524        assert_eq!(stats.hits, 1);
525        assert_eq!(stats.misses, 0);
526        assert_eq!(stats.entry_count, 1);
527        assert!(stats.total_bytes > 0);
528    }
529
530    #[test]
531    fn test_cache_manager_different_hashes() {
532        let (cache, _tmp_cache_dir) = make_test_cache();
533        let hash1 = make_test_hash(0x42);
534        let hash2 = make_test_hash(0x43);
535
536        let summaries = vec![make_test_summary("test_fn")];
537
538        // Insert with hash1
539        cache.insert("test.rs", "rust", hash1, summaries.clone());
540
541        // Get with hash2 should miss (different content)
542        let result = cache.get("test.rs", "rust", hash2);
543        assert!(result.is_none());
544
545        // Get with hash1 should hit
546        let result = cache.get("test.rs", "rust", hash1);
547        assert!(result.is_some());
548    }
549
550    #[test]
551    fn test_cache_manager_different_languages() {
552        let (cache, _tmp_cache_dir) = make_test_cache();
553        let hash = make_test_hash(0x42);
554
555        let summaries = vec![make_test_summary("test_fn")];
556
557        // Insert as Rust
558        cache.insert("test.txt", "rust", hash, summaries.clone());
559
560        // Get as Python should miss (different language)
561        let result = cache.get("test.txt", "python", hash);
562        assert!(result.is_none());
563
564        // Get as Rust should hit
565        let result = cache.get("test.txt", "rust", hash);
566        assert!(result.is_some());
567    }
568
569    #[test]
570    fn test_cache_manager_clear() {
571        let (cache, _tmp_cache_dir) = make_test_cache();
572        let hash = make_test_hash(0x42);
573
574        let summaries = vec![make_test_summary("test_fn")];
575
576        // Insert some data
577        cache.insert("test.rs", "rust", hash, summaries);
578
579        // Verify it's cached
580        assert!(cache.get("test.rs", "rust", hash).is_some());
581        assert_eq!(cache.stats().entry_count, 1);
582
583        // Clear cache
584        cache.clear();
585
586        // Cache should be empty
587        assert!(cache.get("test.rs", "rust", hash).is_none());
588        let stats = cache.stats();
589        assert_eq!(stats.entry_count, 0);
590        assert_eq!(stats.total_bytes, 0);
591    }
592
593    #[test]
594    fn test_cache_manager_stats_tracking() {
595        let (cache, _tmp_cache_dir) = make_test_cache();
596        let hash = make_test_hash(0x42);
597
598        let summaries = vec![make_test_summary("test_fn")];
599
600        // Insert
601        cache.insert("test.rs", "rust", hash, summaries);
602
603        // First get - hit
604        cache.get("test.rs", "rust", hash);
605
606        // Second get - another hit
607        cache.get("test.rs", "rust", hash);
608
609        // Get different file - miss
610        cache.get("other.rs", "rust", hash);
611
612        // Check stats
613        let stats = cache.stats();
614        assert_eq!(stats.hits, 2);
615        assert_eq!(stats.misses, 1);
616        assert_abs_diff_eq!(stats.hit_rate(), 2.0 / 3.0, epsilon = 1e-10);
617    }
618
619    #[test]
620    fn test_cache_manager_eviction() {
621        // Create cache with very small limit to force eviction (postcard is compact)
622        let config = CacheConfig::new().with_max_bytes(100);
623        let cache = CacheManager::new(config);
624
625        let summaries = vec![make_test_summary("test_fn")];
626
627        // Insert multiple entries to trigger eviction
628        for i in 0..10 {
629            let hash = make_test_hash(i);
630            cache.insert(format!("file{i}.rs"), "rust", hash, summaries.clone());
631        }
632
633        // Cache should have evicted some entries
634        let stats = cache.stats();
635        assert!(stats.evictions > 0);
636        assert!(stats.total_bytes <= 100);
637    }
638
639    #[test]
640    fn test_cache_manager_config_access() {
641        let config = CacheConfig::new()
642            .with_max_bytes(100 * 1024 * 1024)
643            .with_cache_root(PathBuf::from("/tmp/test-cache"));
644
645        let cache = CacheManager::new(config);
646
647        // Verify config is accessible
648        assert_eq!(cache.config().max_bytes(), 100 * 1024 * 1024);
649        assert_eq!(
650            cache.config().cache_root(),
651            &PathBuf::from("/tmp/test-cache")
652        );
653    }
654
655    // ========================================================================
656    // Persistence Integration Tests
657    // ========================================================================
658
659    #[test]
660    fn test_persistence_enabled_by_default() {
661        use tempfile::TempDir;
662
663        let tmp_cache_dir = TempDir::new().unwrap();
664        let config = CacheConfig::new()
665            .with_cache_root(tmp_cache_dir.path().to_path_buf())
666            .with_persistence(true);
667
668        let cache = CacheManager::new(config);
669
670        // Verify persistence is initialized
671        assert!(cache.persist.is_some());
672    }
673
674    #[test]
675    fn test_persistence_disabled() {
676        use tempfile::TempDir;
677
678        let tmp_cache_dir = TempDir::new().unwrap();
679        let config = CacheConfig::new()
680            .with_cache_root(tmp_cache_dir.path().to_path_buf())
681            .with_persistence(false);
682
683        let cache = CacheManager::new(config);
684
685        // Verify persistence is not initialized
686        assert!(cache.persist.is_none());
687    }
688
689    #[test]
690    fn test_disk_cache_write_and_read() {
691        use tempfile::TempDir;
692
693        let tmp_cache_dir = TempDir::new().unwrap();
694        let config = CacheConfig::new()
695            .with_cache_root(tmp_cache_dir.path().to_path_buf())
696            .with_persistence(true);
697
698        let cache = CacheManager::new(config);
699        let hash = make_test_hash(0x42);
700
701        // Create and insert test data
702        let summaries = vec![make_test_summary("test_fn")];
703
704        cache.insert("test.rs", "rust", hash, summaries.clone());
705
706        // Verify data was written to disk by creating a new cache instance
707        let cache2 = CacheManager::new(
708            CacheConfig::new()
709                .with_cache_root(tmp_cache_dir.path().to_path_buf())
710                .with_persistence(true),
711        );
712
713        // Get from cache2 (memory is empty, should read from disk)
714        let retrieved = cache2.get("test.rs", "rust", hash);
715        assert!(retrieved.is_some(), "Should retrieve from disk");
716
717        let retrieved = retrieved.unwrap();
718        assert_eq!(retrieved.len(), 1);
719        assert_eq!(retrieved[0].name.as_ref(), "test_fn");
720    }
721
722    #[test]
723    fn test_disk_cache_miss() {
724        use tempfile::TempDir;
725
726        let tmp_cache_dir = TempDir::new().unwrap();
727        let config = CacheConfig::new()
728            .with_cache_root(tmp_cache_dir.path().to_path_buf())
729            .with_persistence(true);
730
731        let cache = CacheManager::new(config);
732        let hash = make_test_hash(0x99);
733
734        // Get non-existent entry
735        let result = cache.get("missing.rs", "rust", hash);
736        assert!(result.is_none());
737
738        // Stats should show miss
739        let stats = cache.stats();
740        assert_eq!(stats.misses, 1);
741    }
742
743    #[test]
744    fn test_clear_removes_disk_cache() {
745        use tempfile::TempDir;
746
747        let tmp_cache_dir = TempDir::new().unwrap();
748        let config = CacheConfig::new()
749            .with_cache_root(tmp_cache_dir.path().to_path_buf())
750            .with_persistence(true);
751
752        let cache = CacheManager::new(config);
753        let hash = make_test_hash(0x42);
754
755        // Insert data
756        let summaries = vec![make_test_summary("test_fn")];
757        cache.insert("test.rs", "rust", hash, summaries.clone());
758
759        // Clear cache
760        cache.clear();
761
762        // Verify memory cache is empty
763        let stats = cache.stats();
764        assert_eq!(stats.entry_count, 0);
765
766        // Create new cache instance and verify disk is also cleared
767        let cache2 = CacheManager::new(
768            CacheConfig::new()
769                .with_cache_root(tmp_cache_dir.path().to_path_buf())
770                .with_persistence(true),
771        );
772
773        let result = cache2.get("test.rs", "rust", hash);
774        assert!(result.is_none(), "Disk cache should be cleared");
775    }
776
777    #[test]
778    fn test_memory_cache_populated_on_disk_hit() {
779        use tempfile::TempDir;
780
781        let tmp_cache_dir = TempDir::new().unwrap();
782        let config = CacheConfig::new()
783            .with_cache_root(tmp_cache_dir.path().to_path_buf())
784            .with_persistence(true);
785
786        // First cache instance - write to disk
787        let cache1 = CacheManager::new(config.clone());
788        let hash = make_test_hash(0x42);
789
790        let summaries = vec![make_test_summary("test_fn")];
791        cache1.insert("test.rs", "rust", hash, summaries.clone());
792
793        // Second cache instance - read from disk
794        let cache2 = CacheManager::new(config);
795
796        // First get reads from disk (miss in memory)
797        let result1 = cache2.get("test.rs", "rust", hash);
798        assert!(result1.is_some());
799        assert_eq!(cache2.stats().hits, 0); // Memory miss
800
801        // Second get should hit memory cache
802        let result2 = cache2.get("test.rs", "rust", hash);
803        assert!(result2.is_some());
804        assert_eq!(cache2.stats().hits, 1); // Memory hit
805    }
806
807    #[test]
808    fn test_persistence_graceful_failure() {
809        use tempfile::TempDir;
810
811        // Test that cache continues to work even if disk writes fail
812        let tmp_cache_dir = TempDir::new().unwrap();
813        let config = CacheConfig::new()
814            .with_cache_root(tmp_cache_dir.path().to_path_buf())
815            .with_persistence(true);
816
817        let cache = CacheManager::new(config);
818
819        // Persistence should be enabled initially
820        assert!(cache.persist.is_some());
821
822        // Insert data - this should work
823        let hash = make_test_hash(0x42);
824        let summaries = vec![make_test_summary("test_fn")];
825
826        // Even if disk write fails (e.g., disk full), memory cache should work
827        cache.insert("test.rs", "rust", hash, summaries.clone());
828        let result = cache.get("test.rs", "rust", hash);
829        assert!(
830            result.is_some(),
831            "Memory cache should work even if disk write fails"
832        );
833
834        // Verify memory cache is functioning
835        let stats = cache.stats();
836        assert_eq!(stats.entry_count, 1);
837        assert_eq!(stats.hits, 1);
838    }
839}