use std::fs; use std::path::Path; use serde::de::DeserializeOwned; use serde::Serialize; use shared::AssetSourceFingerprint; pub(crate) fn fingerprint_file(path: impl AsRef) -> Result { let path = path.as_ref(); let bytes = fs::read(path) .map_err(|error| format!("could not read imported source {}: {error}", path.display()))?; Ok(AssetSourceFingerprint::from_bytes(&bytes)) } /// Writes canonical pretty RON only when the parsed document changes semantically. /// /// Equivalent existing bytes, including custom formatting and final-newline policy, stay intact. pub(crate) fn write_pretty_ron_if_changed( path: impl AsRef, value: &T, ) -> Result where T: DeserializeOwned + PartialEq + Serialize, { let path = path.as_ref(); if fs::read_to_string(path) .ok() .and_then(|text| ron::from_str::(&text).ok()) .is_some_and(|existing| existing == *value) { return Ok(false); } let text = ron::ser::to_string_pretty(value, ron::ser::PrettyConfig::default()) .map_err(|error| format!("could not serialize RON: {error}"))?; if fs::read(path).ok().as_deref() == Some(text.as_bytes()) { return Ok(false); } if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("could not create {}: {error}", parent.display()))?; } fs::write(path, text) .map_err(|error| format!("could not write {}: {error}", path.display()))?; Ok(true) } #[cfg(test)] mod tests { use super::*; use serde::Deserialize; use std::fs::{File, FileTimes}; use std::time::{Duration, SystemTime}; use uuid::Uuid; #[derive(Debug, Deserialize, PartialEq, Serialize)] struct Fixture { count: u32, label: String, } fn fixture_path(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!( "blacksite-fingerprint-{name}-{}.ron", Uuid::new_v4() )) } #[test] fn metadata_only_drift_does_not_change_content_identity() { let path = fixture_path("mtime"); fs::write(&path, b"stable source bytes").unwrap(); let before = fingerprint_file(&path).unwrap(); File::options() .write(true) .open(&path) .unwrap() .set_times( FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(86_400)), ) .unwrap(); assert_eq!(fingerprint_file(&path).unwrap(), before); fs::remove_file(path).unwrap(); } #[test] fn same_size_byte_change_updates_content_identity() { let path = fixture_path("same-size"); fs::write(&path, b"source-a").unwrap(); let before = fingerprint_file(&path).unwrap(); fs::write(&path, b"source-b").unwrap(); let after = fingerprint_file(&path).unwrap(); assert_eq!(before.byte_len, after.byte_len); assert_ne!(before.content_hash, after.content_hash); fs::remove_file(path).unwrap(); } #[test] fn equivalent_ron_preserves_exact_existing_bytes() { let path = fixture_path("semantic"); let existing = b"( label: \"stable\", count: 7, )\n\n"; fs::write(&path, existing).unwrap(); let value = Fixture { count: 7, label: "stable".into(), }; assert!(!write_pretty_ron_if_changed(&path, &value).unwrap()); assert_eq!(fs::read(&path).unwrap(), existing); fs::remove_file(path).unwrap(); } }