use std::path::{Path, PathBuf};
use crate::core_group::depgraph::DepGraph;
use crate::server_group::event_watch::{ChangeBatch, EventWatcher};
use crate::server_group::hmr::{HmrBroadcaster, HmrMessage};
use crate::server_group::watch::{classify_change, ChangeKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchOutcome {
pub frame: Option<HmrMessage>,
pub invalidated_outputs: Vec<PathBuf>,
}
#[must_use]
pub fn process_batch(
batch: &ChangeBatch,
graph: &DepGraph,
output_dir: &Path,
) -> BatchOutcome {
if batch.is_empty() {
return BatchOutcome {
frame: None,
invalidated_outputs: Vec::new(),
};
}
let mut css_paths: Vec<String> = Vec::new();
let mut content_or_template = false;
let mut other = false;
for path in &batch.paths {
match classify_change(path) {
ChangeKind::Css => {
css_paths.push(path.to_string_lossy().into_owned());
}
ChangeKind::Content | ChangeKind::Template => {
content_or_template = true;
}
ChangeKind::Other => {
other = true;
}
}
}
let outputs = graph.invalidated_outputs(&batch.paths);
let url_paths: Vec<String> = outputs
.iter()
.map(|p| output_to_url(p, output_dir))
.collect();
let frame = match (!css_paths.is_empty(), content_or_template, other) {
(true, false, false) => Some(HmrMessage::css(css_paths)),
(false, true, false) => {
if url_paths.is_empty() {
Some(HmrMessage::reload())
} else {
Some(HmrMessage::html(url_paths))
}
}
_ => Some(HmrMessage::reload()),
};
BatchOutcome {
frame,
invalidated_outputs: outputs,
}
}
#[must_use]
pub fn output_to_url(output: &Path, output_dir: &Path) -> String {
let rel = output.strip_prefix(output_dir).unwrap_or(output);
let mut s = rel.to_string_lossy().replace('\\', "/");
for trailing in ["index.html", "index.htm"] {
if let Some(stripped) = s.strip_suffix(trailing) {
s = stripped.to_string();
break;
}
}
if !s.starts_with('/') {
s.insert(0, '/');
}
s
}
pub fn run_dev_loop<F: FnMut(&[PathBuf])>(
watcher: &EventWatcher,
graph: &DepGraph,
broadcaster: &HmrBroadcaster,
output_dir: &Path,
mut rebuild: F,
) -> usize {
let mut processed = 0usize;
while let Some(batch) = watcher.recv() {
let outcome = process_batch(&batch, graph, output_dir);
rebuild(&outcome.invalidated_outputs);
if let Some(frame) = outcome.frame {
let _ = broadcaster.broadcast(&frame);
}
processed += 1;
}
processed
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn pb(s: &str) -> PathBuf {
PathBuf::from(s)
}
#[test]
fn empty_batch_yields_no_frame() {
let batch = ChangeBatch { paths: vec![] };
let graph = DepGraph::new();
let out = process_batch(&batch, &graph, Path::new("build"));
assert!(out.frame.is_none());
assert!(out.invalidated_outputs.is_empty());
}
#[test]
fn pure_css_batch_yields_hmr_css_frame() {
let batch = ChangeBatch {
paths: vec![pb("assets/style.css")],
};
let graph = DepGraph::new();
let out = process_batch(&batch, &graph, Path::new("build"));
let frame = out.frame.expect("frame");
let json = frame.to_json();
assert!(json.contains("hmr-css"));
assert!(json.contains("assets/style.css"));
}
#[test]
fn pure_content_batch_without_graph_falls_back_to_reload() {
let batch = ChangeBatch {
paths: vec![pb("content/blog/foo.md")],
};
let graph = DepGraph::new();
let out = process_batch(&batch, &graph, Path::new("build"));
let json = out.frame.unwrap().to_json();
assert!(json.contains("\"type\":\"reload\""));
}
#[test]
fn mixed_css_and_content_batch_yields_reload() {
let batch = ChangeBatch {
paths: vec![pb("assets/style.css"), pb("content/foo.md")],
};
let graph = DepGraph::new();
let out = process_batch(&batch, &graph, Path::new("build"));
assert!(out.frame.unwrap().to_json().contains("reload"));
}
#[test]
fn other_only_batch_yields_reload() {
let batch = ChangeBatch {
paths: vec![pb("data/config.json")],
};
let graph = DepGraph::new();
let out = process_batch(&batch, &graph, Path::new("build"));
assert!(out.frame.unwrap().to_json().contains("reload"));
}
#[test]
fn output_to_url_strips_index_html_and_prefix() {
let out = pb("build/blog/foo/index.html");
let url = output_to_url(&out, Path::new("build"));
assert_eq!(url, "/blog/foo/");
}
#[test]
fn output_to_url_keeps_non_index_files() {
let out = pb("build/feed.xml");
let url = output_to_url(&out, Path::new("build"));
assert_eq!(url, "/feed.xml");
}
#[test]
fn output_to_url_handles_root_index() {
let out = pb("build/index.html");
let url = output_to_url(&out, Path::new("build"));
assert_eq!(url, "/");
}
#[test]
fn output_to_url_normalises_backslashes() {
let out = pb("build\\blog\\index.html");
let url = output_to_url(&out, Path::new("build"));
assert!(url.starts_with('/'));
assert!(!url.contains('\\'));
}
#[test]
fn output_to_url_strips_index_htm_suffix() {
let out = pb("build/blog/index.htm");
let url = output_to_url(&out, Path::new("build"));
assert_eq!(url, "/blog/");
}
#[test]
fn output_to_url_keeps_leading_slash_when_prefix_strip_fails() {
let out = pb("/abs/feed.xml");
let url = output_to_url(&out, Path::new("build"));
assert_eq!(url, "/abs/feed.xml");
}
#[test]
fn run_dev_loop_processes_live_batches_until_watcher_closes() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().expect("tempdir");
let watcher =
EventWatcher::with_debounce(dir.path(), Duration::from_millis(30))
.expect("watcher");
let graph = DepGraph::new();
let broadcaster = HmrBroadcaster::new();
let frames = std::sync::Arc::new(AtomicUsize::new(0));
let frames_sink = std::sync::Arc::clone(&frames);
broadcaster.subscribe(Box::new(move |_| {
let _ = frames_sink.fetch_add(1, Ordering::SeqCst);
Ok(())
}));
let deadline = Instant::now() + Duration::from_secs(3);
while watcher
.recv_timeout(Duration::from_millis(0))
.batch()
.is_none()
{
if Instant::now() >= deadline {
break;
}
std::fs::write(dir.path().join("page.md"), b"x").expect("write");
std::thread::sleep(Duration::from_millis(50));
}
std::fs::write(dir.path().join("late.md"), b"y").expect("write");
std::thread::sleep(Duration::from_millis(80));
watcher.close_backend_for_test();
let rebuilds = AtomicUsize::new(0);
let processed = run_dev_loop(
&watcher,
&graph,
&broadcaster,
Path::new("build"),
|_outputs| {
let _ = rebuilds.fetch_add(1, Ordering::SeqCst);
},
);
assert_eq!(processed, rebuilds.load(Ordering::SeqCst));
assert_eq!(processed, frames.load(Ordering::SeqCst));
}
#[test]
fn pure_content_with_graph_edges_yields_hmr_html() {
let mut graph = DepGraph::new();
graph.add_output(
Path::new("content/blog/foo.md"),
Path::new("build/blog/foo/index.html"),
);
let batch = ChangeBatch {
paths: vec![pb("content/blog/foo.md")],
};
let out = process_batch(&batch, &graph, Path::new("build"));
let json = out.frame.unwrap().to_json();
assert!(json.contains("hmr-html"), "expected hmr-html, got: {json}");
assert!(
json.contains("/blog/foo/"),
"expected URL list to carry the page, got: {json}"
);
}
}