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)]
102pub struct NegativeCacheConfig {
103 pub max_entries: usize,
107
108 pub timeout: Duration,
112}
113
114impl Default for NegativeCacheConfig {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl NegativeCacheConfig {
121 #[must_use]
123 pub const fn new() -> Self {
124 Self {
125 max_entries: 10_000,
126 timeout: Duration::from_secs(1),
127 }
128 }
129}
130
131#[derive(Debug, Clone, Default)]
133pub struct NegativeCacheStats {
134 pub entries: usize,
136 pub hits: u64,
138 pub misses: u64,
140}
141
142impl NegativeCacheStats {
143 #[must_use]
146 #[allow(clippy::cast_precision_loss)]
147 pub fn hit_ratio(&self) -> f64 {
148 let total = self.hits + self.misses;
149 if total == 0 {
150 0.0
151 } else {
152 (self.hits as f64 / total as f64) * 100.0
153 }
154 }
155}
156
157pub struct NegativeCache {
186 entries: DashMap<PathBuf, Instant>,
188 config: NegativeCacheConfig,
190 hits: AtomicU64,
192 misses: AtomicU64,
194}
195
196impl NegativeCache {
197 #[must_use]
199 pub fn new(config: NegativeCacheConfig) -> Self {
200 Self {
201 entries: DashMap::with_capacity(config.max_entries),
202 config,
203 hits: AtomicU64::new(0),
204 misses: AtomicU64::new(0),
205 }
206 }
207
208 #[must_use]
210 pub fn with_defaults() -> Self {
211 Self::new(NegativeCacheConfig::default())
212 }
213
214 pub fn contains(&self, path: &Path) -> bool {
219 if let Some(entry) = self.entries.get(path) {
220 let inserted_at = *entry;
221 if inserted_at.elapsed() < self.config.timeout {
222 self.hits.fetch_add(1, Ordering::Relaxed);
223 return true;
224 }
225 drop(entry); self.entries.remove(path);
228 }
229 self.misses.fetch_add(1, Ordering::Relaxed);
230 false
231 }
232
233 pub fn insert(&self, path: PathBuf) {
237 if self.entries.len() >= self.config.max_entries {
239 self.evict_expired();
240 }
241
242 self.entries.insert(path, Instant::now());
243 }
244
245 pub fn invalidate(&self, path: &Path) {
253 self.entries.remove(path);
254
255 if let Some(parent) = path.parent() {
257 self.entries.remove(parent);
258 }
259 }
260
261 pub fn evict_expired(&self) {
266 let timeout = self.config.timeout;
267 self.entries
268 .retain(|_, inserted_at| inserted_at.elapsed() < timeout);
269 }
270
271 #[must_use]
273 pub fn stats(&self) -> NegativeCacheStats {
274 NegativeCacheStats {
275 entries: self.entries.len(),
276 hits: self.hits.load(Ordering::Relaxed),
277 misses: self.misses.load(Ordering::Relaxed),
278 }
279 }
280
281 pub fn clear(&self) {
283 self.entries.clear();
284 }
285
286 #[must_use]
288 pub fn len(&self) -> usize {
289 self.entries.len()
290 }
291
292 #[must_use]
294 pub fn is_empty(&self) -> bool {
295 self.entries.is_empty()
296 }
297}
298
299impl std::fmt::Debug for NegativeCache {
300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 f.debug_struct("NegativeCache")
302 .field("entries", &self.entries.len())
303 .field("config", &self.config)
304 .field("hits", &self.hits.load(Ordering::Relaxed))
305 .field("misses", &self.misses.load(Ordering::Relaxed))
306 .finish()
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use std::thread;
314
315 #[test]
316 fn test_insert_and_contains() {
317 let cache = NegativeCache::with_defaults();
318 let path = PathBuf::from("/test/path");
319
320 assert!(!cache.contains(&path));
321 cache.insert(path.clone());
322 assert!(cache.contains(&path));
323 }
324
325 #[test]
326 fn test_expiration() {
327 let config = NegativeCacheConfig {
328 max_entries: 100,
329 timeout: Duration::from_millis(50),
330 };
331 let cache = NegativeCache::new(config);
332 let path = PathBuf::from("/test/expiring");
333
334 cache.insert(path.clone());
335 assert!(cache.contains(&path));
336
337 thread::sleep(Duration::from_millis(100));
339 assert!(!cache.contains(&path));
340 }
341
342 #[test]
343 fn test_invalidate() {
344 let cache = NegativeCache::with_defaults();
345 let path = PathBuf::from("/test/dir/file.txt");
346
347 cache.insert(path.clone());
348 assert!(cache.contains(&path));
349
350 cache.invalidate(&path);
351 assert!(!cache.contains(&path));
352 }
353
354 #[test]
355 fn test_invalidate_removes_parent() {
356 let cache = NegativeCache::with_defaults();
357 let parent = PathBuf::from("/test/dir");
358 let child = PathBuf::from("/test/dir/file.txt");
359
360 cache.insert(parent.clone());
361 cache.insert(child.clone());
362
363 cache.invalidate(&child);
365
366 assert!(!cache.contains(&child));
367 assert!(!cache.contains(&parent));
368 }
369
370 #[test]
371 fn test_concurrent_access() {
372 use std::sync::Arc;
373
374 let cache = Arc::new(NegativeCache::with_defaults());
375 let mut handles = vec![];
376
377 for i in 0..10 {
379 let cache = Arc::clone(&cache);
380 handles.push(thread::spawn(move || {
381 for j in 0..100 {
382 let path = PathBuf::from(format!("/thread_{i}/file_{j}"));
383 cache.insert(path.clone());
384 assert!(cache.contains(&path));
385 }
386 }));
387 }
388
389 for handle in handles {
390 handle.join().expect("Thread panicked");
391 }
392
393 assert!(cache.len() <= 1000);
395 }
396
397 #[test]
398 fn test_max_entries() {
399 let config = NegativeCacheConfig {
400 max_entries: 10,
401 timeout: Duration::from_millis(10), };
403 let cache = NegativeCache::new(config);
404
405 for i in 0..20 {
407 let path = PathBuf::from(format!("/file_{i}"));
408 cache.insert(path);
409 if i == 10 {
411 thread::sleep(Duration::from_millis(15));
412 }
413 }
414
415 assert!(cache.len() <= 20);
418 }
419
420 #[test]
421 fn test_stats() {
422 let cache = NegativeCache::with_defaults();
423 let path1 = PathBuf::from("/path1");
424 let path2 = PathBuf::from("/path2");
425
426 let stats = cache.stats();
428 assert_eq!(stats.entries, 0);
429 assert_eq!(stats.hits, 0);
430 assert_eq!(stats.misses, 0);
431
432 cache.contains(&path1);
434 let stats = cache.stats();
435 assert_eq!(stats.misses, 1);
436
437 cache.insert(path1.clone());
439 cache.contains(&path1);
440 let stats = cache.stats();
441 assert_eq!(stats.entries, 1);
442 assert_eq!(stats.hits, 1);
443 assert_eq!(stats.misses, 1);
444
445 cache.contains(&path2);
447 let stats = cache.stats();
448 assert_eq!(stats.misses, 2);
449 }
450
451 #[test]
452 fn test_hit_ratio() {
453 let stats = NegativeCacheStats {
454 entries: 10,
455 hits: 75,
456 misses: 25,
457 };
458 assert!((stats.hit_ratio() - 75.0).abs() < f64::EPSILON);
459
460 let empty_stats = NegativeCacheStats::default();
461 assert!((empty_stats.hit_ratio() - 0.0).abs() < f64::EPSILON);
462 }
463
464 #[test]
465 fn test_clear() {
466 let cache = NegativeCache::with_defaults();
467
468 for i in 0..10 {
469 cache.insert(PathBuf::from(format!("/file_{i}")));
470 }
471 assert_eq!(cache.len(), 10);
472
473 cache.clear();
474 assert!(cache.is_empty());
475 }
476
477 #[test]
478 fn test_evict_expired() {
479 let config = NegativeCacheConfig {
480 max_entries: 100,
481 timeout: Duration::from_millis(30),
482 };
483 let cache = NegativeCache::new(config);
484
485 for i in 0..10 {
487 cache.insert(PathBuf::from(format!("/old_{i}")));
488 }
489
490 thread::sleep(Duration::from_millis(50));
492
493 for i in 0..5 {
495 cache.insert(PathBuf::from(format!("/new_{i}")));
496 }
497
498 cache.evict_expired();
500
501 assert_eq!(cache.len(), 5);
503 }
504}