use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use crate::fs_probe;
pub const DEFAULT_MAX_CACHED_FILES: usize = 256;
struct CacheEntry<T> {
mtime: SystemTime,
value: Arc<T>,
}
pub struct MtimeFileCache<T> {
files: dashmap::DashMap<PathBuf, CacheEntry<T>>,
capacity: usize,
label: &'static str,
}
impl<T> std::fmt::Debug for MtimeFileCache<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MtimeFileCache")
.field("label", &self.label)
.field("capacity", &self.capacity)
.field("len", &self.files.len())
.finish()
}
}
impl<T> MtimeFileCache<T> {
#[must_use]
pub fn new(capacity: usize, label: &'static str) -> Self {
Self {
files: dashmap::DashMap::new(),
capacity,
label,
}
}
pub fn get_or_parse(&self, path: &Path, parse: impl FnOnce(&str) -> T) -> Option<Arc<T>> {
let metadata = fs_probe::metadata(path).ok()?;
if !metadata.is_file() {
return None;
}
let mtime = metadata.modified().ok()?;
if let Some(existing) = self.files.get(path)
&& existing.mtime == mtime
{
return Some(Arc::clone(&existing.value));
}
let content = fs_probe::read_to_string(path).ok()?;
let value = Arc::new(parse(&content));
if !self.files.contains_key(path) && self.files.len() >= self.capacity {
tracing::warn!(
path = %path.display(),
label = self.label,
cap = self.capacity,
"mtime file cache capacity reached; not caching this file (still used for this parse)"
);
return Some(value);
}
self.files.insert(
path.to_path_buf(),
CacheEntry {
mtime,
value: Arc::clone(&value),
},
);
Some(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq, Eq)]
struct Parsed(String);
fn parse_upper(content: &str) -> Parsed {
Parsed(content.trim().to_uppercase())
}
#[test]
fn hit_reuses_same_arc_without_reparsing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("file.txt");
std::fs::write(&path, "hello").unwrap();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
let first = cache.get_or_parse(&path, parse_upper).unwrap();
let second = cache.get_or_parse(&path, parse_upper).unwrap();
assert!(
Arc::ptr_eq(&first, &second),
"a cache hit must return the same Arc, not re-parse"
);
}
#[test]
fn hit_does_zero_reads_and_exactly_one_stat() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("file.txt");
std::fs::write(&path, "hello").unwrap();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
cache.get_or_parse(&path, parse_upper).unwrap();
let (stats_before, reads_before) = fs_probe::snapshot();
let hit = cache.get_or_parse(&path, parse_upper).unwrap();
let (stats_after, reads_after) = fs_probe::snapshot();
assert_eq!(
reads_after - reads_before,
0,
"a cache hit must perform zero content reads"
);
assert_eq!(
stats_after - stats_before,
1,
"a cache hit still pays exactly one mtime stat"
);
assert_eq!(hit.0, "HELLO");
}
#[test]
fn forward_mtime_bump_invalidates() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("file.txt");
std::fs::write(&path, "one").unwrap();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
let first = cache.get_or_parse(&path, parse_upper).unwrap();
let future = SystemTime::now() + std::time::Duration::from_secs(2);
std::fs::write(&path, "two").unwrap();
std::fs::OpenOptions::new()
.write(true)
.open(&path)
.unwrap()
.set_modified(future)
.unwrap();
let second = cache.get_or_parse(&path, parse_upper).unwrap();
assert!(
!Arc::ptr_eq(&first, &second),
"an mtime bump must invalidate the cache entry"
);
assert_eq!(second.0, "TWO");
}
#[test]
fn backward_mtime_move_invalidates() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("file.txt");
std::fs::write(&path, "one").unwrap();
let future = SystemTime::now() + std::time::Duration::from_secs(10);
std::fs::OpenOptions::new()
.write(true)
.open(&path)
.unwrap()
.set_modified(future)
.unwrap();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
let first = cache.get_or_parse(&path, parse_upper).unwrap();
std::fs::write(&path, "two").unwrap();
let past = SystemTime::now();
std::fs::OpenOptions::new()
.write(true)
.open(&path)
.unwrap()
.set_modified(past)
.unwrap();
let second = cache.get_or_parse(&path, parse_upper).unwrap();
assert!(
!Arc::ptr_eq(&first, &second),
"a backwards mtime move must still invalidate the cache entry"
);
}
#[test]
fn missing_path_returns_none() {
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
assert!(
cache
.get_or_parse(Path::new("/nonexistent/path/file.txt"), parse_upper)
.is_none()
);
}
#[test]
fn directory_path_returns_none() {
let dir = tempfile::tempdir().unwrap();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
assert!(cache.get_or_parse(dir.path(), parse_upper).is_none());
}
#[test]
fn capacity_cap_returns_value_without_inserting() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("a.txt");
let path_b = dir.path().join("b.txt");
std::fs::write(&path_a, "a").unwrap();
std::fs::write(&path_b, "b").unwrap();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(1, "test");
let first = cache.get_or_parse(&path_a, parse_upper).unwrap();
assert_eq!(first.0, "A");
let second = cache.get_or_parse(&path_b, parse_upper).unwrap();
assert_eq!(second.0, "B", "the value is still returned despite the cap");
let second_again = cache.get_or_parse(&path_b, parse_upper).unwrap();
assert!(!Arc::ptr_eq(&second, &second_again));
}
}