Skip to main content

aptu_coder_core/
cache.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! LRU cache for analysis results indexed by path, modification time, and mode.
4//!
5//! Provides thread-safe, capacity-bounded caching of file analysis outputs using LRU eviction.
6//! Recovers gracefully from poisoned mutex conditions.
7
8use crate::analyze::{AnalysisOutput, FileAnalysisOutput, FocusedAnalysisOutput};
9use crate::traversal::WalkEntry;
10use crate::types::{AnalysisMode, SymbolMatchMode};
11use lru::LruCache;
12use rayon::prelude::*;
13use serde::{Serialize, de::DeserializeOwned};
14use std::num::NonZeroUsize;
15#[cfg(unix)]
16use std::os::unix::fs::PermissionsExt;
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex};
19use std::time::SystemTime;
20use tempfile::NamedTempFile;
21use tracing::{debug, error, instrument, warn};
22
23/// Indicates which cache tier served the result.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum CacheTier {
26    L1Memory,
27    L2Disk,
28    Miss,
29}
30
31/// Parse an LRU cache capacity from an environment variable.
32///
33/// Reads `env_key`, parses it as `usize`, and returns the value clamped to a minimum of 1.
34/// Falls back to `default` when the variable is absent or unparseable, then also clamps
35/// the fallback to at least 1.
36///
37/// This helper centralises all three LRU init sites so the `.max(1)` guard lives in one place.
38#[must_use]
39pub fn parse_cache_capacity(env_key: &str, default: usize) -> usize {
40    std::env::var(env_key)
41        .ok()
42        .and_then(|v| v.parse::<usize>().ok())
43        .unwrap_or(default)
44        .max(1)
45}
46
47impl CacheTier {
48    #[must_use]
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            CacheTier::L1Memory => "l1_memory",
52            CacheTier::L2Disk => "l2_disk",
53            CacheTier::Miss => "miss",
54        }
55    }
56}
57
58/// Cache key combining path, modification time, and analysis mode.
59#[derive(Debug, Clone, Eq, PartialEq, Hash)]
60pub struct CacheKey {
61    pub path: PathBuf,
62    pub modified: SystemTime,
63    pub mode: AnalysisMode,
64}
65
66/// Cache key for directory analysis combining file mtimes, mode, and `max_depth`.
67#[derive(Debug, Clone, Eq, PartialEq, Hash)]
68pub struct DirectoryCacheKey {
69    files: Vec<(PathBuf, SystemTime)>,
70    mode: AnalysisMode,
71    max_depth: Option<u32>,
72    git_ref: Option<String>,
73}
74
75impl DirectoryCacheKey {
76    /// Build a cache key from walk entries, capturing mtime for each file.
77    /// Files are sorted by path for deterministic hashing.
78    /// Directories are filtered out; only file entries are processed.
79    /// Metadata collection is parallelized using rayon.
80    /// The `git_ref` is included so that filtered and unfiltered results have distinct keys.
81    #[must_use]
82    pub fn from_entries(
83        entries: &[WalkEntry],
84        max_depth: Option<u32>,
85        mode: AnalysisMode,
86        git_ref: Option<&str>,
87    ) -> Self {
88        let mut files: Vec<(PathBuf, SystemTime)> = entries
89            .par_iter()
90            .filter(|e| !e.is_dir)
91            .map(|e| {
92                let mtime = e.mtime.unwrap_or(SystemTime::UNIX_EPOCH);
93                (e.path.clone(), mtime)
94            })
95            .collect();
96        files.sort_by(|a, b| a.0.cmp(&b.0));
97        Self {
98            files,
99            mode,
100            max_depth,
101            git_ref: git_ref.map(ToOwned::to_owned),
102        }
103    }
104}
105
106/// Recover from a poisoned mutex by clearing the cache.
107/// On poison, creates a new empty cache and returns the recovery value.
108fn lock_or_recover<K, V, T, F>(mutex: &Mutex<LruCache<K, V>>, capacity: usize, recovery: F) -> T
109where
110    K: std::hash::Hash + Eq,
111    F: FnOnce(&mut LruCache<K, V>) -> T,
112{
113    match mutex.lock() {
114        Ok(mut guard) => recovery(&mut guard),
115        Err(poisoned) => {
116            // SAFETY: 100 is a non-zero literal
117            let cache_size = NonZeroUsize::new(capacity)
118                .unwrap_or_else(|| NonZeroUsize::new(100).expect("100 is non-zero"));
119            let new_cache = LruCache::new(cache_size);
120            let mut guard = poisoned.into_inner();
121            *guard = new_cache;
122            recovery(&mut guard)
123        }
124    }
125}
126
127/// Cache key for call graph analysis combining path, parameters, and file mtimes.
128#[derive(Debug, Clone, Eq, PartialEq, Hash)]
129pub struct CallGraphCacheKey {
130    root_path: PathBuf,
131    git_ref: Option<String>,
132    follow_depth: u32,
133    match_mode: SymbolMatchMode,
134    impl_only: bool,
135    ast_recursion_limit: Option<usize>,
136    /// Sorted (path, mtime_as_unix_nanos) pairs for all non-dir entries.
137    file_mtimes: Vec<(PathBuf, u64)>,
138}
139
140impl CallGraphCacheKey {
141    /// Build a `CallGraphCacheKey` from walk entries and analysis parameters.
142    /// Files are sorted by path for deterministic hashing.
143    /// Directories are filtered out; only file entries contribute to the key.
144    #[must_use]
145    pub fn from_entries(
146        root: &std::path::Path,
147        entries: &[WalkEntry],
148        git_ref: Option<&str>,
149        follow_depth: u32,
150        match_mode: &SymbolMatchMode,
151        impl_only: bool,
152        ast_recursion_limit: Option<usize>,
153    ) -> Self {
154        let mut file_mtimes: Vec<(PathBuf, u64)> = entries
155            .par_iter()
156            .filter(|e| !e.is_dir)
157            .map(|e| {
158                let mtime = e
159                    .mtime
160                    .unwrap_or(SystemTime::UNIX_EPOCH)
161                    .duration_since(SystemTime::UNIX_EPOCH)
162                    .map(|d| d.as_nanos() as u64)
163                    .unwrap_or(0);
164                (e.path.clone(), mtime)
165            })
166            .collect();
167        file_mtimes.sort_by(|a, b| a.0.cmp(&b.0));
168        Self {
169            root_path: root.to_path_buf(),
170            git_ref: git_ref.map(ToOwned::to_owned),
171            follow_depth,
172            match_mode: match_mode.clone(),
173            impl_only,
174            ast_recursion_limit,
175            file_mtimes,
176        }
177    }
178}
179
180/// Cached call graph result: the fully-built `FocusedAnalysisOutput`.
181/// `CallGraph` is not serializable, so caching is L1 memory only.
182pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
183
184/// L1 in-memory LRU cache for call graph results.
185/// Capacity is controlled via `APTU_CODER_SYMBOL_CACHE_CAPACITY` env var (default 32).
186pub struct CallGraphCache {
187    capacity: usize,
188    cache: Arc<Mutex<LruCache<CallGraphCacheKey, CallGraphCacheValue>>>,
189}
190
191impl CallGraphCache {
192    /// Create a new `CallGraphCache` with the given capacity.
193    ///
194    /// `capacity` is clamped to a minimum of 1 so a zero value does not panic.
195    #[must_use]
196    pub fn new(capacity: usize) -> Self {
197        let capacity = capacity.max(1);
198        let cache_size = NonZeroUsize::new(capacity).expect("capacity is non-zero after .max(1)");
199        Self {
200            capacity,
201            cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
202        }
203    }
204
205    /// Look up a cached result by key. Returns `None` on miss or mutex poison.
206    #[must_use]
207    pub fn get(&self, key: &CallGraphCacheKey) -> Option<CallGraphCacheValue> {
208        lock_or_recover(&self.cache, self.capacity, |guard| guard.get(key).cloned())
209    }
210
211    /// Store a result in the cache.
212    pub fn put(&self, key: CallGraphCacheKey, value: CallGraphCacheValue) {
213        lock_or_recover(&self.cache, self.capacity, |guard| {
214            guard.put(key, value);
215        });
216    }
217}
218
219impl Clone for CallGraphCache {
220    fn clone(&self) -> Self {
221        Self {
222            capacity: self.capacity,
223            cache: Arc::clone(&self.cache),
224        }
225    }
226}
227
228/// LRU cache for file analysis results with mutex protection.
229pub struct AnalysisCache {
230    file_capacity: usize,
231    dir_capacity: usize,
232    cache: Arc<Mutex<LruCache<CacheKey, Arc<FileAnalysisOutput>>>>,
233    directory_cache: Arc<Mutex<LruCache<DirectoryCacheKey, Arc<AnalysisOutput>>>>,
234}
235
236impl AnalysisCache {
237    /// Create a new cache with the specified file capacity.
238    /// The directory cache capacity is read from the `APTU_CODER_DIR_CACHE_CAPACITY`
239    /// environment variable (default: 20).
240    #[must_use]
241    pub fn new(capacity: usize) -> Self {
242        let file_capacity = capacity.max(1);
243        let dir_capacity = parse_cache_capacity("APTU_CODER_DIR_CACHE_CAPACITY", 20);
244        let cache_size =
245            NonZeroUsize::new(file_capacity).expect("file_capacity is non-zero after .max(1)");
246        let dir_cache_size =
247            NonZeroUsize::new(dir_capacity).expect("dir_capacity is non-zero after .max(1)");
248        Self {
249            file_capacity,
250            dir_capacity,
251            cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
252            directory_cache: Arc::new(Mutex::new(LruCache::new(dir_cache_size))),
253        }
254    }
255
256    /// Get a cached analysis result if it exists.
257    #[instrument(skip(self), fields(path = ?key.path))]
258    pub fn get(&self, key: &CacheKey) -> Option<Arc<FileAnalysisOutput>> {
259        lock_or_recover(&self.cache, self.file_capacity, |guard| {
260            let result = guard.get(key).cloned();
261            let cache_size = guard.len();
262            if let Some(v) = result {
263                debug!(cache_event = "hit", cache_size = cache_size, path = ?key.path);
264                Some(v)
265            } else {
266                debug!(cache_event = "miss", cache_size = cache_size, path = ?key.path);
267                None
268            }
269        })
270    }
271
272    /// Store an analysis result in the cache.
273    #[instrument(skip(self, value), fields(path = ?key.path))]
274    // public API; callers expect owned semantics
275    #[allow(clippy::needless_pass_by_value)]
276    pub fn put(&self, key: CacheKey, value: Arc<FileAnalysisOutput>) {
277        lock_or_recover(&self.cache, self.file_capacity, |guard| {
278            let push_result = guard.push(key.clone(), value);
279            let cache_size = guard.len();
280            match push_result {
281                None => {
282                    debug!(cache_event = "insert", cache_size = cache_size, path = ?key.path);
283                }
284                Some((returned_key, _)) => {
285                    if returned_key == key {
286                        debug!(cache_event = "update", cache_size = cache_size, path = ?key.path);
287                    } else {
288                        debug!(cache_event = "eviction", cache_size = cache_size, path = ?key.path, evicted_path = ?returned_key.path);
289                    }
290                }
291            }
292        });
293    }
294
295    /// Get a cached directory analysis result if it exists.
296    #[instrument(skip(self))]
297    pub fn get_directory(&self, key: &DirectoryCacheKey) -> Option<Arc<AnalysisOutput>> {
298        lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
299            let result = guard.get(key).cloned();
300            let cache_size = guard.len();
301            if let Some(v) = result {
302                debug!(cache_event = "hit", cache_size = cache_size);
303                Some(v)
304            } else {
305                debug!(cache_event = "miss", cache_size = cache_size);
306                None
307            }
308        })
309    }
310
311    /// Store a directory analysis result in the cache.
312    #[instrument(skip(self, value))]
313    pub fn put_directory(&self, key: DirectoryCacheKey, value: Arc<AnalysisOutput>) {
314        lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
315            let push_result = guard.push(key, value);
316            let cache_size = guard.len();
317            match push_result {
318                None => {
319                    debug!(cache_event = "insert", cache_size = cache_size);
320                }
321                Some((_, _)) => {
322                    debug!(cache_event = "eviction", cache_size = cache_size);
323                }
324            }
325        });
326    }
327
328    /// Returns the configured file-cache capacity.
329    /// Exposed for testing across crate boundaries; not part of the stable API.
330    #[doc(hidden)]
331    #[must_use]
332    pub fn file_capacity(&self) -> usize {
333        self.file_capacity
334    }
335
336    /// Invalidate all cache entries for a given file path.
337    /// Removes all entries regardless of modification time or analysis mode.
338    #[instrument(skip(self), fields(path = ?path))]
339    pub fn invalidate_file(&self, path: &std::path::Path) {
340        lock_or_recover(&self.cache, self.file_capacity, |guard| {
341            let keys: Vec<CacheKey> = guard
342                .iter()
343                .filter(|(k, _)| k.path == path)
344                .map(|(k, _)| k.clone())
345                .collect();
346            for key in keys {
347                guard.pop(&key);
348            }
349            let cache_size = guard.len();
350            debug!(cache_event = "invalidate_file", cache_size = cache_size, path = ?path);
351        });
352    }
353}
354
355impl Clone for AnalysisCache {
356    fn clone(&self) -> Self {
357        Self {
358            file_capacity: self.file_capacity,
359            dir_capacity: self.dir_capacity,
360            cache: Arc::clone(&self.cache),
361            directory_cache: Arc::clone(&self.directory_cache),
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::types::SemanticAnalysis;
370
371    #[test]
372    fn test_from_entries_skips_dirs() {
373        // Arrange: create a real temp dir and a real temp file for hermetic isolation.
374        let dir = tempfile::tempdir().expect("tempdir");
375        let file = tempfile::NamedTempFile::new_in(dir.path()).expect("tempfile");
376        let file_path = file.path().to_path_buf();
377
378        let entries = vec![
379            WalkEntry {
380                path: dir.path().to_path_buf(),
381                depth: 0,
382                is_dir: true,
383                is_symlink: false,
384                symlink_target: None,
385                mtime: None,
386                canonical_path: PathBuf::new(),
387            },
388            WalkEntry {
389                path: file_path.clone(),
390                depth: 0,
391                is_dir: false,
392                is_symlink: false,
393                symlink_target: None,
394                mtime: None,
395                canonical_path: PathBuf::new(),
396            },
397        ];
398
399        // Act: build cache key from entries
400        let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
401
402        // Assert: only the file entry should be in the cache key
403        // The directory entry should be filtered out
404        assert_eq!(key.files.len(), 1);
405        assert_eq!(key.files[0].0, file_path);
406    }
407
408    #[test]
409    fn test_invalidate_file_single_mode() {
410        // Arrange: create a cache and insert one entry for a path
411        let cache = AnalysisCache::new(10);
412        let path = PathBuf::from("/test/file.rs");
413        let key = CacheKey {
414            path: path.clone(),
415            modified: SystemTime::UNIX_EPOCH,
416            mode: AnalysisMode::Overview,
417        };
418        let output = Arc::new(FileAnalysisOutput::new(
419            String::new(),
420            SemanticAnalysis::default(),
421            0,
422            None,
423        ));
424        cache.put(key.clone(), output);
425
426        // Act: invalidate the file
427        cache.invalidate_file(&path);
428
429        // Assert: the entry should be removed
430        assert!(cache.get(&key).is_none());
431    }
432
433    #[test]
434    fn test_invalidate_file_multi_mode() {
435        // Arrange: create a cache and insert two entries for the same path with different modes
436        let cache = AnalysisCache::new(10);
437        let path = PathBuf::from("/test/file.rs");
438        let key1 = CacheKey {
439            path: path.clone(),
440            modified: SystemTime::UNIX_EPOCH,
441            mode: AnalysisMode::Overview,
442        };
443        let key2 = CacheKey {
444            path: path.clone(),
445            modified: SystemTime::UNIX_EPOCH,
446            mode: AnalysisMode::FileDetails,
447        };
448        let output = Arc::new(FileAnalysisOutput::new(
449            String::new(),
450            SemanticAnalysis::default(),
451            0,
452            None,
453        ));
454        cache.put(key1.clone(), output.clone());
455        cache.put(key2.clone(), output);
456
457        // Act: invalidate the file
458        cache.invalidate_file(&path);
459
460        // Assert: both entries should be removed
461        assert!(cache.get(&key1).is_none());
462        assert!(cache.get(&key2).is_none());
463    }
464
465    // Mutex serialises the two dir-cache-capacity tests to prevent env var races.
466    static DIR_CACHE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
467
468    #[test]
469    fn test_dir_cache_capacity_default() {
470        let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
471
472        // Arrange: ensure the env var is not set
473        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
474
475        // Act
476        let cache = AnalysisCache::new(100);
477
478        // Assert: default dir capacity is 20
479        assert_eq!(cache.dir_capacity, 20);
480    }
481
482    #[test]
483    fn test_dir_cache_capacity_from_env() {
484        let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
485
486        // Arrange
487        unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
488
489        // Act
490        let cache = AnalysisCache::new(100);
491
492        // Cleanup before assertions to minimise env pollution window
493        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
494
495        // Assert
496        assert_eq!(cache.dir_capacity, 7);
497    }
498
499    // Mutex serialises parse_cache_capacity tests that set env vars.
500    static PARSE_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
501
502    #[test]
503    fn test_parse_cache_capacity_missing_returns_default() {
504        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
505
506        // Arrange: env var is absent
507        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
508
509        // Act
510        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
511
512        // Assert: default is returned as-is
513        assert_eq!(result, 42);
514    }
515
516    #[test]
517    fn test_parse_cache_capacity_valid_returns_value() {
518        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
519
520        // Arrange
521        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
522
523        // Act
524        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
525
526        // Cleanup
527        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
528
529        // Assert: parsed value is returned
530        assert_eq!(result, 64);
531    }
532
533    #[test]
534    fn test_parse_cache_capacity_zero_returns_one() {
535        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
536
537        // Arrange: zero is below the minimum of 1
538        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
539
540        // Act
541        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
542
543        // Cleanup
544        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
545
546        // Assert: clamped to 1
547        assert_eq!(result, 1);
548    }
549
550    #[test]
551    fn test_parse_cache_capacity_garbage_returns_default() {
552        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
553
554        // Arrange: unparseable string
555        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
556
557        // Act
558        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
559
560        // Cleanup
561        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
562
563        // Assert: falls back to default
564        assert_eq!(result, 8);
565    }
566}
567
568/// Persistent content-addressable disk cache for analyze_* tools.
569/// All methods are infallible from the caller's perspective: errors are silently dropped.
570/// Number of consecutive L2 write failures that triggers an `error!` log escalation.
571/// Below this threshold each failure logs at `warn!`. At or above it the cache is
572/// considered degraded and a single `error!` is emitted so operators are alerted
573/// without flooding logs on a sustained disk-full condition.
574const DISK_CACHE_DEGRADED_THRESHOLD: u64 = 3;
575
576pub struct DiskCache {
577    base: std::path::PathBuf,
578    disabled: bool,
579    /// Counts write failures since last drain. Incremented inside `put` on any I/O error.
580    write_failures: std::sync::atomic::AtomicU64,
581    /// Cumulative write failures across all drains. Never reset; used for threshold checks.
582    total_write_failures: std::sync::atomic::AtomicU64,
583}
584
585impl DiskCache {
586    /// Returns the number of write failures accumulated since the last call and resets the
587    /// per-drain counter. The cumulative `total_write_failures` is never reset.
588    pub fn drain_write_failures(&self) -> u64 {
589        self.write_failures
590            .swap(0, std::sync::atomic::Ordering::Relaxed)
591    }
592
593    /// Returns true when cumulative write failures have reached `DISK_CACHE_DEGRADED_THRESHOLD`.
594    /// Callers can use this to emit a degraded health signal without polling the counter.
595    pub fn is_degraded(&self) -> bool {
596        self.total_write_failures
597            .load(std::sync::atomic::Ordering::Relaxed)
598            >= DISK_CACHE_DEGRADED_THRESHOLD
599    }
600}
601
602impl DiskCache {
603    /// Creates the cache directory (mode 0700) and returns a new instance.
604    /// If `disabled` is true, or if directory creation fails, all operations are no-ops.
605    pub fn new(base: std::path::PathBuf, disabled: bool) -> Self {
606        if disabled {
607            return Self {
608                base,
609                disabled: true,
610                write_failures: std::sync::atomic::AtomicU64::new(0),
611                total_write_failures: std::sync::atomic::AtomicU64::new(0),
612            };
613        }
614        if let Err(e) = std::fs::create_dir_all(&base) {
615            warn!(path = %base.display(), error = %e, "disk cache disabled: failed to create cache directory");
616            return Self {
617                base,
618                disabled: true,
619                write_failures: std::sync::atomic::AtomicU64::new(0),
620                total_write_failures: std::sync::atomic::AtomicU64::new(0),
621            };
622        }
623        #[cfg(unix)]
624        if let Err(e) = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)) {
625            warn!(path = %base.display(), error = %e, "disk cache: failed to set directory permissions to 0700");
626        }
627        #[cfg(not(unix))]
628        let _ = &base; // permissions not supported on this platform
629        Self {
630            base,
631            disabled: false,
632            write_failures: std::sync::atomic::AtomicU64::new(0),
633            total_write_failures: std::sync::atomic::AtomicU64::new(0),
634        }
635    }
636
637    pub fn entry_path(&self, tool: &str, key: &blake3::Hash) -> std::path::PathBuf {
638        let hex = format!("{}", key);
639        self.base
640            .join(tool)
641            .join(&hex[..2])
642            .join(format!("{}.json.snap", hex))
643    }
644
645    /// Returns None if entry is absent or corrupt. Never propagates errors.
646    pub fn get<T: DeserializeOwned>(&self, tool: &str, key: &blake3::Hash) -> Option<T> {
647        if self.disabled {
648            return None;
649        }
650        let path = self.entry_path(tool, key);
651        let compressed = match std::fs::read(&path) {
652            Ok(b) => b,
653            Err(_) => return None,
654        };
655        let bytes = match snap::raw::Decoder::new().decompress_vec(&compressed) {
656            Ok(b) => b,
657            Err(e) => {
658                debug!(tool, error = %e, "disk cache decompression failed");
659                return None;
660            }
661        };
662        match serde_json::from_slice(&bytes) {
663            Ok(v) => Some(v),
664            Err(e) => {
665                debug!(tool, error = %e, "disk cache deserialization failed");
666                None
667            }
668        }
669    }
670
671    /// Serialize and compress a value. Returns None if serialization or compression fails.
672    fn serialize_entry<T: Serialize>(value: &T) -> Option<Vec<u8>> {
673        let bytes = serde_json::to_vec(value).ok()?;
674        snap::raw::Encoder::new().compress_vec(&bytes).ok()
675    }
676
677    /// Write compressed data to a temporary file and atomically rename it to the target path.
678    /// Returns Err if any step fails; caller silently drops the error.
679    fn write_entry_atomically(
680        dir: &std::path::Path,
681        path: &std::path::Path,
682        compressed: &[u8],
683    ) -> Result<(), std::io::Error> {
684        use std::io::Write;
685        let mut tmp = NamedTempFile::new_in(dir)?;
686        tmp.write_all(compressed)?;
687        tmp.persist(path).map(|_| ()).map_err(|e| e.error)
688    }
689
690    /// Atomic write via NamedTempFile::persist (rename(2)). Silently drops all errors.
691    pub fn put<T: Serialize>(&self, tool: &str, key: &blake3::Hash, value: &T) {
692        if self.disabled {
693            return;
694        }
695        let path = self.entry_path(tool, key);
696        let dir = match path.parent() {
697            Some(d) => d.to_path_buf(),
698            None => return,
699        };
700        if let Err(e) = std::fs::create_dir_all(&dir) {
701            warn!(tool, error = %e, "disk cache: failed to create cache directory");
702            self.record_write_failure();
703            return;
704        }
705        let compressed = match Self::serialize_entry(value) {
706            Some(c) => c,
707            None => return,
708        };
709        if Self::write_entry_atomically(&dir, &path, &compressed)
710            .ok()
711            .is_none()
712        {
713            self.record_write_failure();
714        }
715    }
716
717    /// Increments both the per-drain and cumulative failure counters. Escalates to `error!`
718    /// once cumulative failures reach `DISK_CACHE_DEGRADED_THRESHOLD` so a sustained
719    /// disk-full or permission problem surfaces above the noise of individual `warn!` entries.
720    fn record_write_failure(&self) {
721        self.write_failures
722            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
723        let total = self
724            .total_write_failures
725            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
726            + 1;
727        if total == DISK_CACHE_DEGRADED_THRESHOLD {
728            error!(
729                path = %self.base.display(),
730                total,
731                threshold = DISK_CACHE_DEGRADED_THRESHOLD,
732                "disk cache is degraded: consecutive write failures have reached the alert threshold; \
733                 check disk space and permissions at the cache directory"
734            );
735        }
736    }
737
738    /// Removes files not accessed within retention_days. Best-effort; silently drops errors.
739    pub fn evict_stale(&self, retention_days: u64) {
740        if self.disabled {
741            return;
742        }
743        let cutoff = std::time::SystemTime::now()
744            .checked_sub(std::time::Duration::from_secs(retention_days * 86_400))
745            .unwrap_or(std::time::UNIX_EPOCH);
746        let _ = evict_dir_recursive(&self.base, cutoff);
747    }
748}
749
750fn evict_dir_recursive(
751    dir: &std::path::Path,
752    cutoff: std::time::SystemTime,
753) -> std::io::Result<()> {
754    for entry in std::fs::read_dir(dir)? {
755        let entry = entry?;
756        let meta = entry.metadata()?;
757        let path = entry.path();
758        if meta.is_dir() {
759            let _ = evict_dir_recursive(&path, cutoff);
760        } else if meta.is_file()
761            && let Ok(mtime) = meta.modified()
762            && mtime < cutoff
763        {
764            let _ = std::fs::remove_file(&path);
765        }
766    }
767    Ok(())
768}
769
770#[cfg(test)]
771mod disk_cache_tests {
772    use super::*;
773    use tempfile::TempDir;
774
775    #[test]
776    fn test_disk_cache_roundtrip() {
777        let dir = TempDir::new().unwrap();
778        let cache1 = DiskCache::new(dir.path().to_path_buf(), false);
779        let key = blake3::hash(b"test-key");
780        let value = serde_json::json!({"result": "hello", "count": 42});
781        cache1.put("analyze_file", &key, &value);
782        let cache2 = DiskCache::new(dir.path().to_path_buf(), false);
783        let result: Option<serde_json::Value> = cache2.get("analyze_file", &key);
784        assert_eq!(result, Some(value));
785    }
786
787    #[cfg(unix)]
788    #[test]
789    fn test_disk_cache_permissions() {
790        use std::os::unix::fs::PermissionsExt;
791        let dir = TempDir::new().unwrap();
792        let cache_dir = dir.path().join("analysis-cache");
793        let _cache = DiskCache::new(cache_dir.clone(), false);
794        let meta = std::fs::metadata(&cache_dir).unwrap();
795        let mode = meta.permissions().mode() & 0o777;
796        assert_eq!(mode, 0o700, "cache dir must be mode 0700");
797    }
798
799    #[test]
800    fn test_disk_cache_corrupt_entry_returns_none() {
801        let dir = TempDir::new().unwrap();
802        let cache = DiskCache::new(dir.path().to_path_buf(), false);
803        let key = blake3::hash(b"corrupt-key");
804        let path = cache.entry_path("analyze_file", &key);
805        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
806        std::fs::write(&path, b"not valid snappy data").unwrap();
807        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
808        assert!(result.is_none(), "corrupt entry must return None");
809    }
810
811    #[test]
812    fn test_disk_cache_disabled_on_dir_creation_failure() {
813        let dir = TempDir::new().unwrap();
814        // Place a regular file where DiskCache::new() would create a directory.
815        // create_dir_all fails with ENOTDIR; new() must flip disabled=true.
816        let blocked = dir.path().join("blocked");
817        std::fs::write(&blocked, b"").unwrap();
818        let cache = DiskCache::new(blocked, false);
819        // disabled=true: put is a no-op, get always returns None
820        let key = blake3::hash(b"should-not-exist");
821        cache.put("analyze_file", &key, &serde_json::json!({"x": 1}));
822        let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
823        assert!(
824            result.is_none(),
825            "cache must be disabled after dir creation failure"
826        );
827        assert!(
828            cache.disabled,
829            "disabled flag must be true after dir creation failure"
830        );
831    }
832}