canvas_core/
store.rs

1//! Shared scene storage for multi-component access.
2//!
3//! Provides a thread-safe [`SceneStore`] that can be shared across MCP handlers,
4//! WebSocket connections, and HTTP routes for consistent scene state management.
5
6use std::collections::HashMap;
7use std::sync::{Arc, RwLock};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::{Element, ElementId, Scene, SceneDocument};
11
12/// Default session identifier.
13pub const DEFAULT_SESSION: &str = "default";
14
15/// Default viewport width in pixels.
16const DEFAULT_WIDTH: f32 = 800.0;
17
18/// Default viewport height in pixels.
19const DEFAULT_HEIGHT: f32 = 600.0;
20
21/// Errors that can occur during store operations.
22#[derive(Debug, thiserror::Error)]
23pub enum StoreError {
24    /// The internal lock was poisoned by a panicking thread.
25    #[error("Lock poisoned")]
26    LockPoisoned,
27    /// The requested session does not exist.
28    #[error("Session not found: {0}")]
29    SessionNotFound(String),
30    /// The requested element does not exist in the session.
31    #[error("Element not found: {0}")]
32    ElementNotFound(String),
33    /// An error occurred while manipulating the scene.
34    #[error("Scene error: {0}")]
35    SceneError(String),
36}
37
38/// Thread-safe scene storage shared across MCP, WebSocket, and HTTP.
39///
40/// # Example
41///
42/// ```
43/// use canvas_core::store::SceneStore;
44/// use canvas_core::{Element, ElementKind};
45///
46/// let store = SceneStore::new();
47///
48/// // Add an element to the default session
49/// let element = Element::new(ElementKind::Text {
50///     content: "Hello".to_string(),
51///     font_size: 16.0,
52///     color: "#000000".to_string(),
53/// });
54///
55/// let id = store.add_element("default", element).unwrap();
56/// ```
57#[derive(Debug, Clone, Default)]
58pub struct SceneStore {
59    scenes: Arc<RwLock<HashMap<String, Scene>>>,
60}
61
62impl SceneStore {
63    /// Create a new store with a default session.
64    ///
65    /// The default session is created with an 800x600 viewport.
66    #[must_use]
67    pub fn new() -> Self {
68        let mut scenes = HashMap::new();
69        scenes.insert(
70            DEFAULT_SESSION.to_string(),
71            Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT),
72        );
73        Self {
74            scenes: Arc::new(RwLock::new(scenes)),
75        }
76    }
77
78    /// Get or create a scene for the given session ID.
79    ///
80    /// If the session does not exist, a new scene with default viewport is created.
81    #[must_use]
82    pub fn get_or_create(&self, session_id: &str) -> Scene {
83        let mut scenes = self
84            .scenes
85            .write()
86            .unwrap_or_else(std::sync::PoisonError::into_inner);
87        scenes
88            .entry(session_id.to_string())
89            .or_insert_with(|| Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT))
90            .clone()
91    }
92
93    /// Get a scene by session ID if it exists.
94    #[must_use]
95    pub fn get(&self, session_id: &str) -> Option<Scene> {
96        let scenes = self
97            .scenes
98            .read()
99            .unwrap_or_else(std::sync::PoisonError::into_inner);
100        scenes.get(session_id).cloned()
101    }
102
103    /// Replace the entire scene for a session.
104    ///
105    /// Creates the session if it does not exist.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`StoreError::LockPoisoned`] if the lock is poisoned (currently
110    /// recovered from, so this variant is reserved for future stricter modes).
111    pub fn replace(&self, session_id: &str, scene: Scene) -> Result<(), StoreError> {
112        let mut scenes = self
113            .scenes
114            .write()
115            .unwrap_or_else(std::sync::PoisonError::into_inner);
116        scenes.insert(session_id.to_string(), scene);
117        Ok(())
118    }
119
120    /// Update a scene using a closure.
121    ///
122    /// The closure receives a mutable reference to the scene and can modify it.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`StoreError::SessionNotFound`] if the session does not exist.
127    pub fn update<F>(&self, session_id: &str, f: F) -> Result<(), StoreError>
128    where
129        F: FnOnce(&mut Scene),
130    {
131        let mut scenes = self
132            .scenes
133            .write()
134            .unwrap_or_else(std::sync::PoisonError::into_inner);
135        let scene = scenes
136            .get_mut(session_id)
137            .ok_or_else(|| StoreError::SessionNotFound(session_id.to_string()))?;
138        f(scene);
139        Ok(())
140    }
141
142    /// Add an element to a session's scene.
143    ///
144    /// Creates the session if it does not exist.
145    ///
146    /// # Errors
147    ///
148    /// Currently infallible but returns `Result` for API consistency.
149    pub fn add_element(&self, session_id: &str, element: Element) -> Result<ElementId, StoreError> {
150        let mut scenes = self
151            .scenes
152            .write()
153            .unwrap_or_else(std::sync::PoisonError::into_inner);
154        let scene = scenes
155            .entry(session_id.to_string())
156            .or_insert_with(|| Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT));
157        let id = scene.add_element(element);
158        Ok(id)
159    }
160
161    /// Remove an element from a session's scene.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`StoreError::SessionNotFound`] if the session does not exist.
166    /// Returns [`StoreError::ElementNotFound`] if the element does not exist.
167    pub fn remove_element(&self, session_id: &str, id: ElementId) -> Result<(), StoreError> {
168        let mut scenes = self
169            .scenes
170            .write()
171            .unwrap_or_else(std::sync::PoisonError::into_inner);
172        let scene = scenes
173            .get_mut(session_id)
174            .ok_or_else(|| StoreError::SessionNotFound(session_id.to_string()))?;
175        scene
176            .remove_element(&id)
177            .map_err(|e| StoreError::ElementNotFound(e.to_string()))?;
178        Ok(())
179    }
180
181    /// Update an element using a closure.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`StoreError::SessionNotFound`] if the session does not exist.
186    /// Returns [`StoreError::ElementNotFound`] if the element does not exist.
187    pub fn update_element<F>(&self, session_id: &str, id: ElementId, f: F) -> Result<(), StoreError>
188    where
189        F: FnOnce(&mut Element),
190    {
191        let mut scenes = self
192            .scenes
193            .write()
194            .unwrap_or_else(std::sync::PoisonError::into_inner);
195        let scene = scenes
196            .get_mut(session_id)
197            .ok_or_else(|| StoreError::SessionNotFound(session_id.to_string()))?;
198        let element = scene
199            .get_element_mut(id)
200            .ok_or_else(|| StoreError::ElementNotFound(id.to_string()))?;
201        f(element);
202        Ok(())
203    }
204
205    /// Get the canonical document representation of a scene.
206    ///
207    /// If the session does not exist, returns a document for an empty scene.
208    #[must_use]
209    pub fn scene_document(&self, session_id: &str) -> SceneDocument {
210        let scenes = self
211            .scenes
212            .read()
213            .unwrap_or_else(std::sync::PoisonError::into_inner);
214        let timestamp = current_timestamp_ms();
215        if let Some(scene) = scenes.get(session_id) {
216            SceneDocument::from_scene(session_id, scene, timestamp)
217        } else {
218            let empty_scene = Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT);
219            SceneDocument::from_scene(session_id, &empty_scene, timestamp)
220        }
221    }
222
223    /// Get a list of all session IDs.
224    #[must_use]
225    pub fn session_ids(&self) -> Vec<String> {
226        let scenes = self
227            .scenes
228            .read()
229            .unwrap_or_else(std::sync::PoisonError::into_inner);
230        scenes.keys().cloned().collect()
231    }
232
233    /// Clear all elements from a session's scene.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`StoreError::SessionNotFound`] if the session does not exist.
238    pub fn clear(&self, session_id: &str) -> Result<(), StoreError> {
239        let mut scenes = self
240            .scenes
241            .write()
242            .unwrap_or_else(std::sync::PoisonError::into_inner);
243        let scene = scenes
244            .get_mut(session_id)
245            .ok_or_else(|| StoreError::SessionNotFound(session_id.to_string()))?;
246        scene.clear();
247        Ok(())
248    }
249}
250
251/// Get the current Unix timestamp in milliseconds.
252fn current_timestamp_ms() -> u64 {
253    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| {
254        // Timestamp will not exceed u64 max for millennia
255        #[allow(clippy::cast_possible_truncation)]
256        {
257            d.as_millis() as u64
258        }
259    })
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::ElementKind;
266
267    #[test]
268    fn test_new_creates_default_session() {
269        let store = SceneStore::new();
270        let ids = store.session_ids();
271        assert!(ids.contains(&DEFAULT_SESSION.to_string()));
272    }
273
274    #[test]
275    fn test_get_or_create_existing() {
276        let store = SceneStore::new();
277        let scene = store.get_or_create(DEFAULT_SESSION);
278        assert!((scene.viewport_width - DEFAULT_WIDTH).abs() < f32::EPSILON);
279        assert!((scene.viewport_height - DEFAULT_HEIGHT).abs() < f32::EPSILON);
280    }
281
282    #[test]
283    fn test_get_or_create_new_session() {
284        let store = SceneStore::new();
285        let scene = store.get_or_create("new-session");
286        assert!(scene.is_empty());
287        assert!(store.session_ids().contains(&"new-session".to_string()));
288    }
289
290    #[test]
291    fn test_get_nonexistent_returns_none() {
292        let store = SceneStore::new();
293        assert!(store.get("nonexistent").is_none());
294    }
295
296    #[test]
297    fn test_add_and_get_element() {
298        let store = SceneStore::new();
299        let element = Element::new(ElementKind::Text {
300            content: "Hello".to_string(),
301            font_size: 16.0,
302            color: "#000000".to_string(),
303        });
304
305        let id = store
306            .add_element(DEFAULT_SESSION, element)
307            .expect("should add element");
308
309        let scene = store.get(DEFAULT_SESSION).expect("session should exist");
310        assert!(scene.get_element(id).is_some());
311    }
312
313    #[test]
314    fn test_remove_element() {
315        let store = SceneStore::new();
316        let element = Element::new(ElementKind::Text {
317            content: "Remove me".to_string(),
318            font_size: 14.0,
319            color: "#FF0000".to_string(),
320        });
321
322        let id = store
323            .add_element(DEFAULT_SESSION, element)
324            .expect("should add");
325
326        store
327            .remove_element(DEFAULT_SESSION, id)
328            .expect("should remove");
329
330        let scene = store.get(DEFAULT_SESSION).expect("session exists");
331        assert!(scene.get_element(id).is_none());
332    }
333
334    #[test]
335    fn test_remove_nonexistent_element_fails() {
336        let store = SceneStore::new();
337        let fake_id = ElementId::new();
338        let result = store.remove_element(DEFAULT_SESSION, fake_id);
339        assert!(matches!(result, Err(StoreError::ElementNotFound(_))));
340    }
341
342    #[test]
343    fn test_update_element() {
344        let store = SceneStore::new();
345        let element = Element::new(ElementKind::Text {
346            content: "Original".to_string(),
347            font_size: 12.0,
348            color: "#000000".to_string(),
349        });
350
351        let id = store
352            .add_element(DEFAULT_SESSION, element)
353            .expect("should add");
354
355        store
356            .update_element(DEFAULT_SESSION, id, |el| {
357                el.transform.x = 100.0;
358                el.transform.y = 200.0;
359            })
360            .expect("should update");
361
362        let scene = store.get(DEFAULT_SESSION).expect("session exists");
363        let updated = scene.get_element(id).expect("element exists");
364        assert!((updated.transform.x - 100.0).abs() < f32::EPSILON);
365        assert!((updated.transform.y - 200.0).abs() < f32::EPSILON);
366    }
367
368    #[test]
369    fn test_replace_scene() {
370        let store = SceneStore::new();
371        let mut new_scene = Scene::new(1920.0, 1080.0);
372        new_scene.add_element(Element::new(ElementKind::Text {
373            content: "New scene".to_string(),
374            font_size: 20.0,
375            color: "#00FF00".to_string(),
376        }));
377
378        store.replace(DEFAULT_SESSION, new_scene).expect("replace");
379
380        let scene = store.get(DEFAULT_SESSION).expect("session exists");
381        assert!((scene.viewport_width - 1920.0).abs() < f32::EPSILON);
382        assert_eq!(scene.element_count(), 1);
383    }
384
385    #[test]
386    fn test_clear_session() {
387        let store = SceneStore::new();
388        store
389            .add_element(
390                DEFAULT_SESSION,
391                Element::new(ElementKind::Text {
392                    content: "Test".to_string(),
393                    font_size: 16.0,
394                    color: "#000".to_string(),
395                }),
396            )
397            .expect("add");
398
399        store.clear(DEFAULT_SESSION).expect("clear");
400
401        let scene = store.get(DEFAULT_SESSION).expect("session exists");
402        assert!(scene.is_empty());
403    }
404
405    #[test]
406    fn test_clear_nonexistent_session_fails() {
407        let store = SceneStore::new();
408        let result = store.clear("nonexistent");
409        assert!(matches!(result, Err(StoreError::SessionNotFound(_))));
410    }
411
412    #[test]
413    fn test_scene_document() {
414        let store = SceneStore::new();
415        store
416            .add_element(
417                DEFAULT_SESSION,
418                Element::new(ElementKind::Text {
419                    content: "Doc test".to_string(),
420                    font_size: 14.0,
421                    color: "#123456".to_string(),
422                }),
423            )
424            .expect("add");
425
426        let doc = store.scene_document(DEFAULT_SESSION);
427        assert_eq!(doc.session_id, DEFAULT_SESSION);
428        assert_eq!(doc.elements.len(), 1);
429    }
430
431    #[test]
432    fn test_scene_document_nonexistent_returns_empty() {
433        let store = SceneStore::new();
434        let doc = store.scene_document("nonexistent");
435        assert_eq!(doc.session_id, "nonexistent");
436        assert!(doc.elements.is_empty());
437    }
438
439    #[test]
440    fn test_update_session() {
441        let store = SceneStore::new();
442        store
443            .update(DEFAULT_SESSION, |scene| {
444                scene.zoom = 2.0;
445                scene.pan_x = 50.0;
446            })
447            .expect("update");
448
449        let scene = store.get(DEFAULT_SESSION).expect("exists");
450        assert!((scene.zoom - 2.0).abs() < f32::EPSILON);
451        assert!((scene.pan_x - 50.0).abs() < f32::EPSILON);
452    }
453
454    #[test]
455    fn test_update_nonexistent_session_fails() {
456        let store = SceneStore::new();
457        let result = store.update("nonexistent", |_| {});
458        assert!(matches!(result, Err(StoreError::SessionNotFound(_))));
459    }
460}