Skip to main content

mini_static/
watcher.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4use std::time::{Duration, SystemTime};
5
6use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
7
8use crate::reload::ChangeType;
9
10/// A change event broadcast when a watched file is added, modified, or removed.
11#[derive(Clone, Debug)]
12pub struct ChangeEvent {
13    /// The path to the file that changed, relative to the watched root.
14    pub path: PathBuf,
15    /// The type of change (CSS, script, HTML, or other).
16    pub change_type: ChangeType,
17}
18
19/// Broadcasts file change events to multiple subscribers.
20///
21/// A single broadcaster can have many subscribers (e.g., multiple browser clients
22/// connected via SSE). When a file changes, all active subscribers are notified.
23/// If a subscriber's channel is full or closed, that subscriber is removed.
24#[derive(Clone)]
25pub struct Broadcaster {
26    senders: Arc<Mutex<Vec<UnboundedSender<ChangeEvent>>>>,
27}
28
29impl Broadcaster {
30    /// Create a new broadcaster with no subscribers.
31    pub fn new() -> Self {
32        Broadcaster {
33            senders: Arc::new(Mutex::new(Vec::new())),
34        }
35    }
36
37    /// Broadcast a change event to all active subscribers.
38    ///
39    /// Removes any subscribers whose channels are closed or full.
40    pub fn broadcast(&self, event: ChangeEvent) {
41        let mut senders = self.senders.lock().unwrap();
42        senders.retain(|sender| sender.send(event.clone()).is_ok());
43    }
44
45    /// Subscribe to change events.
46    ///
47    /// Returns a receiver that will yield each broadcasted change event.
48    pub fn subscribe(&self) -> UnboundedReceiver<ChangeEvent> {
49        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
50        self.senders.lock().unwrap().push(tx);
51        rx
52    }
53
54    /// Get the current number of active subscribers.
55    #[cfg(test)]
56    pub fn subscriber_count(&self) -> usize {
57        self.senders.lock().unwrap().len()
58    }
59}
60
61impl Default for Broadcaster {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67/// Start watching a directory for file changes.
68///
69/// Spawns a background task that periodically polls the directory tree for
70/// modifications using mtime. When changes are detected, broadcasts them to all
71/// active subscribers via the given `broadcaster`.
72///
73/// The poll interval is bounded to prevent busy-waiting (per architecture principle A2).
74/// Polls every 500ms — a balance between responsiveness and system load.
75///
76/// # Panics
77///
78/// Panics if the async task cannot be spawned (e.g., no runtime available).
79pub fn start_watching(dir: Arc<PathBuf>, broadcaster: Broadcaster) {
80    tokio::spawn(async move {
81        let mut mtimes: HashMap<PathBuf, SystemTime> = HashMap::new();
82        let mut first_pass = true;
83        let mut interval = tokio::time::interval(Duration::from_millis(500));
84        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
85
86        loop {
87            interval.tick().await;
88
89            let entries = walk_dir(dir.as_path()).await.unwrap_or_default();
90            let mut current = HashMap::new();
91
92            for path in entries {
93                if let Ok(meta) = tokio::fs::metadata(&path).await {
94                    if let Ok(mtime) = meta.modified() {
95                        current.insert(path.clone(), mtime);
96
97                        if !first_pass {
98                            let is_new = !mtimes.contains_key(&path);
99                            let changed = mtimes.get(&path).is_none_or(|old| *old != mtime);
100                            if is_new || changed {
101                                let change_type = ChangeType::from_path(&path);
102                                broadcaster.broadcast(ChangeEvent { path, change_type });
103                            }
104                        }
105                    }
106                }
107            }
108
109            mtimes = current;
110            first_pass = false;
111        }
112    });
113}
114
115/// Recursively walk a directory tree and return all file paths.
116async fn walk_dir(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
117    let mut files = Vec::new();
118    let mut dirs = vec![dir.to_path_buf()];
119
120    while let Some(dir) = dirs.pop() {
121        let mut rd = tokio::fs::read_dir(&dir).await?;
122        while let Some(entry) = rd.next_entry().await? {
123            let path = entry.path();
124            if entry.file_type().await?.is_dir() {
125                dirs.push(path);
126            } else {
127                files.push(path);
128            }
129        }
130    }
131
132    Ok(files)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::fs;
139    use tempfile::TempDir;
140    use tokio::time::{sleep, timeout};
141
142    #[tokio::test]
143    async fn broadcaster_delivers_events_to_subscribers() {
144        let broadcaster = Broadcaster::new();
145        let mut rx = broadcaster.subscribe();
146
147        let event = ChangeEvent {
148            path: PathBuf::from("style.css"),
149            change_type: ChangeType::Css,
150        };
151        broadcaster.broadcast(event.clone());
152
153        let received = timeout(Duration::from_secs(1), rx.recv())
154            .await
155            .expect("timeout")
156            .expect("channel closed");
157        assert_eq!(received.path, event.path);
158        assert_eq!(received.change_type, event.change_type);
159    }
160
161    #[tokio::test]
162    async fn broadcaster_tracks_subscriber_count() {
163        let broadcaster = Broadcaster::new();
164        assert_eq!(broadcaster.subscriber_count(), 0);
165
166        let _rx1 = broadcaster.subscribe();
167        assert_eq!(broadcaster.subscriber_count(), 1);
168
169        let _rx2 = broadcaster.subscribe();
170        assert_eq!(broadcaster.subscriber_count(), 2);
171
172        drop(_rx1);
173        broadcaster.broadcast(ChangeEvent {
174            path: PathBuf::from("file.js"),
175            change_type: ChangeType::Script,
176        });
177        assert_eq!(broadcaster.subscriber_count(), 1);
178    }
179
180    #[tokio::test]
181    async fn watcher_detects_file_changes() {
182        let temp = TempDir::new().unwrap();
183        let dir_path = Arc::new(temp.path().to_path_buf());
184
185        let broadcaster = Broadcaster::new();
186        let mut rx = broadcaster.subscribe();
187
188        start_watching(Arc::clone(&dir_path), broadcaster);
189
190        sleep(Duration::from_millis(600)).await;
191
192        fs::write(dir_path.join("new_file.js"), "console.log('hello');").unwrap();
193
194        let event = timeout(Duration::from_secs(2), rx.recv())
195            .await
196            .expect("timeout")
197            .expect("channel closed");
198        assert_eq!(event.path.file_name().unwrap(), "new_file.js");
199        assert_eq!(event.change_type, ChangeType::Script);
200    }
201
202    #[tokio::test]
203    async fn watcher_detects_file_modifications() {
204        let temp = TempDir::new().unwrap();
205        let dir_path = Arc::new(temp.path().to_path_buf());
206        let file_path = dir_path.join("style.css");
207        fs::write(&file_path, "body { color: red; }").unwrap();
208
209        let broadcaster = Broadcaster::new();
210        let mut rx = broadcaster.subscribe();
211
212        start_watching(Arc::clone(&dir_path), broadcaster);
213
214        sleep(Duration::from_millis(600)).await;
215
216        fs::write(&file_path, "body { color: blue; }").unwrap();
217
218        let event = timeout(Duration::from_secs(2), rx.recv())
219            .await
220            .expect("timeout")
221            .expect("channel closed");
222        assert_eq!(event.path.file_name().unwrap(), "style.css");
223        assert_eq!(event.change_type, ChangeType::Css);
224    }
225
226    #[tokio::test]
227    async fn watcher_ignores_changes_in_first_pass() {
228        let temp = TempDir::new().unwrap();
229        let dir_path = Arc::new(temp.path().to_path_buf());
230        fs::write(dir_path.join("existing.html"), "<h1>Hello</h1>").unwrap();
231
232        let broadcaster = Broadcaster::new();
233        let mut rx = broadcaster.subscribe();
234
235        start_watching(Arc::clone(&dir_path), broadcaster);
236
237        sleep(Duration::from_millis(600)).await;
238
239        let result = timeout(Duration::from_millis(100), rx.recv()).await;
240        assert!(
241            result.is_err(),
242            "first pass should not emit events for existing files"
243        );
244    }
245}