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)]
59pub struct WorkspaceSnapshot {
60 #[serde(default = "default_workspace_version")]
62 pub schema_version: u16,
63 pub pane_tree: PaneTreeSnapshot,
65 #[serde(default)]
67 pub active_pane_id: Option<PaneId>,
68 pub metadata: WorkspaceMetadata,
70 #[serde(default)]
72 pub interaction_timeline: PaneInteractionTimeline,
73 #[serde(default)]
75 pub extensions: BTreeMap<String, String>,
76}
77
78fn default_workspace_version() -> u16 {
79 WORKSPACE_SCHEMA_VERSION
80}
81
82impl WorkspaceSnapshot {
83 #[must_use]
85 pub fn new(pane_tree: PaneTreeSnapshot, metadata: WorkspaceMetadata) -> Self {
86 Self {
87 schema_version: WORKSPACE_SCHEMA_VERSION,
88 pane_tree,
89 active_pane_id: None,
90 metadata,
91 interaction_timeline: PaneInteractionTimeline::default(),
92 extensions: BTreeMap::new(),
93 }
94 }
95
96 #[must_use]
98 pub fn with_active_pane(mut self, pane_id: PaneId) -> Self {
99 self.active_pane_id = Some(pane_id);
100 self
101 }
102
103 pub fn validate(&self) -> Result<(), WorkspaceValidationError> {
105 if self.schema_version != WORKSPACE_SCHEMA_VERSION {
107 return Err(WorkspaceValidationError::UnsupportedVersion {
108 found: self.schema_version,
109 expected: WORKSPACE_SCHEMA_VERSION,
110 });
111 }
112
113 if self.pane_tree.schema_version != PANE_TREE_SCHEMA_VERSION {
115 return Err(WorkspaceValidationError::PaneTreeVersionMismatch {
116 found: self.pane_tree.schema_version,
117 expected: PANE_TREE_SCHEMA_VERSION,
118 });
119 }
120
121 let report = self.pane_tree.invariant_report();
123 if report.has_errors() {
124 return Err(WorkspaceValidationError::PaneTreeInvalid {
125 issue_count: report.issues.len(),
126 first_issue: report
127 .issues
128 .first()
129 .map(|i| format!("{:?}", i.code))
130 .unwrap_or_default(),
131 });
132 }
133
134 if let Some(active_id) = self.active_pane_id {
136 let found = self.pane_tree.nodes.iter().any(|n| n.id == active_id);
137 if !found {
138 return Err(WorkspaceValidationError::ActivePaneNotFound { pane_id: active_id });
139 }
140 let is_leaf = self
142 .pane_tree
143 .nodes
144 .iter()
145 .find(|n| n.id == active_id)
146 .map(|n| matches!(n.kind, PaneNodeKind::Leaf(_)))
147 .unwrap_or(false);
148 if !is_leaf {
149 return Err(WorkspaceValidationError::ActivePaneNotLeaf { pane_id: active_id });
150 }
151 }
152
153 if self.metadata.name.is_empty() {
155 return Err(WorkspaceValidationError::EmptyWorkspaceName);
156 }
157
158 if self.interaction_timeline.cursor > self.interaction_timeline.entries.len() {
159 return Err(WorkspaceValidationError::TimelineCursorOutOfRange {
160 cursor: self.interaction_timeline.cursor,
161 len: self.interaction_timeline.entries.len(),
162 });
163 }
164
165 if self.interaction_timeline.baseline.is_some()
167 || !self.interaction_timeline.entries.is_empty()
168 {
169 let replayed_tree = self.interaction_timeline.replay().map_err(|err| {
170 WorkspaceValidationError::TimelineReplayFailed {
171 reason: err.to_string(),
172 }
173 })?;
174 let pane_tree = PaneTree::from_snapshot(self.pane_tree.clone())
175 .map_err(WorkspaceValidationError::PaneModel)?;
176 let pane_tree_hash = pane_tree.state_hash();
177 let replay_hash = replayed_tree.state_hash();
178 if replay_hash != pane_tree_hash {
179 return Err(WorkspaceValidationError::TimelineReplayMismatch {
180 pane_tree_hash,
181 replay_hash,
182 });
183 }
184 }
185
186 Ok(())
187 }
188
189 pub fn canonicalize(&mut self) {
191 self.pane_tree.canonicalize();
192 }
193
194 #[must_use]
196 pub fn state_hash(&self) -> u64 {
197 let mut hasher = std::collections::hash_map::DefaultHasher::new();
198 self.schema_version.hash(&mut hasher);
199 self.pane_tree.state_hash().hash(&mut hasher);
200 self.active_pane_id.map(|id| id.get()).hash(&mut hasher);
201 self.metadata.name.hash(&mut hasher);
202 self.metadata.created_generation.hash(&mut hasher);
203 for (k, v) in &self.extensions {
204 k.hash(&mut hasher);
205 v.hash(&mut hasher);
206 }
207 hasher.finish()
208 }
209
210 #[must_use]
212 pub fn leaf_count(&self) -> usize {
213 self.pane_tree
214 .nodes
215 .iter()
216 .filter(|n| matches!(n.kind, PaneNodeKind::Leaf(_)))
217 .count()
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct WorkspaceMetadata {
228 pub name: String,
230 #[serde(default)]
232 pub created_generation: u64,
233 #[serde(default)]
235 pub saved_generation: u64,
236 #[serde(default)]
238 pub app_version: String,
239 #[serde(default)]
241 pub tags: BTreeMap<String, String>,
242}
243
244impl WorkspaceMetadata {
245 #[must_use]
247 pub fn new(name: impl Into<String>) -> Self {
248 Self {
249 name: name.into(),
250 created_generation: 0,
251 saved_generation: 0,
252 app_version: String::new(),
253 tags: BTreeMap::new(),
254 }
255 }
256
257 #[must_use]
259 pub fn with_app_version(mut self, version: impl Into<String>) -> Self {
260 self.app_version = version.into();
261 self
262 }
263
264 pub fn increment_generation(&mut self) {
266 self.saved_generation = self.saved_generation.saturating_add(1);
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum WorkspaceValidationError {
277 UnsupportedVersion { found: u16, expected: u16 },
279 PaneTreeVersionMismatch { found: u16, expected: u16 },
281 PaneTreeInvalid {
283 issue_count: usize,
284 first_issue: String,
285 },
286 ActivePaneNotFound { pane_id: PaneId },
288 ActivePaneNotLeaf { pane_id: PaneId },
290 EmptyWorkspaceName,
292 TimelineCursorOutOfRange { cursor: usize, len: usize },
294 TimelineReplayFailed { reason: String },
296 TimelineReplayMismatch {
298 pane_tree_hash: u64,
299 replay_hash: u64,
300 },
301 PaneModel(PaneModelError),
303}
304
305impl fmt::Display for WorkspaceValidationError {
306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307 match self {
308 Self::UnsupportedVersion { found, expected } => {
309 write!(
310 f,
311 "unsupported workspace schema version {found} (expected {expected})"
312 )
313 }
314 Self::PaneTreeVersionMismatch { found, expected } => {
315 write!(
316 f,
317 "pane tree schema version {found} does not match expected {expected}"
318 )
319 }
320 Self::PaneTreeInvalid {
321 issue_count,
322 first_issue,
323 } => {
324 write!(
325 f,
326 "pane tree has {issue_count} invariant violation(s), first: {first_issue}"
327 )
328 }
329 Self::ActivePaneNotFound { pane_id } => {
330 write!(f, "active pane {} not found in tree", pane_id.get())
331 }
332 Self::ActivePaneNotLeaf { pane_id } => {
333 write!(f, "active pane {} is a split, not a leaf", pane_id.get())
334 }
335 Self::EmptyWorkspaceName => write!(f, "workspace name must not be empty"),
336 Self::TimelineCursorOutOfRange { cursor, len } => write!(
337 f,
338 "interaction timeline cursor {cursor} out of bounds for history length {len}"
339 ),
340 Self::TimelineReplayFailed { reason } => {
341 write!(f, "interaction timeline replay failed: {reason}")
342 }
343 Self::TimelineReplayMismatch {
344 pane_tree_hash,
345 replay_hash,
346 } => write!(
347 f,
348 "interaction timeline replay hash {replay_hash} does not match pane tree hash {pane_tree_hash}"
349 ),
350 Self::PaneModel(e) => write!(f, "pane model error: {e}"),
351 }
352 }
353}
354
355impl From<PaneModelError> for WorkspaceValidationError {
356 fn from(err: PaneModelError) -> Self {
357 Self::PaneModel(err)
358 }
359}
360
361use std::fmt;
362
363#[derive(Debug, Clone)]
369pub struct MigrationResult {
370 pub snapshot: WorkspaceSnapshot,
372 pub from_version: u16,
374 pub to_version: u16,
376 pub warnings: Vec<String>,
378}
379
380impl MigrationResult {
381 #[must_use]
383 pub fn decision(&self) -> &'static str {
384 if self.from_version == self.to_version {
385 "current_schema"
386 } else {
387 "migrated"
388 }
389 }
390
391 #[must_use]
393 pub fn state_checksum(&self) -> u64 {
394 self.snapshot.state_hash()
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub enum WorkspaceMigrationError {
401 UnsupportedVersion { version: u16 },
403 NoMigrationPath { from: u16, to: u16 },
405 DeserializationFailed { reason: String },
407}
408
409impl fmt::Display for WorkspaceMigrationError {
410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411 match self {
412 Self::UnsupportedVersion { version } => {
413 write!(f, "unsupported schema version {version} for migration")
414 }
415 Self::NoMigrationPath { from, to } => {
416 write!(f, "no migration path from v{from} to v{to}")
417 }
418 Self::DeserializationFailed { reason } => {
419 write!(f, "deserialization failed during migration: {reason}")
420 }
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum WorkspaceSnapshotJsonError {
428 DeserializationFailed { reason: String },
430 MigrationFailed { source: WorkspaceMigrationError },
432 ValidationFailed {
434 context: &'static str,
435 source: WorkspaceValidationError,
436 },
437 SerializationFailed { reason: String },
439}
440
441impl fmt::Display for WorkspaceSnapshotJsonError {
442 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443 match self {
444 Self::DeserializationFailed { reason } => {
445 write!(f, "workspace snapshot parse failed: {reason}")
446 }
447 Self::MigrationFailed { source } => {
448 write!(f, "workspace snapshot migration failed: {source}")
449 }
450 Self::ValidationFailed { context, source } => write!(f, "{context}: {source}"),
451 Self::SerializationFailed { reason } => {
452 write!(f, "workspace snapshot encode failed: {reason}")
453 }
454 }
455 }
456}
457
458pub fn migrate_workspace(
463 snapshot: WorkspaceSnapshot,
464) -> Result<MigrationResult, WorkspaceMigrationError> {
465 match snapshot.schema_version {
466 WORKSPACE_SCHEMA_VERSION => {
467 Ok(MigrationResult {
469 from_version: WORKSPACE_SCHEMA_VERSION,
470 to_version: WORKSPACE_SCHEMA_VERSION,
471 warnings: Vec::new(),
472 snapshot,
473 })
474 }
475 v if v > WORKSPACE_SCHEMA_VERSION => {
476 Err(WorkspaceMigrationError::UnsupportedVersion { version: v })
477 }
478 v => Err(WorkspaceMigrationError::NoMigrationPath {
479 from: v,
480 to: WORKSPACE_SCHEMA_VERSION,
481 }),
482 }
483}
484
485#[must_use]
487pub fn needs_migration(snapshot: &WorkspaceSnapshot) -> bool {
488 snapshot.schema_version != WORKSPACE_SCHEMA_VERSION
489}
490
491pub fn canonicalize_workspace_snapshot(snapshot: &mut WorkspaceSnapshot) {
493 snapshot.canonicalize();
494 if let Some(baseline) = snapshot.interaction_timeline.baseline.as_mut() {
495 baseline.canonicalize();
496 }
497}
498
499pub fn decode_workspace_snapshot_json(
501 json: &str,
502) -> Result<MigrationResult, WorkspaceSnapshotJsonError> {
503 let snapshot: WorkspaceSnapshot = serde_json::from_str(json).map_err(|err| {
504 WorkspaceSnapshotJsonError::DeserializationFailed {
505 reason: err.to_string(),
506 }
507 })?;
508 let mut result = migrate_workspace(snapshot)
509 .map_err(|source| WorkspaceSnapshotJsonError::MigrationFailed { source })?;
510 canonicalize_workspace_snapshot(&mut result.snapshot);
511 result
512 .snapshot
513 .validate()
514 .map_err(|source| WorkspaceSnapshotJsonError::ValidationFailed {
515 context: "workspace snapshot invalid",
516 source,
517 })?;
518 Ok(result)
519}
520
521pub fn to_canonical_workspace_snapshot_json(
523 snapshot: &WorkspaceSnapshot,
524) -> Result<String, WorkspaceSnapshotJsonError> {
525 let mut canonical = snapshot.clone();
526 canonicalize_workspace_snapshot(&mut canonical);
527 canonical
528 .validate()
529 .map_err(|source| WorkspaceSnapshotJsonError::ValidationFailed {
530 context: "workspace snapshot validation failed",
531 source,
532 })?;
533 serde_json::to_string(&canonical).map_err(|err| {
534 WorkspaceSnapshotJsonError::SerializationFailed {
535 reason: err.to_string(),
536 }
537 })
538}
539
540#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::pane::{
548 PaneInteractionTimelineEntry, PaneLeaf, PaneNodeKind, PaneNodeRecord, PaneOperation,
549 PaneSplit, PaneSplitRatio, PaneTree, SplitAxis,
550 };
551
552 fn minimal_tree() -> PaneTreeSnapshot {
553 PaneTreeSnapshot {
554 schema_version: PANE_TREE_SCHEMA_VERSION,
555 root: PaneId::default(),
556 next_id: PaneId::new(2).unwrap(),
557 nodes: vec![PaneNodeRecord::leaf(
558 PaneId::default(),
559 None,
560 PaneLeaf::new("main"),
561 )],
562 extensions: BTreeMap::new(),
563 }
564 }
565
566 fn split_tree() -> PaneTreeSnapshot {
567 let root_id = PaneId::new(1).unwrap();
568 let left_id = PaneId::new(2).unwrap();
569 let right_id = PaneId::new(3).unwrap();
570 PaneTreeSnapshot {
571 schema_version: PANE_TREE_SCHEMA_VERSION,
572 root: root_id,
573 next_id: PaneId::new(4).unwrap(),
574 nodes: vec![
575 PaneNodeRecord::split(
576 root_id,
577 None,
578 PaneSplit {
579 axis: SplitAxis::Horizontal,
580 ratio: PaneSplitRatio::new(1, 1).unwrap(),
581 first: left_id,
582 second: right_id,
583 },
584 ),
585 PaneNodeRecord::leaf(left_id, Some(root_id), PaneLeaf::new("left")),
586 PaneNodeRecord::leaf(right_id, Some(root_id), PaneLeaf::new("right")),
587 ],
588 extensions: BTreeMap::new(),
589 }
590 }
591
592 fn minimal_snapshot() -> WorkspaceSnapshot {
593 WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("test"))
594 }
595
596 #[test]
599 fn new_snapshot_has_v1() {
600 let snap = minimal_snapshot();
601 assert_eq!(snap.schema_version, WORKSPACE_SCHEMA_VERSION);
602 assert_eq!(snap.schema_version, 1);
603 }
604
605 #[test]
606 fn with_active_pane_sets_id() {
607 let id = PaneId::default();
608 let snap = minimal_snapshot().with_active_pane(id);
609 assert_eq!(snap.active_pane_id, Some(id));
610 }
611
612 #[test]
613 fn metadata_new_defaults() {
614 let meta = WorkspaceMetadata::new("ws");
615 assert_eq!(meta.name, "ws");
616 assert_eq!(meta.created_generation, 0);
617 assert_eq!(meta.saved_generation, 0);
618 assert!(meta.app_version.is_empty());
619 assert!(meta.tags.is_empty());
620 }
621
622 #[test]
623 fn metadata_with_app_version() {
624 let meta = WorkspaceMetadata::new("ws").with_app_version("0.1.0");
625 assert_eq!(meta.app_version, "0.1.0");
626 }
627
628 #[test]
629 fn metadata_increment_generation() {
630 let mut meta = WorkspaceMetadata::new("ws");
631 meta.increment_generation();
632 assert_eq!(meta.saved_generation, 1);
633 meta.increment_generation();
634 assert_eq!(meta.saved_generation, 2);
635 }
636
637 #[test]
640 fn validate_minimal_ok() {
641 let snap = minimal_snapshot();
642 assert!(snap.validate().is_ok());
643 }
644
645 #[test]
646 fn validate_split_tree_ok() {
647 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("split"));
648 assert!(snap.validate().is_ok());
649 }
650
651 #[test]
652 fn validate_wrong_workspace_version() {
653 let mut snap = minimal_snapshot();
654 snap.schema_version = 99;
655 let err = snap.validate().unwrap_err();
656 assert!(matches!(
657 err,
658 WorkspaceValidationError::UnsupportedVersion {
659 found: 99,
660 expected: 1
661 }
662 ));
663 }
664
665 #[test]
666 fn validate_wrong_pane_tree_version() {
667 let mut snap = minimal_snapshot();
668 snap.pane_tree.schema_version = 42;
669 let err = snap.validate().unwrap_err();
670 assert!(matches!(
671 err,
672 WorkspaceValidationError::PaneTreeVersionMismatch { .. }
673 ));
674 }
675
676 #[test]
677 fn validate_active_pane_not_found() {
678 let snap = minimal_snapshot().with_active_pane(PaneId::new(999).unwrap());
679 let err = snap.validate().unwrap_err();
680 assert!(matches!(
681 err,
682 WorkspaceValidationError::ActivePaneNotFound { .. }
683 ));
684 }
685
686 #[test]
687 fn validate_active_pane_is_split() {
688 let root_id = PaneId::new(1).unwrap();
689 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"))
690 .with_active_pane(root_id);
691 let err = snap.validate().unwrap_err();
692 assert!(matches!(
693 err,
694 WorkspaceValidationError::ActivePaneNotLeaf { .. }
695 ));
696 }
697
698 #[test]
699 fn validate_active_pane_leaf_ok() {
700 let left_id = PaneId::new(2).unwrap();
701 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"))
702 .with_active_pane(left_id);
703 assert!(snap.validate().is_ok());
704 }
705
706 #[test]
707 fn validate_empty_name() {
708 let snap = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new(""));
709 let err = snap.validate().unwrap_err();
710 assert!(matches!(err, WorkspaceValidationError::EmptyWorkspaceName));
711 }
712
713 #[test]
714 fn validate_timeline_cursor_out_of_range() {
715 let mut snap = minimal_snapshot();
716 snap.interaction_timeline.cursor = 2;
717 snap.interaction_timeline
718 .entries
719 .push(PaneInteractionTimelineEntry {
720 sequence: 1,
721 operation_id: 10,
722 operation: PaneOperation::NormalizeRatios,
723 before_hash: 1,
724 after_hash: 2,
725 });
726 let err = snap.validate().unwrap_err();
727 assert!(matches!(
728 err,
729 WorkspaceValidationError::TimelineCursorOutOfRange { .. }
730 ));
731 }
732
733 #[test]
734 fn validate_timeline_with_entries_requires_baseline() {
735 let mut snap = minimal_snapshot();
736 snap.interaction_timeline.cursor = 1;
737 snap.interaction_timeline
738 .entries
739 .push(PaneInteractionTimelineEntry {
740 sequence: 1,
741 operation_id: 10,
742 operation: PaneOperation::NormalizeRatios,
743 before_hash: 1,
744 after_hash: 2,
745 });
746 let err = snap.validate().unwrap_err();
747 assert!(matches!(
748 err,
749 WorkspaceValidationError::TimelineReplayFailed { .. }
750 ));
751 }
752
753 #[test]
754 fn validate_rejects_timeline_replay_mismatch() {
755 let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("mismatch"));
756 let baseline_tree = PaneTree::from_snapshot(minimal_tree())
757 .expect("minimal pane tree snapshot should load");
758 snap.interaction_timeline = PaneInteractionTimeline::with_baseline(&baseline_tree);
759 let err = snap.validate().unwrap_err();
760 assert!(matches!(
761 err,
762 WorkspaceValidationError::TimelineReplayMismatch { .. }
763 ));
764 }
765
766 #[test]
772 fn serde_serialize_minimal_succeeds() {
773 let snap = minimal_snapshot();
774 let json = serde_json::to_string(&snap).unwrap();
775 assert!(json.contains("\"schema_version\":1"));
776 assert!(json.contains("\"name\":\"test\""));
777 }
778
779 #[test]
780 fn serde_serialize_split_tree_succeeds() {
781 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("split"))
782 .with_active_pane(PaneId::new(2).unwrap());
783 let json = serde_json::to_string_pretty(&snap).unwrap();
784 assert!(json.contains("\"active_pane_id\": 2"));
785 assert!(json.contains("\"name\": \"split\""));
786 }
787
788 #[test]
789 fn serde_roundtrip_snapshot_preserves_leaf_and_node_extensions() {
790 let mut tree = minimal_tree();
791 tree.extensions
792 .insert("tree_scope".to_string(), "tree".to_string());
793 tree.nodes[0]
794 .extensions
795 .insert("node_scope".to_string(), "node".to_string());
796 assert!(matches!(&tree.nodes[0].kind, PaneNodeKind::Leaf(_)));
797 if let PaneNodeKind::Leaf(leaf) = &mut tree.nodes[0].kind {
798 leaf.extensions
799 .insert("leaf_scope".to_string(), "leaf".to_string());
800 }
801
802 let mut snap = WorkspaceSnapshot::new(tree, WorkspaceMetadata::new("roundtrip"));
803 snap.extensions
804 .insert("workspace_scope".to_string(), "workspace".to_string());
805 snap.metadata
806 .tags
807 .insert("metadata_scope".to_string(), "metadata".to_string());
808
809 let json = serde_json::to_string(&snap).unwrap();
810 let decoded: WorkspaceSnapshot = serde_json::from_str(&json).unwrap();
811
812 assert_eq!(
813 decoded
814 .extensions
815 .get("workspace_scope")
816 .map(std::string::String::as_str),
817 Some("workspace")
818 );
819 assert_eq!(
820 decoded
821 .pane_tree
822 .extensions
823 .get("tree_scope")
824 .map(std::string::String::as_str),
825 Some("tree")
826 );
827 assert_eq!(
828 decoded.pane_tree.nodes[0]
829 .extensions
830 .get("node_scope")
831 .map(std::string::String::as_str),
832 Some("node")
833 );
834 assert!(matches!(
835 &decoded.pane_tree.nodes[0].kind,
836 PaneNodeKind::Leaf(_)
837 ));
838 if let PaneNodeKind::Leaf(decoded_leaf) = &decoded.pane_tree.nodes[0].kind {
839 assert_eq!(
840 decoded_leaf
841 .extensions
842 .get("leaf_scope")
843 .map(std::string::String::as_str),
844 Some("leaf")
845 );
846 }
847 }
848
849 #[test]
850 fn serde_deserialize_from_handcrafted_json() {
851 let json = r#"{
854 "schema_version": 1,
855 "pane_tree": {
856 "schema_version": 1,
857 "root": 1,
858 "next_id": 2,
859 "nodes": [
860 {"id": 1, "kind": "leaf", "surface_key": "main"}
861 ]
862 },
863 "active_pane_id": 1,
864 "metadata": {"name": "from-json"},
865 "extensions": {"extra": "data"}
866 }"#;
867 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
868 assert_eq!(snap.schema_version, 1);
869 assert_eq!(snap.active_pane_id, Some(PaneId::default()));
870 assert_eq!(snap.metadata.name, "from-json");
871 assert_eq!(snap.extensions.get("extra").unwrap(), "data");
872 assert_eq!(snap.leaf_count(), 1);
873 }
874
875 #[test]
876 fn serde_workspace_extensions_and_tags_preserved() {
877 let json = r#"{
878 "pane_tree": {
879 "root": 1,
880 "next_id": 2,
881 "nodes": [{"id": 1, "kind": "leaf", "surface_key": "main"}]
882 },
883 "metadata": {
884 "name": "ext-test",
885 "tags": {"custom": "tag"}
886 },
887 "extensions": {"future_field": "value"}
888 }"#;
889 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
890 assert_eq!(snap.extensions.get("future_field").unwrap(), "value");
891 assert_eq!(snap.metadata.tags.get("custom").unwrap(), "tag");
892 }
893
894 #[test]
895 fn serde_metadata_roundtrip() {
896 let mut meta = WorkspaceMetadata::new("round-trip");
898 meta.app_version = "1.0.0".to_string();
899 meta.created_generation = 5;
900 meta.saved_generation = 10;
901 meta.tags.insert("k".to_string(), "v".to_string());
902 let json = serde_json::to_string(&meta).unwrap();
903 let deser: WorkspaceMetadata = serde_json::from_str(&json).unwrap();
904 assert_eq!(meta, deser);
905 }
906
907 #[test]
908 fn serde_missing_optional_fields_default() {
909 let json = r#"{
911 "pane_tree": {
912 "root": 1,
913 "next_id": 2,
914 "nodes": [{"id": 1, "kind": "leaf", "surface_key": "main"}]
915 },
916 "metadata": {"name": "test"}
917 }"#;
918 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
919 assert_eq!(snap.schema_version, WORKSPACE_SCHEMA_VERSION);
920 assert!(snap.active_pane_id.is_none());
921 assert!(snap.extensions.is_empty());
922 }
923
924 #[test]
927 fn state_hash_deterministic() {
928 let s1 = minimal_snapshot();
929 let s2 = minimal_snapshot();
930 assert_eq!(s1.state_hash(), s2.state_hash());
931 }
932
933 #[test]
934 fn state_hash_changes_with_active_pane() {
935 let s1 = minimal_snapshot();
936 let s2 = minimal_snapshot().with_active_pane(PaneId::default());
937 assert_ne!(s1.state_hash(), s2.state_hash());
938 }
939
940 #[test]
941 fn state_hash_changes_with_name() {
942 let s1 = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("a"));
943 let s2 = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("b"));
944 assert_ne!(s1.state_hash(), s2.state_hash());
945 }
946
947 #[test]
950 fn canonicalize_sorts_nodes() {
951 let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
952 snap.pane_tree.nodes.reverse();
954 snap.canonicalize();
955 let ids: Vec<u64> = snap.pane_tree.nodes.iter().map(|n| n.id.get()).collect();
956 assert!(
957 ids.windows(2).all(|w| w[0] <= w[1]),
958 "nodes should be sorted by ID"
959 );
960 }
961
962 #[test]
965 fn leaf_count_single() {
966 let snap = minimal_snapshot();
967 assert_eq!(snap.leaf_count(), 1);
968 }
969
970 #[test]
971 fn leaf_count_split() {
972 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
973 assert_eq!(snap.leaf_count(), 2);
974 }
975
976 #[test]
979 fn migrate_v1_is_noop() {
980 let snap = minimal_snapshot();
981 let result = migrate_workspace(snap.clone()).unwrap();
982 assert_eq!(result.from_version, 1);
983 assert_eq!(result.to_version, 1);
984 assert_eq!(result.snapshot, snap);
985 assert!(result.warnings.is_empty());
986 }
987
988 #[test]
989 fn migrate_future_version_fails() {
990 let mut snap = minimal_snapshot();
991 snap.schema_version = 99;
992 let err = migrate_workspace(snap).unwrap_err();
993 assert!(matches!(
994 err,
995 WorkspaceMigrationError::UnsupportedVersion { version: 99 }
996 ));
997 }
998
999 #[test]
1000 fn migrate_old_version_fails_no_path() {
1001 let mut snap = minimal_snapshot();
1002 snap.schema_version = 0;
1003 let err = migrate_workspace(snap).unwrap_err();
1004 assert!(matches!(
1005 err,
1006 WorkspaceMigrationError::NoMigrationPath { from: 0, to: 1 }
1007 ));
1008 }
1009
1010 #[test]
1011 fn needs_migration_false_for_current() {
1012 let snap = minimal_snapshot();
1013 assert!(!needs_migration(&snap));
1014 }
1015
1016 #[test]
1017 fn needs_migration_true_for_old() {
1018 let mut snap = minimal_snapshot();
1019 snap.schema_version = 0;
1020 assert!(needs_migration(&snap));
1021 }
1022
1023 #[test]
1026 fn canonical_json_export_sorts_pane_nodes() {
1027 let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("canonical"));
1028 snap.pane_tree.nodes.reverse();
1029
1030 let json = to_canonical_workspace_snapshot_json(&snap).unwrap();
1031 let decoded: WorkspaceSnapshot = serde_json::from_str(&json).unwrap();
1032 let ids: Vec<u64> = decoded
1033 .pane_tree
1034 .nodes
1035 .iter()
1036 .map(|node| node.id.get())
1037 .collect();
1038
1039 assert_eq!(ids, vec![1, 2, 3]);
1040 }
1041
1042 #[test]
1043 fn canonical_json_current_schema_round_trips_byte_stably() {
1044 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("roundtrip"))
1045 .with_active_pane(PaneId::new(2).unwrap());
1046
1047 let first_json = to_canonical_workspace_snapshot_json(&snap).unwrap();
1048 let first = decode_workspace_snapshot_json(&first_json).unwrap();
1049 let second_json = to_canonical_workspace_snapshot_json(&first.snapshot).unwrap();
1050 let second = decode_workspace_snapshot_json(&second_json).unwrap();
1051
1052 assert_eq!(first.from_version, WORKSPACE_SCHEMA_VERSION);
1053 assert_eq!(first.to_version, WORKSPACE_SCHEMA_VERSION);
1054 assert_eq!(first.decision(), "current_schema");
1055 assert_eq!(first.warnings, second.warnings);
1056 assert_eq!(first.state_checksum(), second.state_checksum());
1057 assert_eq!(first_json, second_json);
1058 }
1059
1060 #[test]
1061 fn canonical_json_missing_schema_version_defaults_to_current() {
1062 let json = r#"{
1063 "pane_tree": {
1064 "root": 1,
1065 "next_id": 2,
1066 "nodes": [{"id": 1, "kind": "leaf", "surface_key": "main"}]
1067 },
1068 "metadata": {"name": "legacy-missing-version"}
1069 }"#;
1070
1071 let result = decode_workspace_snapshot_json(json).unwrap();
1072
1073 assert_eq!(result.from_version, WORKSPACE_SCHEMA_VERSION);
1074 assert_eq!(result.to_version, WORKSPACE_SCHEMA_VERSION);
1075 assert_eq!(result.snapshot.schema_version, WORKSPACE_SCHEMA_VERSION);
1076 assert_eq!(result.snapshot.metadata.name, "legacy-missing-version");
1077 }
1078
1079 #[test]
1080 fn canonical_json_future_schema_reports_migration_failure() {
1081 let mut snap = minimal_snapshot();
1082 snap.schema_version = WORKSPACE_SCHEMA_VERSION.saturating_add(1);
1083 let json = serde_json::to_string(&snap).unwrap();
1084
1085 let err = decode_workspace_snapshot_json(&json).unwrap_err();
1086
1087 assert!(matches!(
1088 err,
1089 WorkspaceSnapshotJsonError::MigrationFailed {
1090 source: WorkspaceMigrationError::UnsupportedVersion { .. }
1091 }
1092 ));
1093 assert!(format!("{err}").contains("migration failed"));
1094 }
1095
1096 #[test]
1097 fn canonical_json_old_schema_reports_missing_path() {
1098 let mut snap = minimal_snapshot();
1099 snap.schema_version = 0;
1100 let json = serde_json::to_string(&snap).unwrap();
1101
1102 let err = decode_workspace_snapshot_json(&json).unwrap_err();
1103
1104 assert!(matches!(
1105 err,
1106 WorkspaceSnapshotJsonError::MigrationFailed {
1107 source: WorkspaceMigrationError::NoMigrationPath { from: 0, to: 1 }
1108 }
1109 ));
1110 }
1111
1112 #[test]
1113 fn canonical_json_parse_error_uses_import_context() {
1114 let err = decode_workspace_snapshot_json("{not json").unwrap_err();
1115
1116 assert!(matches!(
1117 err,
1118 WorkspaceSnapshotJsonError::DeserializationFailed { .. }
1119 ));
1120 assert!(format!("{err}").contains("workspace snapshot parse failed"));
1121 }
1122
1123 #[test]
1124 fn canonical_json_export_error_uses_validation_context() {
1125 let snap = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new(""));
1126
1127 let err = to_canonical_workspace_snapshot_json(&snap).unwrap_err();
1128
1129 assert!(matches!(
1130 err,
1131 WorkspaceSnapshotJsonError::ValidationFailed {
1132 context: "workspace snapshot validation failed",
1133 source: WorkspaceValidationError::EmptyWorkspaceName
1134 }
1135 ));
1136 }
1137
1138 #[test]
1141 fn validation_error_display() {
1142 let err = WorkspaceValidationError::UnsupportedVersion {
1143 found: 99,
1144 expected: 1,
1145 };
1146 let msg = format!("{err}");
1147 assert!(msg.contains("99"));
1148 assert!(msg.contains("1"));
1149 }
1150
1151 #[test]
1152 fn migration_error_display() {
1153 let err = WorkspaceMigrationError::NoMigrationPath { from: 0, to: 1 };
1154 let msg = format!("{err}");
1155 assert!(msg.contains("v0"));
1156 assert!(msg.contains("v1"));
1157 }
1158
1159 #[test]
1160 fn validation_error_from_pane_model() {
1161 let pane_err = PaneModelError::ZeroPaneId;
1162 let ws_err: WorkspaceValidationError = pane_err.into();
1163 assert!(matches!(ws_err, WorkspaceValidationError::PaneModel(_)));
1164 }
1165
1166 #[test]
1169 fn identical_inputs_identical_validation() {
1170 let s1 = minimal_snapshot();
1171 let s2 = minimal_snapshot();
1172 assert_eq!(s1.validate().is_ok(), s2.validate().is_ok());
1173 }
1174
1175 #[test]
1176 fn identical_inputs_identical_migration() {
1177 let s1 = minimal_snapshot();
1178 let s2 = minimal_snapshot();
1179 let r1 = migrate_workspace(s1).unwrap();
1180 let r2 = migrate_workspace(s2).unwrap();
1181 assert_eq!(r1.snapshot, r2.snapshot);
1182 assert_eq!(r1.decision(), r2.decision());
1183 assert_eq!(r1.state_checksum(), r2.state_checksum());
1184 }
1185}