use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use dashmap::DashMap;
#[derive(Debug)]
struct CacheEntry<T> {
value: T,
expires_at: Instant,
}
impl<T> CacheEntry<T> {
fn new(value: T, ttl: Duration) -> Self {
Self {
value,
expires_at: Instant::now() + ttl,
}
}
fn is_expired(&self) -> bool {
Instant::now() >= self.expires_at
}
}
pub struct MetadataCache<K, V> {
entries: RwLock<HashMap<K, CacheEntry<V>>>,
ttl: Duration,
max_entries: usize,
}
impl<K: std::hash::Hash + Eq + Clone, V: Clone> MetadataCache<K, V> {
#[must_use]
pub fn new(ttl: Duration, max_entries: usize) -> Self {
Self {
entries: RwLock::new(HashMap::new()),
ttl,
max_entries,
}
}
#[must_use]
#[allow(clippy::significant_drop_tightening)]
pub fn get(&self, key: &K) -> Option<V> {
let entries = self.entries.read().ok()?;
let entry = entries.get(key)?;
if entry.is_expired() {
None
} else {
Some(entry.value.clone())
}
}
pub fn insert(&self, key: K, value: V) {
if let Ok(mut entries) = self.entries.write() {
if entries.len() >= self.max_entries {
entries.retain(|_, v| !v.is_expired());
}
entries.insert(key, CacheEntry::new(value, self.ttl));
}
}
pub fn remove(&self, key: &K) {
if let Ok(mut entries) = self.entries.write() {
entries.remove(key);
}
}
pub fn clear(&self) {
if let Ok(mut entries) = self.entries.write() {
entries.clear();
}
}
}
#[derive(Debug, Clone)]
pub struct AdaptiveTtlConfig {
pub rules: Vec<TtlRule>,
pub default_ttl: Duration,
}
#[derive(Debug, Clone)]
pub struct TtlRule {
pub prefix: String,
pub ttl: Duration,
}
impl Default for AdaptiveTtlConfig {
fn default() -> Self {
Self {
rules: vec![
TtlRule {
prefix: "/node_modules/".into(),
ttl: Duration::from_secs(30),
},
TtlRule {
prefix: "/.git/".into(),
ttl: Duration::from_secs(60),
},
TtlRule {
prefix: "/.pnpm/".into(),
ttl: Duration::from_secs(30),
},
TtlRule {
prefix: "/target/".into(),
ttl: Duration::from_secs(30),
},
TtlRule {
prefix: "/__pycache__/".into(),
ttl: Duration::from_secs(30),
},
],
default_ttl: Duration::from_secs(5),
}
}
}
impl AdaptiveTtlConfig {
#[must_use]
pub fn ttl_for(&self, path: &str) -> Duration {
for rule in &self.rules {
if path.contains(&rule.prefix) {
return rule.ttl;
}
}
self.default_ttl
}
}
#[derive(Debug, Clone)]
pub struct NegativeCacheConfig {
pub max_entries: usize,
pub timeout: Duration,
pub adaptive_ttl: Option<AdaptiveTtlConfig>,
}
impl Default for NegativeCacheConfig {
fn default() -> Self {
Self::new()
}
}
impl NegativeCacheConfig {
#[must_use]
pub fn new() -> Self {
Self {
max_entries: 10_000,
timeout: Duration::from_secs(5),
adaptive_ttl: Some(AdaptiveTtlConfig::default()),
}
}
#[must_use]
pub const fn fixed(timeout: Duration, max_entries: usize) -> Self {
Self {
max_entries,
timeout,
adaptive_ttl: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NegativeCacheStats {
pub entries: usize,
pub hits: u64,
pub misses: u64,
}
impl NegativeCacheStats {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn hit_ratio(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
(self.hits as f64 / total as f64) * 100.0
}
}
}
#[derive(Debug, Clone, Copy)]
struct NegativeCacheEntry {
expires_at: Instant,
}
impl NegativeCacheEntry {
fn new(ttl: Duration) -> Self {
Self {
expires_at: Instant::now() + ttl,
}
}
fn is_expired(&self) -> bool {
Instant::now() >= self.expires_at
}
}
pub struct NegativeCache {
entries: DashMap<PathBuf, NegativeCacheEntry>,
config: NegativeCacheConfig,
hits: AtomicU64,
misses: AtomicU64,
}
impl NegativeCache {
#[must_use]
pub fn new(config: NegativeCacheConfig) -> Self {
Self {
entries: DashMap::with_capacity(config.max_entries),
config,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(NegativeCacheConfig::default())
}
fn ttl_for_path(&self, path: &Path) -> Duration {
if let Some(ref adaptive) = self.config.adaptive_ttl {
adaptive.ttl_for(&path.to_string_lossy())
} else {
self.config.timeout
}
}
pub fn contains(&self, path: &Path) -> bool {
if let Some(entry) = self.entries.get(path) {
if !entry.is_expired() {
self.hits.fetch_add(1, Ordering::Relaxed);
return true;
}
drop(entry); self.entries.remove(path);
}
self.misses.fetch_add(1, Ordering::Relaxed);
false
}
pub fn insert(&self, path: PathBuf) {
if self.entries.len() >= self.config.max_entries {
self.evict_expired();
}
let ttl = self.ttl_for_path(&path);
self.entries.insert(path, NegativeCacheEntry::new(ttl));
}
pub fn invalidate(&self, path: &Path) {
self.entries.remove(path);
if let Some(parent) = path.parent() {
self.entries.remove(parent);
}
}
pub fn evict_expired(&self) {
self.entries.retain(|_, entry| !entry.is_expired());
}
#[must_use]
pub fn stats(&self) -> NegativeCacheStats {
NegativeCacheStats {
entries: self.entries.len(),
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
}
}
pub fn clear(&self) {
self.entries.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl std::fmt::Debug for NegativeCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NegativeCache")
.field("entries", &self.entries.len())
.field("config", &self.config)
.field("hits", &self.hits.load(Ordering::Relaxed))
.field("misses", &self.misses.load(Ordering::Relaxed))
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_insert_and_contains() {
let cache = NegativeCache::with_defaults();
let path = PathBuf::from("/test/path");
assert!(!cache.contains(&path));
cache.insert(path.clone());
assert!(cache.contains(&path));
}
#[test]
fn test_expiration() {
let config = NegativeCacheConfig {
max_entries: 100,
timeout: Duration::from_millis(50),
adaptive_ttl: None,
};
let cache = NegativeCache::new(config);
let path = PathBuf::from("/test/expiring");
cache.insert(path.clone());
assert!(cache.contains(&path));
thread::sleep(Duration::from_millis(100));
assert!(!cache.contains(&path));
}
#[test]
fn test_invalidate() {
let cache = NegativeCache::with_defaults();
let path = PathBuf::from("/test/dir/file.txt");
cache.insert(path.clone());
assert!(cache.contains(&path));
cache.invalidate(&path);
assert!(!cache.contains(&path));
}
#[test]
fn test_invalidate_removes_parent() {
let cache = NegativeCache::with_defaults();
let parent = PathBuf::from("/test/dir");
let child = PathBuf::from("/test/dir/file.txt");
cache.insert(parent.clone());
cache.insert(child.clone());
cache.invalidate(&child);
assert!(!cache.contains(&child));
assert!(!cache.contains(&parent));
}
#[test]
fn test_concurrent_access() {
use std::sync::Arc;
let cache = Arc::new(NegativeCache::with_defaults());
let mut handles = vec![];
for i in 0..10 {
let cache = Arc::clone(&cache);
handles.push(thread::spawn(move || {
for j in 0..100 {
let path = PathBuf::from(format!("/thread_{i}/file_{j}"));
cache.insert(path.clone());
assert!(cache.contains(&path));
}
}));
}
for handle in handles {
handle.join().expect("Thread panicked");
}
assert!(cache.len() <= 1000);
}
#[test]
fn test_max_entries() {
let config = NegativeCacheConfig {
max_entries: 10,
timeout: Duration::from_millis(10), adaptive_ttl: None,
};
let cache = NegativeCache::new(config);
for i in 0..20 {
let path = PathBuf::from(format!("/file_{i}"));
cache.insert(path);
if i == 10 {
thread::sleep(Duration::from_millis(15));
}
}
assert!(cache.len() <= 20);
}
#[test]
fn test_stats() {
let cache = NegativeCache::with_defaults();
let path1 = PathBuf::from("/path1");
let path2 = PathBuf::from("/path2");
let stats = cache.stats();
assert_eq!(stats.entries, 0);
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
cache.contains(&path1);
let stats = cache.stats();
assert_eq!(stats.misses, 1);
cache.insert(path1.clone());
cache.contains(&path1);
let stats = cache.stats();
assert_eq!(stats.entries, 1);
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
cache.contains(&path2);
let stats = cache.stats();
assert_eq!(stats.misses, 2);
}
#[test]
fn test_hit_ratio() {
let stats = NegativeCacheStats {
entries: 10,
hits: 75,
misses: 25,
};
assert!((stats.hit_ratio() - 75.0).abs() < f64::EPSILON);
let empty_stats = NegativeCacheStats::default();
assert!((empty_stats.hit_ratio() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_clear() {
let cache = NegativeCache::with_defaults();
for i in 0..10 {
cache.insert(PathBuf::from(format!("/file_{i}")));
}
assert_eq!(cache.len(), 10);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_evict_expired() {
let config = NegativeCacheConfig {
max_entries: 100,
timeout: Duration::from_millis(30),
adaptive_ttl: None,
};
let cache = NegativeCache::new(config);
for i in 0..10 {
cache.insert(PathBuf::from(format!("/old_{i}")));
}
thread::sleep(Duration::from_millis(50));
for i in 0..5 {
cache.insert(PathBuf::from(format!("/new_{i}")));
}
cache.evict_expired();
assert_eq!(cache.len(), 5);
}
#[test]
fn test_adaptive_ttl_node_modules() {
let config = AdaptiveTtlConfig::default();
let ttl = config.ttl_for("/app/node_modules/lodash/index.js");
assert_eq!(ttl, Duration::from_secs(30));
}
#[test]
fn test_adaptive_ttl_git() {
let config = AdaptiveTtlConfig::default();
let ttl = config.ttl_for("/repo/.git/objects/ab/cd1234");
assert_eq!(ttl, Duration::from_secs(60));
}
#[test]
fn test_adaptive_ttl_pnpm() {
let config = AdaptiveTtlConfig::default();
let ttl = config.ttl_for("/app/.pnpm/some-package@1.0.0/node_modules/dep");
assert_eq!(ttl, Duration::from_secs(30));
}
#[test]
fn test_adaptive_ttl_target() {
let config = AdaptiveTtlConfig::default();
let ttl = config.ttl_for("/project/target/debug/build/something");
assert_eq!(ttl, Duration::from_secs(30));
}
#[test]
fn test_adaptive_ttl_pycache() {
let config = AdaptiveTtlConfig::default();
let ttl = config.ttl_for("/app/__pycache__/module.cpython-311.pyc");
assert_eq!(ttl, Duration::from_secs(30));
}
#[test]
fn test_adaptive_ttl_source_file_uses_default() {
let config = AdaptiveTtlConfig::default();
let ttl = config.ttl_for("/app/src/main.rs");
assert_eq!(ttl, Duration::from_secs(5));
}
#[test]
fn test_adaptive_ttl_custom_rules() {
let config = AdaptiveTtlConfig {
rules: vec![TtlRule {
prefix: "/vendor/".into(),
ttl: Duration::from_secs(120),
}],
default_ttl: Duration::from_secs(2),
};
assert_eq!(
config.ttl_for("/project/vendor/github.com/foo"),
Duration::from_secs(120)
);
assert_eq!(
config.ttl_for("/project/src/main.go"),
Duration::from_secs(2)
);
}
#[test]
fn test_adaptive_ttl_first_match_wins() {
let config = AdaptiveTtlConfig {
rules: vec![
TtlRule {
prefix: "/a/".into(),
ttl: Duration::from_secs(10),
},
TtlRule {
prefix: "/a/b/".into(),
ttl: Duration::from_secs(20),
},
],
default_ttl: Duration::from_secs(1),
};
assert_eq!(config.ttl_for("/a/b/c"), Duration::from_secs(10));
}
#[test]
fn test_negative_cache_adaptive_ttl_integration() {
let config = NegativeCacheConfig {
max_entries: 100,
timeout: Duration::from_secs(60), adaptive_ttl: Some(AdaptiveTtlConfig {
rules: vec![TtlRule {
prefix: "/fast/".into(),
ttl: Duration::from_millis(50),
}],
default_ttl: Duration::from_secs(60),
}),
};
let cache = NegativeCache::new(config);
let fast_path = PathBuf::from("/fast/file.txt");
cache.insert(fast_path.clone());
assert!(cache.contains(&fast_path));
let slow_path = PathBuf::from("/slow/file.txt");
cache.insert(slow_path.clone());
assert!(cache.contains(&slow_path));
thread::sleep(Duration::from_millis(80));
assert!(!cache.contains(&fast_path));
assert!(cache.contains(&slow_path));
}
}