diff --git a/crates/spaceshd/src/main.rs b/crates/spaceshd/src/main.rs index e693b90..6ea05e2 100644 --- a/crates/spaceshd/src/main.rs +++ b/crates/spaceshd/src/main.rs @@ -7,6 +7,7 @@ mod lifecycle; mod persist; mod registry; mod server; +mod snapshot_store; mod state_store; mod surface; diff --git a/crates/spaceshd/src/snapshot_store.rs b/crates/spaceshd/src/snapshot_store.rs new file mode 100644 index 0000000..fdacf9d --- /dev/null +++ b/crates/spaceshd/src/snapshot_store.rs @@ -0,0 +1,123 @@ +use std::path::PathBuf; +use spacesh_core::snapshot::Snapshot; +use spacesh_proto::SurfaceId; + +/// Stores one visible-screen snapshot per surface as `/.json`. +pub trait SnapshotStore: Send + Sync { + fn save(&self, sid: &SurfaceId, snap: &Snapshot); + fn load(&self, sid: &SurfaceId) -> Option; + fn remove(&self, sid: &SurfaceId); +} + +/// Writer command: persist or delete a surface's snapshot. Shared by the +/// router ticker, the close/remove paths, and each actor's on-exit dump, so a +/// single channel type flows everywhere. +pub enum SnapshotMsg { + Save(SurfaceId, Snapshot), + Remove(SurfaceId), +} + +/// A no-op store for tests and contexts that do not persist snapshots. +pub struct NullSnapshotStore; +impl SnapshotStore for NullSnapshotStore { + fn save(&self, _sid: &SurfaceId, _snap: &Snapshot) {} + fn load(&self, _sid: &SurfaceId) -> Option { None } + fn remove(&self, _sid: &SurfaceId) {} +} + +/// JSON file store. Filenames are the surface id (e.g. `s_1f.json`); ids are +/// `^[a-z]_[0-9a-f]+$` so they are always safe path components. +pub struct JsonSnapshotStore { + dir: PathBuf, +} + +impl JsonSnapshotStore { + pub fn new(dir: PathBuf) -> Self { + let _ = std::fs::create_dir_all(&dir); + Self { dir } + } + fn path(&self, sid: &SurfaceId) -> PathBuf { + self.dir.join(format!("{}.json", sid.0)) + } +} + +impl SnapshotStore for JsonSnapshotStore { + fn save(&self, sid: &SurfaceId, snap: &Snapshot) { + let path = self.path(sid); + let tmp = path.with_extension("json.tmp"); + let Ok(bytes) = serde_json::to_vec(snap) else { return }; + if std::fs::write(&tmp, &bytes).is_err() { return; } + if std::fs::File::open(&tmp).and_then(|f| f.sync_all()).is_err() { return; } + let _ = std::fs::rename(&tmp, &path); + } + fn load(&self, sid: &SurfaceId) -> Option { + let bytes = std::fs::read(self.path(sid)).ok()?; + serde_json::from_slice(&bytes).ok() + } + fn remove(&self, sid: &SurfaceId) { + let _ = std::fs::remove_file(self.path(sid)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir(name: &str) -> PathBuf { + let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let p = std::env::temp_dir().join(format!("spacesh-snap-{name}-{n}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn sample() -> Snapshot { + Snapshot { ansi: "\u{1b}[mhello".into(), cols: 80, rows: 24, cursor_row: 1, cursor_col: 6 } + } + + #[test] + fn save_then_load_round_trips() { + let dir = tmp_dir("roundtrip"); + let store = JsonSnapshotStore::new(dir.clone()); + let sid = SurfaceId("s_1".into()); + store.save(&sid, &sample()); + assert_eq!(store.load(&sid), Some(sample())); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_loads_none() { + let store = JsonSnapshotStore::new(tmp_dir("missing")); + assert_eq!(store.load(&SurfaceId("s_none".into())), None); + } + + #[test] + fn corrupt_loads_none() { + let dir = tmp_dir("corrupt"); + let store = JsonSnapshotStore::new(dir.clone()); + let sid = SurfaceId("s_2".into()); + std::fs::write(dir.join("s_2.json"), b"{ not json").unwrap(); + assert_eq!(store.load(&sid), None); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn remove_deletes_file() { + let dir = tmp_dir("remove"); + let store = JsonSnapshotStore::new(dir.clone()); + let sid = SurfaceId("s_3".into()); + store.save(&sid, &sample()); + assert!(store.load(&sid).is_some()); + store.remove(&sid); + assert_eq!(store.load(&sid), None); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn null_store_is_inert() { + let store = NullSnapshotStore; + let sid = SurfaceId("s_4".into()); + store.save(&sid, &sample()); + assert_eq!(store.load(&sid), None); + store.remove(&sid); + } +}