use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
pub const MAX_BYTES: usize = crate::cart::MEMORY_CAP;
#[derive(Default)]
pub struct Storage {
map: serde_json::Map<String, Value>,
path: Option<PathBuf>,
dirty: bool,
}
impl Storage {
pub fn for_cart(name: &str) -> Storage {
match storage_file(name) {
Some(path) => Self::at_path(path),
None => Storage::default(),
}
}
pub fn for_cart_in(root: &std::path::Path, name: &str) -> Storage {
Self::at_path(root.join(format!("{}.json", sanitize_name(name))))
}
pub fn at_path(path: PathBuf) -> Storage {
let map = std::fs::read(&path)
.ok()
.and_then(|bytes| {
serde_json::from_slice::<StorageFile<serde_json::Map<String, Value>>>(&bytes).ok()
})
.filter(|f| f.version == FORMAT_VERSION)
.map(|f| f.data)
.filter(|map| serde_json::to_string(map).is_ok_and(|s| s.len() <= MAX_BYTES))
.unwrap_or_default();
Storage {
map,
path: Some(path),
dirty: false,
}
}
pub fn set_json(&mut self, key: &str, json: &str) -> bool {
let Ok(value) = serde_json::from_str::<Value>(json) else {
return false;
};
let prev = self.map.insert(key.to_owned(), value);
let size = serde_json::to_string(&self.map)
.map(|s| s.len())
.unwrap_or(usize::MAX);
if size > MAX_BYTES {
match prev {
Some(v) => self.map.insert(key.to_owned(), v),
None => self.map.remove(key),
};
return false;
}
self.dirty = true;
true
}
pub fn get_json(&self, key: &str) -> Option<String> {
let value = self.map.get(key)?;
Some(serde_json::to_string(value).unwrap_or_default())
}
pub fn remove(&mut self, key: &str) -> bool {
let existed = self.map.remove(key).is_some();
self.dirty |= existed;
existed
}
pub fn clear(&mut self) {
if !self.map.is_empty() {
self.map.clear();
self.dirty = true;
}
}
pub fn save_if_dirty(&mut self) -> anyhow::Result<()> {
let Some(path) = &self.path else {
return Ok(());
};
if !self.dirty {
return Ok(());
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let text = crate::wire::to_readable_json(&StorageFile {
version: FORMAT_VERSION,
data: &self.map,
})?;
let tmp = path.with_extension(format!("json.tmp{}", std::process::id()));
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)?;
self.dirty = false;
Ok(())
}
}
impl Drop for Storage {
fn drop(&mut self) {
let _ = self.save_if_dirty();
}
}
const FORMAT_VERSION: u32 = 1;
#[derive(Serialize, Deserialize)]
struct StorageFile<D> {
version: u32,
data: D,
}
fn storage_file(name: &str) -> Option<PathBuf> {
Some(
cache_dir()?
.join("pixel8")
.join("storage")
.join(format!("{}.json", sanitize_name(name))),
)
}
fn cache_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
Some(PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches"))
} else if cfg!(windows) {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?))
} else {
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(".cache")))
}
}
fn sanitize_name(name: &str) -> String {
let mut out = String::new();
for c in name.chars() {
let c = c.to_ascii_lowercase();
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_matches('-');
if out.is_empty() {
"untitled".into()
} else {
out.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_file(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("pixel8_storage_{tag}_{}.json", std::process::id()))
}
#[test]
fn set_get_remove_clear_roundtrip() {
let mut s = Storage::default();
assert!(s.set_json("score", "42"));
assert!(s.set_json("name", "\"ada\""));
assert!(s.set_json("pos", "[1,2]"));
assert_eq!(s.get_json("score").as_deref(), Some("42"));
assert_eq!(s.get_json("name").as_deref(), Some("\"ada\""));
assert_eq!(s.get_json("pos").as_deref(), Some("[1,2]"));
assert_eq!(s.get_json("missing"), None);
assert!(s.remove("score"));
assert!(!s.remove("score"));
assert_eq!(s.get_json("score"), None);
s.clear();
assert_eq!(s.get_json("name"), None);
}
#[test]
fn set_overwrites_and_canonicalizes() {
let mut s = Storage::default();
assert!(s.set_json("k", "1"));
assert!(s.set_json("k", " {\"a\" : 1, \"b\": [1, 2]} "));
assert_eq!(s.get_json("k").as_deref(), Some("{\"a\":1,\"b\":[1,2]}"));
}
#[test]
fn invalid_json_is_rejected() {
let mut s = Storage::default();
assert!(!s.set_json("k", "not json"));
assert!(!s.set_json("k", ""));
assert_eq!(s.get_json("k"), None);
}
#[test]
fn cap_rejects_without_clobbering() {
let mut s = Storage::default();
assert!(s.set_json("k", "\"small\""));
let huge = format!("\"{}\"", "x".repeat(MAX_BYTES));
assert!(!s.set_json("k", &huge));
assert_eq!(s.get_json("k").as_deref(), Some("\"small\""));
assert!(!s.set_json("fresh", &huge));
assert_eq!(s.get_json("fresh"), None);
}
#[test]
fn cap_boundary_is_exact_and_inclusive() {
let mut s = Storage::default();
let exact = format!("\"{}\"", "x".repeat(MAX_BYTES - 8));
assert!(s.set_json("k", &exact));
let over = format!("\"{}\"", "x".repeat(MAX_BYTES - 7));
assert!(!s.set_json("k", &over));
assert_eq!(s.get_json("k").as_deref(), Some(exact.as_str()));
}
#[test]
fn saves_and_reloads_from_disk() {
let path = temp_file("roundtrip");
let _ = std::fs::remove_file(&path);
{
let mut s = Storage::at_path(path.clone());
assert!(s.set_json("score", "42"));
}
let s = Storage::at_path(path.clone());
assert_eq!(s.get_json("score").as_deref(), Some("42"));
std::fs::remove_file(&path).unwrap();
}
#[test]
fn clean_store_does_not_touch_disk() {
let path = temp_file("clean");
let _ = std::fs::remove_file(&path);
{
let _s = Storage::at_path(path.clone());
}
assert!(!path.exists(), "nothing was set, nothing is written");
}
#[test]
fn corrupt_or_wrong_version_file_starts_empty() {
let path = temp_file("corrupt");
std::fs::write(&path, "not json at all").unwrap();
let s = Storage::at_path(path.clone());
assert_eq!(s.get_json("anything"), None);
std::fs::write(&path, r#"{"version": 999, "data": {"k": 1}}"#).unwrap();
let s = Storage::at_path(path.clone());
assert_eq!(s.get_json("k"), None);
std::fs::remove_file(&path).unwrap();
}
#[test]
fn over_cap_file_starts_empty_but_the_cap_measures_compact_form() {
let path = temp_file("overcap");
let huge = format!(
r#"{{"version": 1, "data": {{"k": "{}"}}}}"#,
"x".repeat(MAX_BYTES)
);
std::fs::write(&path, huge).unwrap();
let s = Storage::at_path(path.clone());
assert_eq!(s.get_json("k"), None);
drop(s);
let exact = format!("\"{}\"", "x".repeat(MAX_BYTES - 8));
{
let mut s = Storage::at_path(path.clone());
assert!(s.set_json("k", &exact));
}
assert!(std::fs::metadata(&path).unwrap().len() > MAX_BYTES as u64);
let s = Storage::at_path(path.clone());
assert_eq!(s.get_json("k").as_deref(), Some(exact.as_str()));
std::fs::remove_file(&path).unwrap();
}
#[test]
fn for_cart_in_roots_the_save_under_the_given_dir() {
let root = std::env::temp_dir().join(format!("pixel8_storage_root_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
{
let mut s = Storage::for_cart_in(&root, "My Cool Game!");
assert!(s.set_json("k", "1"));
}
let expected = root.join("my-cool-game.json");
assert!(expected.exists(), "save lands under the injected root");
let s = Storage::for_cart_in(&root, "My Cool Game!");
assert_eq!(s.get_json("k").as_deref(), Some("1"));
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn sanitize_names_for_files() {
assert_eq!(sanitize_name("My Cool Game!"), "my-cool-game");
assert_eq!(sanitize_name("platformer"), "platformer");
assert_eq!(sanitize_name("snake_2"), "snake_2");
assert_eq!(sanitize_name(" ... "), "untitled");
assert_eq!(sanitize_name(""), "untitled");
}
}