henad-build 0.3.0

Build-script support for Henad model crates: build stamps, and shader bindings generated from WGSL.
Documentation
//! Files a stamp hashes, and the `.cargo_vcs_info.json` of a package.

use std::path::{Path, PathBuf};

use henad_core::explore::fingerprint::Fnv1a64;

use super::git::{Repository, list_sources};

/// File `cargo package` writes into a package from a git checkout.
const VCS_INFO: &str = ".cargo_vcs_info.json";
/// Author's original manifest in a package, next to the normalised `Cargo.toml` that Cargo writes.
const ORIGINAL_MANIFEST: &str = "Cargo.toml.orig";
/// Opening of the normalised `Cargo.toml` that `cargo package` writes.
const GENERATED_MANIFEST_HEADER: &str = "# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO";

/// One crate's directory, and the label its files carry in a hash.
#[derive(Debug, Clone)]
pub(crate) struct CrateFiles {
    directory: PathBuf,
    /// Prefix of every label, the crate's directory below the repository root for the engine's crates and empty
    /// otherwise.
    label_prefix: String,
}

impl CrateFiles {
    pub(crate) fn new(directory: &Path) -> Self {
        Self {
            directory: directory.to_path_buf(),
            label_prefix: String::new(),
        }
    }

    /// Returns the crate `package` under `crates/` in `repository`, its files labelled from the repository root.
    pub(crate) fn in_repository(repository: &Repository, package: &str) -> Self {
        Self {
            directory: repository.toplevel().join("crates").join(package),
            label_prefix: format!("crates/{package}/"),
        }
    }

    pub(crate) fn directory(&self) -> &Path {
        &self.directory
    }

    /// Returns whether the crate is a package, as a registry download or a `cargo vendor` copy is.
    ///
    /// A package holds a `.cargo_vcs_info.json`, or a `Cargo.toml.orig` beside the `Cargo.toml` Cargo generated. A
    /// `Cargo.toml.orig` beside a hand-written manifest is a backup, as left behind by `git mergetool` and `patch -b`.
    pub(crate) fn is_package(&self) -> bool {
        let generated = || {
            std::fs::read_to_string(self.directory.join("Cargo.toml"))
                .is_ok_and(|manifest| manifest.starts_with(GENERATED_MANIFEST_HEADER))
        };
        self.directory.join(VCS_INFO).is_file() || (self.directory.join(ORIGINAL_MANIFEST).is_file() && generated())
    }

    /// Returns the files that the hash covers, with their labels: every file under `src` that is not excluded, and the
    /// manifest.
    ///
    /// Under git, the files under `src` are the files that git tracks or does not ignore. In a package, the manifest is
    /// the author's original, `Cargo.toml.orig`. It carries the label `Cargo.toml` either way.
    pub(crate) fn sources(&self, repository: Option<&Repository>) -> Vec<(String, PathBuf)> {
        let listed = match repository {
            Some(_) => list_sources(&self.directory),
            None => walk(&self.directory.join("src")),
        };
        let mut sources: Vec<(String, PathBuf)> = listed
            .into_iter()
            .filter(|below_src| !is_excluded(below_src))
            .map(|below_src| {
                let path = self.directory.join("src").join(&below_src);
                (format!("{}src/{below_src}", self.label_prefix), path)
            })
            .collect();
        let manifest = if self.is_package() {
            ORIGINAL_MANIFEST
        } else {
            "Cargo.toml"
        };
        sources.push((
            format!("{}Cargo.toml", self.label_prefix),
            self.directory.join(manifest),
        ));
        sources
    }

    /// Returns `src` and `Cargo.toml`, each when it exists.
    pub(crate) fn watched(&self) -> Vec<PathBuf> {
        ["src", "Cargo.toml"]
            .into_iter()
            .map(|name| self.directory.join(name))
            .filter(|path| path.exists())
            .collect()
    }

    /// Returns `src` and `Cargo.toml` as pathspecs relative to the root of `repository`.
    ///
    /// An engine crate's label prefix is its directory below the root. Any other crate is the crate that `repository`
    /// was found from, at the prefix git printed for it. No absolute path is compared.
    pub(crate) fn pathspecs(&self, repository: &Repository) -> Vec<String> {
        let below_root = if self.label_prefix.is_empty() {
            repository.prefix()
        } else {
            &self.label_prefix
        };
        ["src", "Cargo.toml"]
            .into_iter()
            .map(|name| format!("{below_root}{name}"))
            .collect()
    }
}

/// Returns whether a file is left out of the hash and the dirty flag, from its path below `src` with `/` separators.
///
/// A component that starts with `.` leaves the file out, as does a name ending in `~`, `.swp`, `.orig` or `.rej`, or
/// a name that starts and ends with `#`. No kernel file is a dotfile or a backup. Finder files, editor backups and
/// autosaves, and files that a merge or a patch leaves behind stay out whether or not git ignores them.
pub(crate) fn is_excluded(below_src: &str) -> bool {
    let mut components = below_src.split('/');
    let name = below_src.rsplit('/').next().unwrap_or(below_src);
    let autosave = name.len() > 1 && name.starts_with('#') && name.ends_with('#');
    components.any(|component| component.starts_with('.'))
        || autosave
        || ["~", ".swp", ".orig", ".rej"]
            .iter()
            .any(|suffix| name.ends_with(suffix))
}

