Blacksite/crates/editor/src/assets/fingerprint.rs

115 lines
3.6 KiB
Rust

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<Path>) -> Result<AssetSourceFingerprint, String> {
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<T>(
path: impl AsRef<Path>,
value: &T,
) -> Result<bool, String>
where
T: DeserializeOwned + PartialEq + Serialize,
{
let path = path.as_ref();
if fs::read_to_string(path)
.ok()
.and_then(|text| ron::from_str::<T>(&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();
}
}