Skip to main content

oxihuman_core/
hot_reload.rs

1//! File watching / hot-reload detection for assets.
2//! Provides a simulated file watcher for testing and integration with real watchers.
3
4#[allow(dead_code)]
5#[derive(Clone, PartialEq, Debug)]
6pub enum ChangeKind {
7    Modified,
8    Created,
9    Deleted,
10    Renamed,
11}
12
13#[allow(dead_code)]
14#[derive(Clone)]
15pub struct FileChange {
16    pub path: String,
17    pub kind: ChangeKind,
18    pub timestamp_ms: u64,
19}
20
21#[allow(dead_code)]
22pub struct HotReloadConfig {
23    pub poll_interval_ms: u64,
24    pub debounce_ms: u64,
25    pub watch_extensions: Vec<String>,
26}
27
28#[allow(dead_code)]
29pub struct HotReloadWatcher {
30    pub watched_paths: Vec<String>,
31    pub changes: Vec<FileChange>,
32    pub config: HotReloadConfig,
33    pub enabled: bool,
34    pub simulated_time_ms: u64,
35}
36
37#[allow(dead_code)]
38pub fn default_hot_reload_config() -> HotReloadConfig {
39    HotReloadConfig {
40        poll_interval_ms: 500,
41        debounce_ms: 100,
42        watch_extensions: vec![
43            "obj".to_string(),
44            "mtl".to_string(),
45            "glb".to_string(),
46            "gltf".to_string(),
47            "png".to_string(),
48            "json".to_string(),
49        ],
50    }
51}
52
53#[allow(dead_code)]
54pub fn new_watcher(cfg: HotReloadConfig) -> HotReloadWatcher {
55    HotReloadWatcher {
56        watched_paths: Vec::new(),
57        changes: Vec::new(),
58        config: cfg,
59        enabled: true,
60        simulated_time_ms: 0,
61    }
62}
63
64#[allow(dead_code)]
65pub fn watch_path(watcher: &mut HotReloadWatcher, path: &str) {
66    if !watcher.watched_paths.contains(&path.to_string()) {
67        watcher.watched_paths.push(path.to_string());
68    }
69}
70
71/// Returns true if the path was found and removed.
72#[allow(dead_code)]
73pub fn unwatch_path(watcher: &mut HotReloadWatcher, path: &str) -> bool {
74    let before = watcher.watched_paths.len();
75    watcher.watched_paths.retain(|p| p != path);
76    watcher.watched_paths.len() < before
77}
78
79/// Simulate a file change event (for testing).
80#[allow(dead_code)]
81pub fn simulate_file_change(watcher: &mut HotReloadWatcher, path: &str, kind: ChangeKind) {
82    if !watcher.enabled {
83        return;
84    }
85    watcher.changes.push(FileChange {
86        path: path.to_string(),
87        kind,
88        timestamp_ms: watcher.simulated_time_ms,
89    });
90}
91
92#[allow(dead_code)]
93pub fn pending_changes(watcher: &HotReloadWatcher) -> &[FileChange] {
94    &watcher.changes
95}
96
97#[allow(dead_code)]
98pub fn clear_changes(watcher: &mut HotReloadWatcher) {
99    watcher.changes.clear();
100}
101
102#[allow(dead_code)]
103pub fn is_watched(watcher: &HotReloadWatcher, path: &str) -> bool {
104    watcher.watched_paths.contains(&path.to_string())
105}
106
107#[allow(dead_code)]
108pub fn changes_for_path<'a>(watcher: &'a HotReloadWatcher, path: &str) -> Vec<&'a FileChange> {
109    watcher.changes.iter().filter(|c| c.path == path).collect()
110}
111
112/// Check whether a file's extension is in the watch list.
113#[allow(dead_code)]
114pub fn extension_matches(watcher: &HotReloadWatcher, path: &str) -> bool {
115    let ext = path.rsplit('.').next().unwrap_or("");
116    watcher.config.watch_extensions.iter().any(|e| e == ext)
117}
118
119#[allow(dead_code)]
120pub fn watched_path_count(watcher: &HotReloadWatcher) -> usize {
121    watcher.watched_paths.len()
122}
123
124#[allow(dead_code)]
125pub fn change_count(watcher: &HotReloadWatcher) -> usize {
126    watcher.changes.len()
127}
128
129#[allow(dead_code)]
130pub fn enable_watcher(watcher: &mut HotReloadWatcher) {
131    watcher.enabled = true;
132}
133
134#[allow(dead_code)]
135pub fn disable_watcher(watcher: &mut HotReloadWatcher) {
136    watcher.enabled = false;
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn make_watcher() -> HotReloadWatcher {
144        new_watcher(default_hot_reload_config())
145    }
146
147    #[test]
148    fn test_default_config() {
149        let cfg = default_hot_reload_config();
150        assert!(cfg.poll_interval_ms > 0);
151        assert!(!cfg.watch_extensions.is_empty());
152    }
153
154    #[test]
155    fn test_new_watcher() {
156        let w = make_watcher();
157        assert!(w.enabled);
158        assert!(w.watched_paths.is_empty());
159        assert!(w.changes.is_empty());
160    }
161
162    #[test]
163    fn test_watch_path() {
164        let mut w = make_watcher();
165        watch_path(&mut w, "assets/model.glb");
166        assert!(is_watched(&w, "assets/model.glb"));
167        assert_eq!(watched_path_count(&w), 1);
168    }
169
170    #[test]
171    fn test_watch_path_no_duplicates() {
172        let mut w = make_watcher();
173        watch_path(&mut w, "assets/model.glb");
174        watch_path(&mut w, "assets/model.glb");
175        assert_eq!(watched_path_count(&w), 1);
176    }
177
178    #[test]
179    fn test_is_watched_false() {
180        let w = make_watcher();
181        assert!(!is_watched(&w, "missing.obj"));
182    }
183
184    #[test]
185    fn test_unwatch_path() {
186        let mut w = make_watcher();
187        watch_path(&mut w, "assets/tex.png");
188        let removed = unwatch_path(&mut w, "assets/tex.png");
189        assert!(removed);
190        assert!(!is_watched(&w, "assets/tex.png"));
191    }
192
193    #[test]
194    fn test_unwatch_path_missing() {
195        let mut w = make_watcher();
196        let removed = unwatch_path(&mut w, "nonexistent.obj");
197        assert!(!removed);
198    }
199
200    #[test]
201    fn test_simulate_file_change() {
202        let mut w = make_watcher();
203        simulate_file_change(&mut w, "scene.json", ChangeKind::Modified);
204        assert_eq!(change_count(&w), 1);
205        assert_eq!(pending_changes(&w)[0].path, "scene.json");
206        assert_eq!(pending_changes(&w)[0].kind, ChangeKind::Modified);
207    }
208
209    #[test]
210    fn test_simulate_disabled_watcher() {
211        let mut w = make_watcher();
212        disable_watcher(&mut w);
213        simulate_file_change(&mut w, "scene.json", ChangeKind::Modified);
214        assert_eq!(change_count(&w), 0);
215    }
216
217    #[test]
218    fn test_clear_changes() {
219        let mut w = make_watcher();
220        simulate_file_change(&mut w, "a.obj", ChangeKind::Created);
221        simulate_file_change(&mut w, "b.obj", ChangeKind::Deleted);
222        clear_changes(&mut w);
223        assert_eq!(change_count(&w), 0);
224    }
225
226    #[test]
227    fn test_changes_for_path() {
228        let mut w = make_watcher();
229        simulate_file_change(&mut w, "a.obj", ChangeKind::Modified);
230        simulate_file_change(&mut w, "b.obj", ChangeKind::Created);
231        simulate_file_change(&mut w, "a.obj", ChangeKind::Deleted);
232        let changes = changes_for_path(&w, "a.obj");
233        assert_eq!(changes.len(), 2);
234    }
235
236    #[test]
237    fn test_extension_matches() {
238        let w = make_watcher();
239        assert!(extension_matches(&w, "model.glb"));
240        assert!(extension_matches(&w, "texture.png"));
241        assert!(!extension_matches(&w, "script.lua"));
242        assert!(!extension_matches(&w, "data.bin"));
243    }
244
245    #[test]
246    fn test_enable_disable_watcher() {
247        let mut w = make_watcher();
248        disable_watcher(&mut w);
249        assert!(!w.enabled);
250        enable_watcher(&mut w);
251        assert!(w.enabled);
252    }
253
254    #[test]
255    fn test_watched_path_count() {
256        let mut w = make_watcher();
257        assert_eq!(watched_path_count(&w), 0);
258        watch_path(&mut w, "a.obj");
259        watch_path(&mut w, "b.glb");
260        assert_eq!(watched_path_count(&w), 2);
261    }
262
263    #[test]
264    fn test_change_kind_eq() {
265        assert_eq!(ChangeKind::Modified, ChangeKind::Modified);
266        assert_ne!(ChangeKind::Created, ChangeKind::Deleted);
267    }
268}