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::sync::mpsc::{channel, Sender};
use std::time::Instant;
use tempfile::TempDir;

use crate::builder::Builder;
use crate::change::Broadcaster;
use crate::css::{CssOptions, CssTool};
use crate::source::SourcePipeline;

/// Long enough for several poll passes at [`POLL_INTERVAL`], short enough that a broken
/// watcher fails the suite in seconds rather than hanging it.
const SETTLE: Duration = Duration::from_secs(5);

/// Wait until `path` contains `expected`, or fail once [`SETTLE`] has elapsed.
///
/// Polling with a deadline rather than one fixed sleep: a fixed sleep either flakes on a
/// slow machine or wastes the difference on a fast one, and the deadline is what bounds
/// the wait (A2).
fn await_contents(path: &Path, expected: &str) {
    let deadline = Instant::now() + SETTLE;
    loop {
        if let Ok(contents) = std::fs::read_to_string(path) {
            if contents == expected {
                return;
            }
        }
        assert!(
            Instant::now() < deadline,
            "{} did not reach {expected:?} within {SETTLE:?} (currently {:?})",
            path.display(),
            std::fs::read_to_string(path).ok()
        );
        std::thread::sleep(Duration::from_millis(25));
    }
}

fn watching_builder(src: &TempDir, out: &TempDir, options: CssOptions) -> Builder {
    Builder::new(out.path())
        .unwrap()
        .source_folder(src.path())
        .unwrap()
        .css_tool(CssTool::TestEcho, options)
}

/// The development loop this mode exists for: edit a source, and the output follows
/// without anyone asking for a rebuild.
#[test]
fn an_edited_source_is_rebuilt_in_per_file_mode() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("a.css"), "ORIGINAL").unwrap();

    let watching = watching_builder(&src, &out, CssOptions::new())
        .watch(|e| panic!("unexpected rebuild failure: {e}"))
        .unwrap();

    // The initial build is part of `watch`, so the output exists before any edit.
    await_contents(&out.path().join("a.css"), "ORIGINAL");

    std::fs::write(src.path().join("a.css"), "EDITED").unwrap();
    await_contents(&out.path().join("a.css"), "EDITED");

    watching.stop();
}

/// A bundle-mode edit rebuilds the bundle rather than mirroring the changed file.
#[test]
fn an_edited_source_rebuilds_the_bundle_in_bundle_mode() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("a.css"), "ORIGINAL").unwrap();

    let watching = watching_builder(&src, &out, CssOptions::new().bundle(true))
        .watch(|e| panic!("unexpected rebuild failure: {e}"))
        .unwrap();

    await_contents(&out.path().join("styles.css"), "ORIGINAL");

    std::fs::write(src.path().join("a.css"), "EDITED").unwrap();
    await_contents(&out.path().join("styles.css"), "EDITED");

    watching.stop();
}

/// A file added after the watch starts is picked up, not just one that already existed.
#[test]
fn a_newly_added_source_is_built() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("a.css"), "A").unwrap();

    let watching = watching_builder(&src, &out, CssOptions::new())
        .watch(|e| panic!("unexpected rebuild failure: {e}"))
        .unwrap();
    await_contents(&out.path().join("a.css"), "A");

    std::fs::write(src.path().join("b.css"), "B").unwrap();
    await_contents(&out.path().join("b.css"), "B");

    watching.stop();
}

/// Every file present at startup is not a change. Without a seeded baseline the first
/// poll would rebuild the entire tree immediately after the initial build already did.
#[test]
fn the_first_pass_does_not_report_pre_existing_files_as_changes() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("a.css"), "A").unwrap();

    let (tx, rx): (Sender<PathBuf>, _) = channel();
    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(),
    );
    let watching = crate::watch::start(pipeline, vec![src.path().to_path_buf()], move |e| {
        let _ = tx.send(PathBuf::from(e.to_string()));
    });

    // Several poll passes with nothing touched.
    std::thread::sleep(Duration::from_millis(1200));
    watching.stop();

    assert!(
        rx.try_recv().is_err(),
        "an untouched tree must produce no rebuild activity at all"
    );
}

/// Dropping the handle must actually stop the thread — a watcher that outlives its handle
/// keeps rebuilding after its caller believes it stopped.
#[test]
fn dropping_the_handle_stops_rebuilding() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("a.css"), "ORIGINAL").unwrap();

    let watching = watching_builder(&src, &out, CssOptions::new())
        .watch(|e| panic!("unexpected rebuild failure: {e}"))
        .unwrap();
    await_contents(&out.path().join("a.css"), "ORIGINAL");
    drop(watching);

    // Edit well after the watch has stopped; the output must not follow.
    std::fs::write(src.path().join("a.css"), "EDITED-AFTER-STOP").unwrap();
    std::thread::sleep(Duration::from_millis(1200));

    assert_eq!(
        std::fs::read_to_string(out.path().join("a.css")).unwrap(),
        "ORIGINAL",
        "the watcher kept running after its handle was dropped"
    );
}

/// A rebuild that fails must reach the caller. Swallowing it leaves the output dir
/// holding stale bytes while the caller believes it is current.
///
/// Bundle mode, specifically: per-file mode degrades a tool failure to a raw copy and
/// returns `Ok` by design (`css::build_css_file`), so it has nothing to report. That
/// asymmetry is the whole reason this test names its mode — an `on_error` test written
/// against per-file mode passes for the wrong reason, by never failing at all.
#[test]
fn a_failing_rebuild_reports_through_on_error() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    std::fs::write(src.path().join("a.css"), "A").unwrap();

    let (tx, rx) = channel();
    let pipeline = SourcePipeline::new(
        vec![src.path().to_path_buf()],
        Vec::new(),
        Vec::new(),
        out.path().to_path_buf(),
        Some((CssTool::TestMissing, CssOptions::new().bundle(true))),
        None,
        false,
        Broadcaster::new(),
    );
    let watching = crate::watch::start(pipeline, vec![src.path().to_path_buf()], move |e| {
        let _ = tx.send(e.to_string());
    });

    std::fs::write(src.path().join("a.css"), "EDITED").unwrap();

    let reported = rx
        .recv_timeout(SETTLE)
        .expect("a failing rebuild must call on_error");
    assert!(
        reported.contains("build failed"),
        "the reported error should say what happened, got: {reported}"
    );

    watching.stop();
}