mockforge_core/snapshots/
manager.rs1use 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
15pub struct SnapshotManager {
20 base_dir: PathBuf,
22}
23
24impl SnapshotManager {
25 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 fn workspace_dir(&self, workspace_id: &str) -> PathBuf {
41 self.base_dir.join(workspace_id)
42 }
43
44 fn snapshot_dir(&self, workspace_id: &str, snapshot_name: &str) -> PathBuf {
46 self.workspace_dir(workspace_id).join(snapshot_name)
47 }
48
49 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 let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
67 fs::create_dir_all(&snapshot_dir).await?;
68
69 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 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 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 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 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 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 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 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 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 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 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 if temp_dir.exists() {
195 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 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 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 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 }
242
243 let components_to_restore = components.unwrap_or_else(|| manifest.components.clone());
245
246 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 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 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 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 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 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 snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
366 Ok(snapshots)
367 }
368
369 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 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 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 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(¤t).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 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 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}