mockforge_core/snapshots/
manager.rs

1//! Snapshot manager for saving and restoring system states
2//!
3//! The snapshot manager provides functionality to save complete system states
4//! to disk and restore them later, enabling time travel capabilities.
5
6use crate::consistency::ConsistencyEngine;
7use crate::snapshots::types::{SnapshotComponents, SnapshotManifest, SnapshotMetadata};
8use crate::workspace_persistence::WorkspacePersistence;
9use crate::Result;
10use sha2::{Digest, Sha256};
11use std::path::{Path, PathBuf};
12use tokio::fs;
13use tracing::{debug, info, warn};
14
15/// Snapshot manager for saving and restoring system states
16///
17/// Manages snapshots stored in a directory structure:
18/// `~/.mockforge/snapshots/{workspace_id}/{snapshot_name}/`
19pub struct SnapshotManager {
20    /// Base directory for snapshots
21    base_dir: PathBuf,
22}
23
24impl SnapshotManager {
25    /// Create a new snapshot manager
26    ///
27    /// Defaults to `~/.mockforge/snapshots` if no base directory is provided.
28    pub fn new(base_dir: Option<PathBuf>) -> Self {
29        let base_dir = base_dir.unwrap_or_else(|| {
30            dirs::home_dir()
31                .unwrap_or_else(|| PathBuf::from("."))
32                .join(".mockforge")
33                .join("snapshots")
34        });
35
36        Self { base_dir }
37    }
38
39    /// Get the snapshot directory for a workspace
40    fn workspace_dir(&self, workspace_id: &str) -> PathBuf {
41        self.base_dir.join(workspace_id)
42    }
43
44    /// Get the snapshot directory for a specific snapshot
45    fn snapshot_dir(&self, workspace_id: &str, snapshot_name: &str) -> PathBuf {
46        self.workspace_dir(workspace_id).join(snapshot_name)
47    }
48
49    /// Save a snapshot of the current system state
50    ///
51    /// This creates a snapshot directory and saves all specified components.
52    pub async fn save_snapshot(
53        &self,
54        name: String,
55        description: Option<String>,
56        workspace_id: String,
57        components: SnapshotComponents,
58        consistency_engine: Option<&ConsistencyEngine>,
59        workspace_persistence: Option<&WorkspacePersistence>,
60        vbr_state: Option<serde_json::Value>,
61        recorder_state: Option<serde_json::Value>,
62    ) -> Result<SnapshotManifest> {
63        info!("Saving snapshot '{}' for workspace '{}'", name, workspace_id);
64
65        // Create snapshot directory
66        let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
67        fs::create_dir_all(&snapshot_dir).await?;
68
69        // Create temporary directory for atomic writes
70        let temp_dir = snapshot_dir.join(".tmp");
71        fs::create_dir_all(&temp_dir).await?;
72
73        let mut manifest =
74            SnapshotManifest::new(name.clone(), workspace_id.clone(), components.clone());
75
76        // Save unified state if requested
77        if components.unified_state {
78            if let Some(engine) = consistency_engine {
79                let unified_state = engine.get_state(&workspace_id).await;
80                if let Some(state) = unified_state {
81                    let state_path = temp_dir.join("unified_state.json");
82                    let state_json = serde_json::to_string_pretty(&state)?;
83                    fs::write(&state_path, &state_json).await?;
84                    debug!("Saved unified state to {}", state_path.display());
85                } else {
86                    warn!("No unified state found for workspace {}", workspace_id);
87                }
88            }
89        }
90
91        // Save workspace config if requested
92        if components.workspace_config {
93            let config_path = temp_dir.join("workspace_config.yaml");
94            if let Some(persistence) = workspace_persistence {
95                match persistence.load_workspace(&workspace_id).await {
96                    Ok(workspace) => {
97                        let config_yaml = serde_yaml::to_string(&workspace).map_err(|e| {
98                            crate::Error::generic(format!("Failed to serialize workspace: {}", e))
99                        })?;
100                        fs::write(&config_path, config_yaml).await?;
101                        debug!("Saved workspace config to {}", config_path.display());
102                    }
103                    Err(e) => {
104                        warn!("Failed to load workspace config for snapshot: {}. Saving empty config.", e);
105                        let empty_config = serde_yaml::to_string(&serde_json::json!({}))?;
106                        fs::write(&config_path, empty_config).await?;
107                    }
108                }
109            } else {
110                warn!("Workspace persistence not provided, saving empty workspace config");
111                let empty_config = serde_yaml::to_string(&serde_json::json!({}))?;
112                fs::write(&config_path, empty_config).await?;
113            }
114        }
115
116        // Save VBR state if requested
117        if components.vbr_state {
118            let vbr_path = temp_dir.join("vbr_state.json");
119            if let Some(state) = vbr_state {
120                let state_json = serde_json::to_string_pretty(&state)?;
121                fs::write(&vbr_path, state_json).await?;
122                debug!("Saved VBR state to {}", vbr_path.display());
123            } else {
124                warn!("VBR state requested but not provided, saving empty state");
125                let empty_state = serde_json::json!({});
126                fs::write(&vbr_path, serde_json::to_string_pretty(&empty_state)?).await?;
127            }
128        }
129
130        // Save Recorder state if requested
131        if components.recorder_state {
132            let recorder_path = temp_dir.join("recorder_state.json");
133            if let Some(state) = recorder_state {
134                let state_json = serde_json::to_string_pretty(&state)?;
135                fs::write(&recorder_path, state_json).await?;
136                debug!("Saved Recorder state to {}", recorder_path.display());
137            } else {
138                warn!("Recorder state requested but not provided, saving empty state");
139                let empty_state = serde_json::json!({});
140                fs::write(&recorder_path, serde_json::to_string_pretty(&empty_state)?).await?;
141            }
142        }
143
144        // Save protocol states if requested
145        if !components.protocols.is_empty() || components.protocols.is_empty() {
146            let protocols_dir = temp_dir.join("protocols");
147            fs::create_dir_all(&protocols_dir).await?;
148
149            if let Some(_engine) = consistency_engine {
150                // Save all protocol states
151                let protocols: Vec<String> = if components.protocols.is_empty() {
152                    vec![
153                        "http".to_string(),
154                        "graphql".to_string(),
155                        "grpc".to_string(),
156                        "websocket".to_string(),
157                        "tcp".to_string(),
158                    ]
159                } else {
160                    components.protocols.clone()
161                };
162
163                for protocol_name in protocols {
164                    // TODO: Get protocol state from engine when protocol adapters are integrated
165                    let protocol_path = protocols_dir.join(format!("{}.json", protocol_name));
166                    let empty_state = serde_json::json!({});
167                    fs::write(&protocol_path, serde_json::to_string_pretty(&empty_state)?).await?;
168                }
169            }
170        }
171
172        // Calculate checksum and size
173        let (size, checksum) = self.calculate_snapshot_checksum(&temp_dir).await?;
174        manifest.size_bytes = size;
175        manifest.checksum = checksum;
176        manifest.description = description;
177
178        // Write manifest
179        let manifest_path = temp_dir.join("manifest.json");
180        let manifest_json = serde_json::to_string_pretty(&manifest)?;
181        fs::write(&manifest_path, &manifest_json).await?;
182
183        // Atomically move temp directory to final location
184        // Remove old snapshot if it exists
185        if snapshot_dir.exists() && snapshot_dir != temp_dir {
186            let old_backup = snapshot_dir.with_extension("old");
187            if old_backup.exists() {
188                fs::remove_dir_all(&old_backup).await?;
189            }
190            fs::rename(&snapshot_dir, &old_backup).await?;
191        }
192
193        // Move temp to final location
194        if temp_dir.exists() {
195            // Move contents from temp_dir to snapshot_dir
196            let mut entries = fs::read_dir(&temp_dir).await?;
197            while let Some(entry) = entries.next_entry().await? {
198                let dest = snapshot_dir.join(entry.file_name());
199                fs::rename(entry.path(), &dest).await?;
200            }
201            fs::remove_dir(&temp_dir).await?;
202        }
203
204        info!("Snapshot '{}' saved successfully ({} bytes)", name, size);
205        Ok(manifest)
206    }
207
208    /// Load a snapshot and restore system state
209    ///
210    /// Restores the specified components from a snapshot.
211    /// Returns the manifest and optionally the VBR and Recorder state as JSON
212    /// (caller is responsible for restoring them to their respective systems).
213    pub async fn load_snapshot(
214        &self,
215        name: String,
216        workspace_id: String,
217        components: Option<SnapshotComponents>,
218        consistency_engine: Option<&ConsistencyEngine>,
219        workspace_persistence: Option<&WorkspacePersistence>,
220    ) -> Result<(SnapshotManifest, Option<serde_json::Value>, Option<serde_json::Value>)> {
221        info!("Loading snapshot '{}' for workspace '{}'", name, workspace_id);
222
223        let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
224        if !snapshot_dir.exists() {
225            return Err(crate::Error::from(format!(
226                "Snapshot '{}' not found for workspace '{}'",
227                name, workspace_id
228            )));
229        }
230
231        // Load manifest
232        let manifest_path = snapshot_dir.join("manifest.json");
233        let manifest_json = fs::read_to_string(&manifest_path).await?;
234        let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
235
236        // Validate checksum
237        let (size, checksum) = self.calculate_snapshot_checksum(&snapshot_dir).await?;
238        if checksum != manifest.checksum {
239            warn!("Snapshot checksum mismatch: expected {}, got {}", manifest.checksum, checksum);
240            // Continue anyway, but log warning
241        }
242
243        // Determine which components to restore
244        let components_to_restore = components.unwrap_or_else(|| manifest.components.clone());
245
246        // Restore unified state if requested
247        if components_to_restore.unified_state && manifest.components.unified_state {
248            if let Some(engine) = consistency_engine {
249                let state_path = snapshot_dir.join("unified_state.json");
250                if state_path.exists() {
251                    let state_json = fs::read_to_string(&state_path).await?;
252                    let unified_state: crate::consistency::UnifiedState =
253                        serde_json::from_str(&state_json)?;
254                    engine.restore_state(unified_state).await?;
255                    debug!("Restored unified state from {}", state_path.display());
256                }
257            }
258        }
259
260        // Restore workspace config if requested
261        if components_to_restore.workspace_config && manifest.components.workspace_config {
262            let config_path = snapshot_dir.join("workspace_config.yaml");
263            if config_path.exists() {
264                if let Some(persistence) = workspace_persistence {
265                    let config_yaml = fs::read_to_string(&config_path).await?;
266                    let workspace: crate::workspace::Workspace = serde_yaml::from_str(&config_yaml)
267                        .map_err(|e| {
268                            crate::Error::generic(format!("Failed to deserialize workspace: {}", e))
269                        })?;
270                    persistence.save_workspace(&workspace).await?;
271                    debug!("Restored workspace config from {}", config_path.display());
272                } else {
273                    warn!(
274                        "Workspace persistence not provided, skipping workspace config restoration"
275                    );
276                }
277            } else {
278                warn!("Workspace config file not found in snapshot: {}", config_path.display());
279            }
280        }
281
282        // Load VBR state if requested (return as JSON for caller to restore)
283        let vbr_state = if components_to_restore.vbr_state && manifest.components.vbr_state {
284            let vbr_path = snapshot_dir.join("vbr_state.json");
285            if vbr_path.exists() {
286                let vbr_json = fs::read_to_string(&vbr_path).await?;
287                let state: serde_json::Value = serde_json::from_str(&vbr_json).map_err(|e| {
288                    crate::Error::generic(format!("Failed to parse VBR state: {}", e))
289                })?;
290                debug!("Loaded VBR state from {}", vbr_path.display());
291                Some(state)
292            } else {
293                warn!("VBR state file not found in snapshot: {}", vbr_path.display());
294                None
295            }
296        } else {
297            None
298        };
299
300        // Load Recorder state if requested (return as JSON for caller to restore)
301        let recorder_state =
302            if components_to_restore.recorder_state && manifest.components.recorder_state {
303                let recorder_path = snapshot_dir.join("recorder_state.json");
304                if recorder_path.exists() {
305                    let recorder_json = fs::read_to_string(&recorder_path).await?;
306                    let state: serde_json::Value =
307                        serde_json::from_str(&recorder_json).map_err(|e| {
308                            crate::Error::generic(format!("Failed to parse Recorder state: {}", e))
309                        })?;
310                    debug!("Loaded Recorder state from {}", recorder_path.display());
311                    Some(state)
312                } else {
313                    warn!("Recorder state file not found in snapshot: {}", recorder_path.display());
314                    None
315                }
316            } else {
317                None
318            };
319
320        info!("Snapshot '{}' loaded successfully", name);
321        Ok((manifest, vbr_state, recorder_state))
322    }
323
324    /// List all snapshots for a workspace
325    pub async fn list_snapshots(&self, workspace_id: &str) -> Result<Vec<SnapshotMetadata>> {
326        let workspace_dir = self.workspace_dir(workspace_id);
327        if !workspace_dir.exists() {
328            return Ok(Vec::new());
329        }
330
331        let mut snapshots = Vec::new();
332        let mut entries = fs::read_dir(&workspace_dir).await?;
333
334        while let Some(entry) = entries.next_entry().await? {
335            let snapshot_name = entry.file_name().to_string_lossy().to_string();
336            // Skip hidden directories and temp directories
337            if snapshot_name.starts_with('.') {
338                continue;
339            }
340
341            let manifest_path = entry.path().join("manifest.json");
342            if manifest_path.exists() {
343                match fs::read_to_string(&manifest_path).await {
344                    Ok(manifest_json) => {
345                        match serde_json::from_str::<SnapshotManifest>(&manifest_json) {
346                            Ok(manifest) => {
347                                snapshots.push(SnapshotMetadata::from(manifest));
348                            }
349                            Err(e) => {
350                                warn!(
351                                    "Failed to parse manifest for snapshot {}: {}",
352                                    snapshot_name, e
353                                );
354                            }
355                        }
356                    }
357                    Err(e) => {
358                        warn!("Failed to read manifest for snapshot {}: {}", snapshot_name, e);
359                    }
360                }
361            }
362        }
363
364        // Sort by creation date (newest first)
365        snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
366        Ok(snapshots)
367    }
368
369    /// Delete a snapshot
370    pub async fn delete_snapshot(&self, name: String, workspace_id: String) -> Result<()> {
371        info!("Deleting snapshot '{}' for workspace '{}'", name, workspace_id);
372        let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
373        if snapshot_dir.exists() {
374            fs::remove_dir_all(&snapshot_dir).await?;
375            info!("Snapshot '{}' deleted successfully", name);
376        } else {
377            return Err(crate::Error::from(format!(
378                "Snapshot '{}' not found for workspace '{}'",
379                name, workspace_id
380            )));
381        }
382        Ok(())
383    }
384
385    /// Get snapshot information
386    pub async fn get_snapshot_info(
387        &self,
388        name: String,
389        workspace_id: String,
390    ) -> Result<SnapshotManifest> {
391        let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
392        let manifest_path = snapshot_dir.join("manifest.json");
393        if !manifest_path.exists() {
394            return Err(crate::Error::from(format!(
395                "Snapshot '{}' not found for workspace '{}'",
396                name, workspace_id
397            )));
398        }
399
400        let manifest_json = fs::read_to_string(&manifest_path).await?;
401        let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
402        Ok(manifest)
403    }
404
405    /// Validate snapshot integrity
406    pub async fn validate_snapshot(&self, name: String, workspace_id: String) -> Result<bool> {
407        let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
408        let manifest_path = snapshot_dir.join("manifest.json");
409        if !manifest_path.exists() {
410            return Err(crate::Error::from(format!(
411                "Snapshot '{}' not found for workspace '{}'",
412                name, workspace_id
413            )));
414        }
415
416        let manifest_json = fs::read_to_string(&manifest_path).await?;
417        let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
418
419        let (_, checksum) = self.calculate_snapshot_checksum(&snapshot_dir).await?;
420        Ok(checksum == manifest.checksum)
421    }
422
423    /// Calculate checksum and size of snapshot directory
424    async fn calculate_snapshot_checksum(&self, dir: &Path) -> Result<(u64, String)> {
425        let mut hasher = Sha256::new();
426        let mut total_size = 0u64;
427
428        let mut stack = vec![dir.to_path_buf()];
429        while let Some(current) = stack.pop() {
430            let mut entries = fs::read_dir(&current).await?;
431            while let Some(entry) = entries.next_entry().await? {
432                let path = entry.path();
433                let metadata = fs::metadata(&path).await?;
434
435                if metadata.is_dir() {
436                    // Skip temp directories
437                    if path
438                        .file_name()
439                        .and_then(|n| n.to_str())
440                        .map(|s| s.starts_with('.'))
441                        .unwrap_or(false)
442                    {
443                        continue;
444                    }
445                    stack.push(path);
446                } else if metadata.is_file() {
447                    // Skip manifest.json from checksum calculation (it contains the checksum)
448                    if path
449                        .file_name()
450                        .and_then(|n| n.to_str())
451                        .map(|s| s == "manifest.json")
452                        .unwrap_or(false)
453                    {
454                        continue;
455                    }
456
457                    let file_size = metadata.len();
458                    total_size += file_size;
459
460                    let content = fs::read(&path).await?;
461                    hasher.update(&content);
462                    hasher
463                        .update(path.file_name().unwrap_or_default().to_string_lossy().as_bytes());
464                }
465            }
466        }
467
468        let checksum = format!("sha256:{:x}", hasher.finalize());
469        Ok((total_size, checksum))
470    }
471}
472
473impl Default for SnapshotManager {
474    fn default() -> Self {
475        Self::new(None)
476    }
477}