statica-cli 0.34.0

A blazingly fast static site generator that builds on just HTML
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use statica::BuildOptions;

use super::{preview, util};
use crate::cli::ConfigCli;
use crate::config::PreviewConfig;
use crate::style;

pub async fn run(dir: &Path, overrides: &ConfigCli) -> Result<()> {
    let (root, config) = util::load_project(dir, overrides)?;
    let opts = util::build_options(&config, &root, overrides, true);
    let host = config.preview.host_addr()?;
    let port = config.preview.port;

    eprintln!(
        "{} {}",
        style::accent("statica watch"),
        style::dim(root.display().to_string()),
    );

    let report = util::run_build(&opts)?;
    util::log_build(&report, &opts.out_dir, "Built", opts.verbose);

    start_watcher(root.clone(), opts.clone(), &config.preview)?;
    preview::serve_dir(&opts.out_dir, host, port).await
}

fn start_watcher(root: PathBuf, opts: BuildOptions, preview_cfg: &PreviewConfig) -> Result<()> {
    let ignore_dirs = opts.ignore_dirs.clone();
    let debounce = Duration::from_millis(preview_cfg.debounce_ms);
    let poll = Duration::from_secs(preview_cfg.poll_interval_secs.max(1));

    let (tx, rx) = mpsc::channel::<Vec<PathBuf>>();
    let watch_root = root.clone();
    let mut watcher = RecommendedWatcher::new(
        {
            let watch_root = watch_root.clone();
            let ignore_dirs = ignore_dirs.clone();
            move |res: notify::Result<notify::Event>| {
                let Ok(event) = res else {
                    return;
                };
                if matches!(
                    event.kind,
                    EventKind::Access(_) | EventKind::Other | EventKind::Any
                ) {
                    return;
                }
                let paths: Vec<PathBuf> = event
                    .paths
                    .into_iter()
                    .filter(|p| !should_ignore_path(&watch_root, p, &ignore_dirs))
                    .collect();
                if !paths.is_empty() {
                    let _ = tx.send(paths);
                }
            }
        },
        Config::default().with_poll_interval(poll),
    )
    .context("failed to start filesystem watcher")?;
    watcher
        .watch(&root, RecursiveMode::Recursive)
        .context("failed to watch project directory")?;

    std::thread::Builder::new()
        .name("statica-watch".into())
        .spawn(move || {
            let _watcher = watcher;
            let mut pending: Vec<PathBuf> = Vec::new();
            loop {
                match rx.recv() {
                    Ok(paths) => pending.extend(paths),
                    Err(_) => break,
                }
                let deadline = Instant::now() + debounce;
                while Instant::now() < deadline {
                    match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
                        Ok(paths) => pending.extend(paths),
                        Err(mpsc::RecvTimeoutError::Timeout) => break,
                        Err(mpsc::RecvTimeoutError::Disconnected) => return,
                    }
                }
                pending.sort();
                pending.dedup();
                if pending.is_empty() {
                    continue;
                }
                let changed = std::mem::take(&mut pending);
                let mut rebuild_opts = opts.clone();
                rebuild_opts.clean = false;
                match util::run_rebuild(&rebuild_opts, &changed) {
                    Ok(report) => util::log_build(
                        &report,
                        &rebuild_opts.out_dir,
                        "Rebuilt",
                        rebuild_opts.verbose,
                    ),
                    Err(e) => eprintln!("{} {e:#}", style::error("rebuild failed:")),
                }
            }
        })
        .context("failed to spawn watch thread")?;
    Ok(())
}

fn should_ignore_path(root: &Path, path: &Path, ignore_dirs: &[String]) -> bool {
    if path
        .file_name()
        .and_then(|n| n.to_str())
        .is_some_and(|n| n.starts_with('.'))
    {
        return true;
    }
    let matches_ignore = |c: &std::ffi::OsStr| {
        ignore_dirs
            .iter()
            .any(|d| c == std::ffi::OsStr::new(d.as_str()))
    };
    if let Ok(rel) = path.strip_prefix(root) {
        return rel.components().any(|c| matches_ignore(c.as_os_str()));
    }
    path.components().any(|c| matches_ignore(c.as_os_str()))
}