1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum CacheTier {
26 L1Memory,
27 L2Disk,
28 Miss,
29}
30
31pub 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#[derive(Debug, Clone, Eq, PartialEq, Hash)]
58pub struct CacheKey {
59 pub path: PathBuf,
60 pub modified: SystemTime,
61 pub mode: AnalysisMode,
62}
63
64#[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 #[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
104fn 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 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#[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 file_mtimes: Vec<(PathBuf, u64)>,
136}
137
138impl CallGraphCacheKey {
139 #[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
178pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
181
182pub struct CallGraphCache {
185 capacity: usize,
186 cache: Arc<Mutex<LruCache<CallGraphCacheKey, CallGraphCacheValue>>>,
187}
188
189impl CallGraphCache {
190 #[must_use]
194 pub fn new(capacity: usize) -> Self {
195 let capacity = capacity.max(1);
196 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 pub fn get(&self, key: &CallGraphCacheKey) -> Option<CallGraphCacheValue> {
206 lock_or_recover(&self.cache, self.capacity, |guard| guard.get(key).cloned())
207 }
208
209 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
226pub 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 #[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 let cache_size = unsafe { NonZeroUsize::new_unchecked(file_capacity) };
244 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 #[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 #[instrument(skip(self, value), fields(path = ?key.path))]
272 #[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 #[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 #[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 #[doc(hidden)]
329 pub fn file_capacity(&self) -> usize {
330 self.file_capacity
331 }
332
333 #[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 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 let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
398
399 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 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 cache.invalidate_file(&path);
425
426 assert!(cache.get(&key).is_none());
428 }
429
430 #[test]
431 fn test_invalidate_file_multi_mode() {
432 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 cache.invalidate_file(&path);
456
457 assert!(cache.get(&key1).is_none());
459 assert!(cache.get(&key2).is_none());
460 }
461
462 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 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
471
472 let cache = AnalysisCache::new(100);
474
475 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 unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
485
486 let cache = AnalysisCache::new(100);
488
489 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
491
492 assert_eq!(cache.dir_capacity, 7);
494 }
495
496 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 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
505
506 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
508
509 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 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
519
520 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
522
523 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
525
526 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 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
536
537 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
539
540 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
542
543 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 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
553
554 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
556
557 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
559
560 assert_eq!(result, 8);
562 }
563}
564
565const DISK_CACHE_DEGRADED_THRESHOLD: u64 = 3;
572
573pub struct DiskCache {
574 base: std::path::PathBuf,
575 disabled: bool,
576 write_failures: std::sync::atomic::AtomicU64,
578 total_write_failures: std::sync::atomic::AtomicU64,
580}
581
582impl DiskCache {
583 pub fn drain_write_failures(&self) -> u64 {
586 self.write_failures
587 .swap(0, std::sync::atomic::Ordering::Relaxed)
588 }
589
590 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 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; 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 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 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 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 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 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 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 let blocked = dir.path().join("blocked");
814 std::fs::write(&blocked, b"").unwrap();
815 let cache = DiskCache::new(blocked, false);
816 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}