1use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::path::PathBuf;
14
15pub const MAX_BYTES: usize = crate::cart::MEMORY_CAP;
18
19#[derive(Default)]
30pub struct Storage {
31 map: serde_json::Map<String, Value>,
32 path: Option<PathBuf>,
34 dirty: bool,
35}
36
37impl Storage {
38 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 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 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 .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 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 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 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 pub fn clear(&mut self) {
118 if !self.map.is_empty() {
119 self.map.clear();
120 self.dirty = true;
121 }
122 }
123
124 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#[derive(Serialize, Deserialize)]
164struct StorageFile<D> {
165 version: u32,
166 data: D,
167}
168
169fn 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
180fn 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
196fn 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 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 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 let exact = format!("\"{}\"", "x".repeat(MAX_BYTES - 8));
276 assert!(s.set_json("k", &exact));
277 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 }
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 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); 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}