Skip to main content

goud_engine/assets/hot_reload/
watcher.rs

1//! File system watcher for the hot reload system.
2
3use crate::assets::AssetServer;
4use notify::{
5    Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Result as NotifyResult, Watcher,
6};
7use std::collections::{HashMap, HashSet};
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::sync::mpsc::{channel, Receiver, Sender};
11use std::time::{Duration, Instant};
12
13use super::config::HotReloadConfig;
14use super::events::AssetChangeEvent;
15
16// =============================================================================
17// HotReloadWatcher
18// =============================================================================
19
20/// Watches asset files for changes and triggers reloads.
21///
22/// # Thread Safety
23///
24/// `HotReloadWatcher` is `Send` but NOT `Sync`. It should be accessed from
25/// the main thread and used with `AssetServer` in the game loop.
26///
27/// # Example
28///
29/// ```no_run
30/// use goud_engine::assets::{AssetServer, HotReloadWatcher, HotReloadConfig};
31/// use std::time::Duration;
32///
33/// let mut server = AssetServer::new();
34/// let config = HotReloadConfig::new()
35///     .with_debounce(Duration::from_millis(200))
36///     .watch_extension("png")
37///     .watch_extension("json");
38///
39/// let mut watcher = HotReloadWatcher::with_config(&server, config).unwrap();
40///
41/// // In game loop
42/// loop {
43///     watcher.process_events(&mut server);
44///     // ... render, update, etc.
45/// }
46/// ```
47pub struct HotReloadWatcher {
48    /// File system watcher (kept alive to receive events).
49    _watcher: RecommendedWatcher,
50
51    /// Channel receiver for file system events.
52    receiver: Receiver<NotifyResult<Event>>,
53
54    /// Configuration.
55    config: HotReloadConfig,
56
57    /// Debounce tracking: path -> last event time.
58    debounce_map: HashMap<PathBuf, Instant>,
59
60    /// Paths currently being watched.
61    watched_paths: HashSet<PathBuf>,
62
63    /// Asset root directory (for relative path calculation).
64    asset_root: PathBuf,
65}
66
67impl HotReloadWatcher {
68    /// Creates a new hot reload watcher for the given asset server.
69    ///
70    /// Uses default configuration (enabled in debug builds).
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if the file watcher cannot be initialized.
75    ///
76    /// # Example
77    ///
78    /// ```no_run
79    /// use goud_engine::assets::{AssetServer, HotReloadWatcher};
80    ///
81    /// let server = AssetServer::new();
82    /// let watcher = HotReloadWatcher::new(&server).unwrap();
83    /// ```
84    pub fn new(server: &AssetServer) -> NotifyResult<Self> {
85        Self::with_config(server, HotReloadConfig::default())
86    }
87
88    /// Creates a new hot reload watcher with custom configuration.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the file watcher cannot be initialized.
93    pub fn with_config(server: &AssetServer, config: HotReloadConfig) -> NotifyResult<Self> {
94        let (sender, receiver) = channel();
95        let asset_root = server.asset_root().to_path_buf();
96
97        // Create watcher
98        let watcher = Self::create_watcher(sender, &config)?;
99
100        Ok(Self {
101            _watcher: watcher,
102            receiver,
103            config,
104            debounce_map: HashMap::new(),
105            watched_paths: HashSet::new(),
106            asset_root,
107        })
108    }
109
110    /// Creates the underlying file system watcher.
111    fn create_watcher(
112        sender: Sender<NotifyResult<Event>>,
113        _config: &HotReloadConfig,
114    ) -> NotifyResult<RecommendedWatcher> {
115        let watcher = RecommendedWatcher::new(
116            move |res| {
117                let _ = sender.send(res);
118            },
119            Config::default(),
120        )?;
121
122        Ok(watcher)
123    }
124
125    /// Starts watching a directory for changes.
126    ///
127    /// # Arguments
128    ///
129    /// * `path` - Directory to watch (typically the asset root)
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the path cannot be watched.
134    ///
135    /// # Example
136    ///
137    /// ```no_run
138    /// # use goud_engine::assets::{AssetServer, HotReloadWatcher};
139    /// # let server = AssetServer::new();
140    /// # let mut watcher = HotReloadWatcher::new(&server).unwrap();
141    /// watcher.watch("assets").unwrap();
142    /// ```
143    pub fn watch(&mut self, path: impl AsRef<Path>) -> NotifyResult<()> {
144        let path = path.as_ref();
145        let mode = if self.config.recursive {
146            RecursiveMode::Recursive
147        } else {
148            RecursiveMode::NonRecursive
149        };
150
151        self._watcher.watch(path, mode)?;
152        self.watched_paths.insert(path.to_path_buf());
153
154        Ok(())
155    }
156
157    /// Stops watching a directory.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if the path cannot be unwatched.
162    pub fn unwatch(&mut self, path: impl AsRef<Path>) -> NotifyResult<()> {
163        let path = path.as_ref();
164        self._watcher.unwatch(path)?;
165        self.watched_paths.remove(path);
166
167        Ok(())
168    }
169
170    /// Returns whether a path is currently being watched.
171    pub fn is_watching(&self, path: &Path) -> bool {
172        self.watched_paths.contains(path)
173    }
174
175    /// Returns the set of watched paths.
176    pub fn watched_paths(&self) -> &HashSet<PathBuf> {
177        &self.watched_paths
178    }
179
180    /// Returns the configuration.
181    pub fn config(&self) -> &HotReloadConfig {
182        &self.config
183    }
184
185    /// Returns a mutable reference to the configuration.
186    ///
187    /// Note: Changing the configuration does not affect already-watched paths.
188    pub fn config_mut(&mut self) -> &mut HotReloadConfig {
189        &mut self.config
190    }
191
192    /// Processes pending file system events and reloads changed assets.
193    ///
194    /// Call this once per frame in your game loop.
195    ///
196    /// # Returns
197    ///
198    /// The number of assets that were reloaded.
199    ///
200    /// # Example
201    ///
202    /// ```no_run
203    /// # use goud_engine::assets::{AssetServer, HotReloadWatcher};
204    /// # let mut server = AssetServer::new();
205    /// # let mut watcher = HotReloadWatcher::new(&server).unwrap();
206    /// loop {
207    ///     let reloaded = watcher.process_events(&mut server);
208    ///     if reloaded > 0 {
209    ///         println!("Reloaded {} assets", reloaded);
210    ///     }
211    ///     // ... rest of game loop
212    /// }
213    /// ```
214    pub fn process_events(&mut self, server: &mut AssetServer) -> usize {
215        if !self.config.enabled {
216            return 0;
217        }
218
219        let now = Instant::now();
220
221        // Collect events (non-blocking)
222        let mut change_events = Vec::new();
223        while let Ok(event_result) = self.receiver.try_recv() {
224            if let Ok(event) = event_result {
225                if let Some(change_event) = self.process_file_event(&event, now) {
226                    change_events.push(change_event);
227                }
228            }
229        }
230
231        // Reload changed assets and their dependents
232        let mut reload_count = 0;
233        for event in &change_events {
234            let path = event.path();
235            if let Some(relative) = self.relative_path(path) {
236                // Reload the changed asset itself
237                if server.reload_by_path(&relative) {
238                    reload_count += 1;
239                }
240
241                // Cascade reload dependents
242                let cascade = server.get_cascade_order(&relative);
243                for dependent_path in &cascade {
244                    if server.reload_by_path(dependent_path) {
245                        reload_count += 1;
246                    }
247                }
248            }
249        }
250
251        // Clean up old debounce entries (keep last 1000)
252        if self.debounce_map.len() > 1000 {
253            self.debounce_map
254                .retain(|_, time| now.duration_since(*time) < Duration::from_secs(10));
255        }
256
257        reload_count
258    }
259
260    /// Converts an absolute file path to a path relative to the asset root.
261    ///
262    /// Returns `None` if the path is not inside the asset root.
263    fn relative_path(&self, path: &Path) -> Option<String> {
264        path.strip_prefix(&self.asset_root)
265            .ok()
266            .and_then(|p| p.to_str())
267            .map(|s| s.to_string())
268    }
269
270    /// Processes a single file system event.
271    fn process_file_event(&mut self, event: &Event, now: Instant) -> Option<AssetChangeEvent> {
272        // Filter event kind
273        let change_event = match &event.kind {
274            EventKind::Modify(_) => {
275                let path = event.paths.first()?.clone();
276                AssetChangeEvent::Modified { path }
277            }
278            EventKind::Create(_) => {
279                let path = event.paths.first()?.clone();
280                AssetChangeEvent::Created { path }
281            }
282            EventKind::Remove(_) => {
283                let path = event.paths.first()?.clone();
284                AssetChangeEvent::Deleted { path }
285            }
286            _ => return None, // Ignore other events
287        };
288
289        let path = change_event.path();
290
291        // Check if we should watch this path
292        if !self.config.should_watch(path) {
293            return None;
294        }
295
296        // Debounce check
297        if let Some(last_time) = self.debounce_map.get(path) {
298            if now.duration_since(*last_time) < self.config.debounce_duration {
299                return None; // Too soon, ignore
300            }
301        }
302
303        // Update debounce map
304        self.debounce_map.insert(path.to_path_buf(), now);
305
306        Some(change_event)
307    }
308
309    /// Clears the debounce map.
310    ///
311    /// Useful for testing or forcing immediate reloads.
312    pub fn clear_debounce(&mut self) {
313        self.debounce_map.clear();
314    }
315}
316
317impl fmt::Debug for HotReloadWatcher {
318    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319        f.debug_struct("HotReloadWatcher")
320            .field("config", &self.config)
321            .field("watched_paths", &self.watched_paths.len())
322            .field("debounce_entries", &self.debounce_map.len())
323            .finish()
324    }
325}
326
327// =============================================================================
328// Tests
329// =============================================================================
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use std::fs;
335    use std::io::Write;
336    use tempfile::TempDir;
337
338    fn create_test_server() -> (AssetServer, TempDir) {
339        let temp_dir = TempDir::new().unwrap();
340        let server = AssetServer::with_root(temp_dir.path());
341        (server, temp_dir)
342    }
343
344    #[test]
345    fn test_new() {
346        let (server, _temp_dir) = create_test_server();
347        let watcher = HotReloadWatcher::new(&server);
348
349        assert!(watcher.is_ok());
350    }
351
352    #[test]
353    fn test_with_config() {
354        let (server, _temp_dir) = create_test_server();
355        let config = HotReloadConfig::new().with_enabled(false);
356        let watcher = HotReloadWatcher::with_config(&server, config);
357
358        assert!(watcher.is_ok());
359        assert!(!watcher.unwrap().config().enabled);
360    }
361
362    #[test]
363    fn test_watch() {
364        let (server, temp_dir) = create_test_server();
365        let mut watcher = HotReloadWatcher::new(&server).unwrap();
366
367        let result = watcher.watch(temp_dir.path());
368        assert!(result.is_ok());
369        assert!(watcher.is_watching(temp_dir.path()));
370        assert_eq!(watcher.watched_paths().len(), 1);
371    }
372
373    #[test]
374    fn test_unwatch() {
375        let (server, temp_dir) = create_test_server();
376        let mut watcher = HotReloadWatcher::new(&server).unwrap();
377
378        watcher.watch(temp_dir.path()).unwrap();
379        assert!(watcher.is_watching(temp_dir.path()));
380
381        watcher.unwatch(temp_dir.path()).unwrap();
382        assert!(!watcher.is_watching(temp_dir.path()));
383    }
384
385    #[test]
386    fn test_process_events_disabled() {
387        let (mut server, temp_dir) = create_test_server();
388        let config = HotReloadConfig::new().with_enabled(false);
389        let mut watcher = HotReloadWatcher::with_config(&server, config).unwrap();
390
391        watcher.watch(temp_dir.path()).unwrap();
392
393        // Create a file
394        let test_file = temp_dir.path().join("test.txt");
395        fs::File::create(&test_file)
396            .unwrap()
397            .write_all(b"test")
398            .unwrap();
399
400        // Process events (should do nothing since disabled)
401        std::thread::sleep(Duration::from_millis(50));
402        let count = watcher.process_events(&mut server);
403
404        assert_eq!(count, 0);
405    }
406
407    #[test]
408    fn test_config_mut() {
409        let (server, _temp_dir) = create_test_server();
410        let mut watcher = HotReloadWatcher::new(&server).unwrap();
411
412        watcher.config_mut().enabled = false;
413        assert!(!watcher.config().enabled);
414    }
415
416    #[test]
417    fn test_clear_debounce() {
418        let (server, _temp_dir) = create_test_server();
419        let mut watcher = HotReloadWatcher::new(&server).unwrap();
420
421        watcher.clear_debounce();
422        // Should not panic
423    }
424
425    #[test]
426    fn test_debug() {
427        let (server, _temp_dir) = create_test_server();
428        let watcher = HotReloadWatcher::new(&server).unwrap();
429
430        let debug = format!("{:?}", watcher);
431        assert!(debug.contains("HotReloadWatcher"));
432    }
433
434    #[test]
435    fn test_is_send() {
436        fn requires_send<T: Send>() {}
437        requires_send::<HotReloadWatcher>();
438    }
439}