Skip to main content

pixel8_runtime/
storage.rs

1//! Persistent key-value cart storage: the save file.
2//!
3//! Carts reach this through four ABI imports (`storage_set` / `storage_get` /
4//! `storage_remove` / `storage_clear`, see docs/ABI.md); values cross the
5//! boundary as JSON text and live here as parsed [`serde_json::Value`]s.
6//! Native frontends back the store with a JSON file under the user's cache
7//! directory, keyed by cart name; the web player and headless `verify` keep
8//! it in memory. The whole store serializes to at most 128 K — the same one
9//! number as every other cart limit (docs/LIMITS.md).
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::path::PathBuf;
14
15/// Serialized-size cap for the whole store: 128 K, shared with the fuel,
16/// memory and cart-size limits.
17pub const MAX_BYTES: usize = crate::cart::MEMORY_CAP;
18
19/// A cart's key-value store, plus its optional disk backing.
20///
21/// `Default` is a purely in-memory store (tests, the web player, headless
22/// `verify`). Dropping a disk-backed store saves it, so every "cart stops"
23/// path — stop, reload, reboot, console exit — persists without ceremony.
24///
25/// Two carts whose names sanitize to the same file stem share a save file,
26/// and two live instances running the same cart each hold a full in-memory
27/// copy that saves whole-store, last-complete-writer-wins — the name *is*
28/// the save identity, by design.
29#[derive(Default)]
30pub struct Storage {
31    map: serde_json::Map<String, Value>,
32    /// Backing file; `None` keeps the store in memory only.
33    path: Option<PathBuf>,
34    dirty: bool,
35}
36
37impl Storage {
38    /// The persistent store for a cart, loaded from (and saved to) a JSON
39    /// file under the user's cache directory, keyed by the cart's name.
40    /// Falls back to an in-memory store when no cache directory is
41    /// discoverable. A missing, malformed, or wrong-version file starts
42    /// empty rather than failing the cart.
43    pub fn for_cart(name: &str) -> Storage {
44        match storage_file(name) {
45            Some(path) => Self::at_path(path),
46            None => Storage::default(),
47        }
48    }
49
50    /// Like [`Storage::for_cart`], but rooted at an explicit directory
51    /// instead of the user's cache directory. Frontends and tests use this
52    /// to keep saves out of (or hermetically inside) a chosen location.
53    pub fn for_cart_in(root: &std::path::Path, name: &str) -> Storage {
54        Self::at_path(root.join(format!("{}.json", sanitize_name(name))))
55    }
56
57    /// A store backed by an explicit file path (used by `for_cart` and by
58    /// tests). The file need not exist yet. A missing, malformed,
59    /// wrong-version, or over-cap file starts empty.
60    pub fn at_path(path: PathBuf) -> Storage {
61        let map = std::fs::read(&path)
62            .ok()
63            .and_then(|bytes| {
64                serde_json::from_slice::<StorageFile<serde_json::Map<String, Value>>>(&bytes).ok()
65            })
66            .filter(|f| f.version == FORMAT_VERSION)
67            .map(|f| f.data)
68            // Enforce the cap on load too (a hand-grown file), with the
69            // same measure `set_json` uses — the compact serialization, not
70            // the file's pretty-printed size.
71            .filter(|map| serde_json::to_string(map).is_ok_and(|s| s.len() <= MAX_BYTES))
72            .unwrap_or_default();
73        Storage {
74            map,
75            path: Some(path),
76            dirty: false,
77        }
78    }
79
80    /// Store `json` (JSON text) under `key`. Returns `false` — storing
81    /// nothing — when the text is not valid JSON or the store would exceed
82    /// [`MAX_BYTES`] serialized; the cap rejects the write, it never
83    /// clobbers old data.
84    pub fn set_json(&mut self, key: &str, json: &str) -> bool {
85        let Ok(value) = serde_json::from_str::<Value>(json) else {
86            return false;
87        };
88        let prev = self.map.insert(key.to_owned(), value);
89        let size = serde_json::to_string(&self.map)
90            .map(|s| s.len())
91            .unwrap_or(usize::MAX);
92        if size > MAX_BYTES {
93            match prev {
94                Some(v) => self.map.insert(key.to_owned(), v),
95                None => self.map.remove(key),
96            };
97            return false;
98        }
99        self.dirty = true;
100        true
101    }
102
103    /// The value under `key` as canonical JSON text, or `None`.
104    pub fn get_json(&self, key: &str) -> Option<String> {
105        let value = self.map.get(key)?;
106        Some(serde_json::to_string(value).unwrap_or_default())
107    }
108
109    /// Remove `key`; `true` if it existed.
110    pub fn remove(&mut self, key: &str) -> bool {
111        let existed = self.map.remove(key).is_some();
112        self.dirty |= existed;
113        existed
114    }
115
116    /// Remove every key.
117    pub fn clear(&mut self) {
118        if !self.map.is_empty() {
119            self.map.clear();
120            self.dirty = true;
121        }
122    }
123
124    /// Write the store to its backing file if anything changed since the
125    /// last save. In-memory stores are a no-op. The write goes through a
126    /// per-process temp file + rename, so neither a crash mid-save nor
127    /// another instance saving the same cart at the same moment can tear
128    /// the file — concurrent saves land whole, last writer wins.
129    pub fn save_if_dirty(&mut self) -> anyhow::Result<()> {
130        let Some(path) = &self.path else {
131            return Ok(());
132        };
133        if !self.dirty {
134            return Ok(());
135        }
136        if let Some(dir) = path.parent() {
137            std::fs::create_dir_all(dir)?;
138        }
139        let text = crate::wire::to_readable_json(&StorageFile {
140            version: FORMAT_VERSION,
141            data: &self.map,
142        })?;
143        let tmp = path.with_extension(format!("json.tmp{}", std::process::id()));
144        std::fs::write(&tmp, text)?;
145        std::fs::rename(&tmp, path)?;
146        self.dirty = false;
147        Ok(())
148    }
149}
150
151impl Drop for Storage {
152    fn drop(&mut self) {
153        let _ = self.save_if_dirty();
154    }
155}
156
157const FORMAT_VERSION: u32 = 1;
158
159/// The on-disk shape: `{ "version": 1, "data": { ... } }`. The cart's keys
160/// are nested under `data` (not flattened) so they can never collide with
161/// the version header. Generic over the data field so saving can borrow
162/// the live map instead of moving it out and back.
163#[derive(Serialize, Deserialize)]
164struct StorageFile<D> {
165    version: u32,
166    data: D,
167}
168
169/// `<cache dir>/pixel8/storage/<sanitized cart name>.json`, or `None` when
170/// no cache directory is discoverable (the browser, bare environments).
171fn storage_file(name: &str) -> Option<PathBuf> {
172    Some(
173        cache_dir()?
174            .join("pixel8")
175            .join("storage")
176            .join(format!("{}.json", sanitize_name(name))),
177    )
178}
179
180/// The platform cache directory, from environment variables alone so the
181/// runtime stays dependency-free: `~/Library/Caches` on macOS,
182/// `%LOCALAPPDATA%` on Windows, `$XDG_CACHE_HOME` or `~/.cache` elsewhere.
183fn cache_dir() -> Option<PathBuf> {
184    if cfg!(target_os = "macos") {
185        Some(PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches"))
186    } else if cfg!(windows) {
187        Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?))
188    } else {
189        std::env::var_os("XDG_CACHE_HOME")
190            .map(PathBuf::from)
191            .filter(|p| p.is_absolute())
192            .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(".cache")))
193    }
194}
195
196/// A cart name as a safe file stem: lowercased, runs of anything outside
197/// `[a-z0-9_-]` collapsed to one `-`, empty falling back to `untitled`.
198fn sanitize_name(name: &str) -> String {
199    let mut out = String::new();
200    for c in name.chars() {
201        let c = c.to_ascii_lowercase();
202        if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
203            out.push(c);
204        } else if !out.ends_with('-') {
205            out.push('-');
206        }
207    }
208    let out = out.trim_matches('-');
209    if out.is_empty() {
210        "untitled".into()
211    } else {
212        out.into()
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn temp_file(tag: &str) -> PathBuf {
221        std::env::temp_dir().join(format!("pixel8_storage_{tag}_{}.json", std::process::id()))
222    }
223
224    #[test]
225    fn set_get_remove_clear_roundtrip() {
226        let mut s = Storage::default();
227        assert!(s.set_json("score", "42"));
228        assert!(s.set_json("name", "\"ada\""));
229        assert!(s.set_json("pos", "[1,2]"));
230        assert_eq!(s.get_json("score").as_deref(), Some("42"));
231        assert_eq!(s.get_json("name").as_deref(), Some("\"ada\""));
232        assert_eq!(s.get_json("pos").as_deref(), Some("[1,2]"));
233        assert_eq!(s.get_json("missing"), None);
234        assert!(s.remove("score"));
235        assert!(!s.remove("score"));
236        assert_eq!(s.get_json("score"), None);
237        s.clear();
238        assert_eq!(s.get_json("name"), None);
239    }
240
241    #[test]
242    fn set_overwrites_and_canonicalizes() {
243        let mut s = Storage::default();
244        assert!(s.set_json("k", "1"));
245        // Whitespace-laden input comes back as canonical JSON.
246        assert!(s.set_json("k", "  {\"a\" : 1,  \"b\": [1, 2]}  "));
247        assert_eq!(s.get_json("k").as_deref(), Some("{\"a\":1,\"b\":[1,2]}"));
248    }
249
250    #[test]
251    fn invalid_json_is_rejected() {
252        let mut s = Storage::default();
253        assert!(!s.set_json("k", "not json"));
254        assert!(!s.set_json("k", ""));
255        assert_eq!(s.get_json("k"), None);
256    }
257
258    #[test]
259    fn cap_rejects_without_clobbering() {
260        let mut s = Storage::default();
261        assert!(s.set_json("k", "\"small\""));
262        // A single value bigger than the whole 128 K budget.
263        let huge = format!("\"{}\"", "x".repeat(MAX_BYTES));
264        assert!(!s.set_json("k", &huge));
265        assert_eq!(s.get_json("k").as_deref(), Some("\"small\""));
266        assert!(!s.set_json("fresh", &huge));
267        assert_eq!(s.get_json("fresh"), None);
268    }
269
270    #[test]
271    fn cap_boundary_is_exact_and_inclusive() {
272        let mut s = Storage::default();
273        // {"k":"xxx...x"} serializes to len(x-run) + 8 bytes of scaffolding,
274        // so this lands on exactly MAX_BYTES: allowed.
275        let exact = format!("\"{}\"", "x".repeat(MAX_BYTES - 8));
276        assert!(s.set_json("k", &exact));
277        // One byte more is rejected, and the exact-cap value survives.
278        let over = format!("\"{}\"", "x".repeat(MAX_BYTES - 7));
279        assert!(!s.set_json("k", &over));
280        assert_eq!(s.get_json("k").as_deref(), Some(exact.as_str()));
281    }
282
283    #[test]
284    fn saves_and_reloads_from_disk() {
285        let path = temp_file("roundtrip");
286        let _ = std::fs::remove_file(&path);
287        {
288            let mut s = Storage::at_path(path.clone());
289            assert!(s.set_json("score", "42"));
290            // Dropped here: saved.
291        }
292        let s = Storage::at_path(path.clone());
293        assert_eq!(s.get_json("score").as_deref(), Some("42"));
294        std::fs::remove_file(&path).unwrap();
295    }
296
297    #[test]
298    fn clean_store_does_not_touch_disk() {
299        let path = temp_file("clean");
300        let _ = std::fs::remove_file(&path);
301        {
302            let _s = Storage::at_path(path.clone());
303        }
304        assert!(!path.exists(), "nothing was set, nothing is written");
305    }
306
307    #[test]
308    fn corrupt_or_wrong_version_file_starts_empty() {
309        let path = temp_file("corrupt");
310        std::fs::write(&path, "not json at all").unwrap();
311        let s = Storage::at_path(path.clone());
312        assert_eq!(s.get_json("anything"), None);
313        std::fs::write(&path, r#"{"version": 999, "data": {"k": 1}}"#).unwrap();
314        let s = Storage::at_path(path.clone());
315        assert_eq!(s.get_json("k"), None);
316        std::fs::remove_file(&path).unwrap();
317    }
318
319    #[test]
320    fn over_cap_file_starts_empty_but_the_cap_measures_compact_form() {
321        let path = temp_file("overcap");
322        // A hand-grown file whose *data* exceeds the cap starts empty, like
323        // a corrupt one.
324        let huge = format!(
325            r#"{{"version": 1, "data": {{"k": "{}"}}}}"#,
326            "x".repeat(MAX_BYTES)
327        );
328        std::fs::write(&path, huge).unwrap();
329        let s = Storage::at_path(path.clone());
330        assert_eq!(s.get_json("k"), None);
331        drop(s); // Nothing was set: the oversized file is left alone.
332
333        // But the measure is the compact serialization, not the on-disk
334        // size: a store at exactly the cap saves as pretty JSON *larger*
335        // than the cap on disk, and must still load.
336        let exact = format!("\"{}\"", "x".repeat(MAX_BYTES - 8));
337        {
338            let mut s = Storage::at_path(path.clone());
339            assert!(s.set_json("k", &exact));
340        }
341        assert!(std::fs::metadata(&path).unwrap().len() > MAX_BYTES as u64);
342        let s = Storage::at_path(path.clone());
343        assert_eq!(s.get_json("k").as_deref(), Some(exact.as_str()));
344        std::fs::remove_file(&path).unwrap();
345    }
346
347    #[test]
348    fn for_cart_in_roots_the_save_under_the_given_dir() {
349        let root = std::env::temp_dir().join(format!("pixel8_storage_root_{}", std::process::id()));
350        let _ = std::fs::remove_dir_all(&root);
351        {
352            let mut s = Storage::for_cart_in(&root, "My Cool Game!");
353            assert!(s.set_json("k", "1"));
354        }
355        let expected = root.join("my-cool-game.json");
356        assert!(expected.exists(), "save lands under the injected root");
357        let s = Storage::for_cart_in(&root, "My Cool Game!");
358        assert_eq!(s.get_json("k").as_deref(), Some("1"));
359        std::fs::remove_dir_all(&root).unwrap();
360    }
361
362    #[test]
363    fn sanitize_names_for_files() {
364        assert_eq!(sanitize_name("My Cool Game!"), "my-cool-game");
365        assert_eq!(sanitize_name("platformer"), "platformer");
366        assert_eq!(sanitize_name("snake_2"), "snake_2");
367        assert_eq!(sanitize_name("  ...  "), "untitled");
368        assert_eq!(sanitize_name(""), "untitled");
369    }
370}