use bytes::Bytes;
use dashmap::DashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
use tracing::{debug, trace};
const DEFAULT_MAX_AGE_SECS: u64 = 3600;
pub struct FileCache {
entries: DashMap<PathBuf, CachedFile>,
max_size: usize,
max_age: Duration,
}
pub struct CachedFile {
pub content: Bytes,
pub gzip_content: Option<Bytes>,
pub brotli_content: Option<Bytes>,
pub content_type: String,
pub etag: String,
pub last_modified: SystemTime,
pub cached_at: Instant,
pub size: u64,
}
impl FileCache {
pub fn new(max_size: usize, max_age_secs: u64) -> Self {
trace!(
max_size_mb = max_size / (1024 * 1024),
max_age_secs = max_age_secs,
"Creating file cache"
);
debug!(
max_size_mb = max_size / (1024 * 1024),
"File cache initialized"
);
Self {
entries: DashMap::new(),
max_size,
max_age: Duration::from_secs(max_age_secs),
}
}
pub fn with_defaults() -> Self {
trace!("Creating file cache with default settings");
Self::new(100 * 1024 * 1024, DEFAULT_MAX_AGE_SECS)
}
pub fn get(&self, path: &std::path::Path) -> Option<CachedFile> {
let result = self.entries.get(path).map(|entry| entry.clone());
trace!(
path = %path.display(),
hit = result.is_some(),
"Cache lookup"
);
result
}
pub fn insert(&self, path: PathBuf, file: CachedFile) {
let file_size = file.size;
self.evict_stale();
if self.entries.len() > 1000 {
trace!(
current_entries = self.entries.len(),
"Cache entry limit reached, evicting oldest"
);
self.evict_oldest(100);
}
self.entries.insert(path.clone(), file);
trace!(
path = %path.display(),
size = file_size,
entry_count = self.entries.len(),
"Inserted file into cache"
);
}
fn evict_stale(&self) {
let before = self.entries.len();
self.entries.retain(|_, v| v.is_fresh());
let evicted = before - self.entries.len();
if evicted > 0 {
trace!(
evicted = evicted,
remaining = self.entries.len(),
"Evicted stale cache entries"
);
}
}
fn evict_oldest(&self, count: usize) {
let mut oldest: Vec<_> = self
.entries
.iter()
.map(|e| (e.key().clone(), e.cached_at))
.collect();
oldest.sort_by_key(|e| e.1);
let mut evicted = 0;
for (path, _) in oldest.iter().take(count) {
self.entries.remove(path);
evicted += 1;
}
trace!(
requested = count,
evicted = evicted,
remaining = self.entries.len(),
"Evicted oldest cache entries"
);
}
pub fn stats(&self) -> CacheStats {
let total_size: usize = self.entries.iter().map(|e| e.size as usize).sum();
let total_compressed: usize = self
.entries
.iter()
.map(|e| {
e.gzip_content.as_ref().map_or(0, |b| b.len())
+ e.brotli_content.as_ref().map_or(0, |b| b.len())
})
.sum();
let stats = CacheStats {
entry_count: self.entries.len(),
total_size,
total_compressed,
max_size: self.max_size,
};
trace!(
entry_count = stats.entry_count,
total_size_kb = stats.total_size / 1024,
compressed_size_kb = stats.total_compressed / 1024,
"Retrieved cache stats"
);
stats
}
pub fn clear(&self) {
let count = self.entries.len();
self.entries.clear();
debug!(cleared_entries = count, "File cache cleared");
}
}
impl CachedFile {
pub fn is_fresh(&self) -> bool {
self.cached_at.elapsed() < Duration::from_secs(DEFAULT_MAX_AGE_SECS)
}
}
impl Clone for CachedFile {
fn clone(&self) -> Self {
Self {
content: self.content.clone(),
gzip_content: self.gzip_content.clone(),
brotli_content: self.brotli_content.clone(),
content_type: self.content_type.clone(),
etag: self.etag.clone(),
last_modified: self.last_modified,
cached_at: self.cached_at,
size: self.size,
}
}
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub entry_count: usize,
pub total_size: usize,
pub total_compressed: usize,
pub max_size: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_insert_get() {
let cache = FileCache::with_defaults();
let path = PathBuf::from("/test/file.txt");
let cached = CachedFile {
content: Bytes::from_static(b"Hello, World!"),
gzip_content: None,
brotli_content: None,
content_type: "text/plain".to_string(),
etag: "abc123".to_string(),
last_modified: SystemTime::now(),
cached_at: Instant::now(),
size: 13,
};
cache.insert(path.clone(), cached);
let retrieved = cache.get(&path);
assert!(retrieved.is_some());
assert_eq!(
retrieved.unwrap().content,
Bytes::from_static(b"Hello, World!")
);
}
#[test]
fn test_cache_stats() {
let cache = FileCache::with_defaults();
let cached = CachedFile {
content: Bytes::from_static(b"Test content"),
gzip_content: Some(Bytes::from_static(b"compressed")),
brotli_content: None,
content_type: "text/plain".to_string(),
etag: "test".to_string(),
last_modified: SystemTime::now(),
cached_at: Instant::now(),
size: 12,
};
cache.insert(PathBuf::from("/test.txt"), cached);
let stats = cache.stats();
assert_eq!(stats.entry_count, 1);
assert_eq!(stats.total_size, 12);
}
#[test]
fn test_cache_clear() {
let cache = FileCache::with_defaults();
for i in 0..10 {
cache.insert(
PathBuf::from(format!("/file{}.txt", i)),
CachedFile {
content: Bytes::from_static(b"test"),
gzip_content: None,
brotli_content: None,
content_type: "text/plain".to_string(),
etag: format!("etag{}", i),
last_modified: SystemTime::now(),
cached_at: Instant::now(),
size: 4,
},
);
}
assert_eq!(cache.stats().entry_count, 10);
cache.clear();
assert_eq!(cache.stats().entry_count, 0);
}
}