//! Content-pack identity: manifest, hash, and raw bundle loading //! (docs/04 §4.4 + §4.7). //! //! Saves and `.hexdemo` replays record the content-pack hash so a replay //! never silently runs against edited data: replay code verifies //! [`pack_hash`] before stepping the sim headless (docs/04 §4.7). //! //! Hash scheme: SHA-256 over the concatenated raw bytes of the 7 canonical //! files, in [`manifest`] order. It is ORDER-SENSITIVE on purpose — //! `pack_hash(&[a, b]) != pack_hash(&[b, a])` — so save/replay code MUST pass //! files in manifest order (use [`load_all`], which does). Hashing raw bytes //! (not parsed values) means whitespace and comment edits also change the //! hash; that is intended: any data touch invalidates old replays. //! //! Hot reload: the debug watcher lives parent-side (cli). This crate only //! exposes the pure [`fmtime`] helper (debug builds) so the watcher can poll //! modification times without pulling in threads or a `notify` dependency. use sha2::{Digest, Sha256}; use crate::load::{load_text, LoadError}; /// The 7 canonical content files, in load/hash order (docs/04 §4.4). /// /// Paths are relative to the workspace root (`assets/...`). [`load_all`] /// joins them onto `root`; [`pack_hash`] callers must pass file bytes in /// exactly this order. pub fn manifest() -> Vec<&'static str> { vec![ "assets/data/items.toml", "assets/data/monsters.toml", "assets/data/hymns.toml", "assets/data/gods.toml", "assets/data/contracts.toml", "assets/data/floors.toml", "assets/data/towns.toml", ] } /// SHA-256 hex digest over the concatenated bytes of `files`. /// /// Order-sensitive by design: callers (save/replay) must pass files in /// [`manifest`] order. Raw bytes in, so any edit — value, comment, or /// whitespace — changes the hash and invalidates dependent replays. pub fn pack_hash(files: &[&[u8]]) -> String { let mut hasher = Sha256::new(); for file in files { hasher.update(file); } format!("{:x}", hasher.finalize()) } /// Read all 7 manifest files under `root`, returning `(path, text)` pairs in /// [`manifest`] order. /// /// Raw text back on purpose: typed parsing belongs to the parent after the /// workstream-1 type unification (this crate must not depend on `schema.rs` /// shapes yet). Errors are [`LoadError`] with the joined path attached. pub fn load_all(root: &str) -> Result, LoadError> { let mut out = Vec::with_capacity(manifest().len()); for rel in manifest() { let path = format!("{root}/{rel}"); let text = load_text(&path)?; out.push((path, text)); } Ok(out) } /// Modification time of `path`, for debug hot-reload polling. /// /// Returns `None` when the file is missing or its metadata is unreadable. /// The polling watcher itself lives parent-side (cli); this stays a pure /// helper with no threads and no `notify` dependency. Debug builds only. #[cfg(debug_assertions)] pub fn fmtime(path: &str) -> Option { std::fs::metadata(path).and_then(|m| m.modified()).ok() } #[cfg(test)] mod tests { use super::*; #[cfg(debug_assertions)] fn data_path(name: &str) -> String { format!("{}/../../assets/data/{name}", env!("CARGO_MANIFEST_DIR")) } #[test] fn manifest_lists_seven_canonical_paths() { let paths = manifest(); assert_eq!(paths.len(), 7, "manifest must list all 7 data files"); assert_eq!(paths[0], "assets/data/items.toml"); assert_eq!(paths[6], "assets/data/towns.toml"); } #[test] fn pack_hash_is_deterministic() { let a: &[u8] = b"lamp oil"; let b: &[u8] = b"cable-rat"; assert_eq!(pack_hash(&[a, b]), pack_hash(&[a, b])); assert_eq!(pack_hash(&[a, b]).len(), 64, "SHA-256 hex is 64 chars"); } #[test] fn pack_hash_is_order_sensitive() { let a: &[u8] = b"lamp oil"; let b: &[u8] = b"cable-rat"; assert_ne!( pack_hash(&[a, b]), pack_hash(&[b, a]), "manifest order is part of the hash" ); } #[test] fn pack_hash_changes_on_edit() { let before: &[u8] = b"pay_cu = 25"; let after: &[u8] = b"pay_cu = 26"; assert_ne!(pack_hash(&[before]), pack_hash(&[after])); } #[test] fn load_all_reads_seven_files_in_manifest_order() { let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); let bundle = load_all(root).expect("all 7 data files must load"); assert_eq!(bundle.len(), 7, "bundle must hold all 7 files"); for (entry, rel) in bundle.iter().zip(manifest()) { assert!( entry.0.ends_with(rel), "bundle order must follow manifest: {} vs {rel}", entry.0 ); assert!(!entry.1.is_empty(), "{} must be non-empty", entry.0); } let items = &bundle[0].1; assert!( items.contains("lamp_oil"), "first bundle entry must be items.toml content" ); } #[test] fn load_all_missing_root_reports_joined_path() { let err = load_all("/no/such/root").expect_err("missing root must fail"); assert!( err.file.contains("assets/data/items.toml"), "error must carry the joined path, got: {}", err.file ); assert!(err.to_string().contains(&err.file)); } #[test] fn real_pack_hash_is_stable_and_hex() { let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); let bundle = load_all(root).expect("all 7 data files must load"); let blobs: Vec<&[u8]> = bundle.iter().map(|(_, t)| t.as_bytes()).collect(); let first = pack_hash(&blobs); let second = pack_hash(&blobs); assert_eq!(first, second, "real pack hash must be deterministic"); assert_eq!(first.len(), 64); assert!( first.chars().all(|c| c.is_ascii_hexdigit()), "hash must be lowercase hex, got: {first}" ); } #[cfg(debug_assertions)] #[test] fn fmtime_reports_real_files_and_missing_as_none() { let path = data_path("items.toml"); assert!( fmtime(&path).is_some(), "fmtime must see the real items.toml" ); assert_eq!( fmtime(&data_path("no_such_file.toml")), None, "missing files report None" ); } }