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 std::collections::BTreeMap;
use std::ffi::OsString;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};

/// Files grouped by the directory that contains them, in deterministic order.
///
/// Grouping is what makes batching safe: a tool writing into one output directory
/// distinguishes its results only by basename, and basenames collide across directories
/// but never within one.
pub(crate) fn group_by_parent(files: &[PathBuf]) -> BTreeMap<PathBuf, Vec<PathBuf>> {
    let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
    for file in files {
        let parent = file.parent().unwrap_or(Path::new(".")).to_path_buf();
        groups.entry(parent).or_default().push(file.clone());
    }
    groups
}

/// How often [`wait_bounded`] asks whether the child has exited.
///
/// Short enough that a fast tool is not held up waiting for the next poll, long enough
/// that a 30-second timeout costs a few thousand cheap syscalls rather than a spin.
const POLL_INTERVAL: Duration = Duration::from_millis(5);

/// Default ceiling on how long a single external tool invocation may run before
/// this crate gives up on it: a hung or misbehaving external process must not
/// block a build indefinitely. Callers may inject a shorter timeout (tests do, to stay
/// fast); production call sites should pass this constant.
pub(crate) const TOOL_TIMEOUT: Duration = Duration::from_secs(30);

/// Why an external tool invocation ([`execute`]) failed.
///
/// `tool` names the CLI tool (`"lightningcss"`, `"esbuild"`) in every variant so a
/// caller juggling both CSS and JS pipelines can report which one broke without
/// threading the name through separately.
#[derive(Debug)]
pub enum ToolError {
    /// `program` was not found on `PATH`. `install_hint` is preset-specific guidance
    /// (e.g. an npm install command) surfaced verbatim in the error message.
    NotFound {
        tool: &'static str,
        install_hint: &'static str,
    },
    /// The process ran and exited with a non-zero status. `stderr` carries whatever
    /// diagnostic the tool itself printed.
    ExitNonZero {
        tool: &'static str,
        code: Option<i32>,
        stderr: String,
    },
    /// The process did not finish within the given timeout. The process is killed
    /// (`kill_on_drop`) rather than left to run in the background.
    TimedOut {
        tool: &'static str,
        timeout: Duration,
    },
    /// The process exited successfully but `output_path` is missing or empty — the
    /// tool silently produced nothing.
    NoOutput { tool: &'static str },
    /// A spawn/wait failure not attributable to a missing binary.
    Io(std::io::Error),
}

impl std::fmt::Display for ToolError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ToolError::NotFound { tool, install_hint } => {
                write!(f, "{tool} not found on PATH ({install_hint})")
            }
            ToolError::ExitNonZero { tool, code, stderr } => {
                write!(f, "{tool} exited with code {code:?}: {stderr}")
            }
            ToolError::TimedOut { tool, timeout } => {
                write!(f, "{tool} timed out after {timeout:?}")
            }
            ToolError::NoOutput { tool } => {
                write!(f, "{tool} exited successfully but produced no output")
            }
            ToolError::Io(e) => write!(f, "failed to run external tool: {e}"),
        }
    }
}

impl std::error::Error for ToolError {}

