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!(out.invalidated_outputs.is_empty());
208    }
209
210    #[test]
211    fn pure_css_batch_yields_hmr_css_frame() {
212        let batch = ChangeBatch {
213            paths: vec![pb("assets/style.css")],
214        };
215        let graph = DepGraph::new();
216        let out = process_batch(&batch, &graph, Path::new("build"));
217        let frame = out.frame.expect("frame");
218        let json = frame.to_json();
219        assert!(json.contains("hmr-css"));
220        assert!(json.contains("assets/style.css"));
221    }
222
223    #[test]
224    fn pure_content_batch_without_graph_falls_back_to_reload() {
225        // No edges in the graph => empty invalidated list => reload.
226        let batch = ChangeBatch {
227            paths: vec![pb("content/blog/foo.md")],
228        };
229        let graph = DepGraph::new();
230        let out = process_batch(&batch, &graph, Path::new("build"));
231        let json = out.frame.unwrap().to_json();
232        assert!(json.contains("\"type\":\"reload\""));
233    }
234
235    #[test]
236    fn mixed_css_and_content_batch_yields_reload() {
237        let batch = ChangeBatch {
238            paths: vec![pb("assets/style.css"), pb("content/foo.md")],
239        };
240        let graph = DepGraph::new();
241        let out = process_batch(&batch, &graph, Path::new("build"));
242        assert!(out.frame.unwrap().to_json().contains("reload"));
243    }
244
245    #[test]
246    fn other_only_batch_yields_reload() {
247        let batch = ChangeBatch {
248            paths: vec![pb("data/config.json")],
249        };
250        let graph = DepGraph::new();
251        let out = process_batch(&batch, &graph, Path::new("build"));
252        assert!(out.frame.unwrap().to_json().contains("reload"));
253    }
254
255    #[test]
256    fn output_to_url_strips_index_html_and_prefix() {
257        let out = pb("build/blog/foo/index.html");
258        let url = output_to_url(&out, Path::new("build"));
259        assert_eq!(url, "/blog/foo/");
260    }
261
262    #[test]
263    fn output_to_url_keeps_non_index_files() {
264        let out = pb("build/feed.xml");
265        let url = output_to_url(&out, Path::new("build"));
266        assert_eq!(url, "/feed.xml");
267    }
268
269    #[test]
270    fn output_to_url_handles_root_index() {
271        let out = pb("build/index.html");
272        let url = output_to_url(&out, Path::new("build"));
273        assert_eq!(url, "/");
274    }
275
276    #[test]
277    fn output_to_url_normalises_backslashes() {
278        let out = pb("build\\blog\\index.html");
279        // strip_prefix won't match because backslashes aren't a path
280        // separator on Unix. Use a path that strip_prefix accepts.
281        let url = output_to_url(&out, Path::new("build"));
282        // The function still normalises any embedded backslashes.
283        assert!(url.starts_with('/'));
284        assert!(!url.contains('\\'));
285    }
286
287    #[test]
288    fn output_to_url_strips_index_htm_suffix() {
289        // Covers the second loop iteration of the `trailing` array in
290        // `output_to_url` — a path ending in the shorter "index.htm"
291        // (no trailing "l") never matches the first `strip_suffix`
292        // check, so the loop must advance to the second entry and take
293        // its `if let Some(stripped)` branch.
294        let out = pb("build/blog/index.htm");
295        let url = output_to_url(&out, Path::new("build"));
296        assert_eq!(url, "/blog/");
297    }
298
299    #[test]
300    fn output_to_url_keeps_leading_slash_when_prefix_strip_fails() {
301        // An absolute output path outside `output_dir` cannot be
302        // stripped; it already starts with '/' so no slash is inserted.
303        let out = pb("/abs/feed.xml");
304        let url = output_to_url(&out, Path::new("build"));
305        assert_eq!(url, "/abs/feed.xml");
306    }
307
308    #[test]
309    fn run_dev_loop_processes_live_batches_until_watcher_closes() {
310        use std::sync::atomic::{AtomicUsize, Ordering};
311        use std::time::{Duration, Instant};
312
313        let dir = tempfile::tempdir().expect("tempdir");
314        let watcher =
315            EventWatcher::with_debounce(dir.path(), Duration::from_millis(30))
316                .expect("watcher");
317        let graph = DepGraph::new();
318        let broadcaster = HmrBroadcaster::new();
319
320        let frames = std::sync::Arc::new(AtomicUsize::new(0));
321        let frames_sink = std::sync::Arc::clone(&frames);
322        broadcaster.subscribe(Box::new(move |_| {
323            let _ = frames_sink.fetch_add(1, Ordering::SeqCst);
324            Ok(())
325        }));
326
327        // Generate FS events and give the backend + debouncer a bounded
328        // window to flush at least one batch into the channel. FSEvents
329        // can drop cold-start events, so poll rather than sleep once.
330        let deadline = Instant::now() + Duration::from_secs(3);
331        while watcher
332            .recv_timeout(Duration::from_millis(0))
333            .batch()
334            .is_none()
335        {
336            if Instant::now() >= deadline {
337                break;
338            }
339            std::fs::write(dir.path().join("page.md"), b"x").expect("write");
340            std::thread::sleep(Duration::from_millis(50));
341        }
342
343        // Closing the backend drops the raw sender; the debounce thread
344        // flushes anything pending and exits, closing the batched
345        // channel — which is what makes run_dev_loop return.
346        std::fs::write(dir.path().join("late.md"), b"y").expect("write");
347        std::thread::sleep(Duration::from_millis(80));
348        watcher.close_backend_for_test();
349
350        let rebuilds = AtomicUsize::new(0);
351        let processed = run_dev_loop(
352            &watcher,
353            &graph,
354            &broadcaster,
355            Path::new("build"),
356            |_outputs| {
357                let _ = rebuilds.fetch_add(1, Ordering::SeqCst);
358            },
359        );
360
361        // Every processed batch triggered exactly one rebuild and one
362        // broadcast frame.
363        assert_eq!(processed, rebuilds.load(Ordering::SeqCst));
364        assert_eq!(processed, frames.load(Ordering::SeqCst));
365    }
366
367    #[test]
368    fn pure_content_with_graph_edges_yields_hmr_html() {
369        // Covers the line ~113 `Some(HmrMessage::html(url_paths))`
370        // arm — a content change that resolves to one or more
371        // outputs via the dep-graph.
372        let mut graph = DepGraph::new();
373        // Source content/blog/foo.md produces build/blog/foo/index.html.
374        graph.add_output(
375            Path::new("content/blog/foo.md"),
376            Path::new("build/blog/foo/index.html"),
377        );
378        let batch = ChangeBatch {
379            paths: vec![pb("content/blog/foo.md")],
380        };
381        let out = process_batch(&batch, &graph, Path::new("build"));
382        let json = out.frame.unwrap().to_json();
383        assert!(json.contains("hmr-html"), "expected hmr-html, got: {json}");
384        assert!(
385            json.contains("/blog/foo/"),
386            "expected URL list to carry the page, got: {json}"
387        );
388    }
389}