use crossbeam_channel::{unbounded, Receiver, Sender};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub enum AssetEvent {
FileChanged { path: PathBuf },
WatchError { path: PathBuf, error: String },
}
pub struct AssetWatcher {
watcher: Option<notify::RecommendedWatcher>,
sender: Sender<AssetEvent>,
receiver: Receiver<AssetEvent>,
}
impl AssetWatcher {
pub fn new() -> Self {
let (sender, receiver) = unbounded();
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,
{
let sender = self.sender.clone();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
Ok(event) => {
if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
if let Some(path) = event.paths.first() {
if filter(path) {
if let Err(e) =
sender.send(AssetEvent::FileChanged { path: path.clone() })
{
log::error!("[asset] Watcher send failed: {:?}", e);
}
}
}
}
}
Err(e) => {
log::error!("[asset] Watcher error: {:?}", e);
}
})
.map_err(|e| format!("Failed to create asset watcher: {}", e))?;
watcher
.watch(dir, RecursiveMode::NonRecursive)
.map_err(|e| format!("Failed to watch directory: {}", e))?;
self.watcher = Some(watcher);
Ok(())
}
pub fn poll_events(&self) -> Vec<AssetEvent> {
let mut events = Vec::new();
while let Ok(event) = self.receiver.try_recv() {
events.push(event);
}
events
}
pub fn receiver(&self) -> &Receiver<AssetEvent> {
&self.receiver
}
}
impl Default for AssetWatcher {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[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") })
.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();
let result = watcher
.watch_directory(Path::new("/tmp/rw_nonexistent_asset_test_dir_xyzzy"), |_| 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(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(())
}
}