use alloc::sync::Arc;
use crossbeam_channel::{bounded, Receiver, Sender};
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub(crate) const ASSET_EVENT_CAPACITY: usize = 1024;
pub(crate) const ASSET_POLL_BATCH_LIMIT: usize = 256;
#[derive(Debug, Clone)]
pub enum AssetEvent {
FileChanged {
path: PathBuf,
kind: EventKind,
timestamp: std::time::SystemTime,
},
WatchError {
path: PathBuf,
error: String,
},
}
type EventMap = HashMap<PathBuf, AssetEvent>;
pub struct AssetWatcher {
watcher: Option<notify::RecommendedWatcher>,
sender: Sender<AssetEvent>,
receiver: Receiver<AssetEvent>,
}
impl AssetWatcher {
pub fn new() -> Self {
let (sender, receiver) = bounded(ASSET_EVENT_CAPACITY);
Self { watcher: None, sender, receiver }
}
pub fn watch_directory<P>(&mut self, dir: &Path, filter: P) -> Result<(), String>
where
P: Fn(&Path) -> bool + Send + 'static,
{
self.watch_internal(dir, false, filter)
}
pub fn watch<F>(&mut self, directory: &Path, recursive: bool, filter: F) -> Result<(), String>
where
F: Fn(&Path) -> bool + Send + Sync + 'static,
{
let filter = Arc::new(filter);
self.watch_internal(directory, recursive, move |p| filter(p))
}
fn watch_internal<P>(&mut self, dir: &Path, recursive: bool, filter: P) -> Result<(), String>
where
P: Fn(&Path) -> bool + Send + 'static,
{
let sender = self.sender.clone();
let mut pending: EventMap = HashMap::new();
let mut watcher: RecommendedWatcher = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| match res {
Ok(event) => {
if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
let timestamp = std::time::SystemTime::now();
for path in &event.paths {
if filter(path) {
let path = path.to_path_buf();
pending.insert(
path.clone(),
AssetEvent::FileChanged { path, kind: event.kind, timestamp },
);
}
}
flush_pending(&sender, &mut pending);
}
}
Err(e) => {
if let Err(log_err) = sender
.send(AssetEvent::WatchError { path: PathBuf::new(), error: e.to_string() })
{
log::error!("[asset] Watcher error send failed: {log_err:?}");
}
}
},
Config::default(),
)
.map_err(|e| {
format!(
"asset watcher could not be created for '{}': {e} (the OS notify backend \
may be unavailable)",
dir.display()
)
})?;
let mode = if recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
watcher.watch(dir, mode).map_err(|e| {
format!(
"directory '{}' could not be watched with mode {mode:?}: {e} (check that the \
path exists and is readable)",
dir.display()
)
})?;
self.watcher = Some(watcher);
Ok(())
}
pub fn poll_events(&self) -> Vec<AssetEvent> {
let mut events = Vec::with_capacity(ASSET_POLL_BATCH_LIMIT.min(ASSET_EVENT_CAPACITY));
for _ in 0..ASSET_POLL_BATCH_LIMIT {
match self.receiver.try_recv() {
Ok(event) => events.push(event),
Err(_) => break,
}
}
events
}
pub fn drain(&self) -> Vec<AssetEvent> {
self.poll_events()
}
pub fn receiver(&self) -> &Receiver<AssetEvent> {
&self.receiver
}
}
crate::impl_default_via_new!(AssetWatcher);
fn flush_pending(sender: &Sender<AssetEvent>, pending: &mut EventMap) {
pending.retain(|_, event| match sender.try_send(event.clone()) {
Ok(()) => false,
Err(crossbeam_channel::TrySendError::Full(_)) => true,
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {
false
}
});
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[test]
fn asset_watcher_create_and_drop() {
let watcher = AssetWatcher::new();
drop(watcher);
let watcher = AssetWatcher::default();
drop(watcher);
}
#[test]
fn asset_watcher_channel_roundtrip() {
let watcher = AssetWatcher::new();
watcher
.sender
.send(AssetEvent::FileChanged {
path: PathBuf::from("test.png"),
kind: EventKind::Create(notify::event::CreateKind::File),
timestamp: std::time::SystemTime::now(),
})
.expect("send should succeed");
let events = watcher.poll_events();
assert_eq!(events.len(), 1);
match &events[0] {
AssetEvent::FileChanged { path, .. } => {
assert_eq!(path, &PathBuf::from("test.png"));
}
other => panic!("Expected FileChanged, got {:?}", other),
}
}
#[test]
fn asset_watcher_watch_nonexistent_directory_returns_error() {
let mut watcher = AssetWatcher::new();
static UNIQUE: AtomicUsize = AtomicUsize::new(0);
let nonexistent = std::env::temp_dir().join(format!(
"rw_nonexistent_asset_{}_{}",
std::process::id(),
UNIQUE.fetch_add(1, Ordering::SeqCst)
));
assert!(!nonexistent.exists(), "fixture path must not already exist");
let result = watcher.watch_directory(&nonexistent, |_| true);
assert!(result.is_err(), "Expected error for nonexistent directory");
}
#[test]
fn asset_watcher_filter_predicate() -> Result<(), String> {
let dir = tempfile::tempdir().map_err(|e| e.to_string())?;
let filter_calls = Arc::new(AtomicUsize::new(0));
let filter_calls_clone = filter_calls.clone();
let mut watcher = AssetWatcher::new();
watcher.watch_directory(dir.path(), {
move |_path| {
filter_calls_clone.fetch_add(1, Ordering::SeqCst);
false }
})?;
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, "hello").map_err(|e| e.to_string())?;
std::thread::sleep(std::time::Duration::from_millis(200));
let events = watcher.poll_events();
assert!(
events.is_empty(),
"Expected no events when predicate filters everything, got {:?}",
events
);
let calls = filter_calls.load(Ordering::SeqCst);
assert!(calls > 0, "Expected at least one filter call, got {}", calls);
Ok(())
}
#[test]
fn asset_watcher_burst_on_one_path_collapses_and_is_delivered() {
let (tx, rx) = crossbeam_channel::bounded::<AssetEvent>(ASSET_EVENT_CAPACITY);
let mut pending: EventMap = HashMap::new();
let path = PathBuf::from("assets/icon.png");
let kind = EventKind::Modify(notify::event::ModifyKind::Any);
for i in 0..5_000u64 {
pending.insert(
path.clone(),
AssetEvent::FileChanged {
path: path.clone(),
kind,
timestamp: std::time::SystemTime::UNIX_EPOCH
+ std::time::Duration::from_millis(i),
},
);
}
assert_eq!(pending.len(), 1, "a burst on one path must collapse in the pending map");
flush_pending(&tx, &mut pending);
assert!(pending.is_empty(), "a within-capacity flush must drain the pending map");
let events: Vec<AssetEvent> = rx.try_iter().collect();
assert_eq!(events.len(), 1, "many changes to one path must deliver one event");
let AssetEvent::FileChanged { path: got, timestamp, .. } = &events[0] else {
panic!("expected FileChanged, got {events:?}");
};
assert_eq!(got, &path);
assert_eq!(
*timestamp,
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(4_999),
"the newest change for the path must be the one delivered"
);
}
#[test]
fn asset_watcher_flush_is_bounded_and_retries_when_full() {
let (tx, rx) = crossbeam_channel::bounded::<AssetEvent>(3);
let mut pending: EventMap = HashMap::new();
let kind = EventKind::Create(notify::event::CreateKind::File);
for i in 0..10u64 {
let path = PathBuf::from(format!("assets/{i}.png"));
pending.insert(
path.clone(),
AssetEvent::FileChanged {
path,
kind,
timestamp: std::time::SystemTime::UNIX_EPOCH
+ std::time::Duration::from_millis(i),
},
);
}
flush_pending(&tx, &mut pending);
assert_eq!(tx.len(), 3, "the flush must fill the channel but not exceed it");
assert_eq!(pending.len(), 7, "the events that did not fit must stay pending, not be lost");
let mut got = 0usize;
loop {
got += rx.try_iter().count();
flush_pending(&tx, &mut pending);
if pending.is_empty() {
break;
}
}
got += rx.try_iter().count();
assert_eq!(got, 10, "every pending event must eventually be delivered");
assert!(pending.is_empty(), "nothing is left pending once the consumer drains");
}
#[test]
fn asset_watcher_paused_consumer_drains_in_bounded_batches() {
let watcher = AssetWatcher::new();
let sender = watcher.sender.clone();
let kind = EventKind::Create(notify::event::CreateKind::File);
let pushed = ASSET_POLL_BATCH_LIMIT * 2;
for i in 0..pushed {
sender
.send(AssetEvent::FileChanged {
path: PathBuf::from(format!("assets/tex/{i}.png")),
kind,
timestamp: std::time::SystemTime::now(),
})
.expect("send stays within capacity");
}
let mut drained = 0usize;
let mut calls = 0usize;
loop {
let batch = watcher.poll_events();
assert!(
batch.len() <= ASSET_POLL_BATCH_LIMIT,
"one poll exceeded the batch limit: {}",
batch.len()
);
if batch.is_empty() {
break;
}
drained += batch.len();
calls += 1;
}
assert_eq!(drained, pushed, "every queued event must eventually be delivered");
assert_eq!(calls, 2, "work is spread over ceil(pushed / batch_limit) calls, not one");
assert_eq!(watcher.poll_events().len(), 0, "the queue is empty in the end");
}
#[test]
fn asset_watcher_create_drain_no_panics() {
let watcher = AssetWatcher::new();
let events = watcher.drain();
assert!(events.is_empty());
}
#[test]
fn asset_watcher_watch_delivers_file_changed_event() {
let dir = tempfile::tempdir().unwrap();
let mut watcher = AssetWatcher::new();
watcher.watch(dir.path(), false, |p| p.extension().is_some_and(|e| e == "txt")).unwrap();
let test_file = dir.path().join("test.txt");
std::fs::write(&test_file, b"hello").unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let events = watcher.drain();
let matched = events.iter().any(
|e| matches!(e, AssetEvent::FileChanged { path, .. } if path.ends_with("test.txt")),
);
assert!(matched, "Expected FileChanged event for test.txt, got {events:?}");
}
#[test]
fn asset_watcher_watch_filter_blocks_unmatched() {
let dir = tempfile::tempdir().unwrap();
let mut watcher = AssetWatcher::new();
watcher.watch(dir.path(), false, |p| p.extension().is_some_and(|e| e == "json")).unwrap();
let txt_file = dir.path().join("ignored.txt");
std::fs::write(&txt_file, b"ignored").unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let events = watcher.drain();
let matched = events.iter().any(
|e| matches!(e, AssetEvent::FileChanged { path, .. } if path.ends_with("ignored.txt")),
);
assert!(!matched, "Filtered file should not produce event, got {events:?}");
}
}