Files
game_engine/crates/voltex_asset/src/watcher.rs
tolelom f4b1174e13 feat(asset): add async loading, file watcher, and hot reload support
- FileWatcher: mtime-based polling change detection
- AssetLoader: background thread loading via channels
- replace_in_place on AssetStorage for hot reload
- LoadState enum: Loading/Ready/Failed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:34:54 +09:00

115 lines
3.4 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
pub struct FileWatcher {
watched: HashMap<PathBuf, Option<SystemTime>>,
poll_interval: Duration,
last_poll: Instant,
}
impl FileWatcher {
pub fn new(poll_interval: Duration) -> Self {
Self {
watched: HashMap::new(),
poll_interval,
last_poll: Instant::now() - poll_interval, // allow immediate first poll
}
}
pub fn watch(&mut self, path: PathBuf) {
// Store None initially — first poll will record the mtime without reporting change
self.watched.insert(path, None);
}
pub fn unwatch(&mut self, path: &Path) {
self.watched.remove(path);
}
pub fn poll_changes(&mut self) -> Vec<PathBuf> {
let now = Instant::now();
if now.duration_since(self.last_poll) < self.poll_interval {
return Vec::new();
}
self.last_poll = now;
let mut changed = Vec::new();
for (path, last_mtime) in &mut self.watched {
let current = std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok());
if let Some(prev) = last_mtime {
// We have a previous mtime — compare
if current != Some(*prev) {
changed.push(path.clone());
}
}
// else: first poll, just record mtime, don't report
*last_mtime = current;
}
changed
}
pub fn watched_count(&self) -> usize {
self.watched.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_watch_and_poll_no_changes() {
let mut watcher = FileWatcher::new(Duration::from_millis(0));
let dir = std::env::temp_dir().join("voltex_watcher_test_1");
let _ = fs::create_dir_all(&dir);
let path = dir.join("test.txt");
fs::write(&path, "hello").unwrap();
watcher.watch(path.clone());
// First poll — should not report as changed (just registered)
let changes = watcher.poll_changes();
assert!(changes.is_empty());
// Second poll without modification — still no changes
let changes = watcher.poll_changes();
assert!(changes.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_detect_file_change() {
let dir = std::env::temp_dir().join("voltex_watcher_test_2");
let _ = fs::create_dir_all(&dir);
let path = dir.join("test2.txt");
fs::write(&path, "v1").unwrap();
let mut watcher = FileWatcher::new(Duration::from_millis(0));
watcher.watch(path.clone());
let _ = watcher.poll_changes(); // register initial mtime
// Modify file
std::thread::sleep(Duration::from_millis(50));
fs::write(&path, "v2 with more data").unwrap();
let changes = watcher.poll_changes();
assert!(changes.contains(&path));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_unwatch() {
let mut watcher = FileWatcher::new(Duration::from_millis(0));
let path = PathBuf::from("/nonexistent/test.txt");
watcher.watch(path.clone());
assert_eq!(watcher.watched_count(), 1);
watcher.unwatch(&path);
assert_eq!(watcher.watched_count(), 0);
assert!(watcher.poll_changes().is_empty());
}
}