Skip to main content

yog_storage/
lib.rs

1//! Scoped, typed, auto-flushing persistent key-value storage for Yog mods.
2//!
3//! # Quick start
4//! ```no_run
5//! use yog_storage::{Storage, Value};
6//!
7//! // Global store — one file per mod
8//! let mut store = Storage::open("/path/to/world", "mymod");
9//! store.set("motd", "Hello!");
10//! store.set("spawn_x", 0i64);
11//!
12//! // Per-player store — one file per UUID
13//! let mut ps = Storage::open_player("/path/to/world", "mymod", "player-uuid");
14//! ps.set("coins", 100i64);
15//! let coins = ps.get_int("coins").unwrap_or(0);
16//!
17//! // Auto-flushed on drop.  Call flush() explicitly for earlier persistence.
18//! ```
19//!
20//! # Where
21//! Every store lives under a `root`: the world's directory
22//! (`game::world_dir`) for what belongs to the world being played — its
23//! blocks' state, its players' progress, its dimensions — or the game's
24//! (`game::dir`) for what belongs to the install, whatever world
25//! (settings).
26//!
27//! # File layout
28//! ```text
29//! <root>/yog-data/<mod_id>/global.ydata
30//! <root>/yog-data/<mod_id>/player/<uuid>.ydata
31//! <root>/yog-data/<mod_id>/world/<dim_safe>.ydata
32//! <root>/yog-data/<mod_id>/entity/<uuid>.ydata
33//! <root>/yog-data/<mod_id>/chunk/<dim_safe>_<cx>_<cz>.ydata
34//! ```
35//!
36//! # File format
37//! Binary Yog Data: one map, key → value. Writes are atomic (to a
38//! temporary file, then renamed over the store) so a crash mid-save leaves
39//! the old data intact.
40
41use std::collections::BTreeMap;
42use std::io::{self, Write};
43use std::path::{Path, PathBuf};
44
45// ── StorageScope ──────────────────────────────────────────────────────────────
46
47/// Determines which backing file a [`Storage`] uses.
48#[derive(Debug, Clone, Copy)]
49pub enum StorageScope<'a> {
50    /// One store shared across the entire server (the default).
51    Global,
52    /// Per-player store, keyed by UUID string.
53    Player(&'a str),
54    /// Per-dimension store, keyed by dimension id (e.g. `"minecraft:overworld"`).
55    World(&'a str),
56    /// Per-entity store, keyed by UUID string.
57    Entity(&'a str),
58    /// Per-chunk store, keyed by dimension + chunk coordinates.
59    Chunk(&'a str, i32, i32),
60}
61
62// ── Value ─────────────────────────────────────────────────────────────────────
63
64/// A typed storage value.
65///
66/// [`From`] is implemented for `String`, `&str`, `i64`, `i32`, `u32`, `u64`,
67/// `usize`, `f64`, `f32`, `bool`, and `Vec<u8>`, so `store.set("k", 42i64)`
68/// works without an explicit constructor.
69#[derive(Debug, Clone, PartialEq)]
70pub enum Value {
71    Str(String),
72    Int(i64),
73    Float(f64),
74    Bool(bool),
75    /// Raw byte sequence.
76    Bytes(Vec<u8>),
77}
78
79impl Value {
80    /// As Yog Data, how it's stored.
81    fn to_data(&self) -> yog_data::Value {
82        match self {
83            Value::Str(s) => yog_data::Value::Str(s.clone()),
84            Value::Int(n) => yog_data::Value::Int(*n),
85            Value::Float(f) => yog_data::Value::Float(*f),
86            Value::Bool(b) => yog_data::Value::Bool(*b),
87            Value::Bytes(b) => yog_data::Value::Bytes(b.clone()),
88        }
89    }
90
91    /// Read back from Yog Data; `None` for what a store never holds.
92    fn from_data(v: &yog_data::Value) -> Option<Value> {
93        Some(match v {
94            yog_data::Value::Str(s) => Value::Str(s.clone()),
95            yog_data::Value::Int(n) => Value::Int(*n),
96            yog_data::Value::Float(f) => Value::Float(*f),
97            yog_data::Value::Bool(b) => Value::Bool(*b),
98            yog_data::Value::Bytes(b) => Value::Bytes(b.clone()),
99            _ => return None,
100        })
101    }
102
103    pub fn as_str(&self) -> Option<&str> {
104        if let Value::Str(s) = self {
105            Some(s)
106        } else {
107            None
108        }
109    }
110
111    /// Returns the integer value.  A `Float` is truncated to `i64`.
112    pub fn as_int(&self) -> Option<i64> {
113        match self {
114            Value::Int(n) => Some(*n),
115            Value::Float(f) => Some(*f as i64),
116            _ => None,
117        }
118    }
119
120    /// Returns the float value.  An `Int` is widened to `f64`.
121    pub fn as_float(&self) -> Option<f64> {
122        match self {
123            Value::Float(f) => Some(*f),
124            Value::Int(n) => Some(*n as f64),
125            _ => None,
126        }
127    }
128
129    pub fn as_bool(&self) -> Option<bool> {
130        if let Value::Bool(b) = self {
131            Some(*b)
132        } else {
133            None
134        }
135    }
136
137    pub fn as_bytes(&self) -> Option<&[u8]> {
138        if let Value::Bytes(b) = self {
139            Some(b)
140        } else {
141            None
142        }
143    }
144}
145
146impl From<String> for Value {
147    fn from(s: String) -> Self {
148        Value::Str(s)
149    }
150}
151impl From<&str> for Value {
152    fn from(s: &str) -> Self {
153        Value::Str(s.to_string())
154    }
155}
156impl From<i64> for Value {
157    fn from(n: i64) -> Self {
158        Value::Int(n)
159    }
160}
161impl From<i32> for Value {
162    fn from(n: i32) -> Self {
163        Value::Int(n as i64)
164    }
165}
166impl From<u32> for Value {
167    fn from(n: u32) -> Self {
168        Value::Int(n as i64)
169    }
170}
171impl From<u64> for Value {
172    fn from(n: u64) -> Self {
173        Value::Int(n as i64)
174    }
175}
176impl From<usize> for Value {
177    fn from(n: usize) -> Self {
178        Value::Int(n as i64)
179    }
180}
181impl From<f64> for Value {
182    fn from(f: f64) -> Self {
183        Value::Float(f)
184    }
185}
186impl From<f32> for Value {
187    fn from(f: f32) -> Self {
188        Value::Float(f as f64)
189    }
190}
191impl From<bool> for Value {
192    fn from(b: bool) -> Self {
193        Value::Bool(b)
194    }
195}
196impl From<Vec<u8>> for Value {
197    fn from(b: Vec<u8>) -> Self {
198        Value::Bytes(b)
199    }
200}
201
202// ── Storage ───────────────────────────────────────────────────────────────────
203
204/// Typed, scoped persistent key-value store.
205///
206/// Mutations are buffered in memory.  The store is auto-flushed on [`Drop`]
207/// if any key was mutated.  Call [`flush`](Self::flush) explicitly when you
208/// need the data on disk immediately (e.g. end of a critical event handler).
209pub struct Storage {
210    path: PathBuf,
211    data: BTreeMap<String, Value>,
212    dirty: bool,
213}
214
215impl Storage {
216    // ── constructors ─────────────────────────────────────────────────────────
217
218    /// Open the **global** store for `mod_id` under `root` (see [the crate docs](crate)).
219    pub fn open(root: &str, mod_id: &str) -> Self {
220        Self::from_path(scope_path(root, mod_id, StorageScope::Global))
221    }
222
223    /// Open a store with an explicit [`StorageScope`].
224    pub fn open_scoped(root: &str, mod_id: &str, scope: StorageScope<'_>) -> Self {
225        Self::from_path(scope_path(root, mod_id, scope))
226    }
227
228    /// Open a **per-player** store (keyed by player UUID).
229    pub fn open_player(root: &str, mod_id: &str, player_uuid: &str) -> Self {
230        Self::from_path(scope_path(root, mod_id, StorageScope::Player(player_uuid)))
231    }
232
233    /// Open a **per-dimension** store.
234    pub fn open_world(root: &str, mod_id: &str, dimension: &str) -> Self {
235        Self::from_path(scope_path(root, mod_id, StorageScope::World(dimension)))
236    }
237
238    /// Open a **per-entity** store (keyed by entity UUID).
239    pub fn open_entity(root: &str, mod_id: &str, entity_uuid: &str) -> Self {
240        Self::from_path(scope_path(root, mod_id, StorageScope::Entity(entity_uuid)))
241    }
242
243    /// Open a **per-chunk** store.
244    pub fn open_chunk(root: &str, mod_id: &str, dimension: &str, cx: i32, cz: i32) -> Self {
245        Self::from_path(scope_path(
246            root,
247            mod_id,
248            StorageScope::Chunk(dimension, cx, cz),
249        ))
250    }
251
252    fn from_path(path: PathBuf) -> Self {
253        let data = load_file(&path);
254        Self {
255            path,
256            data,
257            dirty: false,
258        }
259    }
260
261    // ── read ─────────────────────────────────────────────────────────────────
262
263    pub fn get(&self, key: &str) -> Option<&Value> {
264        self.data.get(key)
265    }
266
267    pub fn get_str(&self, key: &str) -> Option<&str> {
268        self.data.get(key)?.as_str()
269    }
270
271    pub fn get_int(&self, key: &str) -> Option<i64> {
272        self.data.get(key)?.as_int()
273    }
274
275    pub fn get_float(&self, key: &str) -> Option<f64> {
276        self.data.get(key)?.as_float()
277    }
278
279    pub fn get_bool(&self, key: &str) -> Option<bool> {
280        self.data.get(key)?.as_bool()
281    }
282
283    pub fn get_bytes(&self, key: &str) -> Option<&[u8]> {
284        self.data.get(key)?.as_bytes()
285    }
286
287    pub fn contains(&self, key: &str) -> bool {
288        self.data.contains_key(key)
289    }
290
291    // ── write ─────────────────────────────────────────────────────────────────
292
293    /// Insert or replace a value.
294    ///
295    /// Accepts any type that implements `Into<Value>` — `i64`, `f64`, `bool`,
296    /// `&str`, `String`, `Vec<u8>`, etc.
297    pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) {
298        self.data.insert(key.into(), value.into());
299        self.dirty = true;
300    }
301
302    pub fn remove(&mut self, key: &str) -> Option<Value> {
303        let v = self.data.remove(key);
304        if v.is_some() {
305            self.dirty = true;
306        }
307        v
308    }
309
310    pub fn clear(&mut self) {
311        if !self.data.is_empty() {
312            self.data.clear();
313            self.dirty = true;
314        }
315    }
316
317    // ── meta ─────────────────────────────────────────────────────────────────
318
319    pub fn len(&self) -> usize {
320        self.data.len()
321    }
322    pub fn is_empty(&self) -> bool {
323        self.data.is_empty()
324    }
325    pub fn is_dirty(&self) -> bool {
326        self.dirty
327    }
328
329    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
330        self.data.iter().map(|(k, v)| (k.as_str(), v))
331    }
332
333    /// Absolute path of the backing file.
334    pub fn path(&self) -> &Path {
335        &self.path
336    }
337
338    // ── persistence ──────────────────────────────────────────────────────────
339
340    /// Atomically write all data to disk.
341    ///
342    /// Writes to a temporary file beside it first, then renames that over it.
343    /// A crash mid-write leaves the previous on-disk state intact.
344    pub fn flush(&mut self) -> io::Result<()> {
345        if let Some(parent) = self.path.parent() {
346            std::fs::create_dir_all(parent)?;
347        }
348        let tmp = self.path.with_extension(TMP_EXT);
349        let map: yog_data::Map = self
350            .data
351            .iter()
352            .map(|(k, v)| (k.as_str(), v.to_data()))
353            .collect();
354        {
355            let mut f = std::fs::File::create(&tmp)?;
356            f.write_all(&yog_data::binary::encode(&map.into()))?;
357            f.flush()?;
358        }
359        std::fs::rename(&tmp, &self.path)?;
360        self.dirty = false;
361        Ok(())
362    }
363}
364
365impl Drop for Storage {
366    fn drop(&mut self) {
367        if self.dirty {
368            let _ = self.flush();
369        }
370    }
371}
372
373// ── internal helpers ──────────────────────────────────────────────────────────
374
375/// Where a mod's stores go under the save, and how each scope's is named.
376const DATA_DIR: &str = "yog-data";
377const STORE_EXT: &str = yog_ids::Format::Data.ext();
378/// What a store is written to before it's renamed over the old one.
379const TMP_EXT: &str = yog_ids::concatcp!(yog_ids::Format::Data.ext(), yog_ids::sep::EXT, "tmp");
380const GLOBAL: &str = "global";
381const PLAYER_DIR: &str = "player";
382const WORLD_DIR: &str = "world";
383const ENTITY_DIR: &str = "entity";
384const CHUNK_DIR: &str = "chunk";
385
386fn scope_path(root: &str, mod_id: &str, scope: StorageScope<'_>) -> PathBuf {
387    let safe_mod = file_safe(mod_id);
388    let base = Path::new(root).join(DATA_DIR).join(safe_mod);
389    let (dir, name) = match scope {
390        StorageScope::Global => (None, GLOBAL.to_string()),
391        StorageScope::Player(uuid) => (Some(PLAYER_DIR), uuid.to_string()),
392        StorageScope::World(dim) => (Some(WORLD_DIR), dim_safe(dim)),
393        StorageScope::Entity(uuid) => (Some(ENTITY_DIR), uuid.to_string()),
394        StorageScope::Chunk(dim, x, z) => (Some(CHUNK_DIR), format!("{}_{x}_{z}", dim_safe(dim))),
395    };
396    let file = format!("{name}.{STORE_EXT}");
397    match dir {
398        Some(dir) => base.join(dir).join(file),
399        None => base.join(file),
400    }
401}
402
403/// What a name's separators become in a file name.
404const SAFE_SEP: &str = "_";
405
406/// `name` (`ns:path/x`) as one file name: its separators replaced.
407fn file_safe(name: &str) -> String {
408    name.replace(yog_ids::sep::NAMESPACE, SAFE_SEP)
409        .replace(yog_ids::sep::PATH, SAFE_SEP)
410}
411
412fn dim_safe(dim: &str) -> String {
413    file_safe(dim)
414}
415
416fn load_file(path: &Path) -> BTreeMap<String, Value> {
417    let Ok(bytes) = std::fs::read(path) else {
418        return BTreeMap::new();
419    };
420    match yog_data::binary::decode(&bytes) {
421        Ok(yog_data::Value::Map(m)) => m
422            .iter()
423            .filter_map(|(k, v)| Value::from_data(v).map(|v| (k.to_string(), v)))
424            .collect(),
425        _ => BTreeMap::new(),
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn game_dir(name: &str) -> String {
434        let d =
435            std::env::temp_dir().join(format!("yog-storage-test-{}-{name}", std::process::id()));
436        let _ = std::fs::remove_dir_all(&d);
437        d.to_string_lossy().into_owned()
438    }
439
440    #[test]
441    fn every_value_comes_back_after_a_flush() {
442        let dir = game_dir("roundtrip");
443        let odd = "tab\there\nnew line \\ backslash";
444        {
445            let mut s = Storage::open(&dir, "mymod");
446            s.set("text", odd);
447            s.set("int", -42i64);
448            s.set("float", 2.5f64);
449            s.set("yes", true);
450            s.set("bytes", vec![0u8, 9, 10, 255]);
451            assert!(s.is_dirty());
452            s.flush().unwrap();
453            assert!(!s.is_dirty());
454        }
455        let s = Storage::open(&dir, "mymod");
456        assert_eq!(s.get_str("text"), Some(odd), "tabs and newlines survive");
457        assert_eq!(s.get_int("int"), Some(-42));
458        assert_eq!(s.get_float("float"), Some(2.5));
459        assert_eq!(s.get_bool("yes"), Some(true));
460        assert_eq!(s.get_bytes("bytes"), Some(&[0u8, 9, 10, 255][..]));
461        assert_eq!(s.len(), 5);
462        let _ = std::fs::remove_dir_all(&dir);
463    }
464
465    #[test]
466    fn dropping_saves_and_scopes_keep_apart() {
467        let dir = game_dir("scopes");
468        {
469            let mut p = Storage::open_player(&dir, "mymod", "uuid-1");
470            p.set("coins", 7i64);
471            let mut c = Storage::open_chunk(&dir, "mymod", "minecraft:overworld", -1, 2);
472            c.set("ore", "copper");
473        }
474        assert_eq!(
475            Storage::open_player(&dir, "mymod", "uuid-1").get_int("coins"),
476            Some(7),
477            "saved on drop"
478        );
479        assert_eq!(
480            Storage::open_player(&dir, "mymod", "uuid-2").get_int("coins"),
481            None
482        );
483        assert_eq!(
484            Storage::open_chunk(&dir, "mymod", "minecraft:overworld", -1, 2).get_str("ore"),
485            Some("copper")
486        );
487        assert!(
488            Storage::open(&dir, "mymod").is_empty(),
489            "the global store is its own"
490        );
491        let _ = std::fs::remove_dir_all(&dir);
492    }
493
494    #[test]
495    fn values_convert() {
496        assert_eq!(Value::from(3u32).as_int(), Some(3));
497        assert_eq!(Value::from("x").as_str(), Some("x"));
498        assert_eq!(Value::from(true).as_int(), None);
499    }
500}