use ignore::WalkBuilder;
use repolith_core::types::{BuildError, Sha256};
use sha2::{Digest, Sha256 as ShaHasher};
use std::path::Path;
pub fn platform_tag(h: &mut ShaHasher) {
h.update(b":arch:");
h.update(std::env::consts::ARCH.as_bytes());
h.update(b":os:");
h.update(std::env::consts::OS.as_bytes());
}
pub fn tree_digest(path: &Path) -> Result<Sha256, BuildError> {
if !path.exists() {
return Err(BuildError::Io(format!(
"source tree {} does not exist",
path.display()
)));
}
let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
let walker = WalkBuilder::new(path)
.hidden(false) .git_ignore(true)
.require_git(false)
.parents(false)
.git_global(false) .git_exclude(false) .filter_entry(|e| {
!matches!(
e.file_name().to_str(),
Some(".git" | "target" | ".repolith")
)
})
.build();
for entry in walker {
let entry = entry.map_err(|e| BuildError::Io(format!("walk {}: {e}", path.display())))?;
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let rel = entry
.path()
.strip_prefix(path)
.unwrap_or(entry.path())
.to_string_lossy()
.replace('\\', "/");
entries.push((rel, entry.path().to_path_buf()));
}
entries.sort();
let mut h = ShaHasher::new();
h.update(b"tree:v1:");
for (rel, abs) in &entries {
h.update(rel.as_bytes());
h.update(b"\0");
match std::fs::read(abs) {
Ok(bytes) => {
h.update(b"ok:");
h.update(&bytes);
}
Err(e) => {
h.update(b"unreadable:");
h.update(e.kind().to_string().as_bytes());
}
}
h.update(b"\n");
}
Ok(Sha256(h.finalize().into()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn digest(dir: &Path) -> Sha256 {
tree_digest(dir).expect("digest")
}
#[test]
fn missing_tree_is_an_error() {
assert!(tree_digest(Path::new("/nonexistent/repolith/tree")).is_err());
}
#[test]
fn same_content_same_digest() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
for d in [a.path(), b.path()] {
fs::create_dir(d.join("src")).unwrap();
fs::write(d.join("src/main.rs"), b"fn main() {}").unwrap();
fs::write(d.join("Cargo.toml"), b"[package]\nname=\"x\"").unwrap();
}
assert_eq!(digest(a.path()), digest(b.path()), "content-only digest");
}
#[test]
fn one_byte_edit_changes_the_digest() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("f.rs"), b"fn main() {}").unwrap();
let before = digest(dir.path());
fs::write(dir.path().join("f.rs"), b"fn main() { }").unwrap();
assert_ne!(before, digest(dir.path()), "edit must be visible");
}
#[test]
fn touch_alone_does_not_change_the_digest() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("f.rs");
fs::write(&f, b"same").unwrap();
let before = digest(dir.path());
let content = fs::read(&f).unwrap();
fs::write(&f, &content).unwrap(); assert_eq!(before, digest(dir.path()), "mtime must not leak in");
}
#[test]
fn target_and_git_are_excluded() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("f.rs"), b"src").unwrap();
let before = digest(dir.path());
fs::create_dir(dir.path().join("target")).unwrap();
fs::write(dir.path().join("target/huge.bin"), vec![0u8; 4096]).unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".git/HEAD"), b"ref: refs/heads/main").unwrap();
assert_eq!(
before,
digest(dir.path()),
"build output and git internals must not be hashed"
);
}
#[test]
fn gitignored_files_are_excluded() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".gitignore"), b"secret.txt\n").unwrap();
fs::write(dir.path().join("f.rs"), b"src").unwrap();
let before = digest(dir.path());
fs::write(dir.path().join("secret.txt"), b"ignored").unwrap();
assert_eq!(before, digest(dir.path()), ".gitignore must be honored");
}
#[test]
fn adding_a_file_changes_the_digest() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.rs"), b"a").unwrap();
let before = digest(dir.path());
fs::write(dir.path().join("b.rs"), b"b").unwrap();
assert_ne!(before, digest(dir.path()), "new file must be visible");
}
#[test]
fn renaming_changes_the_digest() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.rs"), b"same").unwrap();
let before = digest(dir.path());
fs::rename(dir.path().join("a.rs"), dir.path().join("b.rs")).unwrap();
assert_ne!(before, digest(dir.path()));
}
#[test]
fn platform_tag_is_mixed_in() {
let mut with = ShaHasher::new();
with.update(b"x");
platform_tag(&mut with);
let mut without = ShaHasher::new();
without.update(b"x");
assert_ne!(
with.finalize().to_vec(),
without.finalize().to_vec(),
"platform must contribute to the hash"
);
}
}