Skip to main content

ftui_layout/
workspace.rs

1//! Persisted workspace schema v1 with versioning and migration scaffolding.
2//!
3//! A [`WorkspaceSnapshot`] wraps the pane tree snapshot with workspace-level
4//! metadata, active pane tracking, and forward-compatible extension bags.
5//!
6//! # Schema Versioning Policy
7//!
8//! - **Additive fields** may be carried in `extensions` maps without a version bump.
9//! - **Breaking changes** (field removal, semantic changes) require incrementing
10//!   [`WORKSPACE_SCHEMA_VERSION`] and adding a migration path.
11//! - All snapshots carry their schema version; loaders reject unknown versions
12//!   with actionable diagnostics.
13//!
14//! # Usage
15//!
16//! ```
17//! use ftui_layout::workspace::{WorkspaceSnapshot, WorkspaceMetadata, WORKSPACE_SCHEMA_VERSION};
18//! use ftui_layout::pane::{PaneTreeSnapshot, PaneId, PaneNodeRecord, PaneLeaf, PANE_TREE_SCHEMA_VERSION};
19//! use std::collections::BTreeMap;
20//!
21//! let tree = PaneTreeSnapshot {
22//!     schema_version: PANE_TREE_SCHEMA_VERSION,
23//!     root: PaneId::default(),
24//!     next_id: PaneId::default(),
25//!     nodes: vec![PaneNodeRecord::leaf(PaneId::default(), None, PaneLeaf::new("main"))],
26//!     extensions: BTreeMap::new(),
27//! };
28//!
29//! let snapshot = WorkspaceSnapshot::new(tree, WorkspaceMetadata::new("my-workspace"));
30//! assert_eq!(snapshot.schema_version, WORKSPACE_SCHEMA_VERSION);
31//!
32//! // Validate the snapshot
33//! let result = snapshot.validate();
34//! assert!(result.is_ok());
35//! ```
36
37use 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
46/// Current workspace schema version.
47pub const WORKSPACE_SCHEMA_VERSION: u16 = 1;
48
49// =========================================================================
50// Core schema types
51// =========================================================================
52
53/// Persisted workspace state, wrapping a pane tree with metadata.
54///
55/// Forward-compatible: unknown fields land in `extensions` for round-tripping.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct WorkspaceSnapshot {
58    /// Schema version for migration detection.
59    #[serde(default = "default_workspace_version")]
60    pub schema_version: u16,
61    /// The pane tree layout.
62    pub pane_tree: PaneTreeSnapshot,
63    /// Which pane had focus when the workspace was persisted.
64    #[serde(default)]
65    pub active_pane_id: Option<PaneId>,
66    /// Workspace metadata (name, timestamps, host info).
67    pub metadata: WorkspaceMetadata,
68    /// Forward-compatible extension bag.
69    #[serde(default)]
70    pub extensions: BTreeMap<String, String>,
71}
72
73fn default_workspace_version() -> u16 {
74    WORKSPACE_SCHEMA_VERSION
75}
76
77impl WorkspaceSnapshot {
78    /// Create a new v1 workspace snapshot.
79    #[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    /// Create a snapshot with a focused pane.
91    #[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    /// Validate the snapshot against schema and structural invariants.
98    pub fn validate(&self) -> Result<(), WorkspaceValidationError> {
99        // Version check
100        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        // Pane tree version check
108        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        // Pane tree structural validation
116        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        // Active pane must exist in the tree if set
129        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            // Active pane should be a leaf (not a split)
135            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        // Metadata validation
148        if self.metadata.name.is_empty() {
149            return Err(WorkspaceValidationError::EmptyWorkspaceName);
150        }
151
152        Ok(())
153    }
154
155    /// Canonicalize for deterministic serialization.
156    pub fn canonicalize(&mut self) {
157        self.pane_tree.canonicalize();
158    }
159
160    /// Deterministic hash for state diagnostics.
161    #[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    /// Count of leaf panes in the tree.
177    #[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// =========================================================================
188// Metadata
189// =========================================================================
190
191/// Workspace metadata for provenance and diagnostics.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct WorkspaceMetadata {
194    /// Human-readable workspace name.
195    pub name: String,
196    /// Monotonic generation counter (incremented on each save).
197    #[serde(default)]
198    pub created_generation: u64,
199    /// Last-saved generation counter.
200    #[serde(default)]
201    pub saved_generation: u64,
202    /// Application version that created/saved this workspace.
203    #[serde(default)]
204    pub app_version: String,
205    /// Forward-compatible custom tags.
206    #[serde(default)]
207    pub tags: BTreeMap<String, String>,
208}
209
210impl WorkspaceMetadata {
211    /// Create metadata with a workspace name.
212    #[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    /// Set the application version.
224    #[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    /// Increment the save generation counter.
231    pub fn increment_generation(&mut self) {
232        self.saved_generation = self.saved_generation.saturating_add(1);
233    }
234}
235
236// =========================================================================
237// Validation errors
238// =========================================================================
239
240/// Errors from workspace validation.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum WorkspaceValidationError {
243    /// Schema version is not supported.
244    UnsupportedVersion { found: u16, expected: u16 },
245    /// Pane tree schema version mismatch.
246    PaneTreeVersionMismatch { found: u16, expected: u16 },
247    /// Pane tree has structural invariant violations.
248    PaneTreeInvalid {
249        issue_count: usize,
250        first_issue: String,
251    },
252    /// Active pane ID does not exist in the tree.
253    ActivePaneNotFound { pane_id: PaneId },
254    /// Active pane is a split node, not a leaf.
255    ActivePaneNotLeaf { pane_id: PaneId },
256    /// Workspace name is empty.
257    EmptyWorkspaceName,
258    /// Pane model error from tree operations.
259    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// =========================================================================
307// Migration scaffolding
308// =========================================================================
309
310/// Result of attempting to migrate a workspace from an older schema version.
311#[derive(Debug, Clone)]
312pub struct MigrationResult {
313    /// The migrated snapshot.
314    pub snapshot: WorkspaceSnapshot,
315    /// Source version before migration.
316    pub from_version: u16,
317    /// Target version after migration.
318    pub to_version: u16,
319    /// Warnings or notes from the migration.
320    pub warnings: Vec<String>,
321}
322
323/// Errors from workspace migration.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub enum WorkspaceMigrationError {
326    /// Version is not recognized or too old to migrate.
327    UnsupportedVersion { version: u16 },
328    /// Migration from the given version is not implemented.
329    NoMigrationPath { from: u16, to: u16 },
330    /// Deserialization failed during migration.
331    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
350/// Attempt to migrate a workspace snapshot to the current schema version.
351///
352/// For v1 (current), this is a no-op identity migration. Future versions
353/// will chain migrations through each intermediate version.
354pub fn migrate_workspace(
355    snapshot: WorkspaceSnapshot,
356) -> Result<MigrationResult, WorkspaceMigrationError> {
357    match snapshot.schema_version {
358        WORKSPACE_SCHEMA_VERSION => {
359            // Current version — no migration needed.
360            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/// Check whether a snapshot requires migration.
378#[must_use]
379pub fn needs_migration(snapshot: &WorkspaceSnapshot) -> bool {
380    snapshot.schema_version != WORKSPACE_SCHEMA_VERSION
381}
382
383// =========================================================================
384// Tests
385// =========================================================================
386
387#[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    // ---- Construction ----
437
438    #[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    // ---- Validation ----
478
479    #[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    // ---- Serialization ----
554    //
555    // NOTE: Full JSON round-trip through serde_json is blocked by a known
556    // upstream issue: PaneNodeRecord uses `#[serde(flatten)]` on PaneNodeKind,
557    // which flattens PaneLeaf.extensions alongside PaneNodeRecord.extensions,
558    // producing duplicate `"extensions"` keys in JSON. serde_json rejects
559    // duplicate fields on deserialization.
560    //
561    // We test serialization succeeds and deserialization from hand-crafted JSON
562    // that matches the expected wire format.
563
564    #[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        // Hand-crafted JSON matching the expected wire format, with only
584        // one `extensions` per node (PaneNodeRecord level, not PaneLeaf).
585        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        // WorkspaceMetadata has no flatten issues — full roundtrip works.
629        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        // JSON with minimal fields — optional ones should get defaults
642        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    // ---- Deterministic hashing ----
657
658    #[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    // ---- Canonicalization ----
680
681    #[test]
682    fn canonicalize_sorts_nodes() {
683        let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
684        // Reverse the node order
685        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    // ---- Leaf count ----
695
696    #[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    // ---- Migration ----
709
710    #[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    // ---- Error display ----
756
757    #[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    // ---- Determinism ----
784
785    #[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}