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 data belongs in explicit `extensions` maps for
57/// round-tripping.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct WorkspaceSnapshot {
60    /// Schema version for migration detection.
61    #[serde(default = "default_workspace_version")]
62    pub schema_version: u16,
63    /// The pane tree layout.
64    pub pane_tree: PaneTreeSnapshot,
65    /// Which pane had focus when the workspace was persisted.
66    #[serde(default)]
67    pub active_pane_id: Option<PaneId>,
68    /// Workspace metadata (name, timestamps, host info).
69    pub metadata: WorkspaceMetadata,
70    /// Persistent pane interaction timeline for undo/redo/replay.
71    #[serde(default)]
72    pub interaction_timeline: PaneInteractionTimeline,
73    /// Forward-compatible extension bag.
74    #[serde(default)]
75    pub extensions: BTreeMap<String, String>,
76}
77
78fn default_workspace_version() -> u16 {
79    WORKSPACE_SCHEMA_VERSION
80}
81
82impl WorkspaceSnapshot {
83    /// Create a new v1 workspace snapshot.
84    #[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    /// Create a snapshot with a focused pane.
97    #[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    /// Validate the snapshot against schema and structural invariants.
104    pub fn validate(&self) -> Result<(), WorkspaceValidationError> {
105        // Version check
106        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        // Pane tree version check
114        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        // Pane tree structural validation
122        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        // Active pane must exist in the tree if set
135        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            // Active pane should be a leaf (not a split)
141            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        // Metadata validation
154        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        // Timeline must be internally replayable and agree with persisted pane_tree state.
166        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    /// Canonicalize for deterministic serialization.
190    pub fn canonicalize(&mut self) {
191        self.pane_tree.canonicalize();
192    }
193
194    /// Deterministic hash for state diagnostics.
195    #[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    /// Count of leaf panes in the tree.
211    #[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// =========================================================================
222// Metadata
223// =========================================================================
224
225/// Workspace metadata for provenance and diagnostics.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct WorkspaceMetadata {
228    /// Human-readable workspace name.
229    pub name: String,
230    /// Monotonic generation counter (incremented on each save).
231    #[serde(default)]
232    pub created_generation: u64,
233    /// Last-saved generation counter.
234    #[serde(default)]
235    pub saved_generation: u64,
236    /// Application version that created/saved this workspace.
237    #[serde(default)]
238    pub app_version: String,
239    /// Forward-compatible custom tags.
240    #[serde(default)]
241    pub tags: BTreeMap<String, String>,
242}
243
244impl WorkspaceMetadata {
245    /// Create metadata with a workspace name.
246    #[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    /// Set the application version.
258    #[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    /// Increment the save generation counter.
265    pub fn increment_generation(&mut self) {
266        self.saved_generation = self.saved_generation.saturating_add(1);
267    }
268}
269
270// =========================================================================
271// Validation errors
272// =========================================================================
273
274/// Errors from workspace validation.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum WorkspaceValidationError {
277    /// Schema version is not supported.
278    UnsupportedVersion { found: u16, expected: u16 },
279    /// Pane tree schema version mismatch.
280    PaneTreeVersionMismatch { found: u16, expected: u16 },
281    /// Pane tree has structural invariant violations.
282    PaneTreeInvalid {
283        issue_count: usize,
284        first_issue: String,
285    },
286    /// Active pane ID does not exist in the tree.
287    ActivePaneNotFound { pane_id: PaneId },
288    /// Active pane is a split node, not a leaf.
289    ActivePaneNotLeaf { pane_id: PaneId },
290    /// Workspace name is empty.
291    EmptyWorkspaceName,
292    /// Timeline cursor is outside the recorded history bounds.
293    TimelineCursorOutOfRange { cursor: usize, len: usize },
294    /// Timeline replay failed (missing/invalid baseline or invalid operation).
295    TimelineReplayFailed { reason: String },
296    /// Timeline replay does not match the persisted pane tree state.
297    TimelineReplayMismatch {
298        pane_tree_hash: u64,
299        replay_hash: u64,
300    },
301    /// Pane model error from tree operations.
302    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// =========================================================================
364// Migration scaffolding
365// =========================================================================
366
367/// Result of attempting to migrate a workspace from an older schema version.
368#[derive(Debug, Clone)]
369pub struct MigrationResult {
370    /// The migrated snapshot.
371    pub snapshot: WorkspaceSnapshot,
372    /// Source version before migration.
373    pub from_version: u16,
374    /// Target version after migration.
375    pub to_version: u16,
376    /// Warnings or notes from the migration.
377    pub warnings: Vec<String>,
378}
379
380impl MigrationResult {
381    /// Classify the migration decision for audit logs.
382    #[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    /// Deterministic checksum of the resulting workspace state.
392    #[must_use]
393    pub fn state_checksum(&self) -> u64 {
394        self.snapshot.state_hash()
395    }
396}
397
398/// Errors from workspace migration.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub enum WorkspaceMigrationError {
401    /// Version is not recognized or too old to migrate.
402    UnsupportedVersion { version: u16 },
403    /// Migration from the given version is not implemented.
404    NoMigrationPath { from: u16, to: u16 },
405    /// Deserialization failed during migration.
406    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/// Errors from canonical workspace JSON import/export.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum WorkspaceSnapshotJsonError {
428    /// JSON deserialization failed before schema migration could run.
429    DeserializationFailed { reason: String },
430    /// Schema migration failed.
431    MigrationFailed { source: WorkspaceMigrationError },
432    /// Snapshot validation failed.
433    ValidationFailed {
434        context: &'static str,
435        source: WorkspaceValidationError,
436    },
437    /// JSON serialization failed.
438    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
458/// Attempt to migrate a workspace snapshot to the current schema version.
459///
460/// For v1 (current), this is a no-op identity migration. Future versions
461/// will chain migrations through each intermediate version.
462pub fn migrate_workspace(
463    snapshot: WorkspaceSnapshot,
464) -> Result<MigrationResult, WorkspaceMigrationError> {
465    match snapshot.schema_version {
466        WORKSPACE_SCHEMA_VERSION => {
467            // Current version — no migration needed.
468            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/// Check whether a snapshot requires migration.
486#[must_use]
487pub fn needs_migration(snapshot: &WorkspaceSnapshot) -> bool {
488    snapshot.schema_version != WORKSPACE_SCHEMA_VERSION
489}
490
491/// Canonicalize every schema field that affects deterministic workspace JSON.
492pub 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
499/// Decode, migrate, canonicalize, and validate a workspace JSON payload.
500pub 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
521/// Validate and encode a workspace snapshot as canonical JSON.
522pub 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// =========================================================================
541// Tests
542// =========================================================================
543
544#[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    // ---- Construction ----
597
598    #[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    // ---- Validation ----
638
639    #[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    // ---- Serialization ----
767    //
768    // Roundtrip coverage verifies pane/workspace snapshots remain portable across
769    // hosts and can be parsed back without lossy key collisions.
770
771    #[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        // Hand-crafted JSON matching the expected wire format, with only
852        // one `extensions` per node (PaneNodeRecord level, not PaneLeaf).
853        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        // WorkspaceMetadata has no flatten issues — full roundtrip works.
897        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        // JSON with minimal fields — optional ones should get defaults
910        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    // ---- Deterministic hashing ----
925
926    #[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    // ---- Canonicalization ----
948
949    #[test]
950    fn canonicalize_sorts_nodes() {
951        let mut snap = WorkspaceSnapshot::new(split_tree(), WorkspaceMetadata::new("s"));
952        // Reverse the node order
953        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    // ---- Leaf count ----
963
964    #[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    // ---- Migration ----
977
978    #[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    // ---- Canonical JSON import/export corpus ----
1024
1025    #[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    // ---- Error display ----
1139
1140    #[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    // ---- Determinism ----
1167
1168    #[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}