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