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;
pub const MAX_CACHED_FILE_BYTES: u64 = 8 * 1024 * 1024;
struct CacheEntry<T> {
mtime: SystemTime,
value: Arc<T>,
}
pub struct MtimeFileCache<T> {
files: dashmap::DashMap<PathBuf, CacheEntry<T>>,
oversize_warned: dashmap::DashMap<PathBuf, SystemTime>,
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_non_exhaustive()
}
}
impl<T> MtimeFileCache<T> {
#[must_use]
pub fn new(capacity: usize, label: &'static str) -> Self {
Self {
files: dashmap::DashMap::new(),
oversize_warned: dashmap::DashMap::new(),
capacity,
label,
}
}
fn warn_oversized_once(&self, path: &Path, mtime: Option<SystemTime>, len: Option<u64>) {
let already_warned = mtime.is_some_and(|mtime| {
self.oversize_warned
.get(path)
.is_some_and(|warned| *warned == mtime)
});
if already_warned {
return;
}
tracing::warn!(
path = %path.display(),
label = self.label,
len,
cap = MAX_CACHED_FILE_BYTES,
"file exceeds mtime file cache size cap; not reading"
);
if let Some(mtime) = mtime
&& self.oversize_warned.len() < self.capacity
{
self.oversize_warned.insert(path.to_path_buf(), mtime);
}
}
#[tracing::instrument(skip_all, fields(path = %path.display(), label = self.label), level = "debug")]
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;
}
if metadata.len() > MAX_CACHED_FILE_BYTES {
self.warn_oversized_once(path, metadata.modified().ok(), Some(metadata.len()));
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 = match fs_probe::read_to_string_capped(path, MAX_CACHED_FILE_BYTES).ok()? {
Some(content) => content,
None => {
self.warn_oversized_once(path, Some(mtime), None);
return None;
}
};
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)]
#[allow(clippy::cast_possible_truncation)]
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 _guard = fs_probe::snapshot_guard();
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 _guard = fs_probe::snapshot_guard();
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 _guard = fs_probe::snapshot_guard();
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 _guard = fs_probe::snapshot_guard();
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 _guard = fs_probe::snapshot_guard();
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 _guard = fs_probe::snapshot_guard();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
assert!(cache.get_or_parse(dir.path(), parse_upper).is_none());
}
#[test]
fn oversized_file_returns_none_without_reading_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.txt");
let file = std::fs::File::create(&path).unwrap();
file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
drop(file);
let _guard = fs_probe::snapshot_guard();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
let parse_calls = std::cell::Cell::new(0);
let result = cache.get_or_parse(&path, |content| {
parse_calls.set(parse_calls.get() + 1);
parse_upper(content)
});
assert!(result.is_none());
assert_eq!(
parse_calls.get(),
0,
"parse must not run on an oversized file"
);
}
#[test]
fn file_exactly_at_cap_is_still_cached() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("exact.txt");
let content = "a".repeat(MAX_CACHED_FILE_BYTES as usize);
std::fs::write(&path, &content).unwrap();
let _guard = fs_probe::snapshot_guard();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
let result = cache.get_or_parse(&path, parse_upper);
assert!(result.is_some(), "a file exactly at the cap must be cached");
}
#[cfg(feature = "test-util")]
#[test]
fn oversized_file_warns_once_per_mtime_not_once_per_call() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.txt");
let file = std::fs::File::create(&path).unwrap();
file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
drop(file);
let _guard = fs_probe::snapshot_guard();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
let log = crate::test_util::capture_tracing_output(|| {
assert!(cache.get_or_parse(&path, parse_upper).is_none());
assert!(cache.get_or_parse(&path, parse_upper).is_none());
assert!(cache.get_or_parse(&path, parse_upper).is_none());
});
assert_eq!(
log.matches("file exceeds mtime file cache size cap")
.count(),
1,
"expected exactly one warning across three calls with an unchanged mtime: {log}"
);
}
#[cfg(feature = "test-util")]
#[test]
fn oversized_file_warns_again_after_mtime_changes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.txt");
let file = std::fs::File::create(&path).unwrap();
file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
drop(file);
let _guard = fs_probe::snapshot_guard();
let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
let log = crate::test_util::capture_tracing_output(|| {
assert!(cache.get_or_parse(&path, parse_upper).is_none());
let future = SystemTime::now() + std::time::Duration::from_secs(2);
let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
file.set_len(MAX_CACHED_FILE_BYTES + 2).unwrap();
file.set_modified(future).unwrap();
drop(file);
assert!(cache.get_or_parse(&path, parse_upper).is_none());
});
assert_eq!(
log.matches("file exceeds mtime file cache size cap")
.count(),
2,
"expected a fresh warning after the file's mtime changed: {log}"
);
}
#[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 _guard = fs_probe::snapshot_guard();
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));
}
}