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//! (`Server::world_dir`) for what belongs to the world being played — its
23//! blocks' state, its players' progress, its dimensions — or the game's
24//! (`Server::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.kv
30//! <root>/yog-data/<mod_id>/player/<uuid>.kv
31//! <root>/yog-data/<mod_id>/world/<dim_safe>.kv
32//! <root>/yog-data/<mod_id>/entity/<uuid>.kv
33//! <root>/yog-data/<mod_id>/chunk/<dim_safe>_<cx>_<cz>.kv
34//! ```
35//!
36//! # File format
37//! Plain text, one entry per line: `key\ttype\tvalue`.  Human-readable and
38//! diff-friendly.  Lines starting with `#` are comments.  Writes are atomic
39//! (write to `.kv.tmp`, then rename) so a crash mid-save leaves old data intact.
40
41use std::collections::BTreeMap;
42use std::io::{self, BufRead, 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; stored as lowercase hex on disk.
76    Bytes(Vec<u8>),
77}
78
79impl Value {
80    pub fn as_str(&self) -> Option<&str> {
81        if let Value::Str(s) = self {
82            Some(s)
83        } else {
84            None
85        }
86    }
87
88    /// Returns the integer value.  A `Float` is truncated to `i64`.
89    pub fn as_int(&self) -> Option<i64> {
90        match self {
91            Value::Int(n) => Some(*n),
92            Value::Float(f) => Some(*f as i64),
93            _ => None,
94        }
95    }
96
97    /// Returns the float value.  An `Int` is widened to `f64`.
98    pub fn as_float(&self) -> Option<f64> {
99        match self {
100            Value::Float(f) => Some(*f),
101            Value::Int(n) => Some(*n as f64),
102            _ => None,
103        }
104    }
105
106    pub fn as_bool(&self) -> Option<bool> {
107        if let Value::Bool(b) = self {
108            Some(*b)
109        } else {
110            None
111        }
112    }
113
114    pub fn as_bytes(&self) -> Option<&[u8]> {
115        if let Value::Bytes(b) = self {
116            Some(b)
117        } else {
118            None
119        }
120    }
121}
122
123impl From<String> for Value {
124    fn from(s: String) -> Self {
125        Value::Str(s)
126    }
127}
128impl From<&str> for Value {
129    fn from(s: &str) -> Self {
130        Value::Str(s.to_string())
131    }
132}
133impl From<i64> for Value {
134    fn from(n: i64) -> Self {
135        Value::Int(n)
136    }
137}
138impl From<i32> for Value {
139    fn from(n: i32) -> Self {
140        Value::Int(n as i64)
141    }
142}
143impl From<u32> for Value {
144    fn from(n: u32) -> Self {
145        Value::Int(n as i64)
146    }
147}
148impl From<u64> for Value {
149    fn from(n: u64) -> Self {
150        Value::Int(n as i64)
151    }
152}
153impl From<usize> for Value {
154    fn from(n: usize) -> Self {
155        Value::Int(n as i64)
156    }
157}
158impl From<f64> for Value {
159    fn from(f: f64) -> Self {
160        Value::Float(f)
161    }
162}
163impl From<f32> for Value {
164    fn from(f: f32) -> Self {
165        Value::Float(f as f64)
166    }
167}
168impl From<bool> for Value {
169    fn from(b: bool) -> Self {
170        Value::Bool(b)
171    }
172}
173impl From<Vec<u8>> for Value {
174    fn from(b: Vec<u8>) -> Self {
175        Value::Bytes(b)
176    }
177}
178
179// ── Storage ───────────────────────────────────────────────────────────────────
180
181/// Typed, scoped persistent key-value store.
182///
183/// Mutations are buffered in memory.  The store is auto-flushed on [`Drop`]
184/// if any key was mutated.  Call [`flush`](Self::flush) explicitly when you
185/// need the data on disk immediately (e.g. end of a critical event handler).
186pub struct Storage {
187    path: PathBuf,
188    data: BTreeMap<String, Value>,
189    dirty: bool,
190}
191
192impl Storage {
193    // ── constructors ─────────────────────────────────────────────────────────
194
195    /// Open the **global** store for `mod_id` under `root` (see [the crate docs](crate)).
196    pub fn open(root: &str, mod_id: &str) -> Self {
197        Self::from_path(scope_path(root, mod_id, StorageScope::Global))
198    }
199
200    /// Open a store with an explicit [`StorageScope`].
201    pub fn open_scoped(root: &str, mod_id: &str, scope: StorageScope<'_>) -> Self {
202        Self::from_path(scope_path(root, mod_id, scope))
203    }
204
205    /// Open a **per-player** store (keyed by player UUID).
206    pub fn open_player(root: &str, mod_id: &str, player_uuid: &str) -> Self {
207        Self::from_path(scope_path(
208            root,
209            mod_id,
210            StorageScope::Player(player_uuid),
211        ))
212    }
213
214    /// Open a **per-dimension** store.
215    pub fn open_world(root: &str, mod_id: &str, dimension: &str) -> Self {
216        Self::from_path(scope_path(root, mod_id, StorageScope::World(dimension)))
217    }
218
219    /// Open a **per-entity** store (keyed by entity UUID).
220    pub fn open_entity(root: &str, mod_id: &str, entity_uuid: &str) -> Self {
221        Self::from_path(scope_path(
222            root,
223            mod_id,
224            StorageScope::Entity(entity_uuid),
225        ))
226    }
227
228    /// Open a **per-chunk** store.
229    pub fn open_chunk(root: &str, mod_id: &str, dimension: &str, cx: i32, cz: i32) -> Self {
230        Self::from_path(scope_path(
231            root,
232            mod_id,
233            StorageScope::Chunk(dimension, cx, cz),
234        ))
235    }
236
237    fn from_path(path: PathBuf) -> Self {
238        let data = load_file(&path);
239        Self {
240            path,
241            data,
242            dirty: false,
243        }
244    }
245
246    // ── read ─────────────────────────────────────────────────────────────────
247
248    pub fn get(&self, key: &str) -> Option<&Value> {
249        self.data.get(key)
250    }
251
252    pub fn get_str(&self, key: &str) -> Option<&str> {
253        self.data.get(key)?.as_str()
254    }
255
256    pub fn get_int(&self, key: &str) -> Option<i64> {
257        self.data.get(key)?.as_int()
258    }
259
260    pub fn get_float(&self, key: &str) -> Option<f64> {
261        self.data.get(key)?.as_float()
262    }
263
264    pub fn get_bool(&self, key: &str) -> Option<bool> {
265        self.data.get(key)?.as_bool()
266    }
267
268    pub fn get_bytes(&self, key: &str) -> Option<&[u8]> {
269        self.data.get(key)?.as_bytes()
270    }
271
272    pub fn contains(&self, key: &str) -> bool {
273        self.data.contains_key(key)
274    }
275
276    // ── write ─────────────────────────────────────────────────────────────────
277
278    /// Insert or replace a value.
279    ///
280    /// Accepts any type that implements `Into<Value>` — `i64`, `f64`, `bool`,
281    /// `&str`, `String`, `Vec<u8>`, etc.
282    pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) {
283        self.data.insert(key.into(), value.into());
284        self.dirty = true;
285    }
286
287    pub fn remove(&mut self, key: &str) -> Option<Value> {
288        let v = self.data.remove(key);
289        if v.is_some() {
290            self.dirty = true;
291        }
292        v
293    }
294
295    pub fn clear(&mut self) {
296        if !self.data.is_empty() {
297            self.data.clear();
298            self.dirty = true;
299        }
300    }
301
302    // ── meta ─────────────────────────────────────────────────────────────────
303
304    pub fn len(&self) -> usize {
305        self.data.len()
306    }
307    pub fn is_empty(&self) -> bool {
308        self.data.is_empty()
309    }
310    pub fn is_dirty(&self) -> bool {
311        self.dirty
312    }
313
314    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
315        self.data.iter().map(|(k, v)| (k.as_str(), v))
316    }
317
318    /// Absolute path of the backing file.
319    pub fn path(&self) -> &Path {
320        &self.path
321    }
322
323    // ── persistence ──────────────────────────────────────────────────────────
324
325    /// Atomically write all data to disk.
326    ///
327    /// Writes to `<path>.kv.tmp` first, then renames it over `<path>`.
328    /// A crash mid-write leaves the previous on-disk state intact.
329    pub fn flush(&mut self) -> io::Result<()> {
330        if let Some(parent) = self.path.parent() {
331            std::fs::create_dir_all(parent)?;
332        }
333        let tmp = self.path.with_extension("kv.tmp");
334        {
335            let mut f = std::fs::File::create(&tmp)?;
336            writeln!(f, "# yog-storage v2")?;
337            for (k, v) in &self.data {
338                let (typ, enc) = encode_value(v);
339                writeln!(f, "{}\t{}\t{}", str_escape(k), typ, enc)?;
340            }
341            f.flush()?;
342        }
343        std::fs::rename(&tmp, &self.path)?;
344        self.dirty = false;
345        Ok(())
346    }
347}
348
349impl Drop for Storage {
350    fn drop(&mut self) {
351        if self.dirty {
352            let _ = self.flush();
353        }
354    }
355}
356
357// ── internal helpers ──────────────────────────────────────────────────────────
358
359fn scope_path(root: &str, mod_id: &str, scope: StorageScope<'_>) -> PathBuf {
360    let safe_mod = mod_id.replace([':', '/'], "_");
361    let base = Path::new(root).join("yog-data").join(safe_mod);
362    match scope {
363        StorageScope::Global => base.join("global.kv"),
364        StorageScope::Player(uuid) => base.join("player").join(format!("{uuid}.kv")),
365        StorageScope::World(dim) => base.join("world").join(format!("{}.kv", dim_safe(dim))),
366        StorageScope::Entity(uuid) => base.join("entity").join(format!("{uuid}.kv")),
367        StorageScope::Chunk(dim, x, z) => base
368            .join("chunk")
369            .join(format!("{}_{x}_{z}.kv", dim_safe(dim))),
370    }
371}
372
373fn dim_safe(dim: &str) -> String {
374    dim.replace([':', '/'], "_")
375}
376
377fn load_file(path: &Path) -> BTreeMap<String, Value> {
378    let file = match std::fs::File::open(path) {
379        Ok(f) => f,
380        Err(_) => return BTreeMap::new(),
381    };
382    let mut map = BTreeMap::new();
383    for line in io::BufReader::new(file).lines() {
384        let Ok(line) = line else { continue };
385        if line.is_empty() || line.starts_with('#') {
386            continue;
387        }
388        let mut cols = line.splitn(3, '\t');
389        let (Some(raw_k), Some(typ), Some(raw_v)) = (cols.next(), cols.next(), cols.next()) else {
390            continue;
391        };
392        if let Some(v) = parse_value(typ, raw_v) {
393            map.insert(str_unescape(raw_k), v);
394        }
395    }
396    map
397}
398
399fn parse_value(typ: &str, raw: &str) -> Option<Value> {
400    match typ {
401        "s" => Some(Value::Str(str_unescape(raw))),
402        "i" => raw.parse::<i64>().ok().map(Value::Int),
403        "f" => raw.parse::<f64>().ok().map(Value::Float),
404        "b" => Some(Value::Bool(raw == "1")),
405        "x" => Some(Value::Bytes(hex_decode(raw))),
406        _ => None,
407    }
408}
409
410fn encode_value(v: &Value) -> (&'static str, String) {
411    match v {
412        Value::Str(s) => ("s", str_escape(s)),
413        Value::Int(n) => ("i", n.to_string()),
414        Value::Float(f) => ("f", f.to_string()),
415        Value::Bool(b) => ("b", if *b { "1" } else { "0" }.to_string()),
416        Value::Bytes(b) => ("x", hex_encode(b)),
417    }
418}
419
420fn str_escape(s: &str) -> String {
421    let mut out = String::with_capacity(s.len() + 4);
422    for c in s.chars() {
423        match c {
424            '\\' => out.push_str(r"\\"),
425            '\t' => out.push_str(r"\t"),
426            '\n' => out.push_str(r"\n"),
427            '\r' => out.push_str(r"\r"),
428            c => out.push(c),
429        }
430    }
431    out
432}
433
434fn str_unescape(s: &str) -> String {
435    let mut out = String::with_capacity(s.len());
436    let mut chars = s.chars();
437    while let Some(c) = chars.next() {
438        if c == '\\' {
439            match chars.next() {
440                Some('\\') => out.push('\\'),
441                Some('t') => out.push('\t'),
442                Some('n') => out.push('\n'),
443                Some('r') => out.push('\r'),
444                Some(c) => {
445                    out.push('\\');
446                    out.push(c);
447                }
448                None => out.push('\\'),
449            }
450        } else {
451            out.push(c);
452        }
453    }
454    out
455}
456
457fn hex_encode(b: &[u8]) -> String {
458    use std::fmt::Write as FmtWrite;
459    b.iter()
460        .fold(String::with_capacity(b.len() * 2), |mut s, byte| {
461            let _ = write!(s, "{byte:02x}");
462            s
463        })
464}
465
466fn hex_decode(s: &str) -> Vec<u8> {
467    (0..s.len())
468        .step_by(2)
469        .filter_map(|i| s.get(i..i + 2))
470        .filter_map(|h| u8::from_str_radix(h, 16).ok())
471        .collect()
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    fn game_dir(name: &str) -> String {
479        let d = std::env::temp_dir().join(format!("yog-storage-test-{}-{name}", std::process::id()));
480        let _ = std::fs::remove_dir_all(&d);
481        d.to_string_lossy().into_owned()
482    }
483
484    #[test]
485    fn every_value_comes_back_after_a_flush() {
486        let dir = game_dir("roundtrip");
487        let odd = "tab\there\nnew line \\ backslash";
488        {
489            let mut s = Storage::open(&dir, "mymod");
490            s.set("text", odd);
491            s.set("int", -42i64);
492            s.set("float", 2.5f64);
493            s.set("yes", true);
494            s.set("bytes", vec![0u8, 9, 10, 255]);
495            assert!(s.is_dirty());
496            s.flush().unwrap();
497            assert!(!s.is_dirty());
498        }
499        let s = Storage::open(&dir, "mymod");
500        assert_eq!(s.get_str("text"), Some(odd), "tabs and newlines survive");
501        assert_eq!(s.get_int("int"), Some(-42));
502        assert_eq!(s.get_float("float"), Some(2.5));
503        assert_eq!(s.get_bool("yes"), Some(true));
504        assert_eq!(s.get_bytes("bytes"), Some(&[0u8, 9, 10, 255][..]));
505        assert_eq!(s.len(), 5);
506        let _ = std::fs::remove_dir_all(&dir);
507    }
508
509    #[test]
510    fn dropping_saves_and_scopes_keep_apart() {
511        let dir = game_dir("scopes");
512        {
513            let mut p = Storage::open_player(&dir, "mymod", "uuid-1");
514            p.set("coins", 7i64);
515            let mut c = Storage::open_chunk(&dir, "mymod", "minecraft:overworld", -1, 2);
516            c.set("ore", "copper");
517        }
518        assert_eq!(Storage::open_player(&dir, "mymod", "uuid-1").get_int("coins"), Some(7), "saved on drop");
519        assert_eq!(Storage::open_player(&dir, "mymod", "uuid-2").get_int("coins"), None);
520        assert_eq!(Storage::open_chunk(&dir, "mymod", "minecraft:overworld", -1, 2).get_str("ore"), Some("copper"));
521        assert!(Storage::open(&dir, "mymod").is_empty(), "the global store is its own");
522        let _ = std::fs::remove_dir_all(&dir);
523    }
524
525    #[test]
526    fn values_convert() {
527        assert_eq!(Value::from(3u32).as_int(), Some(3));
528        assert_eq!(Value::from("x").as_str(), Some("x"));
529        assert_eq!(Value::from(true).as_int(), None);
530    }
531}