mini-static 0.20.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use super::*;

use std::sync::Arc;
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.
#[tokio::test]
async fn pipeline_does_not_rebroadcast_its_own_output_echo() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    tokio::fs::write(src.path().join("style.css"), "body { margin: 0; }")
        .await
        .unwrap();

    let broadcaster = Broadcaster::new();
    let pipeline = Arc::new(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(),
    ));

    // The pipeline consumes the same broadcaster it writes to, exactly as in `run_on`.
    // Subscribe both receivers here so they are registered before the broadcast (the
    // spawned task alone could miss it while still scheduling on the test runtime).
    let mut pipeline_rx = broadcaster.subscribe();
    let mut observer = broadcaster.subscribe();
    let pipeline_task = tokio::spawn(async move {
        while let Some(event) = pipeline_rx.recv().await {
            if let Err(e) = pipeline
                .process_change(&event.path, &event.change_type)
                .await
            {
                eprintln!("source pipeline error: {e}");
            }
        }
    });

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

    // Collect every event the SSE clients would see during a bounded window.
    let mut count = 0usize;
    let window = Duration::from_millis(400);
    let deadline = tokio::time::Instant::now() + window;
    loop {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        match tokio::time::timeout(remaining, observer.recv()).await {
            Ok(Some(_)) => count += 1,
            Ok(None) | Err(_) => break,
        }
    }

    pipeline_task.abort();

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

#[tokio::test]
async fn css_bundle_mode_concatenates_into_the_configured_output_name() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    tokio::fs::write(src.path().join("a.css"), "A")
        .await
        .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().await.unwrap();

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

#[tokio::test]
async fn css_per_file_mode_mirrors_every_source_file() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    tokio::fs::write(src.path().join("a.css"), "A")
        .await
        .unwrap();
    tokio::fs::write(src.path().join("b.css"), "B")
        .await
        .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().await.unwrap();

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

#[tokio::test]
async 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");
    tokio::fs::write(&entry, "const x = 1;").await.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().await.unwrap();

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

#[tokio::test]
async fn no_tool_configured_processes_nothing() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    tokio::fs::write(src.path().join("app.css"), "body{}")
        .await
        .unwrap();
    tokio::fs::write(src.path().join("app.js"), "x=1;")
        .await
        .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().await.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"
    );
}

#[tokio::test]
async fn prune_output_removes_stale_css_bundle_when_no_css_sources_remain() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    tokio::fs::write(out.path().join("styles.css"), "/* stale */")
        .await
        .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().await.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"
    );
}

#[tokio::test]
async fn without_prune_stale_css_bundle_is_left_in_place() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    tokio::fs::write(out.path().join("styles.css"), "/* stale */")
        .await
        .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().await.unwrap();

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

#[tokio::test]
async fn asset_folder_mirrors_every_file_byte_identical_on_full_build() {
    let assets = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    tokio::fs::write(assets.path().join("index.html"), "<html></html>")
        .await
        .unwrap();
    tokio::fs::create_dir(assets.path().join("images"))
        .await
        .unwrap();
    tokio::fs::write(assets.path().join("images/logo.svg"), "<svg></svg>")
        .await
        .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().await.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"
    );
}

#[tokio::test]
async 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");
    tokio::fs::write(&source, "v1").await.unwrap();

    let broadcaster = Broadcaster::new();
    let mut 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,
    );

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

    assert_eq!(fs_read(out.path().join("index.html")), "v2");
    let event = observer.recv().await.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()
}