use std::sync::Arc;
use std::time::Duration;
use moka::sync::Cache;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct CachedContent {
pub data: Arc<Vec<u8>>,
pub mime_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedAttr {
pub size: u64,
pub is_dir: bool,
pub mime_type: Option<String>,
pub mtime: i64,
}
#[derive(Debug, Clone)]
pub struct CachedDirEntry {
pub name: String,
pub is_dir: bool,
}
#[derive(Debug, Clone)]
pub struct FileCacheConfig {
pub max_size_mb: u32,
pub ttl_secs: u32,
pub content_ttl_secs: u32,
pub negative_ttl_secs: u32,
}
impl Default for FileCacheConfig {
fn default() -> Self {
Self {
max_size_mb: 100,
ttl_secs: 60,
content_ttl_secs: 300,
negative_ttl_secs: 10,
}
}
}
impl FileCacheConfig {
pub fn from_basic(max_size_mb: u32, ttl_secs: u32) -> Self {
Self {
max_size_mb,
ttl_secs,
content_ttl_secs: ttl_secs.saturating_mul(5),
negative_ttl_secs: 10,
}
}
}
#[derive(Clone)]
pub struct FileCache {
content: Cache<String, CachedContent>,
attrs: Cache<String, CachedAttr>,
dirs: Cache<String, Vec<CachedDirEntry>>,
negative: Cache<String, ()>,
config: FileCacheConfig,
}
impl FileCache {
pub fn new(config: FileCacheConfig) -> Self {
let metadata_ttl = Duration::from_secs(config.ttl_secs as u64);
let content_ttl = Duration::from_secs(config.content_ttl_secs as u64);
let negative_ttl = Duration::from_secs(config.negative_ttl_secs as u64);
let max_capacity = (config.max_size_mb as u64) * 1024;
Self {
content: Cache::builder()
.time_to_live(content_ttl)
.max_capacity(max_capacity)
.build(),
attrs: Cache::builder()
.time_to_live(metadata_ttl)
.max_capacity(max_capacity * 10) .build(),
dirs: Cache::builder()
.time_to_live(metadata_ttl)
.max_capacity(max_capacity)
.build(),
negative: Cache::builder()
.time_to_live(negative_ttl)
.max_capacity(10_000) .build(),
config,
}
}
pub fn get_content(&self, path: &str) -> Option<CachedContent> {
self.content.get(&Self::normalize_key(path))
}
pub fn put_content(&self, path: &str, content: CachedContent) {
self.content.insert(Self::normalize_key(path), content);
}
pub fn get_attr(&self, path: &str) -> Option<CachedAttr> {
self.attrs.get(&Self::normalize_key(path))
}
pub fn put_attr(&self, path: &str, attr: CachedAttr) {
self.attrs.insert(Self::normalize_key(path), attr);
}
pub fn get_dir(&self, path: &str) -> Option<Vec<CachedDirEntry>> {
self.dirs.get(&Self::normalize_key(path))
}
pub fn put_dir(&self, path: &str, entries: Vec<CachedDirEntry>) {
self.dirs.insert(Self::normalize_key(path), entries);
}
pub fn is_negative(&self, path: &str) -> bool {
self.negative.contains_key(&Self::normalize_key(path))
}
pub fn put_negative(&self, path: &str) {
self.negative.insert(Self::normalize_key(path), ());
}
pub fn invalidate(&self, path: &str) {
let key = Self::normalize_key(path);
self.content.invalidate(&key);
self.attrs.invalidate(&key);
self.dirs.invalidate(&key);
self.negative.invalidate(&key);
}
pub fn invalidate_prefix(&self, prefix: &str) {
let prefix = Self::normalize_key(prefix);
self.content.run_pending_tasks();
if prefix == "/" {
self.invalidate_all();
}
}
pub fn invalidate_all(&self) {
self.content.invalidate_all();
self.attrs.invalidate_all();
self.dirs.invalidate_all();
self.negative.invalidate_all();
}
pub fn stats(&self) -> CacheStats {
CacheStats {
content_count: self.content.entry_count(),
attr_count: self.attrs.entry_count(),
dir_count: self.dirs.entry_count(),
negative_count: self.negative.entry_count(),
max_size_mb: self.config.max_size_mb,
metadata_ttl_secs: self.config.ttl_secs,
content_ttl_secs: self.config.content_ttl_secs,
negative_ttl_secs: self.config.negative_ttl_secs,
}
}
fn normalize_key(path: &str) -> String {
let path = path.trim();
if path.is_empty() || path == "/" {
return "/".to_string();
}
let mut key = if path.starts_with('/') {
path.to_string()
} else {
format!("/{}", path)
};
if key.len() > 1 && key.ends_with('/') {
key.pop();
}
key
}
}
impl std::fmt::Debug for FileCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileCache")
.field("config", &self.config)
.field("content_count", &self.content.entry_count())
.field("attr_count", &self.attrs.entry_count())
.field("dir_count", &self.dirs.entry_count())
.field("negative_count", &self.negative.entry_count())
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheStats {
pub content_count: u64,
pub attr_count: u64,
pub dir_count: u64,
pub negative_count: u64,
pub max_size_mb: u32,
pub metadata_ttl_secs: u32,
pub content_ttl_secs: u32,
pub negative_ttl_secs: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_cache() {
let cache = FileCache::new(FileCacheConfig::default());
let content = CachedContent {
data: Arc::new(vec![1, 2, 3]),
mime_type: "text/plain".to_string(),
};
cache.put_content("/foo.txt", content.clone());
let cached = cache.get_content("/foo.txt").unwrap();
assert_eq!(cached.data.as_ref(), &[1, 2, 3]);
assert_eq!(cached.mime_type, "text/plain");
}
#[test]
fn test_attr_cache() {
let cache = FileCache::new(FileCacheConfig::default());
let attr = CachedAttr {
size: 100,
is_dir: false,
mime_type: Some("text/plain".to_string()),
mtime: 1234567890,
};
cache.put_attr("/foo.txt", attr.clone());
let cached = cache.get_attr("/foo.txt").unwrap();
assert_eq!(cached.size, 100);
assert!(!cached.is_dir);
}
#[test]
fn test_dir_cache() {
let cache = FileCache::new(FileCacheConfig::default());
let entries = vec![
CachedDirEntry {
name: "file.txt".to_string(),
is_dir: false,
},
CachedDirEntry {
name: "subdir".to_string(),
is_dir: true,
},
];
cache.put_dir("/", entries.clone());
let cached = cache.get_dir("/").unwrap();
assert_eq!(cached.len(), 2);
assert_eq!(cached[0].name, "file.txt");
}
#[test]
fn test_invalidate() {
let cache = FileCache::new(FileCacheConfig::default());
let content = CachedContent {
data: Arc::new(vec![1, 2, 3]),
mime_type: "text/plain".to_string(),
};
cache.put_content("/foo.txt", content);
assert!(cache.get_content("/foo.txt").is_some());
cache.invalidate("/foo.txt");
assert!(cache.get_content("/foo.txt").is_none());
}
#[test]
fn test_invalidate_all() {
let cache = FileCache::new(FileCacheConfig::default());
cache.put_content(
"/a.txt",
CachedContent {
data: Arc::new(vec![1]),
mime_type: "text/plain".to_string(),
},
);
cache.put_content(
"/b.txt",
CachedContent {
data: Arc::new(vec![2]),
mime_type: "text/plain".to_string(),
},
);
cache.invalidate_all();
assert!(cache.get_content("/a.txt").is_none());
assert!(cache.get_content("/b.txt").is_none());
}
#[test]
fn test_negative_cache() {
let cache = FileCache::new(FileCacheConfig::default());
assert!(!cache.is_negative("/nonexistent"));
cache.put_negative("/nonexistent");
assert!(cache.is_negative("/nonexistent"));
cache.invalidate("/nonexistent");
assert!(!cache.is_negative("/nonexistent"));
}
#[test]
fn test_negative_cache_invalidate_all() {
let cache = FileCache::new(FileCacheConfig::default());
cache.put_negative("/a");
cache.put_negative("/b");
assert!(cache.is_negative("/a"));
assert!(cache.is_negative("/b"));
cache.invalidate_all();
assert!(!cache.is_negative("/a"));
assert!(!cache.is_negative("/b"));
}
#[test]
fn test_normalize_key() {
assert_eq!(FileCache::normalize_key(""), "/");
assert_eq!(FileCache::normalize_key("/"), "/");
assert_eq!(FileCache::normalize_key("foo"), "/foo");
assert_eq!(FileCache::normalize_key("/foo"), "/foo");
assert_eq!(FileCache::normalize_key("/foo/"), "/foo");
}
#[test]
fn test_create_invalidates_parent_dir_cache() {
let cache = FileCache::new(FileCacheConfig::default());
let parent_entries = vec![CachedDirEntry {
name: "existing.txt".to_string(),
is_dir: false,
}];
cache.put_dir("/", parent_entries);
let attr = CachedAttr {
size: 0,
is_dir: false,
mime_type: None,
mtime: 1234567890,
};
cache.put_attr("/new_file.txt", attr);
cache.invalidate("/");
assert!(cache.get_dir("/").is_none());
assert!(cache.get_attr("/new_file.txt").is_some());
}
#[test]
fn test_create_clears_negative_cache_via_parent_invalidation() {
let cache = FileCache::new(FileCacheConfig::default());
cache.put_negative("/new_file.txt");
assert!(cache.is_negative("/new_file.txt"));
let attr = CachedAttr {
size: 0,
is_dir: false,
mime_type: None,
mtime: 1234567890,
};
cache.put_attr("/new_file.txt", attr);
cache.invalidate("/new_file.txt");
let attr2 = CachedAttr {
size: 0,
is_dir: false,
mime_type: None,
mtime: 1234567890,
};
cache.put_attr("/new_file.txt", attr2);
assert!(!cache.is_negative("/new_file.txt"));
assert!(cache.get_attr("/new_file.txt").is_some());
}
}