Skip to main content

qualia_client_core/
canvas_store.rs

1//! Persistent store for Chora canvas world configurations.
2
3use 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    /// Seed the demo world if the store is empty (P0 offline milestone).
91    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}