hauchiwa 0.21.0

Flexible static website generator library with incremental rebuilds and cached image optimization
Documentation
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;

use camino::{Utf8Path, Utf8PathBuf};
use indicatif::ProgressStyle;
use rayon::prelude::*;
use tracing::{Level, info, span};
use tracing_indicatif::span_ext::IndicatifSpanExt;

use crate::core::Hash32;
use crate::error::StepCopyStatic;

/// Configuration for progress bar styles displayed during builds.
///
/// These styles are only visible when an [`IndicatifLayer`] is registered
/// with the `tracing` subscriber (e.g., via [`init_logging()`]). Without
/// the layer, all progress bar operations are silent no-ops.
///
/// Each field has a sensible default. Override individual styles to
/// customise the build output, or replace the entire struct via
/// [`Blueprint::set_progress_styles`].
///
/// [`IndicatifLayer`]: tracing_indicatif::IndicatifLayer
/// [`init_logging()`]: crate::init_logging
/// [`Blueprint::set_progress_styles`]: crate::Blueprint::set_progress_styles
pub struct ProgressStyles {
    /// Style for the overall build progress bar.
    pub build: ProgressStyle,
    /// Style for individual task spinners.
    pub task: ProgressStyle,
    /// Style for tasks that process multiple items (e.g., glob loaders).
    /// Overrides [`task`](Self::task) when the item count is known.
    pub task_items: ProgressStyle,
    /// Style for the static file copy progress bar.
    pub copy: ProgressStyle,
}

#[allow(clippy::expect_used)] // hardcoded template literals - cannot fail
impl Default for ProgressStyles {
    fn default() -> Self {
        Self {
            build: ProgressStyle::default_bar()
                .template(
                    "{spinner:.green} [{elapsed}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
                )
                .expect("hardcoded template")
                .progress_chars("=>-"),
            task: ProgressStyle::default_spinner()
                .template("{spinner:.blue} {msg}")
                .expect("hardcoded template"),
            task_items: ProgressStyle::default_spinner()
                .template("{spinner:.blue} {msg} {pos}/{len} ")
                .expect("hardcoded template"),
            copy: ProgressStyle::default_bar()
                .template("{spinner:.green} [{elapsed}] [{bar:40.cyan/blue}] {pos} {msg}")
                .expect("hardcoded template")
                .progress_chars("#>-"),
        }
    }
}

/// Returns `true` if the `dst` file is considered identical to `src`.
///
/// The check proceeds in increasing order of cost:
/// 1. **Metadata**: Fails early if `dst` is missing or file sizes differ.
/// 2. **Mtime**: Returns `true` immediately if modification times match.
/// 3. **Content**: Performs a full BLAKE3 hash comparison as a final fallback.
#[rustfmt::skip]
fn is_unchanged(src: &Path, dst: &Path) -> bool {
    let Ok(src_meta) = fs::metadata(src) else { return false };
    let Ok(dst_meta) = fs::metadata(dst) else { return false };

    // different file size
    if src_meta.len() != dst_meta.len() {
        return false;
    }

    let Ok(src_mod) = src_meta.modified() else { return false };
    let Ok(dst_mod) = dst_meta.modified() else { return false };

    // same mtime
    if src_mod == dst_mod {
        return true;
    }

    let Ok(src) = Hash32::hash_file(src) else { return false };
    let Ok(dst) = Hash32::hash_file(dst) else { return false };

    // same hash
    src == dst
}

#[derive(Clone, Debug)]
pub(crate) struct StaticFileEntry {
    pub(crate) src: PathBuf,
    pub(crate) dst: PathBuf,
    pub(crate) source_utf8: Utf8PathBuf,
    pub(crate) dist_rel: Utf8PathBuf,
}

