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::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};

use crate::tool::{self, group_by_parent};

/// External tools this crate knows how to invoke for JS bundling/minification.
///
/// `#[non_exhaustive]` so a future preset is an additive variant, not a
/// semver-breaking change for downstream `match` expressions.
///
/// # Installation
///
/// This crate does not install or manage these binaries — only looks them up on
/// `PATH` before a build starts and fails loudly if missing (see [`crate::Builder::js_tool`]).
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsTool {
    /// <https://esbuild.github.io>, invoked via its `esbuild` CLI (npm package
    /// `esbuild`, also distributable as a standalone platform binary).
    Esbuild,
    /// Copies the entry/source file to the output file unchanged. Test-only.
    #[cfg(test)]
    TestEcho,
    /// Always fails with `ToolError::NotFound`. Test-only.
    #[cfg(test)]
    TestMissing,
}

impl JsTool {
    pub(crate) fn binary_name(&self) -> &'static str {
        match self {
            JsTool::Esbuild => "esbuild",
            #[cfg(test)]
            JsTool::TestEcho => "cp",
            #[cfg(test)]
            JsTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
        }
    }

    pub(crate) fn install_hint(&self) -> &'static str {
        match self {
            JsTool::Esbuild => {
                "install via `npm install -g esbuild` (or add it as a project \
                 devDependency and put its bin/ on PATH)"
            }
            #[cfg(test)]
            JsTool::TestEcho | JsTool::TestMissing => "test-only tool, not installable",
        }
    }

    fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
        match self {
            JsTool::Esbuild => {
                let mut args = vec![OsString::from(entry)];
                if bundle {
                    args.push(OsString::from("--bundle"));
                }
                if minify {
                    args.push(OsString::from("--minify"));
                }
                let mut outfile = OsString::from("--outfile=");
                outfile.push(output);
                args.push(outfile);
                args
            }
            #[cfg(test)]
            JsTool::TestEcho => vec![entry.into(), output.into()],
            #[cfg(test)]
            JsTool::TestMissing => vec![],
        }
    }

    /// Arguments for transforming many inputs in one invocation, writing into `out_dir`.
    ///
    /// Process startup dominates a build, so N files should cost one spawn rather than N.
    /// `esbuild` resolves `--outdir` against the inputs' common base directory; callers
    /// pass inputs that share a parent, so results land flat in `out_dir` with their
    /// original basenames.
    fn batch_args(&self, minify: bool, inputs: &[PathBuf], out_dir: &Path) -> Vec<OsString> {
        match self {
            JsTool::Esbuild => {
                let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
                if minify {
                    args.push(OsString::from("--minify"));
                }
                let mut outdir = OsString::from("--outdir=");
                outdir.push(out_dir);
                args.push(outdir);
                args
            }
            #[cfg(test)]
            JsTool::TestEcho => {
                let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
                args.push(out_dir.into());
                args
            }
            #[cfg(test)]
            JsTool::TestMissing => vec![],
        }
    }
}

/// Configuration for [`crate::Builder::js_tool`]: independent `bundle`/`minify`
/// toggles, all four combinations valid.
///
/// Unlike CSS (which discovers and concatenates every source file with no ambiguity),
/// JS module graphs are entry-point-driven — concatenating independent files risks
/// scope collisions and undefined evaluation order. `bundle` mode therefore requires
/// an explicit entry point via [`JsOptions::bundle_entry`]; without it, `bundle: true`
/// has no effect (see [`crate::Builder::js_tool`], which validates this at
/// configuration time).
#[derive(Debug, Clone, Default)]
pub struct JsOptions {
    bundle: bool,
    minify: bool,
    entry: Option<PathBuf>,
    bundle_output_name: Option<String>,
}

impl JsOptions {
    /// Neither bundle nor minify — JS is copied through unchanged.
    pub fn new() -> Self {
        JsOptions::default()
    }

    /// Minify JS via the configured [`JsTool`], per file, mirroring each source file's
    /// relative path into the output dir (no bundling).
    pub fn minify(mut self, minify: bool) -> Self {
        self.minify = minify;
        self
    }

    /// Enable single-entry-point bundling: `entry` is bundled (optionally minified,
    /// per [`Self::minify`]) into `<output_dir>/<output_name>` via the configured
    /// [`JsTool`]. `entry` must lie under a registered source folder — validated by
    /// [`crate::Builder::js_tool`], not here.
    pub fn bundle_entry(mut self, entry: &Path, output_name: impl Into<String>) -> Self {
        self.bundle = true;
        self.entry = Some(entry.to_path_buf());
        self.bundle_output_name = Some(output_name.into());
        self
    }

    pub(crate) fn is_bundle(&self) -> bool {
        self.bundle
    }

    pub(crate) fn is_minify(&self) -> bool {
        self.minify
    }

    pub(crate) fn entry(&self) -> Option<&Path> {
        self.entry.as_deref()
    }

    pub(crate) fn output_file_name(&self) -> Option<&str> {
        self.bundle_output_name.as_deref()
    }
}

/// Errors from JS tool orchestration.
#[derive(Debug)]
pub enum JsError {
    WriteOutput { path: PathBuf, reason: String },
    Tool(tool::ToolError),
}