/// Returns whether `path` is a symlink to something other than a file, or to nothing.
///
/// The hash leaves such a link out, because a dangling link or a link to a directory holds no source to hash. The
/// dirty flag leaves it out as well.
pub(crate) fn is_link_to_no_file(path: &Path) -> bool {
    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
        && !std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
}

/// Returns every file under `src`, relative to it with `/` separators, by walking the directory.
///
/// A symlink to a file counts as the file. A symlink to a directory is not followed. Git does not follow such a link
/// either.
fn walk(src: &Path) -> Vec<String> {
    let mut found = Vec::new();
    let mut pending = vec![(src.to_path_buf(), String::new())];
    while let Some((directory, prefix)) = pending.pop() {
        let Ok(entries) = std::fs::read_dir(&directory) else {
            continue;
        };
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().into_owned();
            let relative = format!("{prefix}{name}");
            let Ok(file_type) = entry.file_type() else {
                continue;
            };
            if file_type.is_dir() {
                pending.push((entry.path(), format!("{relative}/")));
            } else {
                found.push(relative);
            }
        }
    }
    found
}

/// Returns the FNV-1a hash of `sources`, in label order, each file's CRLF line endings read as LF.
///
/// A label is hashed beside its file's bytes. A file that is not there, or a symlink to something other than a file,
/// is left out. So is a file that disappears before it is read, as when an editor's atomic save replaces it. Returns
/// `None` when a file that exists cannot be read.
pub(crate) fn hash_sources(mut sources: Vec<(String, PathBuf)>) -> Option<u64> {
    sources.sort_by(|left, right| left.0.cmp(&right.0));
    sources.dedup_by(|left, right| left.0 == right.0);
    let mut hasher = Fnv1a64::new();
    for (label, path) in &sources {
        if !std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
            continue;
        }
        let bytes = match std::fs::read(path) {
            Ok(bytes) => bytes,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
            Err(_) => return None,
        };
        let normalized = crlf_to_lf(&bytes);
        hasher.write_str(label);
        hasher.write_u64(normalized.len() as u64);
        hasher.write(&normalized);
    }
    Some(hasher.finish())
}

/// Returns `bytes` with every CRLF pair replaced by LF.
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
    let mut normalized = Vec::with_capacity(bytes.len());
    let mut iterator = bytes.iter().peekable();
    while let Some(&byte) = iterator.next() {
        if byte == b'\r' && iterator.peek() == Some(&&b'\n') {
            continue;
        }
        normalized.push(byte);
    }
    normalized
}

/// Returns the nearest `Cargo.lock` in `directory` or a directory above it.
pub(crate) fn nearest_lockfile(directory: &Path) -> Option<PathBuf> {
    directory
        .ancestors()
        .map(|ancestor| ancestor.join("Cargo.lock"))
        .find(|lockfile| lockfile.is_file())
}

/// Commit and dirty flag a package's `.cargo_vcs_info.json` records.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VcsInfo {
    /// Full commit hash, `git.sha1`.
    pub(crate) sha1: String,
    /// `git.dirty`, false when the file leaves it out.
    pub(crate) dirty: bool,
}

impl VcsInfo {
    /// Reads the `.cargo_vcs_info.json` in `directory`, or returns `None` when the file is missing or records no
    /// commit.
    pub(crate) fn read(directory: &Path) -> Option<Self> {
        Self::parse(&std::fs::read_to_string(directory.join(VCS_INFO)).ok()?)
    }

    /// Reads the commit and the dirty flag from the text of a `.cargo_vcs_info.json`.
    ///
    /// The file is the small fixed object Cargo writes, and its two fields are found by their keys.
    pub(crate) fn parse(text: &str) -> Option<Self> {
        let sha1 = field_value(text, "sha1")?
            .strip_prefix('"')?
            .split('"')
            .next()?
            .to_owned();
        if sha1.is_empty() || !sha1.bytes().all(|byte| byte.is_ascii_hexdigit()) {
            return None;
        }
        let dirty = field_value(text, "dirty").is_some_and(|value| value.starts_with("true"));
        Some(Self { sha1, dirty })
    }

    /// Commit shortened to the eight characters `git rev-parse --short=8` prints.
    pub(crate) fn short_commit(&self) -> &str {
        self.sha1.get(..8).unwrap_or(&self.sha1)
    }
}

/// Returns the text after the colon that follows the quoted `key`, leading whitespace removed.
fn field_value<'a>(text: &'a str, key: &str) -> Option<&'a str> {
    let after_key = &text[text.find(&format!("\"{key}\""))? + key.len() + 2..];
    Some(after_key.trim_start().strip_prefix(':')?.trim_start())
}