/// Discovers static files configured via `Blueprint::copy_static`.
pub(crate) fn collect_static(
    copied: &[(String, String)],
    out_dir: &Utf8Path,
) -> Result<Vec<StaticFileEntry>, StepCopyStatic> {
    if copied.is_empty() {
        return Ok(vec![]);
    }

    let mut files: Vec<StaticFileEntry> = Vec::new();

    for (into, from) in copied {
        let path = std::path::Path::new(into);
        let mut depth = 0;
        let mut safe = true;

        for component in path.components() {
            match component {
                std::path::Component::ParentDir => {
                    depth -= 1;
                    if depth < 0 {
                        safe = false;
                        break;
                    }
                }
                std::path::Component::Normal(_) => {
                    depth += 1;
                }
                std::path::Component::RootDir | std::path::Component::Prefix(_) => {
                    safe = false;
                    break;
                }
                std::path::Component::CurDir => {}
            }
        }

        if !safe {
            return Err(StepCopyStatic::UnsafeTarget(into.clone()));
        }

        let target = out_dir.as_std_path().join(into);
        let dist_rel = Utf8Path::new(into);

        let metadata = fs::metadata(from).map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                StepCopyStatic::MissingSource(from.clone())
            } else {
                StepCopyStatic::Io(e)
            }
        })?;

        if metadata.is_dir() {
            collect_files(from, &target, dist_rel, &mut files)?;
        } else {
            let source_utf8 = Utf8PathBuf::try_from(PathBuf::from(from))
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
            files.push(StaticFileEntry {
                src: PathBuf::from(from),
                dst: target,
                source_utf8,
                dist_rel: dist_rel.to_path_buf(),
            });
        }
    }

    Ok(files)
}

/// Copies pre-discovered static files into `dist/`.
///
/// Returns the list of `(source_path, dist_relative_path)` pairs for every file
/// that was copied. These are inserted into the [`Snapshot`](crate::output::Snapshot)
/// by the caller so that reconciliation can track static files without clearing
/// `dist`.
pub(crate) fn copy_static_entries(
    files: &[StaticFileEntry],
    style: &ProgressStyle,
) -> Result<Vec<(Utf8PathBuf, Utf8PathBuf)>, StepCopyStatic> {
    if files.is_empty() {
        return Ok(vec![]);
    }

    let span = span!(Level::INFO, "copy_static", indicatif.pb_show = true);
    span.pb_set_message("Copying static files...");
    span.pb_set_style(style);
    let _enter = span.enter();

    let s = Instant::now();
    span.pb_set_length(files.len() as u64);

    // Pre-create all destination directories before parallelising copies to
    // avoid races between concurrent `fs::copy` calls on the same new path.
    for dir in files
        .iter()
        .filter_map(|f| f.dst.parent())
        .collect::<HashSet<&Path>>()
    {
        fs::create_dir_all(dir)?;
    }

    // Hash-check and copy files in parallel.
    let entries: Vec<(Utf8PathBuf, Utf8PathBuf)> = files
        .par_iter()
        .map(|f| -> std::io::Result<(Utf8PathBuf, Utf8PathBuf)> {
            if !is_unchanged(&f.src, &f.dst) {
                fs::copy(&f.src, &f.dst)?;
            }
            span.pb_inc(1);
            Ok((f.source_utf8.clone(), f.dist_rel.clone()))
        })
        .collect::<std::io::Result<_>>()?;

    info!(duration_ms = s.elapsed().as_millis() as u64, "Finished copying static files");

    Ok(entries)
}

/// Recursively walks `src`, appending one [`StaticFileEntry`] per file to `files`.
/// Directory creation is deferred to the caller.
fn collect_files(
    src: impl AsRef<Path>,
    dst: impl AsRef<Path>,
    dist_rel: &Utf8Path,
    files: &mut Vec<StaticFileEntry>,
) -> std::io::Result<()> {
    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let name = entry.file_name();
        let name_str = name.to_str().ok_or_else(|| {
            std::io::Error::new(std::io::ErrorKind::InvalidData, "non-UTF-8 filename")
        })?;

        if entry.file_type()?.is_dir() {
            collect_files(
                entry.path(),
                dst.as_ref().join(&name),
                &dist_rel.join(name_str),
                files,
            )?;
        } else {
            let src_path = entry.path();
            let source_utf8 = Utf8PathBuf::try_from(src_path.clone())
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
            files.push(StaticFileEntry {
                dst: dst.as_ref().join(&name),
                src: src_path,
                source_utf8,
                dist_rel: dist_rel.join(name_str),
            });
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn clone_static_errors_for_missing_source() {
        let result = collect_static(
            &[("assets".to_string(), "missing-static-source".to_string())],
            Utf8Path::new("dist"),
        );

        assert!(matches!(
            result,
            Err(StepCopyStatic::MissingSource(source)) if source == "missing-static-source"
        ));
    }
}