mini-build 0.1.0

Builds the directory a static server serves: CSS/JS bundling and minification via external tools, plus asset mirroring.
Documentation
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};

/// True when either path contains the other, in either direction.
///
/// Containment either way is a problem, which is why this is symmetric: a source folder
/// inside the output dir would have the build read its own writes, and an output dir
/// inside a source folder would have it write into its own inputs. Both are the same
/// feedback loop wearing different clothes.
fn paths_overlap(a: &Path, b: &Path) -> bool {
    a.starts_with(b) || b.starts_with(a)
}

/// Assembles a build: which folders are inputs, which directory receives the output, and
/// which external tools transform what.
///
/// Every path is canonicalized and checked as it is registered, so a misconfiguration is
/// reported while the builder is being assembled rather than partway through a build that
/// has already written files. [`Builder::build`] then runs every configured pipeline once.
///
/// # Example
///
/// ```no_run
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
/// use mini_build::{Builder, CssOptions, CssTool};
/// use std::path::Path;
///
/// Builder::new(Path::new("./public"))?
///     .source_folder(Path::new("./src/styles"))?
///     .css_tool(CssTool::LightningCss, CssOptions::default())
///     .build()?;
/// # Ok(())
/// # }
/// ```
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 {
    /// Start a build that writes into `output_dir`.
    ///
    /// # Errors
    ///
    /// [`BuildError::Io`] if `output_dir` cannot be canonicalized — it must already exist,
    /// since a typo that silently creates a directory tree is worse than an error.
    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(),
        })
    }

    /// Register a folder of CSS/JS inputs to be transformed into the output dir.
    ///
    /// # Errors
    ///
    /// [`BuildError::Io`] if `dir` cannot be canonicalized; [`BuildError::Config`] if it
    /// overlaps the output dir or an already-registered source or asset folder.
    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)
    }

    /// Register a folder mirrored byte-for-byte into the output dir, whatever the
    /// extension — images, fonts, `robots.txt`, hand-written HTML.
    ///
    /// # Errors
    ///
    /// As [`Builder::source_folder`].
    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)
    }

    /// Canonicalize `dir` and reject it if it overlaps the output dir or an existing
    /// input folder. `kind` names the folder in the error, so a caller registering
    /// several folders learns which one is wrong.
    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)
    }

    /// Transform CSS with `tool`, per `options`.
    pub fn css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
        self.css_tool = Some((tool, options));
        self
    }

    /// Transform JS with `tool`, per `options`.
    ///
    /// # Errors
    ///
    /// [`BuildError::Io`] if a configured bundle entry cannot be canonicalized;
    /// [`BuildError::Config`] if it does not lie under a registered source folder — which
    /// would mean bundling a file this build does not consider an input, so register the
    /// folder first.
    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)
    }

    /// Delete the CSS bundle from the output dir when no CSS sources remain.
    ///
    /// Off by default: pruning deletes files the builder did not necessarily write, and
    /// an output dir shared with hand-placed files should not lose them to a build.
    pub fn prune_output(mut self) -> Self {
        self.prune_output = true;
        self
    }

    /// Subscribe to the change events this build emits as it writes outputs.
    ///
    /// A one-shot [`Builder::build`] emits these too, but they matter for a caller
    /// watching for rebuilds — a dev server reloading a browser, say.
    pub fn subscribe(&self) -> std::sync::mpsc::Receiver<crate::ChangeEvent> {
        self.broadcaster.subscribe()
    }

    /// Every external binary this configuration will actually invoke.
    ///
    /// A tool configured for neither bundling nor minifying never spawns a process, so it
    /// is not required to be installed — checking for it would fail a build that was
    /// never going to run it.
    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
    }

    /// Run every configured pipeline once.
    ///
    /// Tool availability is checked first, before any file is written: a build that is
    /// going to fail for want of `esbuild` should fail before it has half-populated the
    /// output dir.
    ///
    /// # Errors
    ///
    /// [`BuildError::ToolMissing`] if a configured tool's binary is absent from `PATH`;
    /// [`BuildError::Build`] if a pipeline ran and failed.
    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(())
    }

    /// Build once, then keep the output dir in sync with the sources until the returned
    /// handle is dropped.
    ///
    /// The initial build is part of the job: the output dir is not in sync with the
    /// sources until it has run, so watching without it would leave a window where the
    /// two disagree and nothing was going to correct it. Its failures come back through
    /// the returned `Result`; failures of later rebuilds go to `on_error`, since by then
    /// there is no call left to return from.
    ///
    /// This is the development half of the split with a static server: this crate watches
    /// sources and writes the output dir, and the server watches the directory it serves.
    /// Because the server cannot observe a file before it is written, "reload only after
    /// the output exists" holds by construction rather than by careful sequencing.
    ///
    /// # Errors
    ///
    /// As [`Builder::build`] — the initial build runs the same checks.
    ///
    /// # Example
    ///
    /// ```no_run
    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// use mini_build::Builder;
    /// use std::path::Path;
    ///
    /// let watching = Builder::new(Path::new("./public"))?
    ///     .source_folder(Path::new("./src/styles"))?
    ///     .watch(|e| eprintln!("rebuild failed: {e}"))?;
    /// // ... outputs stay current until `watching` is dropped.
    /// watching.stop();
    /// # 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))
    }

    /// The pipeline this configuration describes.
    ///
    /// `bundle_roots` is always empty: it existed in `mini-static` as an extra watch
    /// target for CSS rebuilds, never as an `@import` boundary, and watching is not this
    /// type's concern.
    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;