1use serde::{Deserialize, Serialize};
19use std::path::{Component, Path, PathBuf};
20
21#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
23pub struct SaveInfo {
24 pub slot: String,
26 pub title: String,
28 pub time: u64,
30 pub scene: String,
32}
33
34#[derive(Serialize, Deserialize)]
35struct SaveFile {
36 info: SaveInfo,
37 data: serde_json::Value,
38}
39
40#[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
51fn 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 pub fn new(root: &Path) -> Self {
70 Self { root: root.to_path_buf(), scene: String::new() }
71 }
72 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 pub fn root(&self) -> &Path {
80 &self.root
81 }
82 pub fn set_scene(&mut self, scene: &str) {
84 self.scene = scene.to_string();
85 }
86
87 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 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 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 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 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 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 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 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 fn settings_path(&self) -> PathBuf {
138 self.root.join("settings.json")
139 }
140 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 pub fn setting(&self, key: &str) -> Option<serde_json::Value> {
146 self.settings().get(key).cloned()
147 }
148 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 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 pub fn write_text(&self, rel: &str, text: &str) -> Result<(), String> {
163 self.write_bytes(rel, text.as_bytes())
164 }
165 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 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 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 pub fn exists(&self, rel: &str) -> bool {
189 inside(&self.root, rel).is_ok_and(|p| p.exists())
190 }
191 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 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
214pub mod external {
217 pub fn read_text(path: &str) -> Result<String, String> {
219 std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))
220 }
221 pub fn write_text(path: &str, text: &str) -> Result<(), String> {
223 write_bytes(path, text.as_bytes())
224 }
225 pub fn read_bytes(path: &str) -> Result<Vec<u8>, String> {
227 std::fs::read(path).map_err(|e| format!("{path}: {e}"))
228 }
229 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 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 pub fn exists(path: &str) -> bool {
244 std::path::Path::new(path).exists()
245 }
246 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}