use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use bytes::Bytes;
use crate::minify::MinifyError;
use crate::reload::ChangeType;
use crate::watcher::Broadcaster;
pub(crate) const DEFAULT_MINIFY_CACHE_CAPACITY: usize = 256;
struct CacheEntry {
mtime: SystemTime,
bytes: Bytes,
}
pub(crate) struct MinifyCache {
entries: Mutex<HashMap<PathBuf, CacheEntry>>,
capacity: usize,
}
impl MinifyCache {
pub(crate) fn new(capacity: usize) -> Self {
MinifyCache {
entries: Mutex::new(HashMap::new()),
capacity,
}
}
pub(crate) async fn get_or_minify<F>(
&self,
path: &Path,
mtime: SystemTime,
change_type: ChangeType,
minify_fn: F,
) -> Result<Bytes, MinifyError>
where
F: FnOnce(&[u8], ChangeType) -> Result<Bytes, MinifyError>,
{
if let Some(bytes) = self.hit(path, mtime) {
return Ok(bytes);
}
let source = tokio::fs::read(path).await.map_err(MinifyError::Io)?;
let minified = minify_fn(&source, change_type)?;
self.insert(path.to_path_buf(), mtime, minified.clone());
Ok(minified)
}
pub(crate) fn invalidate(&self, path: &Path) {
self.entries.lock().unwrap().remove(path);
}
pub(crate) fn subscribe_to_invalidation(self: Arc<Self>, broadcaster: &Broadcaster) {
let mut events = broadcaster.subscribe();
tokio::spawn(async move {
while let Some(event) = events.recv().await {
if matches!(event.change_type, ChangeType::Css | ChangeType::Script) {
self.invalidate(&event.path);
}
}
});
}
fn hit(&self, path: &Path, mtime: SystemTime) -> Option<Bytes> {
let entries = self.entries.lock().unwrap();
let entry = entries.get(path)?;
(entry.mtime == mtime).then(|| entry.bytes.clone())
}
fn insert(&self, path: PathBuf, mtime: SystemTime, bytes: Bytes) {
let mut entries = self.entries.lock().unwrap();
if entries.len() >= self.capacity && !entries.contains_key(&path) {
if let Some(victim) = entries.keys().next().cloned() {
entries.remove(&victim);
}
}
entries.insert(path, CacheEntry { mtime, bytes });
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn counting_minify(counter: &AtomicUsize) -> impl Fn(&[u8], ChangeType) -> Result<Bytes, MinifyError> + '_ {
move |bytes, change_type| {
counter.fetch_add(1, Ordering::SeqCst);
crate::minify::minify(bytes, change_type)
}
}
#[tokio::test]
async fn minifies_once_per_mtime_then_serves_from_cache() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("app.css");
std::fs::write(&path, "body { color: red; }").unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let cache = MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY);
let calls = AtomicUsize::new(0);
let first = cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
let second = cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1, "second request with the same mtime should hit the cache");
assert_eq!(first, second);
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&path, "body { color: blue; }").unwrap();
let new_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_ne!(mtime, new_mtime, "test fixture must actually produce a new mtime");
let third = cache
.get_or_minify(&path, new_mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2, "a changed mtime must be treated as a miss");
assert_ne!(first, third, "content changed, so minified bytes must differ");
}
#[tokio::test]
async fn invalidate_forces_a_reminify_even_with_an_unchanged_mtime() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("app.css");
std::fs::write(&path, "body { color: red; }").unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let cache = MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY);
let calls = AtomicUsize::new(0);
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
cache.invalidate(&path);
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2, "invalidate() must force a re-minify on the next request");
}
#[tokio::test]
async fn capacity_is_enforced() {
let dir = tempfile::TempDir::new().unwrap();
let cache = MinifyCache::new(2);
let calls = AtomicUsize::new(0);
for i in 0..5 {
let path = dir.path().join(format!("f{i}.css"));
std::fs::write(&path, format!("body {{ color: red{i}; }}")).unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
}
assert!(
cache.entries.lock().unwrap().len() <= 2,
"cache must never exceed its stated capacity"
);
}
#[tokio::test]
async fn broadcaster_change_event_invalidates_before_the_next_request() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("app.css");
std::fs::write(&path, "body { color: red; }").unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let cache = Arc::new(MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY));
let calls = AtomicUsize::new(0);
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
let broadcaster = Broadcaster::new();
Arc::clone(&cache).subscribe_to_invalidation(&broadcaster);
broadcaster.broadcast(crate::watcher::ChangeEvent {
path: path.clone(),
change_type: ChangeType::Css,
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"the broadcaster's change event must have evicted the entry before this request"
);
}
}