Files
spaceshell/crates/spaceshd/src/state_store.rs
T

132 lines
4.0 KiB
Rust

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<Group>,
#[serde(default)]
pub workspaces: Vec<Workspace>,
}
pub trait StateStore: Send + Sync {
fn load(&self) -> Result<PersistState>;
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<PersistState> {
if !self.path.exists() {
return Ok(PersistState { version: 1, ..Default::default() });
}
let bytes = std::fs::read(&self.path)?;
match serde_json::from_slice::<PersistState>(&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);
}
}