dotzuki_runner/watch.rs
1//! File watching for `dotzuki run --watch`: [`ProjectWatcher`].
2//!
3//! Watches the project's data/gfx/scene directories recursively and reports
4//! changed `.scene`/`.json`/`.png`/`.tmx` files. Same notify version and
5//! poll-per-frame shape as `dotzuki_app::hot_reload::AssetWatcher` — it is not
6//! reused because its extension filter (`tmx/png/js`) lacks `.scene` and is
7//! not configurable.
8//!
9//! The watcher only *reports* paths; reload policy (which paths trigger a
10//! scene recompile or a map reload) lives in [`crate::game::RunnerGame`].
11
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use std::sync::mpsc;
15
16use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
17
18/// Extensions a reload can act on: DSL scenes, TMX maps, tileset PNGs and
19/// JSON data (map sidecars, `map.tmx.json`).
20const SUPPORTED_EXTENSIONS: &[&str] = &["scene", "json", "png", "tmx"];
21
22fn is_supported_file(path: &Path) -> bool {
23 path.extension()
24 .and_then(|ext| ext.to_str())
25 .is_some_and(|ext| SUPPORTED_EXTENSIONS.contains(&ext))
26}
27
28/// Watches project directories for changes to reloadable content files.
29///
30/// Poll-based interface: call [`poll_events`](Self::poll_events) each frame
31/// to collect the changes since the last poll. Duplicate events for the same
32/// file within one poll cycle are deduplicated.
33pub struct ProjectWatcher {
34 _watcher: RecommendedWatcher,
35 rx: mpsc::Receiver<Result<Event, notify::Error>>,
36 seen: HashSet<PathBuf>,
37}
38
39impl ProjectWatcher {
40 /// Watch every directory in `dirs` recursively. Missing directories are
41 /// skipped (with a log line); an error is returned only when the watcher
42 /// itself cannot be created or no directory exists to watch.
43 pub fn new(dirs: &[PathBuf]) -> Result<Self, String> {
44 let (tx, rx) = mpsc::channel();
45 let mut watcher = RecommendedWatcher::new(
46 move |res| {
47 let _ = tx.send(res);
48 },
49 Config::default(),
50 )
51 .map_err(|e| format!("failed to create file watcher: {e}"))?;
52
53 let mut watched_any = false;
54 for dir in dirs {
55 if dir.is_dir() {
56 watcher
57 .watch(dir, RecursiveMode::Recursive)
58 .map_err(|e| format!("failed to watch {}: {e}", dir.display()))?;
59 log::info!("hot-reload: watching {}", dir.display());
60 watched_any = true;
61 } else {
62 log::info!("hot-reload: skipping {} (not found)", dir.display());
63 }
64 }
65
66 if !watched_any {
67 return Err("no valid directories to watch".to_string());
68 }
69
70 Ok(Self {
71 _watcher: watcher,
72 rx,
73 seen: HashSet::new(),
74 })
75 }
76
77 /// Changed content files since the last call, deduplicated by path.
78 pub fn poll_events(&mut self) -> Vec<PathBuf> {
79 self.seen.clear();
80
81 while let Ok(Ok(event)) = self.rx.try_recv() {
82 match event.kind {
83 EventKind::Modify(_) | EventKind::Create(_) => {
84 for path in event.paths {
85 if is_supported_file(&path) {
86 self.seen.insert(path);
87 }
88 }
89 }
90 _ => {}
91 }
92 }
93
94 self.seen.iter().cloned().collect()
95 }
96}