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