1use std::collections::BTreeMap;
38use std::hash::{Hash, Hasher};
39
40use serde::{Deserialize, Serialize};
41
42use crate::pane::{
43 PANE_TREE_SCHEMA_VERSION, PaneId, PaneInteractionTimeline, PaneModelError, PaneNodeKind,
44 PaneTree, PaneTreeSnapshot,
45};
46
47pub const WORKSPACE_SCHEMA_VERSION: u16 = 1;
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct WorkspaceSnapshot {
59 #[serde(default = "default_workspace_version")]
61 pub schema_version: u16,
62 pub pane_tree: PaneTreeSnapshot,
64 #[serde(default)]
66 pub active_pane_id: Option<PaneId>,
67 pub metadata: WorkspaceMetadata,
69 #[serde(default)]
71 pub interaction_timeline: PaneInteractionTimeline,
72 #[serde(default)]
74 pub extensions: BTreeMap<String, String>,
75}
76
77fn default_workspace_version() -> u16 {
78 WORKSPACE_SCHEMA_VERSION
79}
80
81impl WorkspaceSnapshot {
82 #[must_use]
84 pub fn new(pane_tree: PaneTreeSnapshot, metadata: WorkspaceMetadata) -> Self {
85 Self {
86 schema_version: WORKSPACE_SCHEMA_VERSION,
87 pane_tree,
88 active_pane_id: None,
89 metadata,
90 interaction_timeline: PaneInteractionTimeline::default(),
91 extensions: BTreeMap::new(),
92 }
93 }
94
95 #[must_use]
97 pub fn with_active_pane(mut self, pane_id: PaneId) -> Self {
98 self.active_pane_id = Some(pane_id);
99 self
100 }
101
102 pub fn validate(&self) -> Result<(), WorkspaceValidationError> {
104 if self.schema_version != WORKSPACE_SCHEMA_VERSION {
106 return Err(WorkspaceValidationError::UnsupportedVersion {
107 found: self.schema_version,
108 expected: WORKSPACE_SCHEMA_VERSION,
109 });
110 }
111
112 if self.pane_tree.schema_version != PANE_TREE_SCHEMA_VERSION {
114 return Err(WorkspaceValidationError::PaneTreeVersionMismatch {
115 found: self.pane_tree.schema_version,
116 expected: PANE_TREE_SCHEMA_VERSION,
117 });
118 }
119
120 let report = self.pane_tree.invariant_report();
122 if report.has_errors() {
123 return Err(WorkspaceValidationError::PaneTreeInvalid {
124 issue_count: report.issues.len(),
125 first_issue: report
126 .issues
127 .first()
128 .map(|i| format!("{:?}", i.code))
129 .unwrap_or_default(),
130 });
131 }
132
133 if let Some(active_id) = self.active_pane_id {
135 let found = self.pane_tree.nodes.iter().any(|n| n.id == active_id);
136 if !found {
137 return Err(WorkspaceValidationError::ActivePaneNotFound { pane_id: active_id });
138 }
139 let is_leaf = self
141 .pane_tree
142 .nodes
143 .iter()
144 .find(|n| n.id == active_id)
145 .map(|n| matches!(n.kind, PaneNodeKind::Leaf(_)))
146 .unwrap_or(false);
147 if !is_leaf {
148 return Err(WorkspaceValidationError::ActivePaneNotLeaf { pane_id: active_id });
149 }
150 }
151
152 if self.metadata.name.is_empty() {
154 return Err(WorkspaceValidationError::EmptyWorkspaceName);
155 }
156
157 if self.interaction_timeline.cursor > self.interaction_timeline.entries.len() {
158 return Err(WorkspaceValidationError::TimelineCursorOutOfRange {
159 cursor: self.interaction_timeline.cursor,
160 len: self.interaction_timeline.entries.len(),
161 });
162 }
163
164 if self.interaction_timeline.baseline.is_some()
166 || !self.interaction_timeline.entries.is_empty()
167 {
168 let replayed_tree = self.interaction_timeline.replay().map_err(|err| {
169 WorkspaceValidationError::TimelineReplayFailed {
170 reason: err.to_string(),
171 }
172 })?;
173 let pane_tree = PaneTree::from_snapshot(self.pane_tree.clone())
174 .map_err(WorkspaceValidationError::PaneModel)?;
175 let pane_tree_hash = pane_tree.state_hash();
176 let replay_hash = replayed_tree.state_hash();
177 if replay_hash != pane_tree_hash {
178 return Err(WorkspaceValidationError::TimelineReplayMismatch {
179 pane_tree_hash,
180 replay_hash,
181 });
182 }
183 }
184
185 Ok(())
186 }
187
188 pub fn canonicalize(&mut self) {
190 self.pane_tree.canonicalize();
191 }
192
193 #[must_use]
195 pub fn state_hash(&self) -> u64 {
196 let mut hasher = std::collections::hash_map::DefaultHasher::new();
197 self.schema_version.hash(&mut hasher);
198 self.pane_tree.state_hash().hash(&mut hasher);
199 self.active_pane_id.map(|id| id.get()).hash(&mut hasher);
200 self.metadata.name.hash(&mut hasher);
201 self.metadata.created_generation.hash(&mut hasher);
202 for (k, v) in &self.extensions {
203 k.hash(&mut hasher);
204 v.hash(&mut hasher);
205 }
206 hasher.finish()
207 }
208
209 #[must_use]
211 pub fn leaf_count(&self) -> usize {
212 self.pane_tree
213 .nodes
214 .iter()
215 .filter(|n| matches!(n.kind, PaneNodeKind::Leaf(_)))
216 .count()
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct WorkspaceMetadata {
227 pub name: String,
229 #[serde(default)]
231 pub created_generation: u64,
232 #[serde(default)]
234 pub saved_generation: u64,
235 #[serde(default)]
237 pub app_version: String,
238 #[serde(default)]
240 pub tags: BTreeMap<String, String>,
241}
242
243impl WorkspaceMetadata {
244 #[must_use]
246 pub fn new(name: impl Into<String>) -> Self {
247 Self {
248 name: name.into(),
249 created_generation: 0,
250 saved_generation: 0,
251 app_version: String::new(),
252 tags: BTreeMap::new(),
253 }
254 }
255
256 #[must_use]
258 pub fn with_app_version(mut self, version: impl Into<String>) -> Self {
259 self.app_version = version.into();
260 self
261 }
262
263 pub fn increment_generation(&mut self) {
265 self.saved_generation = self.saved_generation.saturating_add(1);
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum WorkspaceValidationError {
276 UnsupportedVersion { found: u16, expected: u16 },
278 PaneTreeVersionMismatch { found: u16, expected: u16 },
280 PaneTreeInvalid {
282 issue_count: usize,
283 first_issue: String,
284 },
285 ActivePaneNotFound { pane_id: PaneId },
287 ActivePaneNotLeaf { pane_id: PaneId },
289 EmptyWorkspaceName,
291 TimelineCursorOutOfRange { cursor: usize, len: usize },
293 TimelineReplayFailed { reason: String },
295 TimelineReplayMismatch {
297 pane_tree_hash: u64,
298 replay_hash: u64,
299 },
300 PaneModel(PaneModelError),
302}
303
304impl fmt::Display for WorkspaceValidationError {
305 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306 match self {
307 Self::UnsupportedVersion { found, expected } => {
308 write!(
309 f,
310 "unsupported workspace schema version {found} (expected {expected})"
311 )
312 }
313 Self::PaneTreeVersionMismatch { found, expected } => {
314 write!(
315 f,
316 "pane tree schema version {found} does not match expected {expected}"
317 )
318 }
319 Self::PaneTreeInvalid {
320 issue_count,
321 first_issue,
322 } => {
323 write!(
324 f,
325 "pane tree has {issue_count} invariant violation(s), first: {first_issue}"
326 )
327 }
328 Self::ActivePaneNotFound { pane_id } => {
329 write!(f, "active pane {} not found in tree", pane_id.get())
330 }
331 Self::ActivePaneNotLeaf { pane_id } => {
332 write!(f, "active pane {} is a split, not a leaf", pane_id.get())
333 }
334 Self::EmptyWorkspaceName => write!(f, "workspace name must not be empty"),
335 Self::TimelineCursorOutOfRange { cursor, len } => write!(
336 f,
337 "interaction timeline cursor {cursor} out of bounds for history length {len}"
338 ),
339 Self::TimelineReplayFailed { reason } => {
340 write!(f, "interaction timeline replay failed: {reason}")
341 }
342 Self::TimelineReplayMismatch {
343 pane_tree_hash,
344 replay_hash,
345 } => write!(
346 f,
347 "interaction timeline replay hash {replay_hash} does not match pane tree hash {pane_tree_hash}"
348 ),
349 Self::PaneModel(e) => write!(f, "pane model error: {e}"),
350 }
351 }
352}
353
354impl From<PaneModelError> for WorkspaceValidationError {
355 fn from(err: PaneModelError) -> Self {
356 Self::PaneModel(err)
357 }
358}
359
360use std::fmt;
361
362#[derive(Debug, Clone)]
368pub struct MigrationResult {
369 pub snapshot: WorkspaceSnapshot,
371 pub from_version: u16,
373 pub to_version: u16,
375 pub warnings: Vec<String>,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
381pub enum WorkspaceMigrationError {
382 UnsupportedVersion { version: u16 },
384 NoMigrationPath { from: u16, to: u16 },
386 DeserializationFailed { reason: String },
388}
389
390impl fmt::Display for WorkspaceMigrationError {
391 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392 match self {
393 Self::UnsupportedVersion { version } => {
394 write!(f, "unsupported schema version {version} for migration")
395 }
396 Self::NoMigrationPath { from, to } => {
397 write!(f, "no migration path from v{from} to v{to}")
398 }
399 Self::DeserializationFailed { reason } => {
400 write!(f, "deserialization failed during migration: {reason}")
401 }
402 }
403 }
404}
405
406pub fn migrate_workspace(
411 snapshot: WorkspaceSnapshot,
412) -> Result<MigrationResult, WorkspaceMigrationError> {
413 match snapshot.schema_version {
414 WORKSPACE_SCHEMA_VERSION => {
415 Ok(MigrationResult {
417 from_version: WORKSPACE_SCHEMA_VERSION,
418 to_version: WORKSPACE_SCHEMA_VERSION,
419 warnings: Vec::new(),
420 snapshot,
421 })
422 }
423 v if v > WORKSPACE_SCHEMA_VERSION => {
424 Err(WorkspaceMigrationError::UnsupportedVersion { version: v })
425 }
426 v => Err(WorkspaceMigrationError::NoMigrationPath {
427 from: v,
428 to: WORKSPACE_SCHEMA_VERSION,
429 }),
430 }
431}
432
433#[must_use]
435pub fn needs_migration(snapshot: &WorkspaceSnapshot) -> bool {
436 snapshot.schema_version != WORKSPACE_SCHEMA_VERSION
437}
438
439#[cfg(test)]
444mod tests {
445 use super::*;
446 use crate::pane::{
447 PaneInteractionTimelineEntry, PaneLeaf, PaneNodeKind, PaneNodeRecord, PaneOperation,
448 PaneSplit, PaneSplitRatio, PaneTree, SplitAxis,
449 };
450
451 fn minimal_tree() -> PaneTreeSnapshot {
452 PaneTreeSnapshot {
453 schema_version: PANE_TREE_SCHEMA_VERSION,
454 root: PaneId::default(),
455 next_id: PaneId::new(2).unwrap(),
456 nodes: vec![PaneNodeRecord::leaf(
457 PaneId::default(),
458 None,
459 PaneLeaf::new("main"),
460 )],
461 extensions: BTreeMap::new(),
462 }
463 }
464
465 fn split_tree() -> PaneTreeSnapshot {
466 let root_id = PaneId::new(1).unwrap();
467 let left_id = PaneId::new(2).unwrap();
468 let right_id = PaneId::new(3).unwrap();
469 PaneTreeSnapshot {
470 schema_version: PANE_TREE_SCHEMA_VERSION,
471 root: root_id,
472 next_id: PaneId::new(4).unwrap(),
473 nodes: vec![
474 PaneNodeRecord::split(
475 root_id,
476 None,
477 PaneSplit {
478 axis: SplitAxis::Horizontal,
479 ratio: PaneSplitRatio::new(1, 1).unwrap(),
480 first: left_id,
481 second: right_id,
482 },
483 ),
484 PaneNodeRecord::leaf(left_id, Some(root_id), PaneLeaf::new("left")),
485 PaneNodeRecord::leaf(right_id, Some(root_id), PaneLeaf::new("right")),
486 ],
487 extensions: BTreeMap::new(),
488 }
489 }
490
491 fn minimal_snapshot() -> WorkspaceSnapshot {
492 WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("test"))
493 }
494
495 #[test]
498 fn new_snapshot_has_v1() {
499 let snap = minimal_snapshot();
500 assert_eq!(snap.schema_version, WORKSPACE_SCHEMA_VERSION);
501 assert_eq!(snap.schema_version, 1);
502 }
503
504 #[test]
505 fn with_active_pane_sets_id() {
506 let id = PaneId::default();
507 let snap = minimal_snapshot().with_active_pane(id);
508 assert_eq!(snap.active_pane_id, Some(id));
509 }
510
511 #[test]
512 fn metadata_new_defaults() {
513 let meta = WorkspaceMetadata::new("ws");
514 assert_eq!(meta.name, "ws");
515 assert_eq!(meta.created_generation, 0);
516 assert_eq!(meta.saved_generation, 0);
517 assert!(meta.app_version.is_empty());
518 assert!(meta.tags.is_empty());
519 }
520
521 #[test]
522 fn metadata_with_app_version() {
523 let meta = WorkspaceMetadata::new("ws").with_app_version("0.1.0");
524 assert_eq!(meta.app_version, "0.1.0");
525 }
526
527 #[test]
528 fn metadata_increment_generation() {
529 let mut meta = WorkspaceMetadata::new("ws");
530 meta.increment_generation();
531 assert_eq!(meta.saved_generation, 1);
532 meta.increment_generation();
533 assert_eq!(meta.saved_generation, 2);
534 }
535
536 #[test]
539 fn validate_minimal_ok() {
540 let snap = minimal_snapshot();
541 assert!(snap.validate().is_ok());
542 }
543
544 #[test]
545 fn validate_split_tree_ok() {
546 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("split"));
547 assert!(snap.validate().is_ok());
548 }
549
550 #[test]
551 fn validate_wrong_workspace_version() {
552 let mut snap = minimal_snapshot();
553 snap.schema_version = 99;
554 let err = snap.validate().unwrap_err();
555 assert!(matches!(
556 err,
557 WorkspaceValidationError::UnsupportedVersion {
558 found: 99,
559 expected: 1
560 }
561 ));
562 }
563
564 #[test]
565 fn validate_wrong_pane_tree_version() {
566 let mut snap = minimal_snapshot();
567 snap.pane_tree.schema_version = 42;
568 let err = snap.validate().unwrap_err();
569 assert!(matches!(
570 err,
571 WorkspaceValidationError::PaneTreeVersionMismatch { .. }
572 ));
573 }
574
575 #[test]
576 fn validate_active_pane_not_found() {
577 let snap = minimal_snapshot().with_active_pane(PaneId::new(999).unwrap());
578 let err = snap.validate().unwrap_err();
579 assert!(matches!(
580 err,
581 WorkspaceValidationError::ActivePaneNotFound { .. }
582 ));
583 }
584
585 #[test]
586 fn validate_active_pane_is_split() {
587 let root_id = PaneId::new(1).unwrap();
588 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"))
589 .with_active_pane(root_id);
590 let err = snap.validate().unwrap_err();
591 assert!(matches!(
592 err,
593 WorkspaceValidationError::ActivePaneNotLeaf { .. }
594 ));
595 }
596
597 #[test]
598 fn validate_active_pane_leaf_ok() {
599 let left_id = PaneId::new(2).unwrap();
600 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"))
601 .with_active_pane(left_id);
602 assert!(snap.validate().is_ok());
603 }
604
605 #[test]
606 fn validate_empty_name() {
607 let snap = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new(""));
608 let err = snap.validate().unwrap_err();
609 assert!(matches!(err, WorkspaceValidationError::EmptyWorkspaceName));
610 }
611
612 #[test]
613 fn validate_timeline_cursor_out_of_range() {
614 let mut snap = minimal_snapshot();
615 snap.interaction_timeline.cursor = 2;
616 snap.interaction_timeline
617 .entries
618 .push(PaneInteractionTimelineEntry {
619 sequence: 1,
620 operation_id: 10,
621 operation: PaneOperation::NormalizeRatios,
622 before_hash: 1,
623 after_hash: 2,
624 });
625 let err = snap.validate().unwrap_err();
626 assert!(matches!(
627 err,
628 WorkspaceValidationError::TimelineCursorOutOfRange { .. }
629 ));
630 }
631
632 #[test]
633 fn validate_timeline_with_entries_requires_baseline() {
634 let mut snap = minimal_snapshot();
635 snap.interaction_timeline.cursor = 1;
636 snap.interaction_timeline
637 .entries
638 .push(PaneInteractionTimelineEntry {
639 sequence: 1,
640 operation_id: 10,
641 operation: PaneOperation::NormalizeRatios,
642 before_hash: 1,
643 after_hash: 2,
644 });
645 let err = snap.validate().unwrap_err();
646 assert!(matches!(
647 err,
648 WorkspaceValidationError::TimelineReplayFailed { .. }
649 ));
650 }
651
652 #[test]
653 fn validate_rejects_timeline_replay_mismatch() {
654 let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("mismatch"));
655 let baseline_tree = PaneTree::from_snapshot(minimal_tree())
656 .expect("minimal pane tree snapshot should load");
657 snap.interaction_timeline = PaneInteractionTimeline::with_baseline(&baseline_tree);
658 let err = snap.validate().unwrap_err();
659 assert!(matches!(
660 err,
661 WorkspaceValidationError::TimelineReplayMismatch { .. }
662 ));
663 }
664
665 #[test]
671 fn serde_serialize_minimal_succeeds() {
672 let snap = minimal_snapshot();
673 let json = serde_json::to_string(&snap).unwrap();
674 assert!(json.contains("\"schema_version\":1"));
675 assert!(json.contains("\"name\":\"test\""));
676 }
677
678 #[test]
679 fn serde_serialize_split_tree_succeeds() {
680 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("split"))
681 .with_active_pane(PaneId::new(2).unwrap());
682 let json = serde_json::to_string_pretty(&snap).unwrap();
683 assert!(json.contains("\"active_pane_id\": 2"));
684 assert!(json.contains("\"name\": \"split\""));
685 }
686
687 #[test]
688 fn serde_roundtrip_snapshot_preserves_leaf_and_node_extensions() {
689 let mut tree = minimal_tree();
690 tree.extensions
691 .insert("tree_scope".to_string(), "tree".to_string());
692 tree.nodes[0]
693 .extensions
694 .insert("node_scope".to_string(), "node".to_string());
695 let PaneNodeKind::Leaf(leaf) = &mut tree.nodes[0].kind else {
696 panic!("minimal tree root should be leaf");
697 };
698 leaf.extensions
699 .insert("leaf_scope".to_string(), "leaf".to_string());
700
701 let mut snap = WorkspaceSnapshot::new(tree, WorkspaceMetadata::new("roundtrip"));
702 snap.extensions
703 .insert("workspace_scope".to_string(), "workspace".to_string());
704 snap.metadata
705 .tags
706 .insert("metadata_scope".to_string(), "metadata".to_string());
707
708 let json = serde_json::to_string(&snap).unwrap();
709 let decoded: WorkspaceSnapshot = serde_json::from_str(&json).unwrap();
710
711 assert_eq!(
712 decoded
713 .extensions
714 .get("workspace_scope")
715 .map(std::string::String::as_str),
716 Some("workspace")
717 );
718 assert_eq!(
719 decoded
720 .pane_tree
721 .extensions
722 .get("tree_scope")
723 .map(std::string::String::as_str),
724 Some("tree")
725 );
726 assert_eq!(
727 decoded.pane_tree.nodes[0]
728 .extensions
729 .get("node_scope")
730 .map(std::string::String::as_str),
731 Some("node")
732 );
733 let PaneNodeKind::Leaf(decoded_leaf) = &decoded.pane_tree.nodes[0].kind else {
734 panic!("decoded minimal tree root should be leaf");
735 };
736 assert_eq!(
737 decoded_leaf
738 .extensions
739 .get("leaf_scope")
740 .map(std::string::String::as_str),
741 Some("leaf")
742 );
743 }
744
745 #[test]
746 fn serde_deserialize_from_handcrafted_json() {
747 let json = r#"{
750 "schema_version": 1,
751 "pane_tree": {
752 "schema_version": 1,
753 "root": 1,
754 "next_id": 2,
755 "nodes": [
756 {"id": 1, "kind": "leaf", "surface_key": "main"}
757 ]
758 },
759 "active_pane_id": 1,
760 "metadata": {"name": "from-json"},
761 "extensions": {"extra": "data"}
762 }"#;
763 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
764 assert_eq!(snap.schema_version, 1);
765 assert_eq!(snap.active_pane_id, Some(PaneId::default()));
766 assert_eq!(snap.metadata.name, "from-json");
767 assert_eq!(snap.extensions.get("extra").unwrap(), "data");
768 assert_eq!(snap.leaf_count(), 1);
769 }
770
771 #[test]
772 fn serde_workspace_extensions_and_tags_preserved() {
773 let json = r#"{
774 "pane_tree": {
775 "root": 1,
776 "next_id": 2,
777 "nodes": [{"id": 1, "kind": "leaf", "surface_key": "main"}]
778 },
779 "metadata": {
780 "name": "ext-test",
781 "tags": {"custom": "tag"}
782 },
783 "extensions": {"future_field": "value"}
784 }"#;
785 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
786 assert_eq!(snap.extensions.get("future_field").unwrap(), "value");
787 assert_eq!(snap.metadata.tags.get("custom").unwrap(), "tag");
788 }
789
790 #[test]
791 fn serde_metadata_roundtrip() {
792 let mut meta = WorkspaceMetadata::new("round-trip");
794 meta.app_version = "1.0.0".to_string();
795 meta.created_generation = 5;
796 meta.saved_generation = 10;
797 meta.tags.insert("k".to_string(), "v".to_string());
798 let json = serde_json::to_string(&meta).unwrap();
799 let deser: WorkspaceMetadata = serde_json::from_str(&json).unwrap();
800 assert_eq!(meta, deser);
801 }
802
803 #[test]
804 fn serde_missing_optional_fields_default() {
805 let json = r#"{
807 "pane_tree": {
808 "root": 1,
809 "next_id": 2,
810 "nodes": [{"id": 1, "kind": "leaf", "surface_key": "main"}]
811 },
812 "metadata": {"name": "test"}
813 }"#;
814 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
815 assert_eq!(snap.schema_version, WORKSPACE_SCHEMA_VERSION);
816 assert!(snap.active_pane_id.is_none());
817 assert!(snap.extensions.is_empty());
818 }
819
820 #[test]
823 fn state_hash_deterministic() {
824 let s1 = minimal_snapshot();
825 let s2 = minimal_snapshot();
826 assert_eq!(s1.state_hash(), s2.state_hash());
827 }
828
829 #[test]
830 fn state_hash_changes_with_active_pane() {
831 let s1 = minimal_snapshot();
832 let s2 = minimal_snapshot().with_active_pane(PaneId::default());
833 assert_ne!(s1.state_hash(), s2.state_hash());
834 }
835
836 #[test]
837 fn state_hash_changes_with_name() {
838 let s1 = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("a"));
839 let s2 = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("b"));
840 assert_ne!(s1.state_hash(), s2.state_hash());
841 }
842
843 #[test]
846 fn canonicalize_sorts_nodes() {
847 let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
848 snap.pane_tree.nodes.reverse();
850 snap.canonicalize();
851 let ids: Vec<u64> = snap.pane_tree.nodes.iter().map(|n| n.id.get()).collect();
852 assert!(
853 ids.windows(2).all(|w| w[0] <= w[1]),
854 "nodes should be sorted by ID"
855 );
856 }
857
858 #[test]
861 fn leaf_count_single() {
862 let snap = minimal_snapshot();
863 assert_eq!(snap.leaf_count(), 1);
864 }
865
866 #[test]
867 fn leaf_count_split() {
868 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
869 assert_eq!(snap.leaf_count(), 2);
870 }
871
872 #[test]
875 fn migrate_v1_is_noop() {
876 let snap = minimal_snapshot();
877 let result = migrate_workspace(snap.clone()).unwrap();
878 assert_eq!(result.from_version, 1);
879 assert_eq!(result.to_version, 1);
880 assert_eq!(result.snapshot, snap);
881 assert!(result.warnings.is_empty());
882 }
883
884 #[test]
885 fn migrate_future_version_fails() {
886 let mut snap = minimal_snapshot();
887 snap.schema_version = 99;
888 let err = migrate_workspace(snap).unwrap_err();
889 assert!(matches!(
890 err,
891 WorkspaceMigrationError::UnsupportedVersion { version: 99 }
892 ));
893 }
894
895 #[test]
896 fn migrate_old_version_fails_no_path() {
897 let mut snap = minimal_snapshot();
898 snap.schema_version = 0;
899 let err = migrate_workspace(snap).unwrap_err();
900 assert!(matches!(
901 err,
902 WorkspaceMigrationError::NoMigrationPath { from: 0, to: 1 }
903 ));
904 }
905
906 #[test]
907 fn needs_migration_false_for_current() {
908 let snap = minimal_snapshot();
909 assert!(!needs_migration(&snap));
910 }
911
912 #[test]
913 fn needs_migration_true_for_old() {
914 let mut snap = minimal_snapshot();
915 snap.schema_version = 0;
916 assert!(needs_migration(&snap));
917 }
918
919 #[test]
922 fn validation_error_display() {
923 let err = WorkspaceValidationError::UnsupportedVersion {
924 found: 99,
925 expected: 1,
926 };
927 let msg = format!("{err}");
928 assert!(msg.contains("99"));
929 assert!(msg.contains("1"));
930 }
931
932 #[test]
933 fn migration_error_display() {
934 let err = WorkspaceMigrationError::NoMigrationPath { from: 0, to: 1 };
935 let msg = format!("{err}");
936 assert!(msg.contains("v0"));
937 assert!(msg.contains("v1"));
938 }
939
940 #[test]
941 fn validation_error_from_pane_model() {
942 let pane_err = PaneModelError::ZeroPaneId;
943 let ws_err: WorkspaceValidationError = pane_err.into();
944 assert!(matches!(ws_err, WorkspaceValidationError::PaneModel(_)));
945 }
946
947 #[test]
950 fn identical_inputs_identical_validation() {
951 let s1 = minimal_snapshot();
952 let s2 = minimal_snapshot();
953 assert_eq!(s1.validate().is_ok(), s2.validate().is_ok());
954 }
955
956 #[test]
957 fn identical_inputs_identical_migration() {
958 let s1 = minimal_snapshot();
959 let s2 = minimal_snapshot();
960 let r1 = migrate_workspace(s1).unwrap();
961 let r2 = migrate_workspace(s2).unwrap();
962 assert_eq!(r1.snapshot, r2.snapshot);
963 }
964}