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#[derive(Clone, Debug)]
12pub struct ChangeEvent {
13 pub path: PathBuf,
15 pub change_type: ChangeType,
17}
18
19#[derive(Clone)]
25pub struct Broadcaster {
26 senders: Arc<Mutex<Vec<UnboundedSender<ChangeEvent>>>>,
27}
28
29impl Broadcaster {
30 pub fn new() -> Self {
32 Broadcaster {
33 senders: Arc::new(Mutex::new(Vec::new())),
34 }
35 }
36
37 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 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 #[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
67pub 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
115async 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}