use std::path::{Path, PathBuf};
use crate::css::{self, CssOptions, CssTool};
use crate::js::{self, JsOptions, JsTool};
use crate::reload::ChangeType;
use crate::watcher::{Broadcaster, ChangeEvent};
pub(crate) struct SourcePipeline {
source_folders: Vec<PathBuf>,
bundle_roots: Vec<PathBuf>,
output_dir: PathBuf,
css_tool: Option<(CssTool, CssOptions)>,
js_tool: Option<(JsTool, JsOptions)>,
prune_output: bool,
broadcaster: Broadcaster,
}
impl SourcePipeline {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
source_folders: Vec<PathBuf>,
bundle_roots: Vec<PathBuf>,
output_dir: PathBuf,
css_tool: Option<(CssTool, CssOptions)>,
js_tool: Option<(JsTool, JsOptions)>,
prune_output: bool,
broadcaster: Broadcaster,
) -> Self {
SourcePipeline {
source_folders,
bundle_roots,
output_dir,
css_tool,
js_tool,
prune_output,
broadcaster,
}
}
pub(crate) async fn full_build(&self) -> Result<(), SourceError> {
let css_written = self.build_css_bundle().await?;
self.build_js_bundle().await?;
self.build_all_per_file().await?;
if self.prune_output {
self.prune_stale_output(css_written.as_deref()).await?;
}
Ok(())
}
pub(crate) async fn process_change(
&self,
path: &Path,
change_type: &ChangeType,
) -> Result<(), SourceError> {
if change_type == &ChangeType::Css && self.is_input(path) {
if let Some((_, options)) = &self.css_tool {
let written = if options.is_bundle() {
self.build_css_bundle().await?
} else {
self.rebuild_css_file(path).await?
};
if let Some(output) = written {
self.broadcast_change(&output);
}
return Ok(());
}
}
if change_type == &ChangeType::Script && self.is_input(path) {
if let Some((_, options)) = &self.js_tool {
let written = if options.is_bundle() {
self.build_js_bundle().await?
} else {
self.rebuild_js_file(path).await?
};
if let Some(output) = written {
self.broadcast_change(&output);
}
return Ok(());
}
}
if self.is_input(path) {
self.broadcaster.broadcast(ChangeEvent {
path: path.to_path_buf(),
change_type: change_type.clone(),
});
}
Ok(())
}
async fn build_css_bundle(&self) -> Result<Option<PathBuf>, SourceError> {
let Some((css_tool, options)) = &self.css_tool else {
return Ok(None);
};
if !options.is_bundle() || !self.has_css_sources() {
return Ok(None);
}
let output = self.output_dir.join(options.output_file_name());
css::build_css_bundle(*css_tool, options, &self.source_folders, &output)
.await
.map_err(SourceError::Css)?;
Ok(Some(output))
}
async fn build_js_bundle(&self) -> Result<Option<PathBuf>, SourceError> {
let Some((js_tool, options)) = &self.js_tool else {
return Ok(None);
};
let (true, Some(entry)) = (options.is_bundle(), options.entry()) else {
return Ok(None);
};
let name = options.output_file_name().unwrap_or("bundle.js");
let output = self.output_dir.join(name);
js::build_js_bundle(*js_tool, options, entry, &output)
.await
.map_err(SourceError::Js)?;
Ok(Some(output))
}
async fn build_all_per_file(&self) -> Result<(), SourceError> {
for folder in &self.source_folders {
let files = list_files(folder).await.map_err(SourceError::Io)?;
for file in files {
if is_css(&file) {
if let Some((css_tool, options)) = &self.css_tool {
if !options.is_bundle() {
let output = self.mirror_output(folder, &file)?;
css::build_css_file(*css_tool, options, &file, &output)
.await
.map_err(SourceError::Css)?;
}
}
} else if is_script(&file) {
if let Some((js_tool, options)) = &self.js_tool {
if !options.is_bundle() {
let output = self.mirror_output(folder, &file)?;
js::build_js_file(*js_tool, options, &file, &output)
.await
.map_err(SourceError::Js)?;
}
}
}
}
}
Ok(())
}
async fn rebuild_css_file(&self, source: &Path) -> Result<Option<PathBuf>, SourceError> {
let Some(folder) = self.containing_source_folder(source) else {
return Ok(None);
};
if !is_css(source) {
return Ok(None);
}
let Some((css_tool, options)) = &self.css_tool else {
return Ok(None);
};
let output = self.mirror_output(folder, source)?;
css::build_css_file(*css_tool, options, source, &output)
.await
.map_err(SourceError::Css)?;
Ok(Some(output))
}
async fn rebuild_js_file(&self, source: &Path) -> Result<Option<PathBuf>, SourceError> {
let Some(folder) = self.containing_source_folder(source) else {
return Ok(None);
};
if !is_script(source) {
return Ok(None);
}
let Some((js_tool, options)) = &self.js_tool else {
return Ok(None);
};
let output = self.mirror_output(folder, source)?;
js::build_js_file(*js_tool, options, source, &output)
.await
.map_err(SourceError::Js)?;
Ok(Some(output))
}
fn mirror_output(&self, folder: &Path, source: &Path) -> Result<PathBuf, SourceError> {
let relative = source
.strip_prefix(folder)
.map_err(|_| SourceError::NotUnderSource(source.to_path_buf()))?;
Ok(self.output_dir.join(relative))
}
fn broadcast_change(&self, output: &Path) {
self.broadcaster.broadcast(ChangeEvent {
path: output.to_path_buf(),
change_type: ChangeType::from_path(output),
});
}
fn is_input(&self, path: &Path) -> bool {
self.source_folders
.iter()
.chain(self.bundle_roots.iter())
.any(|root| path.starts_with(root))
}
fn containing_source_folder(&self, path: &Path) -> Option<&PathBuf> {
self.source_folders
.iter()
.find(|folder| path.starts_with(folder))
}
fn has_css_sources(&self) -> bool {
for folder in &self.source_folders {
if walk_dir(folder).any(|path| is_css(&path)) {
return true;
}
}
false
}
fn css_bundle_output_path(&self) -> Option<PathBuf> {
let (_, options) = self.css_tool.as_ref()?;
if !options.is_bundle() {
return None;
}
Some(self.output_dir.join(options.output_file_name()))
}
async fn prune_stale_output(&self, css_written: Option<&Path>) -> Result<(), SourceError> {
let Some(bundle) = self.css_bundle_output_path() else {
return Ok(());
};
let wrote_bundle = css_written.is_some_and(|written| written == bundle);
if wrote_bundle {
return Ok(());
}
if tokio::fs::metadata(&bundle).await.is_err() {
return Ok(());
}
tokio::fs::remove_file(&bundle)
.await
.map_err(SourceError::Io)?;
eprintln!("pruned stale css bundle output: {}", bundle.display());
Ok(())
}
}
#[derive(Debug)]
pub(crate) enum SourceError {
Css(css::CssError),
Js(js::JsError),
Io(std::io::Error),
NotUnderSource(PathBuf),
}
impl std::fmt::Display for SourceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SourceError::Css(e) => write!(f, "css tool step failed: {e}"),
SourceError::Js(e) => write!(f, "js tool step failed: {e}"),
SourceError::Io(e) => write!(f, "io error: {e}"),
SourceError::NotUnderSource(p) => {
write!(f, "path not under any source folder: {}", p.display())
}
}
}
}
impl std::error::Error for SourceError {}
fn is_css(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()) == Some("css")
}
fn is_script(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("js" | "mjs")
)
}
async fn list_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut dirs = vec![dir.to_path_buf()];
while let Some(current) = dirs.pop() {
let mut entries = tokio::fs::read_dir(¤t).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if entry.file_type().await?.is_dir() {
dirs.push(path);
} else {
files.push(path);
}
}
}
Ok(files)
}
fn walk_dir(dir: &Path) -> impl Iterator<Item = PathBuf> {
let mut dirs = vec![dir.to_path_buf()];
std::iter::from_fn(move || {
while let Some(current) = dirs.pop() {
let Ok(entries) = std::fs::read_dir(¤t) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
dirs.push(path);
} else {
return Some(path);
}
}
}
None
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
#[tokio::test]
async fn pipeline_does_not_rebroadcast_its_own_output_echo() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(src.path().join("style.css"), "body { margin: 0; }")
.await
.unwrap();
let broadcaster = Broadcaster::new();
let pipeline = Arc::new(SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
out.path().to_path_buf(),
Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
None,
false,
broadcaster.clone(),
));
let mut pipeline_rx = broadcaster.subscribe();
let mut observer = broadcaster.subscribe();
let pipeline_task = tokio::spawn(async move {
while let Some(event) = pipeline_rx.recv().await {
if let Err(e) = pipeline
.process_change(&event.path, &event.change_type)
.await
{
eprintln!("source pipeline error: {e}");
}
}
});
broadcaster.broadcast(ChangeEvent {
path: src.path().join("style.css"),
change_type: ChangeType::Css,
});
let mut count = 0usize;
let window = Duration::from_millis(400);
let deadline = tokio::time::Instant::now() + window;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, observer.recv()).await {
Ok(Some(_)) => count += 1,
Ok(None) | Err(_) => break,
}
}
pipeline_task.abort();
assert_eq!(
count, 2,
"a single source css change must emit exactly the source event + the bundle \
broadcast, and then stop — not feed back forever (got {count})"
);
}
#[tokio::test]
async fn css_bundle_mode_concatenates_into_the_configured_output_name() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(src.path().join("a.css"), "A")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
out.path().to_path_buf(),
Some((
CssTool::TestEcho,
CssOptions::new()
.bundle(true)
.bundle_output_name("main.css"),
)),
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert_eq!(fs_read(out.path().join("main.css")), "A");
}
#[tokio::test]
async fn css_per_file_mode_mirrors_every_source_file() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(src.path().join("a.css"), "A")
.await
.unwrap();
tokio::fs::write(src.path().join("b.css"), "B")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
out.path().to_path_buf(),
Some((CssTool::TestEcho, CssOptions::new())),
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert_eq!(fs_read(out.path().join("a.css")), "A");
assert_eq!(fs_read(out.path().join("b.css")), "B");
}
#[tokio::test]
async fn js_bundle_mode_writes_the_entry_through_the_tool() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let entry = src.path().join("main.js");
tokio::fs::write(&entry, "const x = 1;").await.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
out.path().to_path_buf(),
None,
Some((
JsTool::TestEcho,
JsOptions::new().bundle_entry(&entry, "bundle.js"),
)),
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert_eq!(fs_read(out.path().join("bundle.js")), "const x = 1;");
}
#[tokio::test]
async fn no_tool_configured_processes_nothing() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(src.path().join("app.css"), "body{}")
.await
.unwrap();
tokio::fs::write(src.path().join("app.js"), "x=1;")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
out.path().to_path_buf(),
None,
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert!(
!out.path().join("app.css").exists() && !out.path().join("app.js").exists(),
"with no css/js tool configured, nothing should be written to the output dir"
);
}
#[tokio::test]
async fn prune_output_removes_stale_css_bundle_when_no_css_sources_remain() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(out.path().join("styles.css"), "/* stale */")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
out.path().to_path_buf(),
Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
None,
true,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert!(
!out.path().join("styles.css").exists(),
"prune is opt-in: with_prune_output should delete the stale bundle produced by no css sources"
);
}
#[tokio::test]
async fn without_prune_stale_css_bundle_is_left_in_place() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(out.path().join("styles.css"), "/* stale */")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
out.path().to_path_buf(),
Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert!(
out.path().join("styles.css").exists(),
"without with_prune_output the stale bundle must be left in place"
);
}
fn fs_read(path: PathBuf) -> String {
std::fs::read_to_string(path).unwrap()
}
}