use std::path::PathBuf; use anyhow::Result; use serde::{Deserialize, Serialize}; use spacesh_proto::workspace::{Group, Workspace}; /// The full persisted snapshot of structure (no live processes). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct PersistState { pub version: u32, #[serde(default)] pub groups: Vec, #[serde(default)] pub workspaces: Vec, } pub trait StateStore: Send + Sync { fn load(&self) -> Result; fn save(&self, state: &PersistState) -> Result<()>; } /// JSON file store with atomic write (temp + rename) and corrupt-file backup. pub struct JsonStateStore { path: PathBuf, } impl JsonStateStore { pub fn new(path: PathBuf) -> Self { Self { path } } fn backup_corrupt(&self, ts: u128) { let bak = self.path.with_extension(format!("corrupt-{ts}")); let _ = std::fs::rename(&self.path, bak); } } impl StateStore for JsonStateStore { fn load(&self) -> Result { if !self.path.exists() { return Ok(PersistState { version: 1, ..Default::default() }); } let bytes = std::fs::read(&self.path)?; match serde_json::from_slice::(&bytes) { Ok(state) => Ok(state), Err(_) => { // Corrupt file: back it up and start fresh. let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); self.backup_corrupt(ts); Ok(PersistState { version: 1, ..Default::default() }) } } } fn save(&self, state: &PersistState) -> Result<()> { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } let tmp = self.path.with_extension("json.tmp"); let bytes = serde_json::to_vec_pretty(state)?; std::fs::write(&tmp, &bytes)?; // fsync the temp file before rename for durability. let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, &self.path)?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use spacesh_proto::ids::WorkspaceId; fn tmp_file(name: &str) -> PathBuf { let mut p = std::env::temp_dir(); let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); p.push(format!("spacesh-state-{name}-{n}.json")); p } fn sample() -> PersistState { PersistState { version: 1, groups: vec![], workspaces: vec![Workspace { id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(), group_id: None, order: 0, unread: false, layout: None, zoomed: None, surfaces: std::collections::HashMap::new(), }], } } #[test] fn save_then_load_round_trips() { let path = tmp_file("roundtrip"); let store = JsonStateStore::new(path.clone()); store.save(&sample()).unwrap(); let back = store.load().unwrap(); assert_eq!(back, sample()); let _ = std::fs::remove_file(path); } #[test] fn missing_file_loads_default() { let store = JsonStateStore::new(tmp_file("missing")); let s = store.load().unwrap(); assert_eq!(s.version, 1); assert!(s.workspaces.is_empty()); } #[test] fn corrupt_file_is_backed_up_and_load_returns_default() { let path = tmp_file("corrupt"); std::fs::write(&path, b"{ this is not valid json").unwrap(); let store = JsonStateStore::new(path.clone()); let s = store.load().unwrap(); assert!(s.workspaces.is_empty()); // original path no longer holds the corrupt bytes (renamed away) assert!(!path.exists()); let _ = std::fs::remove_file(path); } }