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>,
asset_folders: 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>,
asset_folders: 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,
asset_folders,
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?;
self.build_all_assets().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 let Some(folder) = self.containing_asset_folder(path) {
let output = self.mirror_output(folder, path)?;
copy_asset(path, &output).await?;
self.broadcast_change(&output);
return Ok(());
}
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))
}
async fn build_all_assets(&self) -> Result<(), SourceError> {
for folder in &self.asset_folders {
let files = list_files(folder).await.map_err(SourceError::Io)?;
for file in files {
let output = self.mirror_output(folder, &file)?;
copy_asset(&file, &output).await?;
}
}
Ok(())
}
fn containing_asset_folder(&self, path: &Path) -> Option<&PathBuf> {
self.asset_folders
.iter()
.find(|folder| path.starts_with(folder))
}
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())
.chain(self.asset_folders.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 copy_asset(source: &Path, output: &Path) -> Result<(), SourceError> {
if let Some(parent) = output.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(SourceError::Io)?;
}
tokio::fs::copy(source, output)
.await
.map_err(SourceError::Io)?;
Ok(())
}
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)]
#[path = "../tests/unit/source.rs"]
mod tests;