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