use std::path::{Path, PathBuf};
use crate::change::Broadcaster;
use crate::css::{CssOptions, CssTool};
use crate::error::BuildError;
use crate::js::{JsOptions, JsTool};
use crate::source::SourcePipeline;
use crate::tool;
use crate::watch::{self, WatchHandle};
fn paths_overlap(a: &Path, b: &Path) -> bool {
a.starts_with(b) || b.starts_with(a)
}
pub struct Builder {
source_folders: 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 Builder {
pub fn new(output_dir: &Path) -> Result<Self, BuildError> {
Ok(Builder {
source_folders: Vec::new(),
asset_folders: Vec::new(),
output_dir: output_dir.canonicalize()?,
css_tool: None,
js_tool: None,
prune_output: false,
broadcaster: Broadcaster::new(),
})
}
pub fn source_folder(mut self, dir: &Path) -> Result<Self, BuildError> {
let canon = self.register_input(dir, "source folder")?;
self.source_folders.push(canon);
Ok(self)
}
pub fn asset_folder(mut self, dir: &Path) -> Result<Self, BuildError> {
let canon = self.register_input(dir, "asset folder")?;
self.asset_folders.push(canon);
Ok(self)
}
fn register_input(&self, dir: &Path, kind: &str) -> Result<PathBuf, BuildError> {
let canon = dir.canonicalize()?;
if paths_overlap(&canon, &self.output_dir) {
return Err(BuildError::Config(format!(
"{kind} {} overlaps the output dir {}",
canon.display(),
self.output_dir.display()
)));
}
if self
.source_folders
.iter()
.chain(self.asset_folders.iter())
.any(|existing| paths_overlap(&canon, existing))
{
return Err(BuildError::Config(format!(
"{kind} {} overlaps an already-registered source/asset folder",
canon.display()
)));
}
Ok(canon)
}
pub fn css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
self.css_tool = Some((tool, options));
self
}
pub fn js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, BuildError> {
if let Some(entry) = options.entry() {
let entry_canon = entry.canonicalize()?;
let under_source_folder = self
.source_folders
.iter()
.any(|folder| entry_canon.starts_with(folder));
if !under_source_folder {
return Err(BuildError::Config(format!(
"js bundle entry {} is not under any registered source folder",
entry_canon.display()
)));
}
}
self.js_tool = Some((tool, options));
Ok(self)
}
pub fn prune_output(mut self) -> Self {
self.prune_output = true;
self
}
pub fn subscribe(&self) -> std::sync::mpsc::Receiver<crate::ChangeEvent> {
self.broadcaster.subscribe()
}
fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
let mut required = Vec::new();
if let Some((css_tool, options)) = &self.css_tool {
if options.is_bundle() || options.is_minify() {
required.push((css_tool.binary_name(), css_tool.install_hint()));
}
}
if let Some((js_tool, options)) = &self.js_tool {
if options.is_bundle() || options.is_minify() {
required.push((js_tool.binary_name(), js_tool.install_hint()));
}
}
required
}
pub fn build(&self) -> Result<(), BuildError> {
for (binary, install_hint) in self.required_tool_binaries() {
if !tool::locate_on_path(binary) {
return Err(BuildError::ToolMissing(format!(
"{binary} not found on PATH ({install_hint})"
)));
}
}
self.pipeline().full_build()?;
Ok(())
}
pub fn watch(
self,
on_error: impl FnMut(BuildError) + Send + 'static,
) -> Result<WatchHandle, BuildError> {
self.build()?;
let watched = self
.source_folders
.iter()
.chain(self.asset_folders.iter())
.cloned()
.collect();
Ok(watch::start(self.pipeline(), watched, on_error))
}
fn pipeline(&self) -> SourcePipeline {
SourcePipeline::new(
self.source_folders.clone(),
Vec::new(),
self.asset_folders.clone(),
self.output_dir.clone(),
self.css_tool.clone(),
self.js_tool.clone(),
self.prune_output,
self.broadcaster.clone(),
)
}
}
#[cfg(test)]
#[path = "../tests/unit/builder.rs"]
mod tests;