impl std::fmt::Display for JsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            JsError::WriteOutput { path, reason } => {
                write!(f, "failed to write {}: {reason}", path.display())
            }
            JsError::Tool(e) => write!(f, "{e}"),
        }
    }
}

impl std::error::Error for JsError {}

impl From<tool::ToolError> for JsError {
    fn from(e: tool::ToolError) -> Self {
        JsError::Tool(e)
    }
}

/// Bundle `entry` (and its resolved module graph) into `output_path`, per `options`.
///
/// # Errors
///
/// Returns `Err` without touching `output_path` on tool failure — a broken rebuild
/// leaves the previous good output in place.
pub(crate) fn build_js_bundle(
    js_tool: JsTool,
    options: &JsOptions,
    entry: &Path,
    output_path: &Path,
) -> Result<(), JsError> {
    if !options.bundle && !options.minify {
        return copy_file(entry, output_path);
    }
    Ok(run_tool(js_tool, true, options.minify, entry, output_path)?)
}

/// Process a single JS file (bundle disabled) into its mirrored `output` path.
///
/// A malformed source degrades to a raw copy rather than failing the pipeline — the
/// server must still serve the file and keep the browser in sync. The degradation is
/// logged, not silent.
/// Transform every path in `inputs` in a single invocation, writing into `out_dir`.
fn run_tool_batch(
    js_tool: JsTool,
    minify: bool,
    inputs: &[PathBuf],
    out_dir: &Path,
    expected: &[PathBuf],
) -> Result<(), tool::ToolError> {
    let args = js_tool.batch_args(minify, inputs, out_dir);
    let expected: Vec<&Path> = expected.iter().map(PathBuf::as_path).collect();
    tool::execute(
        js_tool.binary_name(),
        js_tool.install_hint(),
        js_tool.binary_name(),
        &args,
        &expected,
        tool::TOOL_TIMEOUT,
    )
}

/// Transform many files in as few invocations as possible, each into its mirrored
/// `output`. See `css::build_css_files` — same shape, same fallback, same reasons.
pub(crate) fn build_js_files(
    js_tool: JsTool,
    options: &JsOptions,
    pairs: &[(PathBuf, PathBuf)],
) -> Result<(), JsError> {
    let (transform, bypass): (Vec<_>, Vec<_>) = pairs
        .iter()
        .partition(|(source, _)| options.minify && !is_already_minified(source));

    for (source, output) in bypass {
        copy_file(source, output)?;
    }
    if transform.is_empty() {
        return Ok(());
    }

    let by_output_dir = group_by_parent(
        &transform
            .iter()
            .map(|(_, output)| output.clone())
            .collect::<Vec<_>>(),
    );

    for (out_dir, outputs) in by_output_dir {
        fs::create_dir_all(&out_dir).map_err(|e| JsError::WriteOutput {
            path: out_dir.clone(),
            reason: e.to_string(),
        })?;

        let group: Vec<&(PathBuf, PathBuf)> = transform
            .iter()
            .copied()
            .filter(|(_, output)| outputs.contains(output))
            .collect();
        let inputs: Vec<PathBuf> = group.iter().map(|(source, _)| source.clone()).collect();

        if run_tool_batch(js_tool, true, &inputs, &out_dir, &outputs).is_err() {
            for (source, output) in group {
                build_js_file(js_tool, options, source, output)?;
            }
        }
    }

    Ok(())
}

pub(crate) fn build_js_file(
    js_tool: JsTool,
    options: &JsOptions,
    source: &Path,
    output: &Path,
) -> Result<(), JsError> {
    if !options.minify || is_already_minified(source) {
        return copy_file(source, output);
    }

    if let Err(e) = run_tool(js_tool, false, true, source, output) {
        eprintln!(
            "js tool: minify failed for {}, serving raw bytes: {e}",
            source.display()
        );
        return copy_file(source, output);
    }
    Ok(())
}

fn run_tool(
    js_tool: JsTool,
    bundle: bool,
    minify: bool,
    entry: &Path,
    output: &Path,
) -> Result<(), tool::ToolError> {
    if let Some(parent) = output.parent() {
        let _ = fs::create_dir_all(parent);
    }
    let args = js_tool.args(bundle, minify, entry, output);
    tool::execute(
        js_tool.binary_name(),
        js_tool.install_hint(),
        js_tool.binary_name(),
        &args,
        &[output],
        tool::TOOL_TIMEOUT,
    )
}

/// True if `path`'s filename indicates it's already minified (`*.min.js`). Such files
/// should be served as-is — running a minifier on already-minified input is wasted
/// work at best and a correctness risk at worst.
fn is_already_minified(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".min.js"))
}

fn copy_file(source: &Path, output: &Path) -> Result<(), JsError> {
    if let Some(parent) = output.parent() {
        fs::create_dir_all(parent).map_err(|e| JsError::WriteOutput {
            path: output.to_path_buf(),
            reason: e.to_string(),
        })?;
    }
    fs::copy(source, output).map_err(|e| JsError::WriteOutput {
        path: output.to_path_buf(),
        reason: e.to_string(),
    })?;
    Ok(())
}

#[cfg(test)]
#[path = "../tests/unit/js.rs"]
mod tests;