qualia_client_core/
canvas_store.rs1use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::canvas_world::{CanvasWorldConfig, WorldConfigError};
9
10pub const WORLDS_FILE: &str = "wellfair/canvas_worlds.json";
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct CanvasWorldRecord {
14 pub config: CanvasWorldConfig,
15 pub created_unix: u64,
16 pub updated_unix: u64,
17}
18
19pub struct CanvasWorldStore {
20 path: PathBuf,
21}
22
23impl CanvasWorldStore {
24 pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
25 let path = storage_root.as_ref().join(WORLDS_FILE);
26 if let Some(parent) = path.parent() {
27 fs::create_dir_all(parent)?;
28 }
29 Ok(Self { path })
30 }
31
32 pub fn load_all(&self) -> std::io::Result<Vec<CanvasWorldRecord>> {
33 match fs::read(&self.path) {
34 Ok(bytes) => {
35 serde_json::from_slice(&bytes).map_err(|e| std::io::Error::other(e.to_string()))
36 }
37 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
38 Err(e) => Err(e),
39 }
40 }
41
42 fn save_all(&self, records: &[CanvasWorldRecord]) -> std::io::Result<()> {
43 let tmp = self.path.with_extension("json.tmp");
44 let json =
45 serde_json::to_vec_pretty(records).map_err(|e| std::io::Error::other(e.to_string()))?;
46 fs::write(&tmp, &json)?;
47 fs::rename(&tmp, &self.path)?;
48 Ok(())
49 }
50
51 pub fn list(&self) -> std::io::Result<Vec<CanvasWorldConfig>> {
52 Ok(self.load_all()?.into_iter().map(|r| r.config).collect())
53 }
54
55 pub fn get(&self, world_id: &str) -> std::io::Result<Option<CanvasWorldConfig>> {
56 Ok(self
57 .load_all()?
58 .into_iter()
59 .find(|r| r.config.id == world_id)
60 .map(|r| r.config))
61 }
62
63 pub fn upsert(&self, config: CanvasWorldConfig, now_unix: u64) -> Result<(), UpsertError> {
64 config.validate().map_err(UpsertError::Invalid)?;
65 let mut records = self.load_all().map_err(UpsertError::Io)?;
66 if let Some(rec) = records.iter_mut().find(|r| r.config.id == config.id) {
67 rec.config = config;
68 rec.updated_unix = now_unix;
69 } else {
70 records.push(CanvasWorldRecord {
71 config,
72 created_unix: now_unix,
73 updated_unix: now_unix,
74 });
75 }
76 self.save_all(&records).map_err(UpsertError::Io)
77 }
78
79 pub fn remove(&self, world_id: &str) -> std::io::Result<bool> {
80 let mut records = self.load_all()?;
81 let before = records.len();
82 records.retain(|r| r.config.id != world_id);
83 if records.len() == before {
84 return Ok(false);
85 }
86 self.save_all(&records)?;
87 Ok(true)
88 }
89
90 pub fn seed_if_empty(&self, now_unix: u64) -> std::io::Result<bool> {
92 let records = self.load_all()?;
93 if !records.is_empty() {
94 return Ok(false);
95 }
96 let demo = CanvasWorldConfig::seed_demo();
97 self.upsert(demo, now_unix).map_err(|e| match e {
98 UpsertError::Io(e) => e,
99 UpsertError::Invalid(err) => std::io::Error::other(err.to_string()),
100 })?;
101 Ok(true)
102 }
103}
104
105#[derive(Debug)]
106pub enum UpsertError {
107 Io(std::io::Error),
108 Invalid(WorldConfigError),
109}
110
111impl std::fmt::Display for UpsertError {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Self::Io(e) => write!(f, "{e}"),
115 Self::Invalid(e) => write!(f, "{e}"),
116 }
117 }
118}
119impl std::error::Error for UpsertError {}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::canvas_world::CanvasWorldConfig;
125
126 #[test]
127 fn roundtrip_and_seed() {
128 let dir = std::env::temp_dir().join(format!("chora-store-{}", std::process::id()));
129 let _ = fs::remove_dir_all(&dir);
130 let store = CanvasWorldStore::open(&dir).unwrap();
131 assert!(store.seed_if_empty(1_700_000_000).unwrap());
132 assert!(!store.seed_if_empty(1_700_000_001).unwrap());
133
134 let worlds = store.list().unwrap();
135 assert_eq!(worlds.len(), 1);
136 assert_eq!(worlds[0].id, "q42:world:demo-offline");
137
138 let mut custom = CanvasWorldConfig::default();
139 custom.id = "q42:world:test".into();
140 custom.title = "Test".into();
141 custom.assets.push(crate::canvas_world::CanvasAssetRef {
142 asset_id: "hash:abc".into(),
143 lat: None,
144 lon: None,
145 alt_m: None,
146 valid_from: None,
147 valid_until: None,
148 licence: "CC-BY".into(),
149 });
150 store.upsert(custom, 1_700_000_100).unwrap();
151 assert_eq!(store.list().unwrap().len(), 2);
152 assert!(store.remove("q42:world:test").unwrap());
153 assert_eq!(store.list().unwrap().len(), 1);
154 let _ = fs::remove_dir_all(&dir);
155 }
156}