1use std::path::{Path, PathBuf};
20
21use crate::core_group::depgraph::DepGraph;
22use crate::server_group::event_watch::{ChangeBatch, EventWatcher};
23use crate::server_group::hmr::{HmrBroadcaster, HmrMessage};
24use crate::server_group::watch::{classify_change, ChangeKind};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct BatchOutcome {
31 pub frame: Option<HmrMessage>,
35 pub invalidated_outputs: Vec<PathBuf>,
38}
39
40#[must_use]
67pub fn process_batch(
68 batch: &ChangeBatch,
69 graph: &DepGraph,
70 output_dir: &Path,
71) -> BatchOutcome {
72 if batch.is_empty() {
73 return BatchOutcome {
74 frame: None,
75 invalidated_outputs: Vec::new(),
76 };
77 }
78
79 let mut css_paths: Vec<String> = Vec::new();
80 let mut content_or_template = false;
81 let mut other = false;
82
83 for path in &batch.paths {
84 match classify_change(path) {
85 ChangeKind::Css => {
86 css_paths.push(path.to_string_lossy().into_owned());
87 }
88 ChangeKind::Content | ChangeKind::Template => {
89 content_or_template = true;
90 }
91 ChangeKind::Other => {
92 other = true;
93 }
94 }
95 }
96
97 let outputs = graph.invalidated_outputs(&batch.paths);
98 let url_paths: Vec<String> = outputs
99 .iter()
100 .map(|p| output_to_url(p, output_dir))
101 .collect();
102
103 let frame = match (!css_paths.is_empty(), content_or_template, other) {
104 (true, false, false) => Some(HmrMessage::css(css_paths)),
106 (false, true, false) => {
110 if url_paths.is_empty() {
111 Some(HmrMessage::reload())
112 } else {
113 Some(HmrMessage::html(url_paths))
114 }
115 }
116 _ => Some(HmrMessage::reload()),
118 };
119
120 BatchOutcome {
121 frame,
122 invalidated_outputs: outputs,
123 }
124}
125
126#[must_use]
142pub fn output_to_url(output: &Path, output_dir: &Path) -> String {
143 let rel = output.strip_prefix(output_dir).unwrap_or(output);
144 let mut s = rel.to_string_lossy().replace('\\', "/");
145 for trailing in ["index.html", "index.htm"] {
147 if let Some(stripped) = s.strip_suffix(trailing) {
148 s = stripped.to_string();
149 break;
150 }
151 }
152 if !s.starts_with('/') {
153 s.insert(0, '/');
154 }
155 s
156}
157
158pub fn run_dev_loop<F: FnMut(&[PathBuf])>(
175 watcher: &EventWatcher,
176 graph: &DepGraph,
177 broadcaster: &HmrBroadcaster,
178 output_dir: &Path,
179 mut rebuild: F,
180) -> usize {
181 let mut processed = 0usize;
182 while let Some(batch) = watcher.recv() {
183 let outcome = process_batch(&batch, graph, output_dir);
184 rebuild(&outcome.invalidated_outputs);
185 if let Some(frame) = outcome.frame {
186 let _ = broadcaster.broadcast(&frame);
187 }
188 processed += 1;
189 }
190 processed
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn pb(s: &str) -> PathBuf {
198 PathBuf::from(s)
199 }
200
201 #[test]
202 fn empty_batch_yields_no_frame() {
203 let batch = ChangeBatch { paths: vec![] };
204 let graph = DepGraph::new();
205 let out = process_batch(&batch, &graph, Path::new("build"));
206 assert!(out.frame.is_none());
207 assert!(
208 out.invalidated_outputs.is_empty(),
209 "{:?}",
210 out.invalidated_outputs
211 );
212 }
213
214 #[test]
215 fn pure_css_batch_yields_hmr_css_frame() {
216 let batch = ChangeBatch {
217 paths: vec![pb("assets/style.css")],
218 };
219 let graph = DepGraph::new();
220 let out = process_batch(&batch, &graph, Path::new("build"));
221 let frame = out.frame.expect("frame");
222 let json = frame.to_json();
223 assert!(json.contains("hmr-css"));
224 assert!(json.contains("assets/style.css"));
225 }
226
227 #[test]
228 fn pure_content_batch_without_graph_falls_back_to_reload() {
229 let batch = ChangeBatch {
231 paths: vec![pb("content/blog/foo.md")],
232 };
233 let graph = DepGraph::new();
234 let out = process_batch(&batch, &graph, Path::new("build"));
235 let json = out.frame.unwrap().to_json();
236 assert!(json.contains("\"type\":\"reload\""));
237 }
238
239 #[test]
240 fn mixed_css_and_content_batch_yields_reload() {
241 let batch = ChangeBatch {
242 paths: vec![pb("assets/style.css"), pb("content/foo.md")],
243 };
244 let graph = DepGraph::new();
245 let out = process_batch(&batch, &graph, Path::new("build"));
246 assert!(out.frame.unwrap().to_json().contains("reload"));
247 }
248
249 #[test]
250 fn other_only_batch_yields_reload() {
251 let batch = ChangeBatch {
252 paths: vec![pb("data/config.json")],
253 };
254 let graph = DepGraph::new();
255 let out = process_batch(&batch, &graph, Path::new("build"));
256 assert!(out.frame.unwrap().to_json().contains("reload"));
257 }
258
259 #[test]
260 fn output_to_url_strips_index_html_and_prefix() {
261 let out = pb("build/blog/foo/index.html");
262 let url = output_to_url(&out, Path::new("build"));
263 assert_eq!(url, "/blog/foo/");
264 }
265
266 #[test]
267 fn output_to_url_keeps_non_index_files() {
268 let out = pb("build/feed.xml");
269 let url = output_to_url(&out, Path::new("build"));
270 assert_eq!(url, "/feed.xml");
271 }
272
273 #[test]
274 fn output_to_url_handles_root_index() {
275 let out = pb("build/index.html");
276 let url = output_to_url(&out, Path::new("build"));
277 assert_eq!(url, "/");
278 }
279
280 #[test]
281 fn output_to_url_normalises_backslashes() {
282 let out = pb("build\\blog\\index.html");
283 let url = output_to_url(&out, Path::new("build"));
286 assert!(url.starts_with('/'));
288 assert!(!url.contains('\\'));
289 }
290
291 #[test]
292 fn output_to_url_strips_index_htm_suffix() {
293 let out = pb("build/blog/index.htm");
299 let url = output_to_url(&out, Path::new("build"));
300 assert_eq!(url, "/blog/");
301 }
302
303 #[test]
304 fn output_to_url_keeps_leading_slash_when_prefix_strip_fails() {
305 let out = pb("/abs/feed.xml");
308 let url = output_to_url(&out, Path::new("build"));
309 assert_eq!(url, "/abs/feed.xml");
310 }
311
312 #[test]
313 fn run_dev_loop_processes_live_batches_until_watcher_closes() {
314 use std::sync::atomic::{AtomicUsize, Ordering};
315 use std::time::{Duration, Instant};
316
317 let dir = tempfile::tempdir().expect("tempdir");
318 let watcher =
319 EventWatcher::with_debounce(dir.path(), Duration::from_millis(30))
320 .expect("watcher");
321 let graph = DepGraph::new();
322 let broadcaster = HmrBroadcaster::new();
323
324 let frames = std::sync::Arc::new(AtomicUsize::new(0));
325 let frames_sink = std::sync::Arc::clone(&frames);
326 broadcaster.subscribe(Box::new(move |_| {
327 let _ = frames_sink.fetch_add(1, Ordering::SeqCst);
328 Ok(())
329 }));
330
331 let deadline = Instant::now() + Duration::from_secs(3);
335 while watcher
336 .recv_timeout(Duration::from_millis(0))
337 .batch()
338 .is_none()
339 {
340 if Instant::now() >= deadline {
341 break;
342 }
343 std::fs::write(dir.path().join("page.md"), b"x").expect("write");
344 std::thread::sleep(Duration::from_millis(50));
345 }
346
347 std::fs::write(dir.path().join("late.md"), b"y").expect("write");
351 std::thread::sleep(Duration::from_millis(80));
352 watcher.close_backend_for_test();
353
354 let rebuilds = AtomicUsize::new(0);
355 let processed = run_dev_loop(
356 &watcher,
357 &graph,
358 &broadcaster,
359 Path::new("build"),
360 |_outputs| {
361 let _ = rebuilds.fetch_add(1, Ordering::SeqCst);
362 },
363 );
364
365 assert_eq!(processed, rebuilds.load(Ordering::SeqCst));
368 assert_eq!(processed, frames.load(Ordering::SeqCst));
369 }
370
371 #[test]
372 fn pure_content_with_graph_edges_yields_hmr_html() {
373 let mut graph = DepGraph::new();
377 graph.add_output(
379 Path::new("content/blog/foo.md"),
380 Path::new("build/blog/foo/index.html"),
381 );
382 let batch = ChangeBatch {
383 paths: vec![pb("content/blog/foo.md")],
384 };
385 let out = process_batch(&batch, &graph, Path::new("build"));
386 let json = out.frame.unwrap().to_json();
387 assert!(json.contains("hmr-html"), "expected hmr-html, got: {json}");
388 assert!(
389 json.contains("/blog/foo/"),
390 "expected URL list to carry the page, got: {json}"
391 );
392 }
393}