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::new(2).unwrap(),
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, PaneInteractionTimeline, PaneModelError, PaneNodeKind,
44    PaneTree, PaneTreeSnapshot,
45};
46
47/// Current workspace schema version.
48pub const WORKSPACE_SCHEMA_VERSION: u16 = 1;
49
50// =========================================================================
51// Core schema types
52// =========================================================================
53
54/// Persisted workspace state, wrapping a pane tree with metadata.
55///
56/// Forward-compatible: unknown fields land in `extensions` for round-tripping.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct WorkspaceSnapshot {
59    /// Schema version for migration detection.
60    #[serde(default = "default_workspace_version")]
61    pub schema_version: u16,
62    /// The pane tree layout.
63    pub pane_tree: PaneTreeSnapshot,
64    /// Which pane had focus when the workspace was persisted.
65    #[serde(default)]
66    pub active_pane_id: Option<PaneId>,
67    /// Workspace metadata (name, timestamps, host info).
68    pub metadata: WorkspaceMetadata,
69    /// Persistent pane interaction timeline for undo/redo/replay.
70    #[serde(default)]
71    pub interaction_timeline: PaneInteractionTimeline,
72    /// Forward-compatible extension bag.
73    #[serde(default)]
74    pub extensions: BTreeMap<String, String>,
75}
76
77fn default_workspace_version() -> u16 {
78    WORKSPACE_SCHEMA_VERSION
79}
80
81impl WorkspaceSnapshot {
82    /// Create a new v1 workspace snapshot.
83    #[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    /// Create a snapshot with a focused pane.
96    #[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    /// Validate the snapshot against schema and structural invariants.
103    pub fn validate(&self) -> Result<(), WorkspaceValidationError> {
104        // Version check
105        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        // Pane tree version check
113        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        // Pane tree structural validation
121        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        // Active pane must exist in the tree if set
134        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            // Active pane should be a leaf (not a split)
140            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        // Metadata validation
153        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        // Timeline must be internally replayable and agree with persisted pane_tree state.
165        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    /// Canonicalize for deterministic serialization.
189    pub fn canonicalize(&mut self) {
190        self.pane_tree.canonicalize();
191    }
192
193    /// Deterministic hash for state diagnostics.
194    #[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    /// Count of leaf panes in the tree.
210    #[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// =========================================================================
221// Metadata
222// =========================================================================
223
224/// Workspace metadata for provenance and diagnostics.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct WorkspaceMetadata {
227    /// Human-readable workspace name.
228    pub name: String,
229    /// Monotonic generation counter (incremented on each save).
230    #[serde(default)]
231    pub created_generation: u64,
232    /// Last-saved generation counter.
233    #[serde(default)]
234    pub saved_generation: u64,
235    /// Application version that created/saved this workspace.
236    #[serde(default)]
237    pub app_version: String,
238    /// Forward-compatible custom tags.
239    #[serde(default)]
240    pub tags: BTreeMap<String, String>,
241}
242
243impl WorkspaceMetadata {
244    /// Create metadata with a workspace name.
245    #[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    /// Set the application version.
257    #[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    /// Increment the save generation counter.
264    pub fn increment_generation(&mut self) {
265        self.saved_generation = self.saved_generation.saturating_add(1);
266    }
267}
268
269// =========================================================================
270// Validation errors
271// =========================================================================
272
273/// Errors from workspace validation.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum WorkspaceValidationError {
276    /// Schema version is not supported.
277    UnsupportedVersion { found: u16, expected: u16 },
278    /// Pane tree schema version mismatch.
279    PaneTreeVersionMismatch { found: u16, expected: u16 },
280    /// Pane tree has structural invariant violations.
281    PaneTreeInvalid {
282        issue_count: usize,
283        first_issue: String,
284    },
285    /// Active pane ID does not exist in the tree.
286    ActivePaneNotFound { pane_id: PaneId },
287    /// Active pane is a split node, not a leaf.
288    ActivePaneNotLeaf { pane_id: PaneId },
289    /// Workspace name is empty.
290    EmptyWorkspaceName,
291    /// Timeline cursor is outside the recorded history bounds.
292    TimelineCursorOutOfRange { cursor: usize, len: usize },
293    /// Timeline replay failed (missing/invalid baseline or invalid operation).
294    TimelineReplayFailed { reason: String },
295    /// Timeline replay does not match the persisted pane tree state.
296    TimelineReplayMismatch {
297        pane_tree_hash: u64,
298        replay_hash: u64,
299    },
300    /// Pane model error from tree operations.
301    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// =========================================================================
363// Migration scaffolding
364// =========================================================================
365
366/// Result of attempting to migrate a workspace from an older schema version.
367#[derive(Debug, Clone)]
368pub struct MigrationResult {
369    /// The migrated snapshot.
370    pub snapshot: WorkspaceSnapshot,
371    /// Source version before migration.
372    pub from_version: u16,
373    /// Target version after migration.
374    pub to_version: u16,
375    /// Warnings or notes from the migration.
376    pub warnings: Vec<String>,
377}
378
379/// Errors from workspace migration.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub enum WorkspaceMigrationError {
382    /// Version is not recognized or too old to migrate.
383    UnsupportedVersion { version: u16 },
384    /// Migration from the given version is not implemented.
385    NoMigrationPath { from: u16, to: u16 },
386    /// Deserialization failed during migration.
387    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
406/// Attempt to migrate a workspace snapshot to the current schema version.
407///
408/// For v1 (current), this is a no-op identity migration. Future versions
409/// will chain migrations through each intermediate version.
410pub fn migrate_workspace(
411    snapshot: WorkspaceSnapshot,
412) -> Result<MigrationResult, WorkspaceMigrationError> {
413    match snapshot.schema_version {
414        WORKSPACE_SCHEMA_VERSION => {
415            // Current version — no migration needed.
416            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/// Check whether a snapshot requires migration.
434#[must_use]
435pub fn needs_migration(snapshot: &WorkspaceSnapshot) -> bool {
436    snapshot.schema_version != WORKSPACE_SCHEMA_VERSION
437}
438
439// =========================================================================
440// Tests
441// =========================================================================
442
443#[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    // ---- Construction ----
496
497    #[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    // ---- Validation ----
537
538    #[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    // ---- Serialization ----
666    //
667    // Roundtrip coverage verifies pane/workspace snapshots remain portable across
668    // hosts and can be parsed back without lossy key collisions.
669
670    #[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        // Hand-crafted JSON matching the expected wire format, with only
748        // one `extensions` per node (PaneNodeRecord level, not PaneLeaf).
749        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        // WorkspaceMetadata has no flatten issues — full roundtrip works.
793        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        // JSON with minimal fields — optional ones should get defaults
806        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    // ---- Deterministic hashing ----
821
822    #[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    // ---- Canonicalization ----
844
845    #[test]
846    fn canonicalize_sorts_nodes() {
847        let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
848        // Reverse the node order
849        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    // ---- Leaf count ----
859
860    #[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    // ---- Migration ----
873
874    #[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    // ---- Error display ----
920
921    #[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    // ---- Determinism ----
948
949    #[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}