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 { Some(s) } else { None }
75    }
76
77    /// Returns the integer value.  A `Float` is truncated to `i64`.
78    pub fn as_int(&self) -> Option<i64> {
79        match self {
80            Value::Int(n)   => Some(*n),
81            Value::Float(f) => Some(*f as i64),
82            _ => None,
83        }
84    }
85
86    /// Returns the float value.  An `Int` is widened to `f64`.
87    pub fn as_float(&self) -> Option<f64> {
88        match self {
89            Value::Float(f) => Some(*f),
90            Value::Int(n)   => Some(*n as f64),
91            _ => None,
92        }
93    }
94
95    pub fn as_bool(&self) -> Option<bool> {
96        if let Value::Bool(b) = self { Some(*b) } else { None }
97    }
98
99    pub fn as_bytes(&self) -> Option<&[u8]> {
100        if let Value::Bytes(b) = self { Some(b) } else { None }
101    }
102}
103
104impl From<String>  for Value { fn from(s: String)  -> Self { Value::Str(s) } }
105impl From<&str>    for Value { fn from(s: &str)    -> Self { Value::Str(s.to_string()) } }
106impl From<i64>     for Value { fn from(n: i64)     -> Self { Value::Int(n) } }
107impl From<i32>     for Value { fn from(n: i32)     -> Self { Value::Int(n as i64) } }
108impl From<u32>     for Value { fn from(n: u32)     -> Self { Value::Int(n as i64) } }
109impl From<u64>     for Value { fn from(n: u64)     -> Self { Value::Int(n as i64) } }
110impl From<usize>   for Value { fn from(n: usize)   -> Self { Value::Int(n as i64) } }
111impl From<f64>     for Value { fn from(f: f64)     -> Self { Value::Float(f) } }
112impl From<f32>     for Value { fn from(f: f32)     -> Self { Value::Float(f as f64) } }
113impl From<bool>    for Value { fn from(b: bool)    -> Self { Value::Bool(b) } }
114impl From<Vec<u8>> for Value { fn from(b: Vec<u8>) -> Self { Value::Bytes(b) } }
115
116// ── Storage ───────────────────────────────────────────────────────────────────
117
118/// Typed, scoped persistent key-value store.
119///
120/// Mutations are buffered in memory.  The store is auto-flushed on [`Drop`]
121/// if any key was mutated.  Call [`flush`](Self::flush) explicitly when you
122/// need the data on disk immediately (e.g. end of a critical event handler).
123pub struct Storage {
124    path: PathBuf,
125    data: BTreeMap<String, Value>,
126    dirty: bool,
127}
128
129impl Storage {
130    // ── constructors ─────────────────────────────────────────────────────────
131
132    /// Open the **global** store for `mod_id`.
133    pub fn open(game_dir: &str, mod_id: &str) -> Self {
134        Self::from_path(scope_path(game_dir, mod_id, StorageScope::Global))
135    }
136
137    /// Open a store with an explicit [`StorageScope`].
138    pub fn open_scoped(game_dir: &str, mod_id: &str, scope: StorageScope<'_>) -> Self {
139        Self::from_path(scope_path(game_dir, mod_id, scope))
140    }
141
142    /// Open a **per-player** store (keyed by player UUID).
143    pub fn open_player(game_dir: &str, mod_id: &str, player_uuid: &str) -> Self {
144        Self::from_path(scope_path(game_dir, mod_id, StorageScope::Player(player_uuid)))
145    }
146
147    /// Open a **per-dimension** store.
148    pub fn open_world(game_dir: &str, mod_id: &str, dimension: &str) -> Self {
149        Self::from_path(scope_path(game_dir, mod_id, StorageScope::World(dimension)))
150    }
151
152    /// Open a **per-entity** store (keyed by entity UUID).
153    pub fn open_entity(game_dir: &str, mod_id: &str, entity_uuid: &str) -> Self {
154        Self::from_path(scope_path(game_dir, mod_id, StorageScope::Entity(entity_uuid)))
155    }
156
157    /// Open a **per-chunk** store.
158    pub fn open_chunk(game_dir: &str, mod_id: &str, dimension: &str, cx: i32, cz: i32) -> Self {
159        Self::from_path(scope_path(game_dir, mod_id, StorageScope::Chunk(dimension, cx, cz)))
160    }
161
162    fn from_path(path: PathBuf) -> Self {
163        let data = load_file(&path);
164        Self { path, data, dirty: false }
165    }
166
167    // ── read ─────────────────────────────────────────────────────────────────
168
169    pub fn get(&self, key: &str) -> Option<&Value> {
170        self.data.get(key)
171    }
172
173    pub fn get_str(&self, key: &str) -> Option<&str> {
174        self.data.get(key)?.as_str()
175    }
176
177    pub fn get_int(&self, key: &str) -> Option<i64> {
178        self.data.get(key)?.as_int()
179    }
180
181    pub fn get_float(&self, key: &str) -> Option<f64> {
182        self.data.get(key)?.as_float()
183    }
184
185    pub fn get_bool(&self, key: &str) -> Option<bool> {
186        self.data.get(key)?.as_bool()
187    }
188
189    pub fn get_bytes(&self, key: &str) -> Option<&[u8]> {
190        self.data.get(key)?.as_bytes()
191    }
192
193    pub fn contains(&self, key: &str) -> bool {
194        self.data.contains_key(key)
195    }
196
197    // ── write ─────────────────────────────────────────────────────────────────
198
199    /// Insert or replace a value.
200    ///
201    /// Accepts any type that implements `Into<Value>` — `i64`, `f64`, `bool`,
202    /// `&str`, `String`, `Vec<u8>`, etc.
203    pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) {
204        self.data.insert(key.into(), value.into());
205        self.dirty = true;
206    }
207
208    pub fn remove(&mut self, key: &str) -> Option<Value> {
209        let v = self.data.remove(key);
210        if v.is_some() { self.dirty = true; }
211        v
212    }
213
214    pub fn clear(&mut self) {
215        if !self.data.is_empty() {
216            self.data.clear();
217            self.dirty = true;
218        }
219    }
220
221    // ── meta ─────────────────────────────────────────────────────────────────
222
223    pub fn len(&self) -> usize { self.data.len() }
224    pub fn is_empty(&self) -> bool { self.data.is_empty() }
225    pub fn is_dirty(&self) -> bool { self.dirty }
226
227    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
228        self.data.iter().map(|(k, v)| (k.as_str(), v))
229    }
230
231    /// Absolute path of the backing file.
232    pub fn path(&self) -> &Path { &self.path }
233
234    // ── persistence ──────────────────────────────────────────────────────────
235
236    /// Atomically write all data to disk.
237    ///
238    /// Writes to `<path>.kv.tmp` first, then renames it over `<path>`.
239    /// A crash mid-write leaves the previous on-disk state intact.
240    pub fn flush(&mut self) -> io::Result<()> {
241        if let Some(parent) = self.path.parent() {
242            std::fs::create_dir_all(parent)?;
243        }
244        let tmp = self.path.with_extension("kv.tmp");
245        {
246            let mut f = std::fs::File::create(&tmp)?;
247            writeln!(f, "# yog-storage v2")?;
248            for (k, v) in &self.data {
249                let (typ, enc) = encode_value(v);
250                writeln!(f, "{}\t{}\t{}", str_escape(k), typ, enc)?;
251            }
252            f.flush()?;
253        }
254        std::fs::rename(&tmp, &self.path)?;
255        self.dirty = false;
256        Ok(())
257    }
258}
259
260impl Drop for Storage {
261    fn drop(&mut self) {
262        if self.dirty {
263            let _ = self.flush();
264        }
265    }
266}
267
268// ── internal helpers ──────────────────────────────────────────────────────────
269
270fn scope_path(game_dir: &str, mod_id: &str, scope: StorageScope<'_>) -> PathBuf {
271    let safe_mod = mod_id.replace([':', '/'], "_");
272    let base = Path::new(game_dir).join("yog-data").join(safe_mod);
273    match scope {
274        StorageScope::Global          => base.join("global.kv"),
275        StorageScope::Player(uuid)    => base.join("player").join(format!("{uuid}.kv")),
276        StorageScope::World(dim)      => base.join("world").join(format!("{}.kv", dim_safe(dim))),
277        StorageScope::Entity(uuid)    => base.join("entity").join(format!("{uuid}.kv")),
278        StorageScope::Chunk(dim,x,z)  => {
279            base.join("chunk").join(format!("{}_{x}_{z}.kv", dim_safe(dim)))
280        }
281    }
282}
283
284fn dim_safe(dim: &str) -> String { dim.replace([':', '/'], "_") }
285
286fn load_file(path: &Path) -> BTreeMap<String, Value> {
287    let file = match std::fs::File::open(path) {
288        Ok(f) => f,
289        Err(_) => return BTreeMap::new(),
290    };
291    let mut map = BTreeMap::new();
292    for line in io::BufReader::new(file).lines() {
293        let Ok(line) = line else { continue };
294        if line.is_empty() || line.starts_with('#') { continue }
295        let mut cols = line.splitn(3, '\t');
296        let (Some(raw_k), Some(typ), Some(raw_v)) =
297            (cols.next(), cols.next(), cols.next()) else { continue };
298        if let Some(v) = parse_value(typ, raw_v) {
299            map.insert(str_unescape(raw_k), v);
300        }
301    }
302    map
303}
304
305fn parse_value(typ: &str, raw: &str) -> Option<Value> {
306    match typ {
307        "s" => Some(Value::Str(str_unescape(raw))),
308        "i" => raw.parse::<i64>().ok().map(Value::Int),
309        "f" => raw.parse::<f64>().ok().map(Value::Float),
310        "b" => Some(Value::Bool(raw == "1")),
311        "x" => Some(Value::Bytes(hex_decode(raw))),
312        _   => None,
313    }
314}
315
316fn encode_value(v: &Value) -> (&'static str, String) {
317    match v {
318        Value::Str(s)   => ("s", str_escape(s)),
319        Value::Int(n)   => ("i", n.to_string()),
320        Value::Float(f) => ("f", f.to_string()),
321        Value::Bool(b)  => ("b", if *b { "1" } else { "0" }.to_string()),
322        Value::Bytes(b) => ("x", hex_encode(b)),
323    }
324}
325
326fn str_escape(s: &str) -> String {
327    let mut out = String::with_capacity(s.len() + 4);
328    for c in s.chars() {
329        match c {
330            '\\' => out.push_str(r"\\"),
331            '\t' => out.push_str(r"\t"),
332            '\n' => out.push_str(r"\n"),
333            '\r' => out.push_str(r"\r"),
334            c    => out.push(c),
335        }
336    }
337    out
338}
339
340fn str_unescape(s: &str) -> String {
341    let mut out = String::with_capacity(s.len());
342    let mut chars = s.chars();
343    while let Some(c) = chars.next() {
344        if c == '\\' {
345            match chars.next() {
346                Some('\\') => out.push('\\'),
347                Some('t')  => out.push('\t'),
348                Some('n')  => out.push('\n'),
349                Some('r')  => out.push('\r'),
350                Some(c)    => { out.push('\\'); out.push(c); }
351                None       => out.push('\\'),
352            }
353        } else {
354            out.push(c);
355        }
356    }
357    out
358}
359
360fn hex_encode(b: &[u8]) -> String {
361    use std::fmt::Write as FmtWrite;
362    b.iter().fold(String::with_capacity(b.len() * 2), |mut s, byte| {
363        let _ = write!(s, "{byte:02x}");
364        s
365    })
366}
367
368fn hex_decode(s: &str) -> Vec<u8> {
369    (0..s.len())
370        .step_by(2)
371        .filter_map(|i| s.get(i..i + 2))
372        .filter_map(|h| u8::from_str_radix(h, 16).ok())
373        .collect()
374}