Skip to main content

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::path::PathBuf;
8use std::sync::{Arc, RwLock};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::{Element, ElementId, Scene, SceneDocument};
12
13/// Default session identifier.
14pub const DEFAULT_SESSION: &str = "default";
15
16/// Default viewport width in pixels.
17const DEFAULT_WIDTH: f32 = 800.0;
18
19/// Default viewport height in pixels.
20const DEFAULT_HEIGHT: f32 = 600.0;
21
22/// Errors that can occur during store operations.
23#[derive(Debug, thiserror::Error)]
24pub enum StoreError {
25    /// The internal lock was poisoned by a panicking thread.
26    #[error("Lock poisoned")]
27    LockPoisoned,
28    /// The requested session does not exist.
29    #[error("Session not found: {0}")]
30    SessionNotFound(String),
31    /// The requested element does not exist in the session.
32    #[error("Element not found: {0}")]
33    ElementNotFound(String),
34    /// An error occurred while manipulating the scene.
35    #[error("Scene error: {0}")]
36    SceneError(String),
37    /// An I/O error occurred during persistence.
38    #[error("IO error: {0}")]
39    Io(#[from] std::io::Error),
40    /// A serialization or deserialization error occurred.
41    #[error("Serialization error: {0}")]
42    Serialization(String),
43}
44
45/// Thread-safe scene storage shared across MCP, WebSocket, and HTTP.
46///
47/// # Example
48///
49/// ```
50/// use canvas_core::store::SceneStore;
51/// use canvas_core::{Element, ElementKind};
52///
53/// let store = SceneStore::new();
54///
55/// // Add an element to the default session
56/// let element = Element::new(ElementKind::Text {
57///     content: "Hello".to_string(),
58///     font_size: 16.0,
59///     color: "#000000".to_string(),
60/// });
61///
62/// let id = store.add_element("default", element).unwrap();
63/// ```
64#[derive(Debug, Clone, Default)]
65pub struct SceneStore {
66    scenes: Arc<RwLock<HashMap<String, Scene>>>,
67    /// Optional data directory for filesystem persistence.
68    data_dir: Option<PathBuf>,
69}
70
71impl SceneStore {
72    /// Create a new store with a default session (no persistence).
73    ///
74    /// The default session is created with an 800x600 viewport.
75    #[must_use]
76    pub fn new() -> Self {
77        let mut scenes = HashMap::new();
78        scenes.insert(
79            DEFAULT_SESSION.to_string(),
80            Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT),
81        );
82        Self {
83            scenes: Arc::new(RwLock::new(scenes)),
84            data_dir: None,
85        }
86    }
87
88    /// Create a store with filesystem persistence.
89    ///
90    /// Sessions are saved as JSON files in `data_dir`. The directory is created
91    /// if it doesn't exist.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`StoreError::Io`] if the directory cannot be created.
96    pub fn with_data_dir(data_dir: impl Into<PathBuf>) -> Result<Self, StoreError> {
97        let data_dir = data_dir.into();
98        std::fs::create_dir_all(&data_dir)?;
99        let mut scenes = HashMap::new();
100        scenes.insert(
101            DEFAULT_SESSION.to_string(),
102            Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT),
103        );
104        Ok(Self {
105            scenes: Arc::new(RwLock::new(scenes)),
106            data_dir: Some(data_dir),
107        })
108    }
109
110    /// Get or create a scene for the given session ID.
111    ///
112    /// If the session does not exist, a new scene with default viewport is created.
113    #[must_use]
114    pub fn get_or_create(&self, session_id: &str) -> Scene {
115        let mut scenes = self
116            .scenes
117            .write()
118            .unwrap_or_else(std::sync::PoisonError::into_inner);
119        scenes
120            .entry(session_id.to_string())
121            .or_insert_with(|| Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT))
122            .clone()
123    }
124
125    /// Get a scene by session ID if it exists.
126    #[must_use]
127    pub fn get(&self, session_id: &str) -> Option<Scene> {
128        let scenes = self
129            .scenes
130            .read()
131            .unwrap_or_else(std::sync::PoisonError::into_inner);
132        scenes.get(session_id).cloned()
133    }
134
135    /// Replace the entire scene for a session.
136    ///
137    /// Creates the session if it does not exist.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`StoreError::LockPoisoned`] if the lock is poisoned (currently
142    /// recovered from, so this variant is reserved for future stricter modes).
143    pub fn replace(&self, session_id: &str, scene: Scene) -> Result<(), StoreError> {
144        {
145            let mut scenes = self
146                .scenes
147                .write()
148                .unwrap_or_else(std::sync::PoisonError::into_inner);
149            scenes.insert(session_id.to_string(), scene);
150        }
151        self.persist_session(session_id);
152        Ok(())
153    }
154
155    /// Update a scene using a closure.
156    ///
157    /// The closure receives a mutable reference to the scene and can modify it.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`StoreError::SessionNotFound`] if the session does not exist.
162    pub fn update<F>(&self, session_id: &str, f: F) -> Result<(), StoreError>
163    where
164        F: FnOnce(&mut Scene),
165    {
166        {
167            let mut scenes = self
168                .scenes
169                .write()
170                .unwrap_or_else(std::sync::PoisonError::into_inner);
171            let scene = scenes
172                .get_mut(session_id)
173                .ok_or_else(|| StoreError::SessionNotFound(session_id.to_string()))?;
174            f(scene);
175        }
176        self.persist_session(session_id);
177        Ok(())
178    }
179
180    /// Add an element to a session's scene.
181    ///
182    /// Creates the session if it does not exist.
183    ///
184    /// # Errors
185    ///
186    /// Currently infallible but returns `Result` for API consistency.
187    pub fn add_element(&self, session_id: &str, element: Element) -> Result<ElementId, StoreError> {
188        let id = {
189            let mut scenes = self
190                .scenes
191                .write()
192                .unwrap_or_else(std::sync::PoisonError::into_inner);
193            let scene = scenes
194                .entry(session_id.to_string())
195                .or_insert_with(|| Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT));
196            scene.add_element(element)
197        };
198        self.persist_session(session_id);
199        Ok(id)
200    }
201
202    /// Remove an element from a session's scene.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`StoreError::SessionNotFound`] if the session does not exist.
207    /// Returns [`StoreError::ElementNotFound`] if the element does not exist.
208    pub fn remove_element(&self, session_id: &str, id: ElementId) -> Result<(), StoreError> {
209        {
210            let mut scenes = self
211                .scenes
212                .write()
213                .unwrap_or_else(std::sync::PoisonError::into_inner);
214            let scene = scenes
215                .get_mut(session_id)
216                .ok_or_else(|| StoreError::SessionNotFound(session_id.to_string()))?;
217            scene
218                .remove_element(&id)
219                .map_err(|e| StoreError::ElementNotFound(e.to_string()))?;
220        }
221        self.persist_session(session_id);
222        Ok(())
223    }
224
225    /// Update an element using a closure.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`StoreError::SessionNotFound`] if the session does not exist.
230    /// Returns [`StoreError::ElementNotFound`] if the element does not exist.
231    pub fn update_element<F>(&self, session_id: &str, id: ElementId, f: F) -> Result<(), StoreError>
232    where
233        F: FnOnce(&mut Element),
234    {
235        {
236            let mut scenes = self
237                .scenes
238                .write()
239                .unwrap_or_else(std::sync::PoisonError::into_inner);
240            let scene = scenes
241                .get_mut(session_id)
242                .ok_or_else(|| StoreError::SessionNotFound(session_id.to_string()))?;
243            let element = scene
244                .get_element_mut(id)
245                .ok_or_else(|| StoreError::ElementNotFound(id.to_string()))?;
246            f(element);
247        }
248        self.persist_session(session_id);
249        Ok(())
250    }
251
252    /// Get the canonical document representation of a scene.
253    ///
254    /// If the session does not exist, returns a document for an empty scene.
255    #[must_use]
256    pub fn scene_document(&self, session_id: &str) -> SceneDocument {
257        let scenes = self
258            .scenes
259            .read()
260            .unwrap_or_else(std::sync::PoisonError::into_inner);
261        let timestamp = current_timestamp_ms();
262        if let Some(scene) = scenes.get(session_id) {
263            SceneDocument::from_scene(session_id, scene, timestamp)
264        } else {
265            let empty_scene = Scene::new(DEFAULT_WIDTH, DEFAULT_HEIGHT);
266            SceneDocument::from_scene(session_id, &empty_scene, timestamp)
267        }
268    }
269
270    /// Get a list of all session IDs.
271    #[must_use]
272    pub fn session_ids(&self) -> Vec<String> {
273        let scenes = self
274            .scenes
275            .read()
276            .unwrap_or_else(std::sync::PoisonError::into_inner);
277        scenes.keys().cloned().collect()
278    }
279
280    // -----------------------------------------------------------------------
281    // Persistence
282    // -----------------------------------------------------------------------
283
284    /// Save a session's scene to disk as JSON.
285    ///
286    /// No-op if the store was created without a data directory.
287    fn persist_session(&self, session_id: &str) {
288        let Some(ref data_dir) = self.data_dir else {
289            return;
290        };
291        let doc = self.scene_document(session_id);
292        let json = match serde_json::to_string_pretty(&doc) {
293            Ok(j) => j,
294            Err(e) => {
295                tracing::warn!("Failed to serialize session {session_id}: {e}");
296                return;
297            }
298        };
299        let path = data_dir.join(format!("{}.json", sanitize_filename(session_id)));
300        if let Err(e) = std::fs::write(&path, json) {
301            tracing::warn!(
302                "Failed to persist session {session_id} to {}: {e}",
303                path.display()
304            );
305        }
306    }
307
308    /// Load a single session from disk into memory.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if the file doesn't exist or can't be parsed.
313    pub fn load_session_from_disk(&self, session_id: &str) -> Result<(), StoreError> {
314        let data_dir = self
315            .data_dir
316            .as_ref()
317            .ok_or_else(|| StoreError::SceneError("No data directory configured".into()))?;
318        let path = data_dir.join(format!("{}.json", sanitize_filename(session_id)));
319        let contents = std::fs::read_to_string(&path)?;
320        let doc: SceneDocument = serde_json::from_str(&contents)
321            .map_err(|e| StoreError::Serialization(e.to_string()))?;
322
323        // Rebuild Scene from SceneDocument.
324        let mut scene = Scene::new(doc.viewport.width, doc.viewport.height);
325        scene.zoom = doc.viewport.zoom;
326        scene.pan_x = doc.viewport.pan_x;
327        scene.pan_y = doc.viewport.pan_y;
328        for elem_doc in &doc.elements {
329            let element = crate::Element::new(elem_doc.kind.clone())
330                .with_transform(elem_doc.transform)
331                .with_interactive(elem_doc.interactive);
332            scene.add_element(element);
333        }
334
335        let mut scenes = self
336            .scenes
337            .write()
338            .unwrap_or_else(std::sync::PoisonError::into_inner);
339        scenes.insert(session_id.to_string(), scene);
340        Ok(())
341    }
342
343    /// Discover and load all persisted sessions from the data directory.
344    ///
345    /// Returns a list of session IDs that were found on disk.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if the data directory can't be read.
350    pub fn load_all_sessions(&self) -> Result<Vec<String>, StoreError> {
351        let data_dir = self
352            .data_dir
353            .as_ref()
354            .ok_or_else(|| StoreError::SceneError("No data directory configured".into()))?;
355        let mut session_ids = Vec::new();
356        for entry in std::fs::read_dir(data_dir)? {
357            let entry = entry?;
358            let path = entry.path();
359            if path.extension().is_some_and(|ext| ext == "json") {
360                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
361                    session_ids.push(stem.to_string());
362                }
363            }
364        }
365        Ok(session_ids)
366    }
367
368    /// Remove a session's persisted file from disk.
369    ///
370    /// No-op if the store has no data directory or the file doesn't exist.
371    pub fn delete_session_file(&self, session_id: &str) {
372        let Some(ref data_dir) = self.data_dir else {
373            return;
374        };
375        let path = data_dir.join(format!("{}.json", sanitize_filename(session_id)));
376        if path.exists() {
377            if let Err(e) = std::fs::remove_file(&path) {
378                tracing::warn!("Failed to delete session file {}: {e}", path.display());
379            }
380        }
381    }
382
383    /// Clear all elements from a session's scene.
384    ///
385    /// # Errors
386    ///
387    /// Returns [`StoreError::SessionNotFound`] if the session does not exist.
388    pub fn clear(&self, session_id: &str) -> Result<(), StoreError> {
389        {
390            let mut scenes = self
391                .scenes
392                .write()
393                .unwrap_or_else(std::sync::PoisonError::into_inner);
394            let scene = scenes
395                .get_mut(session_id)
396                .ok_or_else(|| StoreError::SessionNotFound(session_id.to_string()))?;
397            scene.clear();
398        }
399        self.persist_session(session_id);
400        Ok(())
401    }
402}
403
404/// Sanitize a session ID for use as a filename.
405///
406/// Replaces any character that is not alphanumeric, `-`, or `_` with `_`.
407fn sanitize_filename(session_id: &str) -> String {
408    session_id
409        .chars()
410        .map(|c| {
411            if c.is_alphanumeric() || c == '-' || c == '_' {
412                c
413            } else {
414                '_'
415            }
416        })
417        .collect()
418}
419
420/// Get the current Unix timestamp in milliseconds.
421fn current_timestamp_ms() -> u64 {
422    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| {
423        // Timestamp will not exceed u64 max for millennia
424        #[allow(clippy::cast_possible_truncation)]
425        {
426            d.as_millis() as u64
427        }
428    })
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::ElementKind;
435
436    #[test]
437    fn test_new_creates_default_session() {
438        let store = SceneStore::new();
439        let ids = store.session_ids();
440        assert!(ids.contains(&DEFAULT_SESSION.to_string()));
441    }
442
443    #[test]
444    fn test_get_or_create_existing() {
445        let store = SceneStore::new();
446        let scene = store.get_or_create(DEFAULT_SESSION);
447        assert!((scene.viewport_width - DEFAULT_WIDTH).abs() < f32::EPSILON);
448        assert!((scene.viewport_height - DEFAULT_HEIGHT).abs() < f32::EPSILON);
449    }
450
451    #[test]
452    fn test_get_or_create_new_session() {
453        let store = SceneStore::new();
454        let scene = store.get_or_create("new-session");
455        assert!(scene.is_empty());
456        assert!(store.session_ids().contains(&"new-session".to_string()));
457    }
458
459    #[test]
460    fn test_get_nonexistent_returns_none() {
461        let store = SceneStore::new();
462        assert!(store.get("nonexistent").is_none());
463    }
464
465    #[test]
466    fn test_add_and_get_element() {
467        let store = SceneStore::new();
468        let element = Element::new(ElementKind::Text {
469            content: "Hello".to_string(),
470            font_size: 16.0,
471            color: "#000000".to_string(),
472        });
473
474        let id = store
475            .add_element(DEFAULT_SESSION, element)
476            .expect("should add element");
477
478        let scene = store.get(DEFAULT_SESSION).expect("session should exist");
479        assert!(scene.get_element(id).is_some());
480    }
481
482    #[test]
483    fn test_remove_element() {
484        let store = SceneStore::new();
485        let element = Element::new(ElementKind::Text {
486            content: "Remove me".to_string(),
487            font_size: 14.0,
488            color: "#FF0000".to_string(),
489        });
490
491        let id = store
492            .add_element(DEFAULT_SESSION, element)
493            .expect("should add");
494
495        store
496            .remove_element(DEFAULT_SESSION, id)
497            .expect("should remove");
498
499        let scene = store.get(DEFAULT_SESSION).expect("session exists");
500        assert!(scene.get_element(id).is_none());
501    }
502
503    #[test]
504    fn test_remove_nonexistent_element_fails() {
505        let store = SceneStore::new();
506        let fake_id = ElementId::new();
507        let result = store.remove_element(DEFAULT_SESSION, fake_id);
508        assert!(matches!(result, Err(StoreError::ElementNotFound(_))));
509    }
510
511    #[test]
512    fn test_update_element() {
513        let store = SceneStore::new();
514        let element = Element::new(ElementKind::Text {
515            content: "Original".to_string(),
516            font_size: 12.0,
517            color: "#000000".to_string(),
518        });
519
520        let id = store
521            .add_element(DEFAULT_SESSION, element)
522            .expect("should add");
523
524        store
525            .update_element(DEFAULT_SESSION, id, |el| {
526                el.transform.x = 100.0;
527                el.transform.y = 200.0;
528            })
529            .expect("should update");
530
531        let scene = store.get(DEFAULT_SESSION).expect("session exists");
532        let updated = scene.get_element(id).expect("element exists");
533        assert!((updated.transform.x - 100.0).abs() < f32::EPSILON);
534        assert!((updated.transform.y - 200.0).abs() < f32::EPSILON);
535    }
536
537    #[test]
538    fn test_replace_scene() {
539        let store = SceneStore::new();
540        let mut new_scene = Scene::new(1920.0, 1080.0);
541        new_scene.add_element(Element::new(ElementKind::Text {
542            content: "New scene".to_string(),
543            font_size: 20.0,
544            color: "#00FF00".to_string(),
545        }));
546
547        store.replace(DEFAULT_SESSION, new_scene).expect("replace");
548
549        let scene = store.get(DEFAULT_SESSION).expect("session exists");
550        assert!((scene.viewport_width - 1920.0).abs() < f32::EPSILON);
551        assert_eq!(scene.element_count(), 1);
552    }
553
554    #[test]
555    fn test_clear_session() {
556        let store = SceneStore::new();
557        store
558            .add_element(
559                DEFAULT_SESSION,
560                Element::new(ElementKind::Text {
561                    content: "Test".to_string(),
562                    font_size: 16.0,
563                    color: "#000".to_string(),
564                }),
565            )
566            .expect("add");
567
568        store.clear(DEFAULT_SESSION).expect("clear");
569
570        let scene = store.get(DEFAULT_SESSION).expect("session exists");
571        assert!(scene.is_empty());
572    }
573
574    #[test]
575    fn test_clear_nonexistent_session_fails() {
576        let store = SceneStore::new();
577        let result = store.clear("nonexistent");
578        assert!(matches!(result, Err(StoreError::SessionNotFound(_))));
579    }
580
581    #[test]
582    fn test_scene_document() {
583        let store = SceneStore::new();
584        store
585            .add_element(
586                DEFAULT_SESSION,
587                Element::new(ElementKind::Text {
588                    content: "Doc test".to_string(),
589                    font_size: 14.0,
590                    color: "#123456".to_string(),
591                }),
592            )
593            .expect("add");
594
595        let doc = store.scene_document(DEFAULT_SESSION);
596        assert_eq!(doc.session_id, DEFAULT_SESSION);
597        assert_eq!(doc.elements.len(), 1);
598    }
599
600    #[test]
601    fn test_scene_document_nonexistent_returns_empty() {
602        let store = SceneStore::new();
603        let doc = store.scene_document("nonexistent");
604        assert_eq!(doc.session_id, "nonexistent");
605        assert!(doc.elements.is_empty());
606    }
607
608    #[test]
609    fn test_update_session() {
610        let store = SceneStore::new();
611        store
612            .update(DEFAULT_SESSION, |scene| {
613                scene.zoom = 2.0;
614                scene.pan_x = 50.0;
615            })
616            .expect("update");
617
618        let scene = store.get(DEFAULT_SESSION).expect("exists");
619        assert!((scene.zoom - 2.0).abs() < f32::EPSILON);
620        assert!((scene.pan_x - 50.0).abs() < f32::EPSILON);
621    }
622
623    #[test]
624    fn test_update_nonexistent_session_fails() {
625        let store = SceneStore::new();
626        let result = store.update("nonexistent", |_| {});
627        assert!(matches!(result, Err(StoreError::SessionNotFound(_))));
628    }
629
630    // -----------------------------------------------------------------------
631    // Persistence tests
632    // -----------------------------------------------------------------------
633
634    #[test]
635    fn test_persistence_save_and_load() {
636        let dir = tempfile::tempdir().expect("tempdir");
637        let store = SceneStore::with_data_dir(dir.path()).expect("store");
638
639        // Add an element and verify it persists
640        let element = Element::new(ElementKind::Text {
641            content: "Persisted".to_string(),
642            font_size: 20.0,
643            color: "#ABCDEF".to_string(),
644        });
645        store.add_element(DEFAULT_SESSION, element).expect("add");
646
647        // Load into a fresh store and verify
648        let store2 = SceneStore::with_data_dir(dir.path()).expect("store2");
649        store2
650            .load_session_from_disk(DEFAULT_SESSION)
651            .expect("load");
652
653        let scene = store2.get(DEFAULT_SESSION).expect("session exists");
654        assert_eq!(scene.element_count(), 1);
655    }
656
657    #[test]
658    fn test_persistence_load_nonexistent_session() {
659        let dir = tempfile::tempdir().expect("tempdir");
660        let store = SceneStore::with_data_dir(dir.path()).expect("store");
661        let result = store.load_session_from_disk("does-not-exist");
662        assert!(result.is_err());
663    }
664
665    #[test]
666    fn test_persistence_auto_save_on_mutation() {
667        let dir = tempfile::tempdir().expect("tempdir");
668        let store = SceneStore::with_data_dir(dir.path()).expect("store");
669
670        // add_element triggers auto-save
671        let element = Element::new(ElementKind::Text {
672            content: "Auto-saved".to_string(),
673            font_size: 14.0,
674            color: "#000000".to_string(),
675        });
676        let id = store.add_element(DEFAULT_SESSION, element).expect("add");
677
678        // Verify file exists
679        let path = dir.path().join(format!("{DEFAULT_SESSION}.json"));
680        assert!(path.exists(), "JSON file should be written on add_element");
681
682        // update_element triggers auto-save
683        store
684            .update_element(DEFAULT_SESSION, id, |el| {
685                el.transform.x = 42.0;
686            })
687            .expect("update");
688
689        // Load fresh and verify
690        let store2 = SceneStore::with_data_dir(dir.path()).expect("store2");
691        store2
692            .load_session_from_disk(DEFAULT_SESSION)
693            .expect("load");
694        let scene = store2.get(DEFAULT_SESSION).expect("exists");
695        let elements: Vec<_> = scene.elements().collect();
696        assert_eq!(elements.len(), 1);
697        assert!((elements[0].transform.x - 42.0).abs() < f32::EPSILON);
698    }
699
700    #[test]
701    fn test_load_all_sessions() {
702        let dir = tempfile::tempdir().expect("tempdir");
703        let store = SceneStore::with_data_dir(dir.path()).expect("store");
704
705        // Create multiple sessions with elements so they get persisted
706        for name in &["session-a", "session-b", "session-c"] {
707            store
708                .add_element(
709                    name,
710                    Element::new(ElementKind::Text {
711                        content: format!("In {name}"),
712                        font_size: 12.0,
713                        color: "#000".to_string(),
714                    }),
715                )
716                .expect("add");
717        }
718
719        let found = store.load_all_sessions().expect("list");
720        assert!(found.contains(&"session-a".to_string()));
721        assert!(found.contains(&"session-b".to_string()));
722        assert!(found.contains(&"session-c".to_string()));
723    }
724
725    #[test]
726    fn test_persistence_clear_saves() {
727        let dir = tempfile::tempdir().expect("tempdir");
728        let store = SceneStore::with_data_dir(dir.path()).expect("store");
729
730        store
731            .add_element(
732                DEFAULT_SESSION,
733                Element::new(ElementKind::Text {
734                    content: "Clearable".to_string(),
735                    font_size: 12.0,
736                    color: "#000".to_string(),
737                }),
738            )
739            .expect("add");
740
741        store.clear(DEFAULT_SESSION).expect("clear");
742
743        // Load fresh and verify cleared
744        let store2 = SceneStore::with_data_dir(dir.path()).expect("store2");
745        store2
746            .load_session_from_disk(DEFAULT_SESSION)
747            .expect("load");
748        let scene = store2.get(DEFAULT_SESSION).expect("exists");
749        assert!(scene.is_empty());
750    }
751
752    #[test]
753    fn test_persistence_delete_session_file() {
754        let dir = tempfile::tempdir().expect("tempdir");
755        let store = SceneStore::with_data_dir(dir.path()).expect("store");
756
757        store
758            .add_element(
759                DEFAULT_SESSION,
760                Element::new(ElementKind::Text {
761                    content: "Delete me".to_string(),
762                    font_size: 12.0,
763                    color: "#000".to_string(),
764                }),
765            )
766            .expect("add");
767
768        let path = dir.path().join(format!("{DEFAULT_SESSION}.json"));
769        assert!(path.exists());
770
771        store.delete_session_file(DEFAULT_SESSION);
772        assert!(!path.exists());
773    }
774
775    #[test]
776    fn test_sanitize_filename() {
777        assert_eq!(sanitize_filename("simple"), "simple");
778        assert_eq!(sanitize_filename("with-dash"), "with-dash");
779        assert_eq!(sanitize_filename("with_under"), "with_under");
780        assert_eq!(sanitize_filename("has/slash"), "has_slash");
781        assert_eq!(sanitize_filename("has space"), "has_space");
782        assert_eq!(sanitize_filename("a.b.c"), "a_b_c");
783    }
784}