mini-build 0.1.0

Builds the directory a static server serves: CSS/JS bundling and minification via external tools, plus asset mirroring.
Documentation
//! Proves `mini-build` produces byte-for-byte what `mini-static`'s pipeline produced.
//!
//! The pipeline moved out of `mini-static` in one 1,200-line commit. "It compiles and the
//! unit tests pass" is not evidence that a build still writes the same bytes, and the
//! next commit rewrites the whole thing from async to synchronous — so an equivalence
//! check that survives both is the only thing standing between a silent behavior change
//! and a consumer's website.
//!
//! Both implementations run **in the same process, against the same fixture, invoking the
//! same tool binaries**. Committed golden files were rejected deliberately: pipeline
//! output depends on the installed `lightningcss`/`esbuild` version, so goldens would
//! fail spuriously on a tool upgrade, and the natural response — regenerating them —
//! would destroy the only evidence the move preserved behavior.
//!
//! `mini-static` is pinned to `=0.28.10`, the last published version that still contains
//! the pipeline. When B1 removes it, this file's reference implementation is frozen in
//! the registry and keeps working.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use tempfile::TempDir;

/// Every file under `dir`, keyed by path relative to `dir`, with its exact bytes.
///
/// A `BTreeMap` so comparison is order-independent and a difference reports which path
/// disagrees rather than "the directories differ".
fn snapshot(dir: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
    fn walk(dir: &Path, base: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
        let Ok(entries) = fs::read_dir(dir) else {
            return;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                walk(&path, base, out);
            } else if let Ok(bytes) = fs::read(&path) {
                let relative = path.strip_prefix(base).unwrap_or(&path).to_path_buf();
                out.insert(relative, bytes);
            }
        }
    }

    let mut out = BTreeMap::new();
    walk(dir, dir, &mut out);
    out
}

/// A source tree exercising every branch the two implementations share: CSS and JS
/// sources that a tool transforms, an already-minified file that bypasses the tool, a
/// nested directory, and a non-buildable asset that is mirrored rather than transformed.
fn write_fixture(src: &Path, assets: &Path) {
    fs::create_dir_all(src.join("nested")).unwrap();
    fs::write(
        src.join("a.css"),
        b"body {\n  color: red;\n}\n/* a comment */\n",
    )
    .unwrap();
    fs::write(src.join("nested/b.css"), b".nested {\n  margin: 0;\n}\n").unwrap();
    fs::write(src.join("already.min.css"), b".m{padding:0}").unwrap();
    fs::write(src.join("script.js"), b"export const x = 1;\n").unwrap();
    fs::write(src.join("already.min.js"), b"const y=2;").unwrap();

    fs::create_dir_all(assets.join("img")).unwrap();
    fs::write(assets.join("robots.txt"), b"User-agent: *\n").unwrap();
    fs::write(assets.join("img/pixel.bin"), [0u8, 1, 2, 3, 255]).unwrap();
}

/// Build the fixture with `mini-static`'s pipeline and with `mini-build`, into separate
/// output dirs, and return both snapshots.
async fn build_both() -> (BTreeMap<PathBuf, Vec<u8>>, BTreeMap<PathBuf, Vec<u8>>) {
    let src = TempDir::new().unwrap();
    let assets = TempDir::new().unwrap();
    write_fixture(src.path(), assets.path());

    let reference_out = TempDir::new().unwrap();
    let subject_out = TempDir::new().unwrap();

    // Both tools are configured in passthrough mode (`bundle`/`minify` both off, the
    // default). That is what makes this test both meaningful and portable: configuring a
    // tool is what routes CSS/JS through the pipeline at all — with none configured,
    // sources are watched but never copied, and the comparison would silently cover only
    // the asset folder — while passthrough spawns no process, so no binary need be
    // installed. Bundling and minifying are compared in A5's tools-installed CI job.
    mini_static::Server::new(reference_out.path())
        .unwrap()
        .with_source_folder(src.path())
        .unwrap()
        .with_asset_folder(assets.path())
        .unwrap()
        .with_css_tool(
            mini_static::CssTool::LightningCss,
            mini_static::CssOptions::default(),
        )
        .with_js_tool(
            mini_static::JsTool::Esbuild,
            mini_static::JsOptions::default(),
        )
        .unwrap()
        .build()
        .await
        .expect("reference build");

    mini_build::Builder::new(subject_out.path())
        .unwrap()
        .source_folder(src.path())
        .unwrap()
        .asset_folder(assets.path())
        .unwrap()
        .css_tool(
            mini_build::CssTool::LightningCss,
            mini_build::CssOptions::default(),
        )
        .js_tool(
            mini_build::JsTool::Esbuild,
            mini_build::JsOptions::default(),
        )
        .unwrap()
        // Synchronous as of A3, while the reference above remains async. The two
        // implementations no longer share an execution model at all — which is exactly
        // why comparing their output bytes, rather than reading their code, is the check
        // that means anything here.
        .build()
        .expect("subject build");

    (snapshot(reference_out.path()), snapshot(subject_out.path()))
}

/// The headline check. Passthrough mode needs no external tool, so this runs everywhere —
/// CI included — rather than only on machines with `lightningcss` installed.
#[tokio::test]
async fn mini_build_reproduces_mini_statics_output_byte_for_byte() {
    let (reference, subject) = build_both().await;

    assert!(
        !reference.is_empty(),
        "the reference build produced nothing, so this test would pass vacuously"
    );

    let reference_paths: Vec<_> = reference.keys().collect();
    let subject_paths: Vec<_> = subject.keys().collect();
    assert_eq!(
        reference_paths, subject_paths,
        "the two builds produced different sets of files"
    );

    for (path, reference_bytes) in &reference {
        assert_eq!(
            subject.get(path),
            Some(reference_bytes),
            "{} differs between the two implementations",
            path.display()
        );
    }
}

/// The `.min.*` bypass is behavior, not an optimization: running a minifier over
/// already-minified input is wasted work at best and a correctness risk at worst. Pinned
/// separately so a regression names itself rather than appearing as one path among many.
#[tokio::test]
async fn already_minified_sources_are_copied_unchanged_by_both() {
    let (reference, subject) = build_both().await;

    for name in ["already.min.css", "already.min.js"] {
        let path = PathBuf::from(name);
        assert_eq!(
            reference.get(&path).map(Vec::as_slice),
            subject.get(&path).map(Vec::as_slice),
            "{name} disagrees between implementations"
        );
    }
    assert_eq!(
        subject.get(&PathBuf::from("already.min.css")).unwrap(),
        b".m{padding:0}",
        "an already-minified source must reach the output untouched"
    );
}

/// Assets are mirrored, not transformed — including binary content, which a
/// text-assuming copy would corrupt.
#[tokio::test]
async fn assets_are_mirrored_identically_by_both() {
    let (reference, subject) = build_both().await;

    let pixel = PathBuf::from("img/pixel.bin");
    assert_eq!(
        reference.get(&pixel),
        subject.get(&pixel),
        "binary asset differs between implementations"
    );
    assert_eq!(
        subject.get(&pixel).map(Vec::as_slice),
        Some([0u8, 1, 2, 3, 255].as_slice()),
        "binary bytes must survive the copy exactly"
    );
}