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}