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::time::Duration;
use tempfile::TempDir;

/// Regression: the pipeline must not loop on its own output. The pipeline subscribes
/// to the same broadcaster it broadcasts into. After rebuilding the CSS bundle it
/// broadcasts a `css` event for the output path — that echo must NOT be re-broadcast,
/// or the pipeline loops forever and floods the SSE stream (the bug where the browser
/// kept cycling fresh `_mr` values). Here one source change must produce exactly two
/// events (the source change itself plus the bundle broadcast), then stop.
#[test]
fn pipeline_does_not_rebroadcast_its_own_output_echo() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();

    let broadcaster = Broadcaster::new();
    let pipeline = SourcePipeline::new(
        vec![src.path().to_path_buf()],
        Vec::new(),
        Vec::new(),
        out.path().to_path_buf(),
        Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
        None,
        false,
        broadcaster.clone(),
    );

    // Both receivers are registered before anything is broadcast: the pipeline's, which
    // feeds it the changes it reacts to, and an observer standing in for a subscribed
    // browser.
    let pipeline_rx = broadcaster.subscribe();
    let observer = broadcaster.subscribe();

    broadcaster.broadcast(ChangeEvent {
        path: src.path().join("style.css"),
        change_type: ChangeType::Css,
    });

    // Drive the pipeline's own loop directly rather than on a thread: every step is
    // synchronous now, so the feedback behavior can be observed deterministically instead
    // of through a timing window. The iteration cap is the actual assertion — an echo
    // that re-broadcasts its own output never stops producing work, and this bounds the
    // test rather than hanging it.
    const MAX_ITERATIONS: usize = 10;
    let mut iterations = 0usize;
    while let Ok(event) = pipeline_rx.recv_timeout(Duration::from_millis(200)) {
        iterations += 1;
        assert!(
            iterations <= MAX_ITERATIONS,
            "the pipeline is feeding on its own output: still producing events after \
             {MAX_ITERATIONS} rounds"
        );
        if let Err(e) = pipeline.process_change(&event.path, &event.change_type) {
            eprintln!("source pipeline error: {e}");
        }
    }

    // What a subscribed browser would have seen.
    let mut count = 0usize;
    while observer.try_recv().is_ok() {
        count += 1;
    }

    assert_eq!(
        count, 2,
        "a single source css change must emit exactly the source event plus the bundle \
         broadcast, and then stop — not feed back forever (got {count})"
    );
}

#[test]
fn css_bundle_mode_concatenates_into_the_configured_output_name() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("a.css"), "A").unwrap();

    let pipeline = SourcePipeline::new(
        vec![src.path().to_path_buf()],
        Vec::new(),
        Vec::new(),
        out.path().to_path_buf(),
        Some((
            CssTool::TestEcho,
            CssOptions::new()
                .bundle(true)
                .bundle_output_name("main.css"),
        )),
        None,
        false,
        Broadcaster::new(),
    );

    pipeline.full_build().unwrap();

    assert_eq!(fs_read(out.path().join("main.css")), "A");
}

#[test]
fn css_per_file_mode_mirrors_every_source_file() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("a.css"), "A").unwrap();
    std::fs::write(src.path().join("b.css"), "B").unwrap();

    let pipeline = SourcePipeline::new(
        vec![src.path().to_path_buf()],
        Vec::new(),
        Vec::new(),
        out.path().to_path_buf(),
        Some((CssTool::TestEcho, CssOptions::new())),
        None,
        false,
        Broadcaster::new(),
    );

    pipeline.full_build().unwrap();

    assert_eq!(fs_read(out.path().join("a.css")), "A");
    assert_eq!(fs_read(out.path().join("b.css")), "B");
}

