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::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, SystemTime};

use crate::change::ChangeType;
use crate::error::BuildError;
use crate::source::SourcePipeline;

/// How often the watcher re-walks the source tree looking for modified files.
///
/// Polling rather than an OS notification API keeps the crate dependency-free; 500 ms is
/// the interval `mini-static`'s live-reload used for the same job, fast enough to feel
/// immediate in a browser and slow enough that a large tree costs little.
const POLL_INTERVAL: Duration = Duration::from_millis(500);

/// A running watch. Dropping it stops the watcher and waits for its thread to finish.
///
/// Held rather than detached on purpose: a background thread that outlives the handle
/// its caller was given is a leak that only shows up as a build mysteriously running
/// after the caller thought it had stopped.
pub struct WatchHandle {
    stop: Arc<AtomicBool>,
    thread: Option<JoinHandle<()>>,
}

impl WatchHandle {
    /// Stop watching and wait for the watcher thread to finish.
    ///
    /// Equivalent to dropping the handle; offered explicitly so a caller can stop at a
    /// chosen point rather than at end of scope.
    pub fn stop(self) {
        // The work happens in `Drop`, which runs as this returns.
    }
}

impl Drop for WatchHandle {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(thread) = self.thread.take() {
            // The loop checks the stop flag every `POLL_INTERVAL`, so this waits at most
            // that long — bounded, not indefinite.
            let _ = thread.join();
        }
    }
}

/// Rebuild `pipeline`'s outputs whenever a file under `watched` changes, until the
/// returned handle is dropped.
///
/// `on_error` receives any failure from an individual rebuild. It exists rather than a
/// log line because a library writing to its host's stderr uninvited is a surprise, and
/// rather than an error queue because a queue nobody drains grows without bound. A
/// rebuild failure must reach *somewhere*: silently swallowing it leaves the output dir
/// holding stale bytes while the caller believes it is current.
pub(crate) fn start(
    pipeline: SourcePipeline,
    watched: Vec<PathBuf>,
    mut on_error: impl FnMut(BuildError) + Send + 'static,
) -> WatchHandle {
    let stop = Arc::new(AtomicBool::new(false));
    let thread_stop = Arc::clone(&stop);

    // The baseline is taken *here*, before the thread starts, so that once this function
    // returns every subsequent write is guaranteed to be seen as a change. Taking it
    // inside the thread instead leaves a window in which a caller who edits a file
    // immediately has that edit absorbed into the baseline and silently never built —
    // a race that is invisible in casual use and reliable under a script.
    let mut seen = snapshot_mtimes(&watched);

    let thread = std::thread::spawn(move || {
        while !thread_stop.load(Ordering::Relaxed) {
            std::thread::sleep(POLL_INTERVAL);
            if thread_stop.load(Ordering::Relaxed) {
                break;
            }

            let current = snapshot_mtimes(&watched);
            for (path, mtime) in &current {
                let unchanged = seen.get(path).is_some_and(|previous| previous == mtime);
                if unchanged {
                    continue;
                }
                let change_type = ChangeType::from_path(path);
                if let Err(e) = pipeline.process_change(path, &change_type) {
                    on_error(BuildError::Build(e));
                }
            }
            seen = current;
        }
    });

    WatchHandle {
        stop,
        thread: Some(thread),
    }
}

/// Modification time of every file under every path in `roots`.
///
/// Unreadable entries are skipped rather than reported: a file that cannot be stat'd this
/// pass is either gone or being written, and both resolve themselves by the next one.
fn snapshot_mtimes(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
    let mut mtimes = HashMap::new();
    for root in roots {
        for path in walk_files(root) {
            if let Ok(mtime) = std::fs::metadata(&path).and_then(|meta| meta.modified()) {
                mtimes.insert(path, mtime);
            }
        }
    }
    mtimes
}

/// Every file under `dir`, recursively.
///
/// Bounded by the filesystem: directories are pushed onto a stack and popped until none
/// remain, and a directory tree is finite. Unreadable directories are skipped so one
/// permission error does not blind the whole watch.
fn walk_files(dir: &Path) -> Vec<PathBuf> {
    let mut files = Vec::new();
    let mut pending = vec![dir.to_path_buf()];

    while let Some(current) = pending.pop() {
        let Ok(entries) = std::fs::read_dir(&current) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            match entry.file_type() {
                Ok(file_type) if file_type.is_dir() => pending.push(path),
                Ok(_) => files.push(path),
                Err(_) => {}
            }
        }
    }

    files
}

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