//! Transactional scene persistence and user-local recovery snapshot storage. use std::fs::{self, File, OpenOptions}; use std::hash::Hasher; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; const STATE_DIR: &str = "blacksite-editor"; const RECOVERY_DIR: &str = "recovery"; pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { atomic_write_with_pre_rename(path, bytes, || Ok(())) } fn atomic_write_with_pre_rename( path: &Path, bytes: &[u8], before_rename: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); fs::create_dir_all(parent) .map_err(|error| format!("could not create {}: {error}", parent.display()))?; let file_name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("scene"); let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let temporary = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id())); let result = (|| { let mut file = OpenOptions::new() .create_new(true) .write(true) .open(&temporary) .map_err(|error| format!("could not create {}: {error}", temporary.display()))?; file.write_all(bytes) .map_err(|error| format!("could not write {}: {error}", temporary.display()))?; file.sync_all() .map_err(|error| format!("could not sync {}: {error}", temporary.display()))?; before_rename()?; fs::rename(&temporary, path).map_err(|error| { format!( "could not replace {} with {}: {error}", path.display(), temporary.display() ) })?; sync_parent_directory(parent); Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&temporary); } result } #[cfg(unix)] fn sync_parent_directory(parent: &Path) { if let Ok(directory) = File::open(parent) { let _ = directory.sync_all(); } } #[cfg(not(unix))] fn sync_parent_directory(_parent: &Path) {} pub(crate) fn default_state_root() -> Option { if let Some(path) = std::env::var_os("XDG_STATE_HOME") { return Some(PathBuf::from(path).join(STATE_DIR)); } std::env::var_os("HOME") .map(PathBuf::from) .map(|home| home.join(".local/state").join(STATE_DIR)) } pub(crate) fn write_recovery_snapshot( state_root: &Path, project_root: &Path, scene_path: &Path, bytes: &[u8], max_generations: usize, ) -> Result { let directory = recovery_directory(state_root, project_root, scene_path); fs::create_dir_all(&directory) .map_err(|error| format!("could not create {}: {error}", directory.display()))?; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(); let snapshot = directory.join(format!( "recovery-{timestamp:020}-{}.scn.ron", std::process::id() )); atomic_write(&snapshot, bytes)?; prune_recovery_generations(&directory, max_generations.max(1))?; Ok(snapshot) } pub(crate) fn latest_recovery_snapshot( state_root: &Path, project_root: &Path, scene_path: &Path, ) -> Option { let directory = recovery_directory(state_root, project_root, scene_path); let source_modified = fs::metadata(scene_path) .and_then(|metadata| metadata.modified()) .unwrap_or(UNIX_EPOCH); recovery_files(&directory) .into_iter() .filter(|path| { fs::metadata(path) .and_then(|metadata| metadata.modified()) .is_ok_and(|modified| modified > source_modified) }) .max_by_key(|path| path.file_name().map(|name| name.to_os_string())) } pub(crate) fn discard_recovery_snapshots( state_root: &Path, project_root: &Path, scene_path: &Path, ) -> Result<(), String> { let directory = recovery_directory(state_root, project_root, scene_path); if !directory.exists() { return Ok(()); } fs::remove_dir_all(&directory) .map_err(|error| format!("could not remove {}: {error}", directory.display())) } fn recovery_directory(state_root: &Path, project_root: &Path, scene_path: &Path) -> PathBuf { state_root .join(RECOVERY_DIR) .join(stable_path_hash(project_root)) .join(stable_path_hash(scene_path)) } fn stable_path_hash(path: &Path) -> String { let normalized = path.to_string_lossy().replace('\\', "/"); let mut hasher = Fnv1a64::default(); hasher.write(normalized.as_bytes()); format!("{:016x}", hasher.finish()) } fn prune_recovery_generations(directory: &Path, max_generations: usize) -> Result<(), String> { let mut files = recovery_files(directory); files.sort_by_key(|path| path.file_name().map(|name| name.to_os_string())); let remove_count = files.len().saturating_sub(max_generations); for path in files.into_iter().take(remove_count) { fs::remove_file(&path) .map_err(|error| format!("could not prune {}: {error}", path.display()))?; } Ok(()) } fn recovery_files(directory: &Path) -> Vec { let Ok(entries) = fs::read_dir(directory) else { return Vec::new(); }; entries .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| { path.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.starts_with("recovery-") && name.ends_with(".scn.ron")) }) .collect() } #[derive(Default)] struct Fnv1a64(u64); impl Hasher for Fnv1a64 { fn finish(&self) -> u64 { if self.0 == 0 { 0xcbf29ce484222325 } else { self.0 } } fn write(&mut self, bytes: &[u8]) { let mut hash = self.finish(); for byte in bytes { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x100000001b3); } self.0 = hash; } } #[cfg(test)] mod tests { use super::*; fn temp_directory(label: &str) -> PathBuf { std::env::temp_dir().join(format!("blacksite-{label}-{}", uuid::Uuid::new_v4())) } #[test] fn atomic_write_replaces_content_without_leaving_temporary_files() { let root = temp_directory("atomic-write"); let path = root.join("scene.scn.ron"); fs::create_dir_all(&root).unwrap(); fs::write(&path, b"old").unwrap(); atomic_write(&path, b"new scene").unwrap(); assert_eq!(fs::read(&path).unwrap(), b"new scene"); assert_eq!(fs::read_dir(&root).unwrap().count(), 1); fs::remove_dir_all(root).unwrap(); } #[test] fn interrupted_atomic_write_preserves_the_last_authored_scene() { let root = temp_directory("atomic-interruption"); let path = root.join("scene.scn.ron"); fs::create_dir_all(&root).unwrap(); fs::write(&path, b"last known good scene").unwrap(); let result = atomic_write_with_pre_rename(&path, b"partial replacement", || { Err("simulated interruption before rename".to_string()) }); assert_eq!(result.unwrap_err(), "simulated interruption before rename"); assert_eq!(fs::read(&path).unwrap(), b"last known good scene"); assert_eq!(fs::read_dir(&root).unwrap().count(), 1); fs::remove_dir_all(root).unwrap(); } #[test] fn recovery_paths_are_stable_and_scene_specific() { let state = Path::new("/state"); let project = Path::new("/project"); let first = recovery_directory(state, project, Path::new("assets/levels/a.scn.ron")); let repeated = recovery_directory(state, project, Path::new("assets/levels/a.scn.ron")); let second = recovery_directory(state, project, Path::new("assets/levels/b.scn.ron")); assert_eq!(first, repeated); assert_ne!(first, second); assert!(first.starts_with("/state/recovery")); } #[test] fn newer_recovery_generation_is_discovered_for_authored_scene() { let root = temp_directory("recovery-discovery"); let project = Path::new("/project"); let scene = root.join("assets/levels/test.scn.ron"); fs::create_dir_all(scene.parent().unwrap()).unwrap(); fs::write(&scene, b"authored").unwrap(); std::thread::sleep(std::time::Duration::from_millis(10)); let snapshot = write_recovery_snapshot(&root, project, &scene, b"recovered", 5).unwrap(); assert_eq!( latest_recovery_snapshot(&root, project, &scene), Some(snapshot) ); fs::remove_dir_all(root).unwrap(); } #[test] fn recovery_generations_are_bounded() { let root = temp_directory("recovery-prune"); let project = Path::new("/project"); let scene = Path::new("assets/levels/test.scn.ron"); let directory = recovery_directory(&root, project, scene); fs::create_dir_all(&directory).unwrap(); for index in 0..5 { fs::write( directory.join(format!("recovery-{index:020}-1.scn.ron")), index.to_string(), ) .unwrap(); } prune_recovery_generations(&directory, 3).unwrap(); let mut names: Vec = recovery_files(&directory) .into_iter() .map(|path| path.file_name().unwrap().to_string_lossy().into_owned()) .collect(); names.sort(); assert_eq!( names, vec![ "recovery-00000000000000000002-1.scn.ron", "recovery-00000000000000000003-1.scn.ron", "recovery-00000000000000000004-1.scn.ron", ] ); fs::remove_dir_all(root).unwrap(); } #[test] fn discard_removes_all_scene_generations() { let root = temp_directory("recovery-discard"); let project = Path::new("/project"); let scene = Path::new("assets/levels/test.scn.ron"); let directory = recovery_directory(&root, project, scene); fs::create_dir_all(&directory).unwrap(); fs::write( directory.join("recovery-00000000000000000001-1.scn.ron"), b"one", ) .unwrap(); fs::write( directory.join("recovery-00000000000000000002-1.scn.ron"), b"two", ) .unwrap(); discard_recovery_snapshots(&root, project, scene).unwrap(); assert!(!directory.exists()); fs::remove_dir_all(root).unwrap(); } }