1use std::collections::BTreeMap;
38use std::hash::{Hash, Hasher};
39
40use serde::{Deserialize, Serialize};
41
42use crate::pane::{
43 PANE_TREE_SCHEMA_VERSION, PaneId, PaneModelError, PaneNodeKind, PaneTreeSnapshot,
44};
45
46pub const WORKSPACE_SCHEMA_VERSION: u16 = 1;
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct WorkspaceSnapshot {
58 #[serde(default = "default_workspace_version")]
60 pub schema_version: u16,
61 pub pane_tree: PaneTreeSnapshot,
63 #[serde(default)]
65 pub active_pane_id: Option<PaneId>,
66 pub metadata: WorkspaceMetadata,
68 #[serde(default)]
70 pub extensions: BTreeMap<String, String>,
71}
72
73fn default_workspace_version() -> u16 {
74 WORKSPACE_SCHEMA_VERSION
75}
76
77impl WorkspaceSnapshot {
78 #[must_use]
80 pub fn new(pane_tree: PaneTreeSnapshot, metadata: WorkspaceMetadata) -> Self {
81 Self {
82 schema_version: WORKSPACE_SCHEMA_VERSION,
83 pane_tree,
84 active_pane_id: None,
85 metadata,
86 extensions: BTreeMap::new(),
87 }
88 }
89
90 #[must_use]
92 pub fn with_active_pane(mut self, pane_id: PaneId) -> Self {
93 self.active_pane_id = Some(pane_id);
94 self
95 }
96
97 pub fn validate(&self) -> Result<(), WorkspaceValidationError> {
99 if self.schema_version != WORKSPACE_SCHEMA_VERSION {
101 return Err(WorkspaceValidationError::UnsupportedVersion {
102 found: self.schema_version,
103 expected: WORKSPACE_SCHEMA_VERSION,
104 });
105 }
106
107 if self.pane_tree.schema_version != PANE_TREE_SCHEMA_VERSION {
109 return Err(WorkspaceValidationError::PaneTreeVersionMismatch {
110 found: self.pane_tree.schema_version,
111 expected: PANE_TREE_SCHEMA_VERSION,
112 });
113 }
114
115 let report = self.pane_tree.invariant_report();
117 if report.has_errors() {
118 return Err(WorkspaceValidationError::PaneTreeInvalid {
119 issue_count: report.issues.len(),
120 first_issue: report
121 .issues
122 .first()
123 .map(|i| format!("{:?}", i.code))
124 .unwrap_or_default(),
125 });
126 }
127
128 if let Some(active_id) = self.active_pane_id {
130 let found = self.pane_tree.nodes.iter().any(|n| n.id == active_id);
131 if !found {
132 return Err(WorkspaceValidationError::ActivePaneNotFound { pane_id: active_id });
133 }
134 let is_leaf = self
136 .pane_tree
137 .nodes
138 .iter()
139 .find(|n| n.id == active_id)
140 .map(|n| matches!(n.kind, PaneNodeKind::Leaf(_)))
141 .unwrap_or(false);
142 if !is_leaf {
143 return Err(WorkspaceValidationError::ActivePaneNotLeaf { pane_id: active_id });
144 }
145 }
146
147 if self.metadata.name.is_empty() {
149 return Err(WorkspaceValidationError::EmptyWorkspaceName);
150 }
151
152 Ok(())
153 }
154
155 pub fn canonicalize(&mut self) {
157 self.pane_tree.canonicalize();
158 }
159
160 #[must_use]
162 pub fn state_hash(&self) -> u64 {
163 let mut hasher = std::collections::hash_map::DefaultHasher::new();
164 self.schema_version.hash(&mut hasher);
165 self.pane_tree.state_hash().hash(&mut hasher);
166 self.active_pane_id.map(|id| id.get()).hash(&mut hasher);
167 self.metadata.name.hash(&mut hasher);
168 self.metadata.created_generation.hash(&mut hasher);
169 for (k, v) in &self.extensions {
170 k.hash(&mut hasher);
171 v.hash(&mut hasher);
172 }
173 hasher.finish()
174 }
175
176 #[must_use]
178 pub fn leaf_count(&self) -> usize {
179 self.pane_tree
180 .nodes
181 .iter()
182 .filter(|n| matches!(n.kind, PaneNodeKind::Leaf(_)))
183 .count()
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct WorkspaceMetadata {
194 pub name: String,
196 #[serde(default)]
198 pub created_generation: u64,
199 #[serde(default)]
201 pub saved_generation: u64,
202 #[serde(default)]
204 pub app_version: String,
205 #[serde(default)]
207 pub tags: BTreeMap<String, String>,
208}
209
210impl WorkspaceMetadata {
211 #[must_use]
213 pub fn new(name: impl Into<String>) -> Self {
214 Self {
215 name: name.into(),
216 created_generation: 0,
217 saved_generation: 0,
218 app_version: String::new(),
219 tags: BTreeMap::new(),
220 }
221 }
222
223 #[must_use]
225 pub fn with_app_version(mut self, version: impl Into<String>) -> Self {
226 self.app_version = version.into();
227 self
228 }
229
230 pub fn increment_generation(&mut self) {
232 self.saved_generation = self.saved_generation.saturating_add(1);
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum WorkspaceValidationError {
243 UnsupportedVersion { found: u16, expected: u16 },
245 PaneTreeVersionMismatch { found: u16, expected: u16 },
247 PaneTreeInvalid {
249 issue_count: usize,
250 first_issue: String,
251 },
252 ActivePaneNotFound { pane_id: PaneId },
254 ActivePaneNotLeaf { pane_id: PaneId },
256 EmptyWorkspaceName,
258 PaneModel(PaneModelError),
260}
261
262impl fmt::Display for WorkspaceValidationError {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 match self {
265 Self::UnsupportedVersion { found, expected } => {
266 write!(
267 f,
268 "unsupported workspace schema version {found} (expected {expected})"
269 )
270 }
271 Self::PaneTreeVersionMismatch { found, expected } => {
272 write!(
273 f,
274 "pane tree schema version {found} does not match expected {expected}"
275 )
276 }
277 Self::PaneTreeInvalid {
278 issue_count,
279 first_issue,
280 } => {
281 write!(
282 f,
283 "pane tree has {issue_count} invariant violation(s), first: {first_issue}"
284 )
285 }
286 Self::ActivePaneNotFound { pane_id } => {
287 write!(f, "active pane {} not found in tree", pane_id.get())
288 }
289 Self::ActivePaneNotLeaf { pane_id } => {
290 write!(f, "active pane {} is a split, not a leaf", pane_id.get())
291 }
292 Self::EmptyWorkspaceName => write!(f, "workspace name must not be empty"),
293 Self::PaneModel(e) => write!(f, "pane model error: {e}"),
294 }
295 }
296}
297
298impl From<PaneModelError> for WorkspaceValidationError {
299 fn from(err: PaneModelError) -> Self {
300 Self::PaneModel(err)
301 }
302}
303
304use std::fmt;
305
306#[derive(Debug, Clone)]
312pub struct MigrationResult {
313 pub snapshot: WorkspaceSnapshot,
315 pub from_version: u16,
317 pub to_version: u16,
319 pub warnings: Vec<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
325pub enum WorkspaceMigrationError {
326 UnsupportedVersion { version: u16 },
328 NoMigrationPath { from: u16, to: u16 },
330 DeserializationFailed { reason: String },
332}
333
334impl fmt::Display for WorkspaceMigrationError {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 match self {
337 Self::UnsupportedVersion { version } => {
338 write!(f, "unsupported schema version {version} for migration")
339 }
340 Self::NoMigrationPath { from, to } => {
341 write!(f, "no migration path from v{from} to v{to}")
342 }
343 Self::DeserializationFailed { reason } => {
344 write!(f, "deserialization failed during migration: {reason}")
345 }
346 }
347 }
348}
349
350pub fn migrate_workspace(
355 snapshot: WorkspaceSnapshot,
356) -> Result<MigrationResult, WorkspaceMigrationError> {
357 match snapshot.schema_version {
358 WORKSPACE_SCHEMA_VERSION => {
359 Ok(MigrationResult {
361 from_version: WORKSPACE_SCHEMA_VERSION,
362 to_version: WORKSPACE_SCHEMA_VERSION,
363 warnings: Vec::new(),
364 snapshot,
365 })
366 }
367 v if v > WORKSPACE_SCHEMA_VERSION => {
368 Err(WorkspaceMigrationError::UnsupportedVersion { version: v })
369 }
370 v => Err(WorkspaceMigrationError::NoMigrationPath {
371 from: v,
372 to: WORKSPACE_SCHEMA_VERSION,
373 }),
374 }
375}
376
377#[must_use]
379pub fn needs_migration(snapshot: &WorkspaceSnapshot) -> bool {
380 snapshot.schema_version != WORKSPACE_SCHEMA_VERSION
381}
382
383#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::pane::{PaneLeaf, PaneNodeRecord, PaneSplit, PaneSplitRatio, SplitAxis};
391
392 fn minimal_tree() -> PaneTreeSnapshot {
393 PaneTreeSnapshot {
394 schema_version: PANE_TREE_SCHEMA_VERSION,
395 root: PaneId::default(),
396 next_id: PaneId::new(2).unwrap(),
397 nodes: vec![PaneNodeRecord::leaf(
398 PaneId::default(),
399 None,
400 PaneLeaf::new("main"),
401 )],
402 extensions: BTreeMap::new(),
403 }
404 }
405
406 fn split_tree() -> PaneTreeSnapshot {
407 let root_id = PaneId::new(1).unwrap();
408 let left_id = PaneId::new(2).unwrap();
409 let right_id = PaneId::new(3).unwrap();
410 PaneTreeSnapshot {
411 schema_version: PANE_TREE_SCHEMA_VERSION,
412 root: root_id,
413 next_id: PaneId::new(4).unwrap(),
414 nodes: vec![
415 PaneNodeRecord::split(
416 root_id,
417 None,
418 PaneSplit {
419 axis: SplitAxis::Horizontal,
420 ratio: PaneSplitRatio::new(1, 1).unwrap(),
421 first: left_id,
422 second: right_id,
423 },
424 ),
425 PaneNodeRecord::leaf(left_id, Some(root_id), PaneLeaf::new("left")),
426 PaneNodeRecord::leaf(right_id, Some(root_id), PaneLeaf::new("right")),
427 ],
428 extensions: BTreeMap::new(),
429 }
430 }
431
432 fn minimal_snapshot() -> WorkspaceSnapshot {
433 WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("test"))
434 }
435
436 #[test]
439 fn new_snapshot_has_v1() {
440 let snap = minimal_snapshot();
441 assert_eq!(snap.schema_version, WORKSPACE_SCHEMA_VERSION);
442 assert_eq!(snap.schema_version, 1);
443 }
444
445 #[test]
446 fn with_active_pane_sets_id() {
447 let id = PaneId::default();
448 let snap = minimal_snapshot().with_active_pane(id);
449 assert_eq!(snap.active_pane_id, Some(id));
450 }
451
452 #[test]
453 fn metadata_new_defaults() {
454 let meta = WorkspaceMetadata::new("ws");
455 assert_eq!(meta.name, "ws");
456 assert_eq!(meta.created_generation, 0);
457 assert_eq!(meta.saved_generation, 0);
458 assert!(meta.app_version.is_empty());
459 assert!(meta.tags.is_empty());
460 }
461
462 #[test]
463 fn metadata_with_app_version() {
464 let meta = WorkspaceMetadata::new("ws").with_app_version("0.1.0");
465 assert_eq!(meta.app_version, "0.1.0");
466 }
467
468 #[test]
469 fn metadata_increment_generation() {
470 let mut meta = WorkspaceMetadata::new("ws");
471 meta.increment_generation();
472 assert_eq!(meta.saved_generation, 1);
473 meta.increment_generation();
474 assert_eq!(meta.saved_generation, 2);
475 }
476
477 #[test]
480 fn validate_minimal_ok() {
481 let snap = minimal_snapshot();
482 assert!(snap.validate().is_ok());
483 }
484
485 #[test]
486 fn validate_split_tree_ok() {
487 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("split"));
488 assert!(snap.validate().is_ok());
489 }
490
491 #[test]
492 fn validate_wrong_workspace_version() {
493 let mut snap = minimal_snapshot();
494 snap.schema_version = 99;
495 let err = snap.validate().unwrap_err();
496 assert!(matches!(
497 err,
498 WorkspaceValidationError::UnsupportedVersion {
499 found: 99,
500 expected: 1
501 }
502 ));
503 }
504
505 #[test]
506 fn validate_wrong_pane_tree_version() {
507 let mut snap = minimal_snapshot();
508 snap.pane_tree.schema_version = 42;
509 let err = snap.validate().unwrap_err();
510 assert!(matches!(
511 err,
512 WorkspaceValidationError::PaneTreeVersionMismatch { .. }
513 ));
514 }
515
516 #[test]
517 fn validate_active_pane_not_found() {
518 let snap = minimal_snapshot().with_active_pane(PaneId::new(999).unwrap());
519 let err = snap.validate().unwrap_err();
520 assert!(matches!(
521 err,
522 WorkspaceValidationError::ActivePaneNotFound { .. }
523 ));
524 }
525
526 #[test]
527 fn validate_active_pane_is_split() {
528 let root_id = PaneId::new(1).unwrap();
529 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"))
530 .with_active_pane(root_id);
531 let err = snap.validate().unwrap_err();
532 assert!(matches!(
533 err,
534 WorkspaceValidationError::ActivePaneNotLeaf { .. }
535 ));
536 }
537
538 #[test]
539 fn validate_active_pane_leaf_ok() {
540 let left_id = PaneId::new(2).unwrap();
541 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"))
542 .with_active_pane(left_id);
543 assert!(snap.validate().is_ok());
544 }
545
546 #[test]
547 fn validate_empty_name() {
548 let snap = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new(""));
549 let err = snap.validate().unwrap_err();
550 assert!(matches!(err, WorkspaceValidationError::EmptyWorkspaceName));
551 }
552
553 #[test]
565 fn serde_serialize_minimal_succeeds() {
566 let snap = minimal_snapshot();
567 let json = serde_json::to_string(&snap).unwrap();
568 assert!(json.contains("\"schema_version\":1"));
569 assert!(json.contains("\"name\":\"test\""));
570 }
571
572 #[test]
573 fn serde_serialize_split_tree_succeeds() {
574 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("split"))
575 .with_active_pane(PaneId::new(2).unwrap());
576 let json = serde_json::to_string_pretty(&snap).unwrap();
577 assert!(json.contains("\"active_pane_id\": 2"));
578 assert!(json.contains("\"name\": \"split\""));
579 }
580
581 #[test]
582 fn serde_deserialize_from_handcrafted_json() {
583 let json = r#"{
586 "schema_version": 1,
587 "pane_tree": {
588 "schema_version": 1,
589 "root": 1,
590 "next_id": 2,
591 "nodes": [
592 {"id": 1, "kind": "leaf", "surface_key": "main"}
593 ]
594 },
595 "active_pane_id": 1,
596 "metadata": {"name": "from-json"},
597 "extensions": {"extra": "data"}
598 }"#;
599 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
600 assert_eq!(snap.schema_version, 1);
601 assert_eq!(snap.active_pane_id, Some(PaneId::default()));
602 assert_eq!(snap.metadata.name, "from-json");
603 assert_eq!(snap.extensions.get("extra").unwrap(), "data");
604 assert_eq!(snap.leaf_count(), 1);
605 }
606
607 #[test]
608 fn serde_workspace_extensions_and_tags_preserved() {
609 let json = r#"{
610 "pane_tree": {
611 "root": 1,
612 "next_id": 2,
613 "nodes": [{"id": 1, "kind": "leaf", "surface_key": "main"}]
614 },
615 "metadata": {
616 "name": "ext-test",
617 "tags": {"custom": "tag"}
618 },
619 "extensions": {"future_field": "value"}
620 }"#;
621 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
622 assert_eq!(snap.extensions.get("future_field").unwrap(), "value");
623 assert_eq!(snap.metadata.tags.get("custom").unwrap(), "tag");
624 }
625
626 #[test]
627 fn serde_metadata_roundtrip() {
628 let mut meta = WorkspaceMetadata::new("round-trip");
630 meta.app_version = "1.0.0".to_string();
631 meta.created_generation = 5;
632 meta.saved_generation = 10;
633 meta.tags.insert("k".to_string(), "v".to_string());
634 let json = serde_json::to_string(&meta).unwrap();
635 let deser: WorkspaceMetadata = serde_json::from_str(&json).unwrap();
636 assert_eq!(meta, deser);
637 }
638
639 #[test]
640 fn serde_missing_optional_fields_default() {
641 let json = r#"{
643 "pane_tree": {
644 "root": 1,
645 "next_id": 2,
646 "nodes": [{"id": 1, "kind": "leaf", "surface_key": "main"}]
647 },
648 "metadata": {"name": "test"}
649 }"#;
650 let snap: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
651 assert_eq!(snap.schema_version, WORKSPACE_SCHEMA_VERSION);
652 assert!(snap.active_pane_id.is_none());
653 assert!(snap.extensions.is_empty());
654 }
655
656 #[test]
659 fn state_hash_deterministic() {
660 let s1 = minimal_snapshot();
661 let s2 = minimal_snapshot();
662 assert_eq!(s1.state_hash(), s2.state_hash());
663 }
664
665 #[test]
666 fn state_hash_changes_with_active_pane() {
667 let s1 = minimal_snapshot();
668 let s2 = minimal_snapshot().with_active_pane(PaneId::default());
669 assert_ne!(s1.state_hash(), s2.state_hash());
670 }
671
672 #[test]
673 fn state_hash_changes_with_name() {
674 let s1 = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("a"));
675 let s2 = WorkspaceSnapshot::new(minimal_tree(), WorkspaceMetadata::new("b"));
676 assert_ne!(s1.state_hash(), s2.state_hash());
677 }
678
679 #[test]
682 fn canonicalize_sorts_nodes() {
683 let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
684 snap.pane_tree.nodes.reverse();
686 snap.canonicalize();
687 let ids: Vec<u64> = snap.pane_tree.nodes.iter().map(|n| n.id.get()).collect();
688 assert!(
689 ids.windows(2).all(|w| w[0] <= w[1]),
690 "nodes should be sorted by ID"
691 );
692 }
693
694 #[test]
697 fn leaf_count_single() {
698 let snap = minimal_snapshot();
699 assert_eq!(snap.leaf_count(), 1);
700 }
701
702 #[test]
703 fn leaf_count_split() {
704 let snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
705 assert_eq!(snap.leaf_count(), 2);
706 }
707
708 #[test]
711 fn migrate_v1_is_noop() {
712 let snap = minimal_snapshot();
713 let result = migrate_workspace(snap.clone()).unwrap();
714 assert_eq!(result.from_version, 1);
715 assert_eq!(result.to_version, 1);
716 assert_eq!(result.snapshot, snap);
717 assert!(result.warnings.is_empty());
718 }
719
720 #[test]
721 fn migrate_future_version_fails() {
722 let mut snap = minimal_snapshot();
723 snap.schema_version = 99;
724 let err = migrate_workspace(snap).unwrap_err();
725 assert!(matches!(
726 err,
727 WorkspaceMigrationError::UnsupportedVersion { version: 99 }
728 ));
729 }
730
731 #[test]
732 fn migrate_old_version_fails_no_path() {
733 let mut snap = minimal_snapshot();
734 snap.schema_version = 0;
735 let err = migrate_workspace(snap).unwrap_err();
736 assert!(matches!(
737 err,
738 WorkspaceMigrationError::NoMigrationPath { from: 0, to: 1 }
739 ));
740 }
741
742 #[test]
743 fn needs_migration_false_for_current() {
744 let snap = minimal_snapshot();
745 assert!(!needs_migration(&snap));
746 }
747
748 #[test]
749 fn needs_migration_true_for_old() {
750 let mut snap = minimal_snapshot();
751 snap.schema_version = 0;
752 assert!(needs_migration(&snap));
753 }
754
755 #[test]
758 fn validation_error_display() {
759 let err = WorkspaceValidationError::UnsupportedVersion {
760 found: 99,
761 expected: 1,
762 };
763 let msg = format!("{err}");
764 assert!(msg.contains("99"));
765 assert!(msg.contains("1"));
766 }
767
768 #[test]
769 fn migration_error_display() {
770 let err = WorkspaceMigrationError::NoMigrationPath { from: 0, to: 1 };
771 let msg = format!("{err}");
772 assert!(msg.contains("v0"));
773 assert!(msg.contains("v1"));
774 }
775
776 #[test]
777 fn validation_error_from_pane_model() {
778 let pane_err = PaneModelError::ZeroPaneId;
779 let ws_err: WorkspaceValidationError = pane_err.into();
780 assert!(matches!(ws_err, WorkspaceValidationError::PaneModel(_)));
781 }
782
783 #[test]
786 fn identical_inputs_identical_validation() {
787 let s1 = minimal_snapshot();
788 let s2 = minimal_snapshot();
789 assert_eq!(s1.validate().is_ok(), s2.validate().is_ok());
790 }
791
792 #[test]
793 fn identical_inputs_identical_migration() {
794 let s1 = minimal_snapshot();
795 let s2 = minimal_snapshot();
796 let r1 = migrate_workspace(s1).unwrap();
797 let r2 = migrate_workspace(s2).unwrap();
798 assert_eq!(r1.snapshot, r2.snapshot);
799 }
800}