use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum PersistError {
Io(std::io::Error),
Json(serde_json::Error),
}
impl std::fmt::Display for PersistError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PersistError::Io(e) => write!(f, "Persistence I/O error: {e}"),
PersistError::Json(e) => write!(f, "Persistence JSON error: {e}"),
}
}
}
impl std::error::Error for PersistError {}
impl From<std::io::Error> for PersistError {
fn from(e: std::io::Error) -> Self {
PersistError::Io(e)
}
}
impl From<serde_json::Error> for PersistError {
fn from(e: serde_json::Error) -> Self {
PersistError::Json(e)
}
}
pub struct StateStore {
path: PathBuf,
}
impl StateStore {
pub fn new(app_name: &str) -> Self {
let dir = std::env::var("APPDATA")
.or_else(|_| std::env::var("XDG_DATA_HOME"))
.or_else(|_| {
std::env::var("HOME").map(|h| {
let mut p = PathBuf::from(h);
p.push(".local/share");
p.to_string_lossy().into_owned()
})
})
.map(PathBuf::from)
.unwrap_or_default();
let app_dir = if dir.as_os_str().is_empty() {
PathBuf::from(".")
} else {
dir.join(app_name)
};
Self {
path: app_dir.join("state.json"),
}
}
pub fn with_path(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn save<T: serde::Serialize>(&self, state: &T) -> Result<(), PersistError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(state)?;
std::fs::write(&self.path, json)?;
Ok(())
}
pub fn load<T: serde::de::DeserializeOwned>(&self) -> Result<T, PersistError> {
let data = std::fs::read_to_string(&self.path)?;
let state = serde_json::from_str(&data)?;
Ok(state)
}
pub fn load_or_none<T: serde::de::DeserializeOwned>(&self) -> Result<Option<T>, PersistError> {
match std::fs::read_to_string(&self.path) {
Ok(data) => {
let state = serde_json::from_str(&data)?;
Ok(Some(state))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(PersistError::Io(e)),
}
}
pub fn load_or_default<T: serde::de::DeserializeOwned + Default>(
&self,
) -> Result<T, PersistError> {
self.load_or_none().map(|opt| opt.unwrap_or_default())
}
pub fn clear(&self) -> Result<(), PersistError> {
match std::fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(PersistError::Io(e)),
}
}
pub fn exists(&self) -> bool {
self.path.exists()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct TestState {
count: i32,
name: String,
}
#[test]
fn round_trip() {
let dir = std::env::temp_dir().join("dewey_persist_test");
let store = StateStore::with_path(dir.join("test_state.json"));
let state = TestState {
count: 42,
name: "hello".into(),
};
store.save(&state).unwrap();
let loaded: TestState = store.load().unwrap();
assert_eq!(state, loaded);
store.clear().unwrap();
}
#[test]
fn load_or_none_missing() {
let store = StateStore::with_path("/tmp/dewey_nonexistent_persist_test_12345.json");
let result: Result<Option<TestState>, _> = store.load_or_none();
assert!(result.unwrap().is_none());
}
}