Skip to main content

orcs_component/
snapshot.rs

1//! Component snapshot support for persistence and resume.
2//!
3//! The [`Snapshottable`] trait enables components to save and restore their state,
4//! supporting session persistence and resume functionality.
5//!
6//! # Design
7//!
8//! Snapshottable is an **optional** trait. Components that don't implement it
9//! will not have their state persisted across sessions.
10//!
11//! # Example
12//!
13//! ```
14//! use orcs_component::{Snapshottable, ComponentSnapshot, SnapshotError};
15//! use serde::{Serialize, Deserialize};
16//!
17//! #[derive(Serialize, Deserialize)]
18//! struct CounterState {
19//!     count: u64,
20//! }
21//!
22//! struct CounterComponent {
23//!     count: u64,
24//! }
25//!
26//! impl Snapshottable for CounterComponent {
27//!     fn snapshot(&self) -> ComponentSnapshot {
28//!         let state = CounterState { count: self.count };
29//!         ComponentSnapshot::from_state("counter", &state).expect("state should serialize")
30//!     }
31//!
32//!     fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError> {
33//!         let state: CounterState = snapshot.to_state()?;
34//!         self.count = state.count;
35//!         Ok(())
36//!     }
37//! }
38//! ```
39
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use thiserror::Error;
43
44/// Errors that can occur during snapshot operations.
45#[derive(Debug, Error)]
46pub enum SnapshotError {
47    /// Serialization failed.
48    #[error("serialization error: {0}")]
49    Serialization(#[from] serde_json::Error),
50
51    /// Snapshot version mismatch.
52    #[error("version mismatch: expected {expected}, got {actual}")]
53    VersionMismatch { expected: u32, actual: u32 },
54
55    /// Component ID mismatch.
56    #[error("component mismatch: expected {expected}, got {actual}")]
57    ComponentMismatch { expected: String, actual: String },
58
59    /// Invalid snapshot data.
60    #[error("invalid snapshot data: {0}")]
61    InvalidData(String),
62
63    /// Component does not support snapshots.
64    #[error("component {0} does not support snapshots")]
65    NotSupported(String),
66
67    /// Restore failed for a required component.
68    #[error("restore failed for component {component}: {reason}")]
69    RestoreFailed { component: String, reason: String },
70}
71
72/// Declares whether a component supports snapshot persistence.
73///
74/// Components use this to declare their snapshot capability.
75/// The engine uses this to determine how to handle snapshot operations.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum SnapshotSupport {
78    /// Component supports and requires snapshot persistence.
79    ///
80    /// - `snapshot()` will be called during session save
81    /// - `restore()` will be called during session load
82    /// - **Restore failure is an error** (not a warning)
83    Enabled,
84
85    /// Component does not support snapshots (default).
86    ///
87    /// - `snapshot()` and `restore()` will NOT be called
88    /// - If called anyway, returns `SnapshotError::NotSupported`
89    #[default]
90    Disabled,
91}
92
93/// Current snapshot format version.
94pub const SNAPSHOT_VERSION: u32 = 1;
95
96/// Component state snapshot.
97///
98/// Stores a component's serialized state along with metadata
99/// for safe restoration.
100///
101/// # Fields
102///
103/// - `component_fqn`: Fully qualified name (`namespace::name`) for cross-session matching
104/// - `version`: Format version for compatibility checking
105/// - `state`: Serialized component state
106/// - `metadata`: Optional additional data (timestamps, checksums, etc.)
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ComponentSnapshot {
109    /// Fully qualified name (`namespace::name`) for validation.
110    ///
111    /// Uses FQN instead of full `ComponentId` because UUIDs differ between sessions.
112    pub component_fqn: String,
113
114    /// Snapshot format version.
115    pub version: u32,
116
117    /// Serialized component state.
118    pub state: serde_json::Value,
119
120    /// Optional metadata.
121    pub metadata: HashMap<String, serde_json::Value>,
122}
123
124impl ComponentSnapshot {
125    /// Creates a new snapshot from serializable state.
126    ///
127    /// # Arguments
128    ///
129    /// * `fqn` - The component's fully qualified name (`namespace::name`)
130    /// * `state` - The state to serialize
131    ///
132    /// # Errors
133    ///
134    /// Returns `SnapshotError::Serialization` if the state cannot be serialized.
135    pub fn from_state<T: Serialize>(
136        fqn: impl Into<String>,
137        state: &T,
138    ) -> Result<Self, SnapshotError> {
139        Ok(Self {
140            component_fqn: fqn.into(),
141            version: SNAPSHOT_VERSION,
142            state: serde_json::to_value(state)?,
143            metadata: HashMap::new(),
144        })
145    }
146
147    /// Creates an empty snapshot (for components with no state).
148    #[must_use]
149    pub fn empty(fqn: impl Into<String>) -> Self {
150        Self {
151            component_fqn: fqn.into(),
152            version: SNAPSHOT_VERSION,
153            state: serde_json::Value::Null,
154            metadata: HashMap::new(),
155        }
156    }
157
158    /// Deserializes the state.
159    ///
160    /// # Errors
161    ///
162    /// Returns `SnapshotError::Serialization` if deserialization fails.
163    pub fn to_state<T: for<'de> Deserialize<'de>>(&self) -> Result<T, SnapshotError> {
164        Ok(serde_json::from_value(self.state.clone())?)
165    }
166
167    /// Adds metadata.
168    #[must_use]
169    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
170        self.metadata.insert(key.into(), value);
171        self
172    }
173
174    /// Validates that this snapshot matches the expected component.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the FQN or version doesn't match.
179    pub fn validate(&self, expected_fqn: &str) -> Result<(), SnapshotError> {
180        if self.component_fqn != expected_fqn {
181            return Err(SnapshotError::ComponentMismatch {
182                expected: expected_fqn.to_string(),
183                actual: self.component_fqn.clone(),
184            });
185        }
186
187        if self.version != SNAPSHOT_VERSION {
188            return Err(SnapshotError::VersionMismatch {
189                expected: SNAPSHOT_VERSION,
190                actual: self.version,
191            });
192        }
193
194        Ok(())
195    }
196
197    /// Returns true if the state is empty/null.
198    #[must_use]
199    pub fn is_empty(&self) -> bool {
200        self.state.is_null()
201    }
202}
203
204/// Trait for components that support state persistence.
205///
206/// Implementing this trait allows a component's state to be saved
207/// when a session is paused and restored when resumed.
208///
209/// # Contract
210///
211/// - `snapshot()` must produce a complete representation of the component's state
212/// - `restore()` must return the component to the exact state represented by the snapshot
213/// - Restored components must behave identically to their pre-snapshot state
214///
215/// # Thread Safety
216///
217/// Both methods take `&self` / `&mut self` as appropriate and don't require
218/// additional synchronization.
219pub trait Snapshottable {
220    /// Creates a snapshot of the current state.
221    ///
222    /// The snapshot should contain all information needed to restore
223    /// the component to its current state.
224    fn snapshot(&self) -> ComponentSnapshot;
225
226    /// Restores state from a snapshot.
227    ///
228    /// # Errors
229    ///
230    /// Returns `SnapshotError` if restoration fails (version mismatch,
231    /// invalid data, etc.).
232    fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError>;
233
234    /// Returns true if this component has meaningful state to persist.
235    ///
236    /// Components that always start fresh can return `false` to skip
237    /// snapshot/restore operations.
238    fn has_state(&self) -> bool {
239        true
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248    struct TestState {
249        value: i32,
250        name: String,
251    }
252
253    #[test]
254    fn snapshot_roundtrip() {
255        let state = TestState {
256            value: 42,
257            name: "test".into(),
258        };
259
260        let snapshot =
261            ComponentSnapshot::from_state("test-component", &state).expect("create snapshot");
262        let restored: TestState = snapshot.to_state().expect("deserialize state");
263
264        assert_eq!(state, restored);
265    }
266
267    #[test]
268    fn snapshot_validation() {
269        let snapshot = ComponentSnapshot::empty("my-component");
270
271        assert!(snapshot.validate("my-component").is_ok());
272        assert!(snapshot.validate("other-component").is_err());
273    }
274
275    #[test]
276    fn snapshot_with_metadata() {
277        let snapshot = ComponentSnapshot::empty("test")
278            .with_metadata("timestamp", serde_json::json!(12345))
279            .with_metadata("version", serde_json::json!("1.0.0"));
280
281        assert_eq!(snapshot.metadata.len(), 2);
282        assert_eq!(snapshot.metadata["timestamp"], serde_json::json!(12345));
283    }
284
285    #[test]
286    fn empty_snapshot() {
287        let snapshot = ComponentSnapshot::empty("empty-component");
288        assert!(snapshot.is_empty());
289        assert_eq!(snapshot.component_fqn, "empty-component");
290    }
291
292    #[test]
293    fn version_mismatch_error() {
294        let mut snapshot = ComponentSnapshot::empty("test");
295        snapshot.version = 999;
296
297        let result = snapshot.validate("test");
298        assert!(matches!(result, Err(SnapshotError::VersionMismatch { .. })));
299    }
300
301    struct TestComponent {
302        count: u64,
303    }
304
305    impl Snapshottable for TestComponent {
306        fn snapshot(&self) -> ComponentSnapshot {
307            ComponentSnapshot::from_state("test", &self.count).expect("create snapshot")
308        }
309
310        fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError> {
311            self.count = snapshot.to_state()?;
312            Ok(())
313        }
314    }
315
316    #[test]
317    fn snapshottable_trait() {
318        let mut comp = TestComponent { count: 100 };
319        let snapshot = comp.snapshot();
320
321        comp.count = 0;
322        comp.restore(&snapshot).expect("restore snapshot");
323
324        assert_eq!(comp.count, 100);
325    }
326}