Skip to main content

kvas_engine/
storage.rs

1//! Files of a game on the player's computer: save slots, persistent settings and a small file
2//! system API. Everything lives under the game's data folder (set by the host: `%APPDATA%/<game>`
3//! for a built game, `<project>/.kvas/data` while playing in the editor), so a script cannot
4//! touch other files by accident. [`external`] reads and writes anywhere, on purpose.
5//!
6//! ```
7//! use kvas_engine::storage::Storage;
8//! let dir = std::env::temp_dir().join(format!("kvas-storage-doc-{}", std::process::id()));
9//! let s = Storage::new(&dir);
10//! s.save("slot1", "Chapter 2", &serde_json::json!({"hp": 3})).unwrap();
11//! assert_eq!(s.load("slot1").unwrap()["hp"], 3);
12//! assert_eq!(s.slots()[0].title, "Chapter 2");
13//! s.set_setting("volume", serde_json::json!(0.5)).unwrap();
14//! assert_eq!(s.setting("volume"), Some(serde_json::json!(0.5)));
15//! let _ = std::fs::remove_dir_all(dir);
16//! ```
17
18use serde::{Deserialize, Serialize};
19use std::path::{Component, Path, PathBuf};
20
21/// One save slot as listed by [`Storage::slots`].
22#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
23pub struct SaveInfo {
24    /// Slot name (file name without `.json`).
25    pub slot: String,
26    /// Text shown in a load menu (chapter, place...).
27    pub title: String,
28    /// Seconds since 1970 when it was written.
29    pub time: u64,
30    /// Scene the game was in.
31    pub scene: String,
32}
33
34#[derive(Serialize, Deserialize)]
35struct SaveFile {
36    info: SaveInfo,
37    data: serde_json::Value,
38}
39
40/// The game's data folder.
41#[derive(Clone, Debug, Default)]
42pub struct Storage {
43    root: PathBuf,
44    scene: String,
45}
46
47fn now() -> u64 {
48    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
49}
50
51/// Turns a script path into one inside `root`, refusing `..`, drive letters and absolute paths.
52fn inside(root: &Path, rel: &str) -> Result<PathBuf, String> {
53    let p = Path::new(rel);
54    if rel.is_empty() || p.components().any(|c| !matches!(c, Component::Normal(_))) {
55        return Err(format!("'{rel}' must be a relative path inside the game folder"));
56    }
57    Ok(root.join(p))
58}
59
60fn slot_ok(slot: &str) -> Result<(), String> {
61    if slot.is_empty() || slot.len() > 64 || !slot.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
62        return Err(format!("save slot '{slot}': use 1-64 letters, digits, '-' or '_'"));
63    }
64    Ok(())
65}
66
67impl Storage {
68    /// Storage rooted at a folder (created on first write).
69    pub fn new(root: &Path) -> Self {
70        Self { root: root.to_path_buf(), scene: String::new() }
71    }
72    /// The standard folder for a game: `%APPDATA%/Kvas/<game>` on Windows, `~/.local/share/kvas/<game>` elsewhere.
73    pub fn for_game(game: &str) -> Self {
74        let safe: String = game.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' }).collect();
75        let base = std::env::var_os("APPDATA").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))).unwrap_or_else(std::env::temp_dir);
76        Self::new(&base.join("Kvas").join(if safe.trim().is_empty() { "game" } else { safe.trim() }))
77    }
78    /// The data folder.
79    pub fn root(&self) -> &Path {
80        &self.root
81    }
82    /// Records the running scene (written into saves).
83    pub fn set_scene(&mut self, scene: &str) {
84        self.scene = scene.to_string();
85    }
86
87    // ---- saves
88    /// Writes a save slot. `title` is what a load menu shows.
89    pub fn save(&self, slot: &str, title: &str, data: &serde_json::Value) -> Result<(), String> {
90        slot_ok(slot)?;
91        let file = SaveFile { info: SaveInfo { slot: slot.into(), title: title.into(), time: now(), scene: self.scene.clone() }, data: data.clone() };
92        let text = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?;
93        let dir = self.root.join("saves");
94        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
95        // Write next to it and rename, so a crash never leaves half a save.
96        let tmp = dir.join(format!("{slot}.json.tmp"));
97        std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
98        std::fs::rename(&tmp, dir.join(format!("{slot}.json"))).map_err(|e| e.to_string())
99    }
100    /// Reads a save slot's data.
101    pub fn load(&self, slot: &str) -> Option<serde_json::Value> {
102        slot_ok(slot).ok()?;
103        let text = std::fs::read_to_string(self.root.join("saves").join(format!("{slot}.json"))).ok()?;
104        serde_json::from_str::<SaveFile>(&text).ok().map(|f| f.data)
105    }
106    /// Typed save: any `Serialize` value.
107    pub fn save_as<T: Serialize>(&self, slot: &str, title: &str, value: &T) -> Result<(), String> {
108        self.save(slot, title, &serde_json::to_value(value).map_err(|e| e.to_string())?)
109    }
110    /// Typed load.
111    pub fn load_as<T: for<'de> Deserialize<'de>>(&self, slot: &str) -> Option<T> {
112        serde_json::from_value(self.load(slot)?).ok()
113    }
114    /// Every save, newest first.
115    pub fn slots(&self) -> Vec<SaveInfo> {
116        let mut out: Vec<SaveInfo> = std::fs::read_dir(self.root.join("saves"))
117            .into_iter()
118            .flatten()
119            .flatten()
120            .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
121            .filter_map(|e| serde_json::from_str::<SaveFile>(&std::fs::read_to_string(e.path()).ok()?).ok().map(|f| f.info))
122            .collect();
123        out.sort_by(|a, b| b.time.cmp(&a.time).then(a.slot.cmp(&b.slot)));
124        out
125    }
126    /// Whether a slot exists.
127    pub fn has_save(&self, slot: &str) -> bool {
128        slot_ok(slot).is_ok() && self.root.join("saves").join(format!("{slot}.json")).exists()
129    }
130    /// Removes a slot.
131    pub fn delete_save(&self, slot: &str) -> Result<(), String> {
132        slot_ok(slot)?;
133        std::fs::remove_file(self.root.join("saves").join(format!("{slot}.json"))).map_err(|e| e.to_string())
134    }
135
136    // ---- settings (one JSON object kept across runs)
137    fn settings_path(&self) -> PathBuf {
138        self.root.join("settings.json")
139    }
140    /// All settings.
141    pub fn settings(&self) -> serde_json::Map<String, serde_json::Value> {
142        std::fs::read_to_string(self.settings_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
143    }
144    /// One setting.
145    pub fn setting(&self, key: &str) -> Option<serde_json::Value> {
146        self.settings().get(key).cloned()
147    }
148    /// Changes one setting and writes the file.
149    pub fn set_setting(&self, key: &str, value: serde_json::Value) -> Result<(), String> {
150        let mut all = self.settings();
151        all.insert(key.to_string(), value);
152        std::fs::create_dir_all(&self.root).map_err(|e| e.to_string())?;
153        std::fs::write(self.settings_path(), serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?).map_err(|e| e.to_string())
154    }
155
156    // ---- files inside the data folder
157    /// Reads a text file of the data folder.
158    pub fn read_text(&self, rel: &str) -> Result<String, String> {
159        std::fs::read_to_string(inside(&self.root, rel)?).map_err(|e| format!("{rel}: {e}"))
160    }
161    /// Writes a text file (folders are created).
162    pub fn write_text(&self, rel: &str, text: &str) -> Result<(), String> {
163        self.write_bytes(rel, text.as_bytes())
164    }
165    /// Reads bytes.
166    pub fn read_bytes(&self, rel: &str) -> Result<Vec<u8>, String> {
167        std::fs::read(inside(&self.root, rel)?).map_err(|e| format!("{rel}: {e}"))
168    }
169    /// Writes bytes (folders are created).
170    pub fn write_bytes(&self, rel: &str, bytes: &[u8]) -> Result<(), String> {
171        let path = inside(&self.root, rel)?;
172        if let Some(parent) = path.parent() {
173            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
174        }
175        std::fs::write(path, bytes).map_err(|e| format!("{rel}: {e}"))
176    }
177    /// Appends a line (logs, statistics).
178    pub fn append_line(&self, rel: &str, line: &str) -> Result<(), String> {
179        use std::io::Write;
180        let path = inside(&self.root, rel)?;
181        if let Some(parent) = path.parent() {
182            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
183        }
184        let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
185        writeln!(f, "{line}").map_err(|e| e.to_string())
186    }
187    /// Whether a file or folder exists.
188    pub fn exists(&self, rel: &str) -> bool {
189        inside(&self.root, rel).is_ok_and(|p| p.exists())
190    }
191    /// Names in a folder (`""` = the data folder itself), folders end with `/`.
192    pub fn list(&self, rel: &str) -> Vec<String> {
193        let dir = if rel.is_empty() { Ok(self.root.clone()) } else { inside(&self.root, rel) };
194        let Ok(dir) = dir else { return Vec::new() };
195        let mut out: Vec<String> = std::fs::read_dir(dir)
196            .into_iter()
197            .flatten()
198            .flatten()
199            .map(|e| {
200                let name = e.file_name().to_string_lossy().to_string();
201                if e.path().is_dir() { format!("{name}/") } else { name }
202            })
203            .collect();
204        out.sort();
205        out
206    }
207    /// Removes a file or a whole folder.
208    pub fn delete(&self, rel: &str) -> Result<(), String> {
209        let p = inside(&self.root, rel)?;
210        if p.is_dir() { std::fs::remove_dir_all(p) } else { std::fs::remove_file(p) }.map_err(|e| e.to_string())
211    }
212}
213
214/// Unrestricted access to the computer's file system (any absolute path). Use for tools, mods
215/// and import/export; prefer [`Storage`] for game data.
216pub mod external {
217    /// Reads a text file.
218    pub fn read_text(path: &str) -> Result<String, String> {
219        std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))
220    }
221    /// Writes a text file, creating folders.
222    pub fn write_text(path: &str, text: &str) -> Result<(), String> {
223        write_bytes(path, text.as_bytes())
224    }
225    /// Reads bytes.
226    pub fn read_bytes(path: &str) -> Result<Vec<u8>, String> {
227        std::fs::read(path).map_err(|e| format!("{path}: {e}"))
228    }
229    /// Writes bytes, creating folders.
230    pub fn write_bytes(path: &str, bytes: &[u8]) -> Result<(), String> {
231        if let Some(parent) = std::path::Path::new(path).parent() {
232            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
233        }
234        std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))
235    }
236    /// Entries of a folder (full paths).
237    pub fn list(path: &str) -> Vec<String> {
238        let mut out: Vec<String> = std::fs::read_dir(path).into_iter().flatten().flatten().map(|e| e.path().to_string_lossy().to_string()).collect();
239        out.sort();
240        out
241    }
242    /// Whether the path exists.
243    pub fn exists(path: &str) -> bool {
244        std::path::Path::new(path).exists()
245    }
246    /// The user's documents folder (or home), for "export to..." features.
247    pub fn documents_dir() -> String {
248        std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).map(|h| format!("{h}/Documents")).unwrap_or_default()
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn sandbox_slots_and_files() {
258        let dir = std::env::temp_dir().join(format!("kvas-storage-test-{}", std::process::id()));
259        let mut s = Storage::new(&dir);
260        s.set_scene("scenes/a.scene.json");
261        assert!(s.save("../x", "t", &serde_json::json!(1)).is_err());
262        assert!(s.write_text("../escape.txt", "no").is_err());
263        assert!(s.write_text("C:/escape.txt", "no").is_err());
264        s.save_as("auto", "Start", &vec![1, 2, 3]).unwrap();
265        std::thread::sleep(std::time::Duration::from_millis(1100));
266        s.save("manual", "Later", &serde_json::json!({"a": 1})).unwrap();
267        let slots = s.slots();
268        assert_eq!(slots.iter().map(|i| i.slot.as_str()).collect::<Vec<_>>(), ["manual", "auto"]);
269        assert_eq!(slots[1].scene, "scenes/a.scene.json");
270        assert_eq!(s.load_as::<Vec<i32>>("auto"), Some(vec![1, 2, 3]));
271        s.write_text("notes/day1.txt", "hello").unwrap();
272        s.append_line("notes/day1.txt", "world").unwrap();
273        assert_eq!(s.read_text("notes/day1.txt").unwrap(), "helloworld\n");
274        assert_eq!(s.list("notes"), ["day1.txt"]);
275        assert!(s.list("").contains(&"notes/".to_string()));
276        s.delete_save("auto").unwrap();
277        assert!(!s.has_save("auto"));
278        let _ = std::fs::remove_dir_all(dir);
279    }
280}