/// Run `program args...`, expecting it to write every path in `expected_outputs`.
///
/// Bounded by `timeout` (A2): a hung process is killed and reported as `TimedOut`
/// rather than blocking the caller forever. Captures stderr so a non-zero exit carries
/// the tool's own diagnostic, not just a bare exit code (A5).
///
/// # Errors
///
/// - `NotFound` if `program` isn't on `PATH`.
/// - `ExitNonZero` if the process exits with a non-zero code.
/// - `TimedOut` if the process doesn't finish within `timeout`.
/// - `NoOutput` if the process exits 0 but any expected output is missing or empty.
/// - `Io` for any other spawn/wait failure.
pub(crate) fn execute(
    tool: &'static str,
    install_hint: &'static str,
    program: &str,
    args: &[OsString],
    expected_outputs: &[&Path],
    timeout: Duration,
) -> Result<(), ToolError> {
    let spawned = Command::new(program)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn();

    let mut child = match spawned {
        Ok(child) => child,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(ToolError::NotFound { tool, install_hint });
        }
        Err(e) => return Err(ToolError::Io(e)),
    };

    // Both pipes are drained on their own threads for the whole life of the process. A
    // tool that writes more than a pipe buffer would otherwise block forever on a full
    // pipe while we sit waiting for it to exit — a deadlock that looks exactly like a
    // hung tool and would only surface on unusually chatty output. `wait_with_output`
    // did this for us, but it cannot be combined with a timeout.
    let stdout_reader = child.stdout.take().map(drain_on_thread);
    let stderr_reader = child.stderr.take().map(drain_on_thread);
    let collect = |reader: Option<std::thread::JoinHandle<Vec<u8>>>| {
        reader
            .and_then(|handle| handle.join().ok())
            .unwrap_or_default()
    };

    let status = match wait_bounded(&mut child, timeout) {
        Ok(Some(status)) => status,
        Ok(None) => {
            // `std::process::Child` does not kill on drop the way tokio's does, so a
            // timed-out child must be killed explicitly or it outlives the build.
            let _ = child.kill();
            let _ = child.wait();
            collect(stdout_reader);
            collect(stderr_reader);
            return Err(ToolError::TimedOut { tool, timeout });
        }
        Err(e) => {
            let _ = child.kill();
            let _ = child.wait();
            collect(stdout_reader);
            collect(stderr_reader);
            return Err(ToolError::Io(e));
        }
    };

    let stderr = collect(stderr_reader);
    collect(stdout_reader);

    if !status.success() {
        return Err(ToolError::ExitNonZero {
            tool,
            code: status.code(),
            stderr: String::from_utf8_lossy(&stderr).into_owned(),
        });
    }

    // Every expected output must exist and be non-empty. A batched invocation that
    // silently skipped one of its inputs would otherwise look like success and leave a
    // hole in the build.
    let produced_everything = expected_outputs
        .iter()
        .all(|path| matches!(std::fs::metadata(path), Ok(meta) if meta.len() > 0));

    if produced_everything {
        Ok(())
    } else {
        Err(ToolError::NoOutput { tool })
    }
}

/// Read `pipe` to end on a dedicated thread, yielding whatever arrived.
///
/// Read errors collapse to the bytes received so far: the process's exit status and the
/// output file are what decide success, and failing a build because its diagnostic
/// output could not be captured would report the wrong problem.
fn drain_on_thread<R: Read + Send + 'static>(mut pipe: R) -> std::thread::JoinHandle<Vec<u8>> {
    std::thread::spawn(move || {
        let mut buffer = Vec::new();
        let _ = pipe.read_to_end(&mut buffer);
        buffer
    })
}

/// Wait for `child` to exit, giving up after `timeout`.
///
/// `Ok(None)` means the deadline passed with the child still running — the caller is
/// responsible for killing it. The polling loop is bounded by the deadline, so it cannot
/// spin indefinitely regardless of what the child does.
fn wait_bounded(child: &mut Child, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
    let deadline = Instant::now() + timeout;
    loop {
        if let Some(status) = child.try_wait()? {
            return Ok(Some(status));
        }
        if Instant::now() >= deadline {
            return Ok(None);
        }
        std::thread::sleep(POLL_INTERVAL);
    }
}

/// True if `binary_name` resolves to an executable file somewhere on `PATH`.
///
/// A synchronous, no-subprocess scan (just directory listings) — cheap enough to run
/// at server startup as a fail-fast precondition check (A4) before any build is
/// attempted, so a missing tool is visible immediately rather than on the first
/// rebuild that needs it.
pub(crate) fn locate_on_path(binary_name: &str) -> bool {
    let Some(path_var) = std::env::var_os("PATH") else {
        return false;
    };

    std::env::split_paths(&path_var).any(|dir| is_executable_file(&dir.join(binary_name)))
}

#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;
    std::fs::metadata(path)
        .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
    path.is_file()
}

#[cfg(test)]
#[path = "../tests/unit/tool.rs"]
mod tests;