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 super::*;

use std::fs;
use tempfile::TempDir;

/// A source folder inside the output dir would have the build read its own writes; an
/// output dir inside a source folder would have it write into its own inputs. Both are
/// rejected while the builder is assembled, before any file is touched.
#[test]
fn a_source_folder_overlapping_the_output_dir_is_rejected() {
    let out = TempDir::new().unwrap();
    let nested = out.path().join("styles");
    fs::create_dir(&nested).unwrap();

    let result = Builder::new(out.path()).unwrap().source_folder(&nested);

    assert!(
        matches!(result, Err(BuildError::Config(_))),
        "a source folder under the output dir must be refused"
    );
}

#[test]
fn an_output_dir_inside_a_source_folder_is_rejected() {
    let src = TempDir::new().unwrap();
    let out = src.path().join("public");
    fs::create_dir(&out).unwrap();

    let result = Builder::new(&out).unwrap().source_folder(src.path());

    assert!(
        matches!(result, Err(BuildError::Config(_))),
        "overlap must be caught in both directions, not just one"
    );
}

#[test]
fn two_overlapping_input_folders_are_rejected() {
    let out = TempDir::new().unwrap();
    let src = TempDir::new().unwrap();
    let nested = src.path().join("nested");
    fs::create_dir(&nested).unwrap();

    let result = Builder::new(out.path())
        .unwrap()
        .source_folder(src.path())
        .unwrap()
        .asset_folder(&nested);

    assert!(matches!(result, Err(BuildError::Config(_))));
}

#[test]
fn disjoint_folders_are_accepted() {
    let out = TempDir::new().unwrap();
    let src = TempDir::new().unwrap();
    let assets = TempDir::new().unwrap();

    let builder = Builder::new(out.path())
        .unwrap()
        .source_folder(src.path())
        .unwrap()
        .asset_folder(assets.path());

    assert!(builder.is_ok());
}

#[test]
fn a_missing_folder_is_an_io_error_not_a_config_error() {
    let out = TempDir::new().unwrap();

    let result = Builder::new(out.path())
        .unwrap()
        .source_folder(Path::new("/definitely/not/here"));

    assert!(
        matches!(result, Err(BuildError::Io(_))),
        "a nonexistent path is an I/O problem, distinct from a rejected relationship"
    );
}

/// Bundling a file the build does not consider an input would silently pull in code from
/// outside every registered source folder.
#[test]
fn a_js_entry_outside_every_source_folder_is_rejected() {
    let out = TempDir::new().unwrap();
    let src = TempDir::new().unwrap();
    let stray = TempDir::new().unwrap();
    let entry = stray.path().join("main.js");
    fs::write(&entry, b"export default 1;").unwrap();

    let result = Builder::new(out.path())
        .unwrap()
        .source_folder(src.path())
        .unwrap()
        .js_tool(
            JsTool::Esbuild,
            JsOptions::default().bundle_entry(&entry, "bundle.js"),
        );

    assert!(matches!(result, Err(BuildError::Config(_))));
}

#[test]
fn a_js_entry_under_a_source_folder_is_accepted() {
    let out = TempDir::new().unwrap();
    let src = TempDir::new().unwrap();
    let entry = src.path().join("main.js");
    fs::write(&entry, b"export default 1;").unwrap();

    let result = Builder::new(out.path())
        .unwrap()
        .source_folder(src.path())
        .unwrap()
        .js_tool(
            JsTool::Esbuild,
            JsOptions::default().bundle_entry(&entry, "bundle.js"),
        );

    assert!(result.is_ok());
}

/// The check runs before any file is written, so a build that cannot succeed does not
/// half-populate the output dir first. `minify(true)` is what makes the binary required:
/// the default options are pure passthrough and legitimately need no tool.
#[test]
fn a_missing_tool_binary_fails_before_any_output_is_written() {
    let out = TempDir::new().unwrap();
    let src = TempDir::new().unwrap();
    fs::write(src.path().join("a.css"), b"body{color:red}").unwrap();

    let result = Builder::new(out.path())
        .unwrap()
        .source_folder(src.path())
        .unwrap()
        .css_tool(CssTool::TestMissing, CssOptions::default().minify(true))
        .build();

    assert!(
        matches!(result, Err(BuildError::ToolMissing(_))),
        "expected ToolMissing, got: {result:?}"
    );
    assert_eq!(
        fs::read_dir(out.path()).unwrap().count(),
        0,
        "nothing may be written when a required tool is absent"
    );
}

/// A tool configured for neither bundling nor minifying never spawns a process, so its
/// absence must not fail a build that was never going to invoke it.
#[test]
fn a_passthrough_tool_does_not_require_its_binary() {
    let out = TempDir::new().unwrap();
    let src = TempDir::new().unwrap();
    fs::write(src.path().join("a.css"), b"body{color:red}").unwrap();

    let result = Builder::new(out.path())
        .unwrap()
        .source_folder(src.path())
        .unwrap()
        .css_tool(
            CssTool::TestMissing,
            CssOptions::default().bundle(false).minify(false),
        )
        .build();

    assert!(
        result.is_ok(),
        "passthrough must not need a binary: {result:?}"
    );
    assert_eq!(
        fs::read(out.path().join("a.css")).unwrap(),
        b"body{color:red}",
        "passthrough copies the source byte-for-byte"
    );
}