Skip to main content

ssg/server/
dev_server.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Dev-server glue (issue #526) — wires [`EventWatcher`] → [`DepGraph`]
5//! → [`HmrBroadcaster`] into one loop.
6//!
7//! Lifted out of `bin/dev.rs` so each policy decision (classify a change,
8//! pick the HMR frame type, resolve invalidated outputs) is unit-testable
9//! without spawning threads or binding a port.
10//!
11//! ```text
12//!   FS event   ──▶ ChangeBatch ──▶ process_batch ──▶ HmrMessage ──▶ tabs
13//!     (notify)        (debounced)    (DepGraph)        (broadcaster)
14//! ```
15//!
16//! See [`process_batch`] for the policy table and [`run_dev_loop`] for
17//! the integration point.
18
19use 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/// Outcome of processing one debounced batch — the frame (if any) the
27/// caller should broadcast and the page paths invalidated by the batch
28/// (used by the rebuild step).
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct BatchOutcome {
31    /// The HMR frame to push to every connected tab. `None` if every
32    /// path in the batch was classified [`ChangeKind::Other`] (e.g.
33    /// editor backup, build artefact under the watched root).
34    pub frame: Option<HmrMessage>,
35    /// Page output paths the dep graph says were invalidated. These
36    /// drive the rebuild scope.
37    pub invalidated_outputs: Vec<PathBuf>,
38}
39
40/// Policy table: turn a [`ChangeBatch`] + a [`DepGraph`] into the right
41/// HMR frame.
42///
43/// Rules:
44/// * **All CSS** → `hmr-css` (AC2) — list every changed `.css` path.
45/// * **Markdown / HTML content (no templates)** → `hmr-html` (AC3) —
46///   list invalidated output URLs from the dep graph.
47/// * **Template touched** → `hmr-html` if the dep graph resolves
48///   affected pages (AC4); falls back to `reload` if no pages are
49///   linked (e.g. a brand-new template).
50/// * **Mixed CSS + content** → `reload` to keep the protocol simple.
51/// * **All other** (data, fonts, images that aren't fingerprinted) →
52///   `reload` so the user always sees the latest build.
53///
54/// # Examples
55///
56/// ```rust
57/// use ssg::dev_server::process_batch;
58/// use ssg::depgraph::DepGraph;
59/// use ssg::event_watch::ChangeBatch;
60/// use std::path::Path;
61///
62/// let batch = ChangeBatch { paths: vec![] };
63/// let outcome = process_batch(&batch, &DepGraph::new(), Path::new("build"));
64/// assert!(outcome.frame.is_none());
65/// ```
66#[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        // CSS only (no content/template/other) → hmr-css.
105        (true, false, false) => Some(HmrMessage::css(css_paths)),
106        // Pure content/template change (no CSS, no other) → hmr-html
107        // with the dep-graph-resolved page list. If the graph has no
108        // edges yet, the URL list is empty — fall back to reload.
109        (false, true, false) => {
110            if url_paths.is_empty() {
111                Some(HmrMessage::reload())
112            } else {
113                Some(HmrMessage::html(url_paths))
114            }
115        }
116        // Anything mixed, or only "other" → full reload.
117        _ => Some(HmrMessage::reload()),
118    };
119
120    BatchOutcome {
121        frame,
122        invalidated_outputs: outputs,
123    }
124}
125
126/// Convert an output filesystem path (e.g. `build/blog/foo/index.html`)
127/// into the URL the browser tab is loaded at (`/blog/foo/`).
128///
129/// Strips the `output_dir` prefix and the trailing `index.html` so
130/// directory-style URLs match the path the browser is sitting at.
131///
132/// # Examples
133///
134/// ```rust
135/// use ssg::dev_server::output_to_url;
136/// use std::path::Path;
137///
138/// let url = output_to_url(Path::new("build/blog/foo/index.html"), Path::new("build"));
139/// assert_eq!(url, "/blog/foo/");
140/// ```
141#[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    // Strip trailing index.html to match clean URLs.
146    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
158/// Pumps debounced batches off `watcher` and broadcasts each frame.
159///
160/// The closure `rebuild` is called once per batch with the invalidated
161/// output list so callers can plug in the incremental pipeline.
162///
163/// Returns the number of batches processed before the watcher closed,
164/// which is useful for tests that inject a finite event stream.
165///
166/// # Examples
167///
168/// ```ignore
169/// // Requires a real EventWatcher + HmrBroadcaster bound to a port,
170/// // which a doctest sandbox can't safely set up. See the `dev` binary
171/// // (`src/bin/dev.rs`) for a runnable wiring.
172/// use ssg::dev_server::run_dev_loop;
173/// ```
174pub 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        // No edges in the graph => empty invalidated list => reload.
230        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        // strip_prefix won't match because backslashes aren't a path
284        // separator on Unix. Use a path that strip_prefix accepts.
285        let url = output_to_url(&out, Path::new("build"));
286        // The function still normalises any embedded backslashes.
287        assert!(url.starts_with('/'));
288        assert!(!url.contains('\\'));
289    }
290
291    #[test]
292    fn output_to_url_strips_index_htm_suffix() {
293        // Covers the second loop iteration of the `trailing` array in
294        // `output_to_url` — a path ending in the shorter "index.htm"
295        // (no trailing "l") never matches the first `strip_suffix`
296        // check, so the loop must advance to the second entry and take
297        // its `if let Some(stripped)` branch.
298        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        // An absolute output path outside `output_dir` cannot be
306        // stripped; it already starts with '/' so no slash is inserted.
307        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        // Generate FS events and give the backend + debouncer a bounded
332        // window to flush at least one batch into the channel. FSEvents
333        // can drop cold-start events, so poll rather than sleep once.
334        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        // Closing the backend drops the raw sender; the debounce thread
348        // flushes anything pending and exits, closing the batched
349        // channel — which is what makes run_dev_loop return.
350        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        // Every processed batch triggered exactly one rebuild and one
366        // broadcast frame.
367        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        // Covers the line ~113 `Some(HmrMessage::html(url_paths))`
374        // arm — a content change that resolves to one or more
375        // outputs via the dep-graph.
376        let mut graph = DepGraph::new();
377        // Source content/blog/foo.md produces build/blog/foo/index.html.
378        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}