#![allow(clippy::expect_used)]
#![cfg_attr(not(test), deny(clippy::indexing_slicing))]
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub fn emit_content_hash() {
let crate_root = env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR not set — emit_content_hash must run inside a build.rs");
let crate_root = PathBuf::from(crate_root);
let mut hasher = blake3::Hasher::new();
let src_dir = crate_root.join("src");
let mut src_files = Vec::new();
if src_dir.is_dir() {
collect_files(&src_dir, &mut src_files).expect("walk src/");
}
src_files.sort();
for path in &src_files {
let rel = path
.strip_prefix(&crate_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
hasher.update(rel.as_bytes());
hasher.update(b"\0");
let bytes = fs::read(path).expect("read source file");
hasher.update(&bytes);
hasher.update(b"\0");
}
let cargo_toml = crate_root.join("Cargo.toml");
if let Ok(bytes) = fs::read(&cargo_toml) {
hasher.update(b"Cargo.toml\0");
hasher.update(&bytes);
hasher.update(b"\0");
}
let mut lockfile_used = false;
let mut search = crate_root.clone();
for _ in 0..6 {
let candidate = search.join("Cargo.lock");
if candidate.is_file() {
if let Ok(bytes) = fs::read(&candidate) {
hasher.update(b"Cargo.lock\0");
hasher.update(&bytes);
hasher.update(b"\0");
lockfile_used = true;
println!(
"cargo:rerun-if-changed={}",
candidate.to_string_lossy().replace('\\', "/")
);
}
break;
}
let Some(parent) = search.parent() else { break };
search = parent.to_path_buf();
}
if !lockfile_used {
println!(
"cargo:warning=maat-plugin-build: no Cargo.lock found in 6 ancestors of {}; \
content_hash falls back to src/ + Cargo.toml only",
crate_root.to_string_lossy()
);
}
let hex = hex::encode(hasher.finalize().as_bytes());
println!("cargo:rustc-env=METAGAMER_PLUGIN_CONTENT_HASH={hex}");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=build.rs");
}
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let ft = entry.file_type()?;
if ft.is_dir() {
collect_files(&path, out)?;
} else if ft.is_file() {
out.push(path);
}
}
Ok(())
}
#[allow(
clippy::indexing_slicing,
reason = "explicit `s.len() != 64` early return above guarantees \
s.as_bytes()[i*2] and s.as_bytes()[i*2+1] are in range \
for every i in 0..32"
)]
pub fn decode_blake3_hex(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let hi = hex_digit(s.as_bytes()[i * 2])?;
let lo = hex_digit(s.as_bytes()[i * 2 + 1])?;
*byte = (hi << 4) | lo;
}
Some(out)
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_blake3_hex_roundtrip() {
let bytes = [0xABu8; 32];
let s = hex::encode(bytes);
let back = decode_blake3_hex(&s).unwrap();
assert_eq!(back, bytes);
}
#[test]
fn decode_blake3_hex_rejects_bad_length() {
assert!(decode_blake3_hex("abcd").is_none());
assert!(decode_blake3_hex("").is_none());
}
#[test]
fn decode_blake3_hex_rejects_non_hex() {
let bad: String = std::iter::repeat_n('z', 64).collect();
assert!(decode_blake3_hex(&bad).is_none());
}
#[test]
fn collect_files_sorted_after_explicit_sort() {
let dir = std::env::temp_dir().join(format!(
"maat-plugin-build-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("b.txt"), b"B").unwrap();
fs::write(dir.join("a.txt"), b"A").unwrap();
let mut files = Vec::new();
collect_files(&dir, &mut files).unwrap();
files.sort();
assert_eq!(files.len(), 2);
assert!(files[0].ends_with("a.txt"));
assert!(files[1].ends_with("b.txt"));
let _ = fs::remove_dir_all(&dir);
}
}