1use 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
13pub const DEFAULT_SESSION: &str = "default";
15
16const DEFAULT_WIDTH: f32 = 800.0;
18
19const DEFAULT_HEIGHT: f32 = 600.0;
21
22#[derive(Debug, thiserror::Error)]
24pub enum StoreError {
25 #[error("Lock poisoned")]
27 LockPoisoned,
28 #[error("Session not found: {0}")]
30 SessionNotFound(String),
31 #[error("Element not found: {0}")]
33 ElementNotFound(String),
34 #[error("Scene error: {0}")]
36 SceneError(String),
37 #[error("IO error: {0}")]
39 Io(#[from] std::io::Error),
40 #[error("Serialization error: {0}")]
42 Serialization(String),
43}
44
45#[derive(Debug, Clone, Default)]
65pub struct SceneStore {
66 scenes: Arc<RwLock<HashMap<String, Scene>>>,
67 data_dir: Option<PathBuf>,
69}
70
71impl SceneStore {
72 #[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 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 #[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 #[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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 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
404fn 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
420fn current_timestamp_ms() -> u64 {
422 SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| {
423 #[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 #[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 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 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 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 let path = dir.path().join(format!("{DEFAULT_SESSION}.json"));
680 assert!(path.exists(), "JSON file should be written on add_element");
681
682 store
684 .update_element(DEFAULT_SESSION, id, |el| {
685 el.transform.x = 42.0;
686 })
687 .expect("update");
688
689 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 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 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}