use anyhow::Context;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::Mutex,
time::{Duration, Instant, SystemTime},
};
#[derive(Debug)]
pub(super) struct FsCache {
ttl: Duration,
entries: Mutex<HashMap<FsCacheKey, CacheEntry>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct FsCacheKey {
dir: PathBuf,
modified: SystemTime,
}
#[derive(Debug, Clone)]
struct CacheEntry {
inserted: Instant,
listing: Vec<DirectoryListingEntry>,
}
#[derive(Debug, Clone)]
pub(super) struct DirectoryListingEntry {
pub(super) name: String,
pub(super) file_type: fs::FileType,
}
#[derive(Debug, Clone)]
pub(super) struct DirectoryListing {
pub(super) entries: Vec<DirectoryListingEntry>,
#[cfg(test)]
pub(super) cache_hit: bool,
}
impl Default for FsCache {
fn default() -> Self {
Self::with_ttl(Duration::from_secs(2))
}
}
impl FsCache {
pub(super) fn with_ttl(ttl: Duration) -> Self {
Self {
ttl,
entries: Mutex::new(HashMap::new()),
}
}
pub(super) fn read_dir_listing(&self, dir: &Path) -> anyhow::Result<DirectoryListing> {
let key = self.key_for_dir(dir)?;
if let Some(entries) = self.get_valid(&key)? {
return Ok(DirectoryListing {
entries,
#[cfg(test)]
cache_hit: true,
});
}
let entries = read_directory_entries(dir)?;
self.store_keyed(key, entries.clone())?;
Ok(DirectoryListing {
entries,
#[cfg(test)]
cache_hit: false,
})
}
pub(super) fn len(&self) -> anyhow::Result<usize> {
self.entries
.lock()
.map(|entries| entries.len())
.map_err(|_| anyhow::anyhow!("fs cache lock poisoned"))
}
fn key_for_dir(&self, dir: &Path) -> anyhow::Result<FsCacheKey> {
let canonical = dir
.canonicalize()
.with_context(|| format!("failed to canonicalize directory '{}'", dir.display()))?;
let modified = fs::metadata(&canonical)
.with_context(|| format!("failed to read metadata for '{}'", canonical.display()))?
.modified()
.with_context(|| {
format!(
"failed to read modified time for directory '{}'",
canonical.display()
)
})?;
Ok(FsCacheKey {
dir: canonical,
modified,
})
}
fn get_valid(&self, key: &FsCacheKey) -> anyhow::Result<Option<Vec<DirectoryListingEntry>>> {
let entries = self
.entries
.lock()
.map_err(|_| anyhow::anyhow!("fs cache lock poisoned"))?;
Ok(entries.get(key).and_then(|entry| {
(entry.inserted.elapsed() <= self.ttl).then(|| entry.listing.clone())
}))
}
fn store_keyed(
&self,
key: FsCacheKey,
listing: Vec<DirectoryListingEntry>,
) -> anyhow::Result<()> {
let mut entries = self
.entries
.lock()
.map_err(|_| anyhow::anyhow!("fs cache lock poisoned"))?;
entries.insert(
key,
CacheEntry {
inserted: Instant::now(),
listing,
},
);
Ok(())
}
}
fn read_directory_entries(dir: &Path) -> anyhow::Result<Vec<DirectoryListingEntry>> {
let mut entries = Vec::new();
for entry in fs::read_dir(dir)
.with_context(|| format!("failed to read directory '{}'", dir.display()))?
{
let entry = entry
.with_context(|| format!("failed to read entry in directory '{}'", dir.display()))?;
let path = entry.path();
let file_type = entry
.file_type()
.with_context(|| format!("failed to read file type for '{}'", path.display()))?;
entries.push(DirectoryListingEntry {
name: entry.file_name().to_string_lossy().into_owned(),
file_type,
});
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{thread, time::Duration};
#[test]
fn fs_cache_repeated_read_hits_cache() {
let temp = tempfile::TempDir::new().unwrap();
fs::write(temp.path().join("visible.txt"), "content").unwrap();
let cache = FsCache::default();
let first = cache.read_dir_listing(temp.path()).unwrap();
let second = cache.read_dir_listing(temp.path()).unwrap();
assert!(!first.cache_hit);
assert!(second.cache_hit);
assert_eq!(second.entries.len(), 1);
assert_eq!(second.entries[0].name, "visible.txt");
assert!(second.entries[0].file_type.is_file());
}
#[test]
fn fs_cache_directory_mtime_change_misses_cache() {
let temp = tempfile::TempDir::new().unwrap();
let cache = FsCache::default();
let first = cache.read_dir_listing(temp.path()).unwrap();
assert!(!first.cache_hit);
thread::sleep(Duration::from_millis(20));
fs::write(temp.path().join("new.txt"), "content").unwrap();
let second = cache.read_dir_listing(temp.path()).unwrap();
assert!(!second.cache_hit);
assert!(second.entries.iter().any(|entry| entry.name == "new.txt"));
}
#[test]
fn fs_cache_ttl_expiry_misses_cache() {
let temp = tempfile::TempDir::new().unwrap();
let cache = FsCache::with_ttl(Duration::from_millis(1));
assert!(!cache.read_dir_listing(temp.path()).unwrap().cache_hit);
thread::sleep(Duration::from_millis(5));
assert!(!cache.read_dir_listing(temp.path()).unwrap().cache_hit);
}
}