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!(result.is_err(), "first pass should not emit events for existing files");
241	}
242}