2b1ccaf31d
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
132 lines
4.0 KiB
Rust
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);
|
|
}
|
|
}
|