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
31#[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#[derive(Debug, Clone, Eq, PartialEq, Hash)]
60pub struct CacheKey {
61 pub path: PathBuf,
62 pub modified: SystemTime,
63 pub mode: AnalysisMode,
64}
65
66#[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 #[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
106fn 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 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#[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 file_mtimes: Vec<(PathBuf, u64)>,
138}
139
140impl CallGraphCacheKey {
141 #[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
180pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
183
184pub struct CallGraphCache {
187 capacity: usize,
188 cache: Arc<Mutex<LruCache<CallGraphCacheKey, CallGraphCacheValue>>>,
189}
190
191impl CallGraphCache {
192 #[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 #[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 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
228pub 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 #[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 #[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 #[instrument(skip(self, value), fields(path = ?key.path))]
274 #[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 #[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 #[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 #[doc(hidden)]
331 #[must_use]
332 pub fn file_capacity(&self) -> usize {
333 self.file_capacity
334 }
335
336 #[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 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 let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
401
402 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 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 cache.invalidate_file(&path);
428
429 assert!(cache.get(&key).is_none());
431 }
432
433 #[test]
434 fn test_invalidate_file_multi_mode() {
435 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 cache.invalidate_file(&path);
459
460 assert!(cache.get(&key1).is_none());
462 assert!(cache.get(&key2).is_none());
463 }
464
465 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 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
474
475 let cache = AnalysisCache::new(100);
477
478 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 unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
488
489 let cache = AnalysisCache::new(100);
491
492 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
494
495 assert_eq!(cache.dir_capacity, 7);
497 }
498
499 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 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
508
509 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
511
512 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 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
522
523 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
525
526 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
528
529 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 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
539
540 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
542
543 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
545
546 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 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
556
557 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
559
560 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
562
563 assert_eq!(result, 8);
565 }
566}
567
568const DISK_CACHE_DEGRADED_THRESHOLD: u64 = 3;
575
576pub struct DiskCache {
577 base: std::path::PathBuf,
578 disabled: bool,
579 write_failures: std::sync::atomic::AtomicU64,
581 total_write_failures: std::sync::atomic::AtomicU64,
583}
584
585impl DiskCache {
586 pub fn drain_write_failures(&self) -> u64 {
589 self.write_failures
590 .swap(0, std::sync::atomic::Ordering::Relaxed)
591 }
592
593 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 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; 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 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 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 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 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 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 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 let blocked = dir.path().join("blocked");
817 std::fs::write(&blocked, b"").unwrap();
818 let cache = DiskCache::new(blocked, false);
819 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}