#[test]
fn js_bundle_mode_writes_the_entry_through_the_tool() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    let entry = src.path().join("main.js");
    std::fs::write(&entry, "const x = 1;").unwrap();

    let pipeline = SourcePipeline::new(
        vec![src.path().to_path_buf()],
        Vec::new(),
        Vec::new(),
        out.path().to_path_buf(),
        None,
        Some((
            JsTool::TestEcho,
            JsOptions::new().bundle_entry(&entry, "bundle.js"),
        )),
        false,
        Broadcaster::new(),
    );

    pipeline.full_build().unwrap();

    assert_eq!(fs_read(out.path().join("bundle.js")), "const x = 1;");
}

#[test]
fn no_tool_configured_processes_nothing() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("app.css"), "body{}").unwrap();
    std::fs::write(src.path().join("app.js"), "x=1;").unwrap();

    let pipeline = SourcePipeline::new(
        vec![src.path().to_path_buf()],
        Vec::new(),
        Vec::new(),
        out.path().to_path_buf(),
        None,
        None,
        false,
        Broadcaster::new(),
    );

    pipeline.full_build().unwrap();

    assert!(
        !out.path().join("app.css").exists() && !out.path().join("app.js").exists(),
        "with no css/js tool configured, nothing should be written to the output dir"
    );
}

#[test]
fn prune_output_removes_stale_css_bundle_when_no_css_sources_remain() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(out.path().join("styles.css"), "/* stale */").unwrap();

    let pipeline = SourcePipeline::new(
        vec![src.path().to_path_buf()],
        Vec::new(),
        Vec::new(),
        out.path().to_path_buf(),
        Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
        None,
        true,
        Broadcaster::new(),
    );

    pipeline.full_build().unwrap();

    assert!(
        !out.path().join("styles.css").exists(),
        "prune is opt-in: with_prune_output should delete the stale bundle produced by no css sources"
    );
}

#[test]
fn without_prune_stale_css_bundle_is_left_in_place() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(out.path().join("styles.css"), "/* stale */").unwrap();

    let pipeline = SourcePipeline::new(
        vec![src.path().to_path_buf()],
        Vec::new(),
        Vec::new(),
        out.path().to_path_buf(),
        Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
        None,
        false,
        Broadcaster::new(),
    );

    pipeline.full_build().unwrap();

    assert!(
        out.path().join("styles.css").exists(),
        "without with_prune_output the stale bundle must be left in place"
    );
}

#[test]
fn asset_folder_mirrors_every_file_byte_identical_on_full_build() {
    let assets = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(assets.path().join("index.html"), "<html></html>").unwrap();
    std::fs::create_dir(assets.path().join("images")).unwrap();
    std::fs::write(assets.path().join("images/logo.svg"), "<svg></svg>").unwrap();

    let pipeline = SourcePipeline::new(
        Vec::new(),
        Vec::new(),
        vec![assets.path().to_path_buf()],
        out.path().to_path_buf(),
        None,
        None,
        false,
        Broadcaster::new(),
    );

    pipeline.full_build().unwrap();

    assert_eq!(fs_read(out.path().join("index.html")), "<html></html>");
    assert_eq!(
        fs_read(out.path().join("images/logo.svg")),
        "<svg></svg>",
        "nested paths under the asset folder must be preserved in the output"
    );
}

#[test]
fn asset_folder_change_recopies_just_that_file_and_broadcasts() {
    let assets = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    let source = assets.path().join("index.html");
    std::fs::write(&source, "v1").unwrap();

    let broadcaster = Broadcaster::new();
    let observer = broadcaster.subscribe();
    let pipeline = SourcePipeline::new(
        Vec::new(),
        Vec::new(),
        vec![assets.path().to_path_buf()],
        out.path().to_path_buf(),
        None,
        None,
        false,
        broadcaster,
    );

    std::fs::write(&source, "v2").unwrap();
    pipeline.process_change(&source, &ChangeType::Html).unwrap();

    assert_eq!(fs_read(out.path().join("index.html")), "v2");
    let event = observer.recv().expect("must broadcast the rebuild");
    assert_eq!(event.path, out.path().join("index.html"));
}

fn fs_read(path: PathBuf) -> String {
    std::fs::read_to_string(path).unwrap()
}