use std::path::{Path, PathBuf};
pub fn fingerprint(root: &Path, skip: Option<&Path>) -> u64 {
let mut total: u64 = 0;
let mut stack = vec![root.to_path_buf()];
let skip = skip.and_then(|p| std::fs::canonicalize(p).ok());
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
if is_skipped(&entry.path(), skip.as_deref()) {
continue;
}
stack.push(entry.path());
continue;
}
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_millis() as u64);
let mut hash = fnv(FNV_OFFSET, entry.path().to_string_lossy().as_bytes());
hash = fnv(hash, &meta.len().to_le_bytes());
hash = fnv(hash, &mtime.to_le_bytes());
total = total.wrapping_add(hash);
}
}
total
}
fn is_skipped(dir: &Path, skip: Option<&Path>) -> bool {
let Some(skip) = skip else { return false };
std::fs::canonicalize(dir).is_ok_and(|dir| dir == skip)
}
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
fn fnv(mut hash: u64, bytes: &[u8]) -> u64 {
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x1000_0000_01b3);
}
hash
}
pub fn build_dir_within(root: &Path, dest: &Path) -> Option<PathBuf> {
let root = std::fs::canonicalize(root).ok()?;
let dest = std::fs::canonicalize(dest).ok()?;
dest.starts_with(&root).then_some(dest)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_fingerprint_moves_only_when_a_file_does() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.md"), "one").unwrap();
let before = fingerprint(dir.path(), None);
assert_eq!(
before,
fingerprint(dir.path(), None),
"stable when nothing moves"
);
std::fs::write(dir.path().join("a.md"), "one and a half").unwrap();
assert_ne!(before, fingerprint(dir.path(), None), "an edit is seen");
}
#[test]
fn dot_prefixed_bookkeeping_is_not_a_change() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.md"), "one").unwrap();
let before = fingerprint(dir.path(), None);
std::fs::create_dir(dir.path().join(".prov")).unwrap();
std::fs::write(dir.path().join(".prov/cache"), "x").unwrap();
assert_eq!(before, fingerprint(dir.path(), None));
}
#[test]
fn the_build_directory_is_not_part_of_the_archive() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.md"), "one").unwrap();
let out = dir.path().join("_site");
std::fs::create_dir(&out).unwrap();
let before = fingerprint(dir.path(), Some(&out));
std::fs::write(out.join("index.html"), "<html>").unwrap();
assert_eq!(before, fingerprint(dir.path(), Some(&out)));
assert_ne!(
before,
fingerprint(dir.path(), None),
"and it would have been seen without the skip"
);
}
}