1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::RwLock;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::{Duration, Instant};
11
12use dashmap::DashMap;
13
14#[derive(Debug)]
16struct CacheEntry<T> {
17 value: T,
18 expires_at: Instant,
19}
20
21impl<T> CacheEntry<T> {
22 fn new(value: T, ttl: Duration) -> Self {
23 Self {
24 value,
25 expires_at: Instant::now() + ttl,
26 }
27 }
28
29 fn is_expired(&self) -> bool {
30 Instant::now() >= self.expires_at
31 }
32}
33
34pub struct MetadataCache<K, V> {
36 entries: RwLock<HashMap<K, CacheEntry<V>>>,
37 ttl: Duration,
38 max_entries: usize,
39}
40
41impl<K: std::hash::Hash + Eq + Clone, V: Clone> MetadataCache<K, V> {
42 #[must_use]
44 pub fn new(ttl: Duration, max_entries: usize) -> Self {
45 Self {
46 entries: RwLock::new(HashMap::new()),
47 ttl,
48 max_entries,
49 }
50 }
51
52 #[must_use]
54 #[allow(clippy::significant_drop_tightening)]
55 pub fn get(&self, key: &K) -> Option<V> {
56 let entries = self.entries.read().ok()?;
57 let entry = entries.get(key)?;
58 if entry.is_expired() {
59 None
60 } else {
61 Some(entry.value.clone())
62 }
63 }
64
65 pub fn insert(&self, key: K, value: V) {
67 if let Ok(mut entries) = self.entries.write() {
68 if entries.len() >= self.max_entries {
70 entries.retain(|_, v| !v.is_expired());
71 }
72
73 entries.insert(key, CacheEntry::new(value, self.ttl));
74 }
75 }
76
77 pub fn remove(&self, key: &K) {
79 if let Ok(mut entries) = self.entries.write() {
80 entries.remove(key);
81 }
82 }
83
84 pub fn clear(&self) {
86 if let Ok(mut entries) = self.entries.write() {
87 entries.clear();
88 }
89 }
90}
91
92#[derive(Debug, Clone)]
99pub struct AdaptiveTtlConfig {
100 pub rules: Vec<TtlRule>,
102 pub default_ttl: Duration,
104}
105
106#[derive(Debug, Clone)]
108pub struct TtlRule {
109 pub prefix: String,
111 pub ttl: Duration,
113}
114
115impl Default for AdaptiveTtlConfig {
116 fn default() -> Self {
117 Self {
118 rules: vec![
119 TtlRule {
120 prefix: "/node_modules/".into(),
121 ttl: Duration::from_secs(30),
122 },
123 TtlRule {
124 prefix: "/.git/".into(),
125 ttl: Duration::from_secs(60),
126 },
127 TtlRule {
128 prefix: "/.pnpm/".into(),
129 ttl: Duration::from_secs(30),
130 },
131 TtlRule {
132 prefix: "/target/".into(),
133 ttl: Duration::from_secs(30),
134 },
135 TtlRule {
136 prefix: "/__pycache__/".into(),
137 ttl: Duration::from_secs(30),
138 },
139 ],
140 default_ttl: Duration::from_secs(5),
141 }
142 }
143}
144
145impl AdaptiveTtlConfig {
146 #[must_use]
151 pub fn ttl_for(&self, path: &str) -> Duration {
152 for rule in &self.rules {
153 if path.contains(&rule.prefix) {
154 return rule.ttl;
155 }
156 }
157 self.default_ttl
158 }
159}
160
161#[derive(Debug, Clone)]
167pub struct NegativeCacheConfig {
168 pub max_entries: usize,
172
173 pub timeout: Duration,
177
178 pub adaptive_ttl: Option<AdaptiveTtlConfig>,
182}
183
184impl Default for NegativeCacheConfig {
185 fn default() -> Self {
186 Self::new()
187 }
188}
189
190impl NegativeCacheConfig {
191 #[must_use]
193 pub fn new() -> Self {
194 Self {
195 max_entries: 10_000,
196 timeout: Duration::from_secs(5),
197 adaptive_ttl: Some(AdaptiveTtlConfig::default()),
198 }
199 }
200
201 #[must_use]
203 pub const fn fixed(timeout: Duration, max_entries: usize) -> Self {
204 Self {
205 max_entries,
206 timeout,
207 adaptive_ttl: None,
208 }
209 }
210}
211
212#[derive(Debug, Clone, Default)]
214pub struct NegativeCacheStats {
215 pub entries: usize,
217 pub hits: u64,
219 pub misses: u64,
221}
222
223impl NegativeCacheStats {
224 #[must_use]
227 #[allow(clippy::cast_precision_loss)]
228 pub fn hit_ratio(&self) -> f64 {
229 let total = self.hits + self.misses;
230 if total == 0 {
231 0.0
232 } else {
233 (self.hits as f64 / total as f64) * 100.0
234 }
235 }
236}
237
238#[derive(Debug, Clone, Copy)]
243struct NegativeCacheEntry {
244 expires_at: Instant,
246}
247
248impl NegativeCacheEntry {
249 fn new(ttl: Duration) -> Self {
250 Self {
251 expires_at: Instant::now() + ttl,
252 }
253 }
254
255 fn is_expired(&self) -> bool {
256 Instant::now() >= self.expires_at
257 }
258}
259
260pub struct NegativeCache {
294 entries: DashMap<PathBuf, NegativeCacheEntry>,
296 config: NegativeCacheConfig,
298 hits: AtomicU64,
300 misses: AtomicU64,
302}
303
304impl NegativeCache {
305 #[must_use]
307 pub fn new(config: NegativeCacheConfig) -> Self {
308 Self {
309 entries: DashMap::with_capacity(config.max_entries),
310 config,
311 hits: AtomicU64::new(0),
312 misses: AtomicU64::new(0),
313 }
314 }
315
316 #[must_use]
318 pub fn with_defaults() -> Self {
319 Self::new(NegativeCacheConfig::default())
320 }
321
322 fn ttl_for_path(&self, path: &Path) -> Duration {
327 if let Some(ref adaptive) = self.config.adaptive_ttl {
328 adaptive.ttl_for(&path.to_string_lossy())
331 } else {
332 self.config.timeout
333 }
334 }
335
336 pub fn contains(&self, path: &Path) -> bool {
341 if let Some(entry) = self.entries.get(path) {
342 if !entry.is_expired() {
343 self.hits.fetch_add(1, Ordering::Relaxed);
344 return true;
345 }
346 drop(entry); self.entries.remove(path);
349 }
350 self.misses.fetch_add(1, Ordering::Relaxed);
351 false
352 }
353
354 pub fn insert(&self, path: PathBuf) {
361 if self.entries.len() >= self.config.max_entries {
363 self.evict_expired();
364 }
365
366 let ttl = self.ttl_for_path(&path);
367 self.entries.insert(path, NegativeCacheEntry::new(ttl));
368 }
369
370 pub fn invalidate(&self, path: &Path) {
378 self.entries.remove(path);
379
380 if let Some(parent) = path.parent() {
382 self.entries.remove(parent);
383 }
384 }
385
386 pub fn evict_expired(&self) {
391 self.entries.retain(|_, entry| !entry.is_expired());
392 }
393
394 #[must_use]
396 pub fn stats(&self) -> NegativeCacheStats {
397 NegativeCacheStats {
398 entries: self.entries.len(),
399 hits: self.hits.load(Ordering::Relaxed),
400 misses: self.misses.load(Ordering::Relaxed),
401 }
402 }
403
404 pub fn clear(&self) {
406 self.entries.clear();
407 }
408
409 #[must_use]
411 pub fn len(&self) -> usize {
412 self.entries.len()
413 }
414
415 #[must_use]
417 pub fn is_empty(&self) -> bool {
418 self.entries.is_empty()
419 }
420}
421
422impl std::fmt::Debug for NegativeCache {
423 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424 f.debug_struct("NegativeCache")
425 .field("entries", &self.entries.len())
426 .field("config", &self.config)
427 .field("hits", &self.hits.load(Ordering::Relaxed))
428 .field("misses", &self.misses.load(Ordering::Relaxed))
429 .finish()
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use std::thread;
437
438 #[test]
439 fn test_insert_and_contains() {
440 let cache = NegativeCache::with_defaults();
441 let path = PathBuf::from("/test/path");
442
443 assert!(!cache.contains(&path));
444 cache.insert(path.clone());
445 assert!(cache.contains(&path));
446 }
447
448 #[test]
449 fn test_expiration() {
450 let config = NegativeCacheConfig {
451 max_entries: 100,
452 timeout: Duration::from_millis(50),
453 adaptive_ttl: None,
454 };
455 let cache = NegativeCache::new(config);
456 let path = PathBuf::from("/test/expiring");
457
458 cache.insert(path.clone());
459 assert!(cache.contains(&path));
460
461 thread::sleep(Duration::from_millis(100));
463 assert!(!cache.contains(&path));
464 }
465
466 #[test]
467 fn test_invalidate() {
468 let cache = NegativeCache::with_defaults();
469 let path = PathBuf::from("/test/dir/file.txt");
470
471 cache.insert(path.clone());
472 assert!(cache.contains(&path));
473
474 cache.invalidate(&path);
475 assert!(!cache.contains(&path));
476 }
477
478 #[test]
479 fn test_invalidate_removes_parent() {
480 let cache = NegativeCache::with_defaults();
481 let parent = PathBuf::from("/test/dir");
482 let child = PathBuf::from("/test/dir/file.txt");
483
484 cache.insert(parent.clone());
485 cache.insert(child.clone());
486
487 cache.invalidate(&child);
489
490 assert!(!cache.contains(&child));
491 assert!(!cache.contains(&parent));
492 }
493
494 #[test]
495 fn test_concurrent_access() {
496 use std::sync::Arc;
497
498 let cache = Arc::new(NegativeCache::with_defaults());
499 let mut handles = vec![];
500
501 for i in 0..10 {
503 let cache = Arc::clone(&cache);
504 handles.push(thread::spawn(move || {
505 for j in 0..100 {
506 let path = PathBuf::from(format!("/thread_{i}/file_{j}"));
507 cache.insert(path.clone());
508 assert!(cache.contains(&path));
509 }
510 }));
511 }
512
513 for handle in handles {
514 handle.join().expect("Thread panicked");
515 }
516
517 assert!(cache.len() <= 1000);
519 }
520
521 #[test]
522 fn test_max_entries() {
523 let config = NegativeCacheConfig {
524 max_entries: 10,
525 timeout: Duration::from_millis(10), adaptive_ttl: None,
527 };
528 let cache = NegativeCache::new(config);
529
530 for i in 0..20 {
532 let path = PathBuf::from(format!("/file_{i}"));
533 cache.insert(path);
534 if i == 10 {
536 thread::sleep(Duration::from_millis(15));
537 }
538 }
539
540 assert!(cache.len() <= 20);
543 }
544
545 #[test]
546 fn test_stats() {
547 let cache = NegativeCache::with_defaults();
548 let path1 = PathBuf::from("/path1");
549 let path2 = PathBuf::from("/path2");
550
551 let stats = cache.stats();
553 assert_eq!(stats.entries, 0);
554 assert_eq!(stats.hits, 0);
555 assert_eq!(stats.misses, 0);
556
557 cache.contains(&path1);
559 let stats = cache.stats();
560 assert_eq!(stats.misses, 1);
561
562 cache.insert(path1.clone());
564 cache.contains(&path1);
565 let stats = cache.stats();
566 assert_eq!(stats.entries, 1);
567 assert_eq!(stats.hits, 1);
568 assert_eq!(stats.misses, 1);
569
570 cache.contains(&path2);
572 let stats = cache.stats();
573 assert_eq!(stats.misses, 2);
574 }
575
576 #[test]
577 fn test_hit_ratio() {
578 let stats = NegativeCacheStats {
579 entries: 10,
580 hits: 75,
581 misses: 25,
582 };
583 assert!((stats.hit_ratio() - 75.0).abs() < f64::EPSILON);
584
585 let empty_stats = NegativeCacheStats::default();
586 assert!((empty_stats.hit_ratio() - 0.0).abs() < f64::EPSILON);
587 }
588
589 #[test]
590 fn test_clear() {
591 let cache = NegativeCache::with_defaults();
592
593 for i in 0..10 {
594 cache.insert(PathBuf::from(format!("/file_{i}")));
595 }
596 assert_eq!(cache.len(), 10);
597
598 cache.clear();
599 assert!(cache.is_empty());
600 }
601
602 #[test]
603 fn test_evict_expired() {
604 let config = NegativeCacheConfig {
605 max_entries: 100,
606 timeout: Duration::from_millis(30),
607 adaptive_ttl: None,
608 };
609 let cache = NegativeCache::new(config);
610
611 for i in 0..10 {
613 cache.insert(PathBuf::from(format!("/old_{i}")));
614 }
615
616 thread::sleep(Duration::from_millis(50));
618
619 for i in 0..5 {
621 cache.insert(PathBuf::from(format!("/new_{i}")));
622 }
623
624 cache.evict_expired();
626
627 assert_eq!(cache.len(), 5);
629 }
630
631 #[test]
636 fn test_adaptive_ttl_node_modules() {
637 let config = AdaptiveTtlConfig::default();
638 let ttl = config.ttl_for("/app/node_modules/lodash/index.js");
639 assert_eq!(ttl, Duration::from_secs(30));
640 }
641
642 #[test]
643 fn test_adaptive_ttl_git() {
644 let config = AdaptiveTtlConfig::default();
645 let ttl = config.ttl_for("/repo/.git/objects/ab/cd1234");
646 assert_eq!(ttl, Duration::from_secs(60));
647 }
648
649 #[test]
650 fn test_adaptive_ttl_pnpm() {
651 let config = AdaptiveTtlConfig::default();
652 let ttl = config.ttl_for("/app/.pnpm/some-package@1.0.0/node_modules/dep");
653 assert_eq!(ttl, Duration::from_secs(30));
655 }
656
657 #[test]
658 fn test_adaptive_ttl_target() {
659 let config = AdaptiveTtlConfig::default();
660 let ttl = config.ttl_for("/project/target/debug/build/something");
661 assert_eq!(ttl, Duration::from_secs(30));
662 }
663
664 #[test]
665 fn test_adaptive_ttl_pycache() {
666 let config = AdaptiveTtlConfig::default();
667 let ttl = config.ttl_for("/app/__pycache__/module.cpython-311.pyc");
668 assert_eq!(ttl, Duration::from_secs(30));
669 }
670
671 #[test]
672 fn test_adaptive_ttl_source_file_uses_default() {
673 let config = AdaptiveTtlConfig::default();
674 let ttl = config.ttl_for("/app/src/main.rs");
675 assert_eq!(ttl, Duration::from_secs(5));
676 }
677
678 #[test]
679 fn test_adaptive_ttl_custom_rules() {
680 let config = AdaptiveTtlConfig {
681 rules: vec![TtlRule {
682 prefix: "/vendor/".into(),
683 ttl: Duration::from_secs(120),
684 }],
685 default_ttl: Duration::from_secs(2),
686 };
687 assert_eq!(
688 config.ttl_for("/project/vendor/github.com/foo"),
689 Duration::from_secs(120)
690 );
691 assert_eq!(
692 config.ttl_for("/project/src/main.go"),
693 Duration::from_secs(2)
694 );
695 }
696
697 #[test]
698 fn test_adaptive_ttl_first_match_wins() {
699 let config = AdaptiveTtlConfig {
700 rules: vec![
701 TtlRule {
702 prefix: "/a/".into(),
703 ttl: Duration::from_secs(10),
704 },
705 TtlRule {
706 prefix: "/a/b/".into(),
707 ttl: Duration::from_secs(20),
708 },
709 ],
710 default_ttl: Duration::from_secs(1),
711 };
712 assert_eq!(config.ttl_for("/a/b/c"), Duration::from_secs(10));
714 }
715
716 #[test]
717 fn test_negative_cache_adaptive_ttl_integration() {
718 let config = NegativeCacheConfig {
721 max_entries: 100,
722 timeout: Duration::from_secs(60), adaptive_ttl: Some(AdaptiveTtlConfig {
724 rules: vec![TtlRule {
725 prefix: "/fast/".into(),
726 ttl: Duration::from_millis(50),
727 }],
728 default_ttl: Duration::from_secs(60),
729 }),
730 };
731 let cache = NegativeCache::new(config);
732
733 let fast_path = PathBuf::from("/fast/file.txt");
735 cache.insert(fast_path.clone());
736 assert!(cache.contains(&fast_path));
737
738 let slow_path = PathBuf::from("/slow/file.txt");
740 cache.insert(slow_path.clone());
741 assert!(cache.contains(&slow_path));
742
743 thread::sleep(Duration::from_millis(80));
745
746 assert!(!cache.contains(&fast_path));
748 assert!(cache.contains(&slow_path));
749 }
750}