1use crate::consistency::ConsistencyEngine;
7use crate::snapshots::state_exporter::ProtocolStateExporter;
8use crate::snapshots::types::{SnapshotComponents, SnapshotManifest, SnapshotMetadata};
9use crate::workspace_persistence::WorkspacePersistence;
10use crate::Result;
11use sha2::{Digest, Sha256};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use tokio::fs;
16use tracing::{debug, info, warn};
17
18pub struct SnapshotManager {
23 base_dir: PathBuf,
25}
26
27impl SnapshotManager {
28 pub fn new(base_dir: Option<PathBuf>) -> Self {
32 let base_dir = base_dir.unwrap_or_else(|| {
33 dirs::home_dir()
34 .unwrap_or_else(|| PathBuf::from("."))
35 .join(".mockforge")
36 .join("snapshots")
37 });
38
39 Self { base_dir }
40 }
41
42 fn workspace_dir(&self, workspace_id: &str) -> PathBuf {
44 self.base_dir.join(workspace_id)
45 }
46
47 fn snapshot_dir(&self, workspace_id: &str, snapshot_name: &str) -> PathBuf {
49 self.workspace_dir(workspace_id).join(snapshot_name)
50 }
51
52 #[allow(clippy::too_many_arguments)]
66 pub async fn save_snapshot(
67 &self,
68 name: String,
69 description: Option<String>,
70 workspace_id: String,
71 components: SnapshotComponents,
72 consistency_engine: Option<&ConsistencyEngine>,
73 workspace_persistence: Option<&WorkspacePersistence>,
74 vbr_state: Option<serde_json::Value>,
75 recorder_state: Option<serde_json::Value>,
76 ) -> Result<SnapshotManifest> {
77 self.save_snapshot_with_exporters(
78 name,
79 description,
80 workspace_id,
81 components,
82 consistency_engine,
83 workspace_persistence,
84 vbr_state,
85 recorder_state,
86 HashMap::new(),
87 )
88 .await
89 }
90
91 #[allow(clippy::too_many_arguments)]
96 pub async fn save_snapshot_with_exporters(
97 &self,
98 name: String,
99 description: Option<String>,
100 workspace_id: String,
101 components: SnapshotComponents,
102 consistency_engine: Option<&ConsistencyEngine>,
103 workspace_persistence: Option<&WorkspacePersistence>,
104 vbr_state: Option<serde_json::Value>,
105 recorder_state: Option<serde_json::Value>,
106 protocol_exporters: HashMap<String, Arc<dyn ProtocolStateExporter>>,
107 ) -> Result<SnapshotManifest> {
108 info!("Saving snapshot '{}' for workspace '{}'", name, workspace_id);
109
110 let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
112 fs::create_dir_all(&snapshot_dir).await?;
113
114 let temp_dir = snapshot_dir.join(".tmp");
116 fs::create_dir_all(&temp_dir).await?;
117
118 let mut manifest =
119 SnapshotManifest::new(name.clone(), workspace_id.clone(), components.clone());
120
121 if components.unified_state {
123 if let Some(engine) = consistency_engine {
124 let unified_state = engine.get_state(&workspace_id).await;
125 if let Some(state) = unified_state {
126 let state_path = temp_dir.join("unified_state.json");
127 let state_json = serde_json::to_string_pretty(&state)?;
128 fs::write(&state_path, &state_json).await?;
129 debug!("Saved unified state to {}", state_path.display());
130 } else {
131 warn!("No unified state found for workspace {}", workspace_id);
132 }
133 }
134 }
135
136 if components.workspace_config {
138 let config_path = temp_dir.join("workspace_config.yaml");
139 if let Some(persistence) = workspace_persistence {
140 match persistence.load_workspace(&workspace_id).await {
141 Ok(workspace) => {
142 let config_yaml = serde_yaml::to_string(&workspace).map_err(|e| {
143 crate::Error::io_with_context("serialize workspace", e.to_string())
144 })?;
145 fs::write(&config_path, config_yaml).await?;
146 debug!("Saved workspace config to {}", config_path.display());
147 }
148 Err(e) => {
149 warn!("Failed to load workspace config for snapshot: {}. Saving empty config.", e);
150 let empty_config = serde_yaml::to_string(&serde_json::json!({}))?;
151 fs::write(&config_path, empty_config).await?;
152 }
153 }
154 } else {
155 warn!("Workspace persistence not provided, saving empty workspace config");
156 let empty_config = serde_yaml::to_string(&serde_json::json!({}))?;
157 fs::write(&config_path, empty_config).await?;
158 }
159 }
160
161 if components.vbr_state {
163 let vbr_path = temp_dir.join("vbr_state.json");
164 if let Some(state) = vbr_state {
165 let state_json = serde_json::to_string_pretty(&state)?;
166 fs::write(&vbr_path, state_json).await?;
167 debug!("Saved VBR state to {}", vbr_path.display());
168 } else {
169 warn!("VBR state requested but not provided, saving empty state");
170 let empty_state = serde_json::json!({});
171 fs::write(&vbr_path, serde_json::to_string_pretty(&empty_state)?).await?;
172 }
173 }
174
175 if components.recorder_state {
177 let recorder_path = temp_dir.join("recorder_state.json");
178 if let Some(state) = recorder_state {
179 let state_json = serde_json::to_string_pretty(&state)?;
180 fs::write(&recorder_path, state_json).await?;
181 debug!("Saved Recorder state to {}", recorder_path.display());
182 } else {
183 warn!("Recorder state requested but not provided, saving empty state");
184 let empty_state = serde_json::json!({});
185 fs::write(&recorder_path, serde_json::to_string_pretty(&empty_state)?).await?;
186 }
187 }
188
189 if !components.protocols.is_empty() || !protocol_exporters.is_empty() {
191 let protocols_dir = temp_dir.join("protocols");
192 fs::create_dir_all(&protocols_dir).await?;
193
194 let protocols_to_save: Vec<String> = if components.protocols.is_empty() {
196 protocol_exporters.keys().cloned().collect()
198 } else {
199 components.protocols.clone()
200 };
201
202 for protocol_name in protocols_to_save {
203 let protocol_path = protocols_dir.join(format!("{}.json", protocol_name));
204
205 if let Some(exporter) = protocol_exporters.get(&protocol_name) {
207 match exporter.export_state().await {
208 Ok(state) => {
209 let summary = exporter.state_summary().await;
210 fs::write(&protocol_path, serde_json::to_string_pretty(&state)?)
211 .await?;
212 debug!(
213 "Saved {} protocol state to {}: {}",
214 protocol_name,
215 protocol_path.display(),
216 summary
217 );
218 }
219 Err(e) => {
220 warn!(
221 "Failed to export {} protocol state: {}. Saving empty state.",
222 protocol_name, e
223 );
224 let empty_state = serde_json::json!({
225 "error": format!("Failed to export state: {}", e),
226 "protocol": protocol_name
227 });
228 fs::write(&protocol_path, serde_json::to_string_pretty(&empty_state)?)
229 .await?;
230 }
231 }
232 } else {
233 debug!(
235 "No exporter available for protocol {}, saving placeholder",
236 protocol_name
237 );
238 let placeholder_state = serde_json::json!({
239 "protocol": protocol_name,
240 "state": "no_exporter_available"
241 });
242 fs::write(&protocol_path, serde_json::to_string_pretty(&placeholder_state)?)
243 .await?;
244 }
245 }
246 }
247
248 let (size, checksum) = self.calculate_snapshot_checksum(&temp_dir).await?;
250 manifest.size_bytes = size;
251 manifest.checksum = checksum;
252 manifest.description = description;
253
254 let manifest_path = temp_dir.join("manifest.json");
256 let manifest_json = serde_json::to_string_pretty(&manifest)?;
257 fs::write(&manifest_path, &manifest_json).await?;
258
259 if snapshot_dir.exists() && snapshot_dir != temp_dir {
262 let old_backup = snapshot_dir.with_extension("old");
263 if old_backup.exists() {
264 fs::remove_dir_all(&old_backup).await?;
265 }
266 fs::rename(&snapshot_dir, &old_backup).await?;
267 }
268
269 if temp_dir.exists() {
271 let mut entries = fs::read_dir(&temp_dir).await?;
273 while let Some(entry) = entries.next_entry().await? {
274 let dest = snapshot_dir.join(entry.file_name());
275 fs::rename(entry.path(), &dest).await?;
276 }
277 fs::remove_dir(&temp_dir).await?;
278 }
279
280 info!("Snapshot '{}' saved successfully ({} bytes)", name, size);
281 Ok(manifest)
282 }
283
284 pub async fn load_snapshot(
290 &self,
291 name: String,
292 workspace_id: String,
293 components: Option<SnapshotComponents>,
294 consistency_engine: Option<&ConsistencyEngine>,
295 workspace_persistence: Option<&WorkspacePersistence>,
296 ) -> Result<(SnapshotManifest, Option<serde_json::Value>, Option<serde_json::Value>)> {
297 info!("Loading snapshot '{}' for workspace '{}'", name, workspace_id);
298
299 let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
300 if !snapshot_dir.exists() {
301 return Err(crate::Error::from(format!(
302 "Snapshot '{}' not found for workspace '{}'",
303 name, workspace_id
304 )));
305 }
306
307 let manifest_path = snapshot_dir.join("manifest.json");
309 let manifest_json = fs::read_to_string(&manifest_path).await?;
310 let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
311
312 let (_size, checksum) = self.calculate_snapshot_checksum(&snapshot_dir).await?;
314 if checksum != manifest.checksum {
315 warn!("Snapshot checksum mismatch: expected {}, got {}", manifest.checksum, checksum);
316 }
318
319 let components_to_restore = components.unwrap_or_else(|| manifest.components.clone());
321
322 if components_to_restore.unified_state && manifest.components.unified_state {
324 if let Some(engine) = consistency_engine {
325 let state_path = snapshot_dir.join("unified_state.json");
326 if state_path.exists() {
327 let state_json = fs::read_to_string(&state_path).await?;
328 let unified_state: crate::consistency::UnifiedState =
329 serde_json::from_str(&state_json)?;
330 engine.restore_state(unified_state).await?;
331 debug!("Restored unified state from {}", state_path.display());
332 }
333 }
334 }
335
336 if components_to_restore.workspace_config && manifest.components.workspace_config {
338 let config_path = snapshot_dir.join("workspace_config.yaml");
339 if config_path.exists() {
340 if let Some(persistence) = workspace_persistence {
341 let config_yaml = fs::read_to_string(&config_path).await?;
342 let workspace: crate::workspace::Workspace = serde_yaml::from_str(&config_yaml)
343 .map_err(|e| {
344 crate::Error::io_with_context("deserialize workspace", e.to_string())
345 })?;
346 persistence.save_workspace(&workspace).await?;
347 debug!("Restored workspace config from {}", config_path.display());
348 } else {
349 warn!(
350 "Workspace persistence not provided, skipping workspace config restoration"
351 );
352 }
353 } else {
354 warn!("Workspace config file not found in snapshot: {}", config_path.display());
355 }
356 }
357
358 let vbr_state = if components_to_restore.vbr_state && manifest.components.vbr_state {
360 let vbr_path = snapshot_dir.join("vbr_state.json");
361 if vbr_path.exists() {
362 let vbr_json = fs::read_to_string(&vbr_path).await?;
363 let state: serde_json::Value = serde_json::from_str(&vbr_json)
364 .map_err(|e| crate::Error::io_with_context("parse VBR state", e.to_string()))?;
365 debug!("Loaded VBR state from {}", vbr_path.display());
366 Some(state)
367 } else {
368 warn!("VBR state file not found in snapshot: {}", vbr_path.display());
369 None
370 }
371 } else {
372 None
373 };
374
375 let recorder_state =
377 if components_to_restore.recorder_state && manifest.components.recorder_state {
378 let recorder_path = snapshot_dir.join("recorder_state.json");
379 if recorder_path.exists() {
380 let recorder_json = fs::read_to_string(&recorder_path).await?;
381 let state: serde_json::Value =
382 serde_json::from_str(&recorder_json).map_err(|e| {
383 crate::Error::io_with_context("parse Recorder state", e.to_string())
384 })?;
385 debug!("Loaded Recorder state from {}", recorder_path.display());
386 Some(state)
387 } else {
388 warn!("Recorder state file not found in snapshot: {}", recorder_path.display());
389 None
390 }
391 } else {
392 None
393 };
394
395 info!("Snapshot '{}' loaded successfully", name);
396 Ok((manifest, vbr_state, recorder_state))
397 }
398
399 pub async fn load_snapshot_with_exporters(
412 &self,
413 name: String,
414 workspace_id: String,
415 components: Option<SnapshotComponents>,
416 consistency_engine: Option<&ConsistencyEngine>,
417 workspace_persistence: Option<&WorkspacePersistence>,
418 protocol_exporters: HashMap<String, Arc<dyn ProtocolStateExporter>>,
419 ) -> Result<(SnapshotManifest, Option<serde_json::Value>, Option<serde_json::Value>)> {
420 let (manifest, vbr_state, recorder_state) = self
422 .load_snapshot(
423 name.clone(),
424 workspace_id.clone(),
425 components.clone(),
426 consistency_engine,
427 workspace_persistence,
428 )
429 .await?;
430
431 let components_to_restore = components.unwrap_or_else(|| manifest.components.clone());
433
434 if !protocol_exporters.is_empty()
436 && (!components_to_restore.protocols.is_empty()
437 || !manifest.components.protocols.is_empty())
438 {
439 let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
440 let protocols_dir = snapshot_dir.join("protocols");
441
442 if protocols_dir.exists() {
443 let protocols_to_restore: Vec<String> =
445 if components_to_restore.protocols.is_empty() {
446 manifest.components.protocols.clone()
448 } else {
449 components_to_restore.protocols.clone()
450 };
451
452 for protocol_name in protocols_to_restore {
453 let protocol_path = protocols_dir.join(format!("{}.json", protocol_name));
454
455 if protocol_path.exists() {
456 if let Some(exporter) = protocol_exporters.get(&protocol_name) {
457 match fs::read_to_string(&protocol_path).await {
458 Ok(state_json) => {
459 match serde_json::from_str::<serde_json::Value>(&state_json) {
460 Ok(state) => {
461 if state.get("state")
463 == Some(&serde_json::json!("no_exporter_available"))
464 {
465 debug!(
466 "Skipping {} protocol restore - no exporter was available during save",
467 protocol_name
468 );
469 continue;
470 }
471 if state.get("error").is_some() {
472 warn!(
473 "Skipping {} protocol restore - state contains error from save",
474 protocol_name
475 );
476 continue;
477 }
478
479 match exporter.import_state(state).await {
480 Ok(_) => {
481 debug!(
482 "Restored {} protocol state from {}",
483 protocol_name,
484 protocol_path.display()
485 );
486 }
487 Err(e) => {
488 warn!(
489 "Failed to restore {} protocol state: {}",
490 protocol_name, e
491 );
492 }
493 }
494 }
495 Err(e) => {
496 warn!(
497 "Failed to parse {} protocol state: {}",
498 protocol_name, e
499 );
500 }
501 }
502 }
503 Err(e) => {
504 warn!(
505 "Failed to read {} protocol state file: {}",
506 protocol_name, e
507 );
508 }
509 }
510 } else {
511 debug!(
512 "No exporter provided for protocol {}, skipping restore",
513 protocol_name
514 );
515 }
516 } else {
517 debug!(
518 "Protocol state file not found for {}: {}",
519 protocol_name,
520 protocol_path.display()
521 );
522 }
523 }
524 }
525 }
526
527 Ok((manifest, vbr_state, recorder_state))
528 }
529
530 pub async fn list_snapshots(&self, workspace_id: &str) -> Result<Vec<SnapshotMetadata>> {
532 let workspace_dir = self.workspace_dir(workspace_id);
533 if !workspace_dir.exists() {
534 return Ok(Vec::new());
535 }
536
537 let mut snapshots = Vec::new();
538 let mut entries = fs::read_dir(&workspace_dir).await?;
539
540 while let Some(entry) = entries.next_entry().await? {
541 let snapshot_name = entry.file_name().to_string_lossy().to_string();
542 if snapshot_name.starts_with('.') {
544 continue;
545 }
546
547 let manifest_path = entry.path().join("manifest.json");
548 if manifest_path.exists() {
549 match fs::read_to_string(&manifest_path).await {
550 Ok(manifest_json) => {
551 match serde_json::from_str::<SnapshotManifest>(&manifest_json) {
552 Ok(manifest) => {
553 snapshots.push(SnapshotMetadata::from(manifest));
554 }
555 Err(e) => {
556 warn!(
557 "Failed to parse manifest for snapshot {}: {}",
558 snapshot_name, e
559 );
560 }
561 }
562 }
563 Err(e) => {
564 warn!("Failed to read manifest for snapshot {}: {}", snapshot_name, e);
565 }
566 }
567 }
568 }
569
570 snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
572 Ok(snapshots)
573 }
574
575 pub async fn delete_snapshot(&self, name: String, workspace_id: String) -> Result<()> {
577 info!("Deleting snapshot '{}' for workspace '{}'", name, workspace_id);
578 let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
579 if snapshot_dir.exists() {
580 fs::remove_dir_all(&snapshot_dir).await?;
581 info!("Snapshot '{}' deleted successfully", name);
582 } else {
583 return Err(crate::Error::from(format!(
584 "Snapshot '{}' not found for workspace '{}'",
585 name, workspace_id
586 )));
587 }
588 Ok(())
589 }
590
591 pub async fn get_snapshot_info(
593 &self,
594 name: String,
595 workspace_id: String,
596 ) -> Result<SnapshotManifest> {
597 let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
598 let manifest_path = snapshot_dir.join("manifest.json");
599 if !manifest_path.exists() {
600 return Err(crate::Error::from(format!(
601 "Snapshot '{}' not found for workspace '{}'",
602 name, workspace_id
603 )));
604 }
605
606 let manifest_json = fs::read_to_string(&manifest_path).await?;
607 let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
608 Ok(manifest)
609 }
610
611 pub async fn validate_snapshot(&self, name: String, workspace_id: String) -> Result<bool> {
613 let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
614 let manifest_path = snapshot_dir.join("manifest.json");
615 if !manifest_path.exists() {
616 return Err(crate::Error::from(format!(
617 "Snapshot '{}' not found for workspace '{}'",
618 name, workspace_id
619 )));
620 }
621
622 let manifest_json = fs::read_to_string(&manifest_path).await?;
623 let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
624
625 let (_, checksum) = self.calculate_snapshot_checksum(&snapshot_dir).await?;
626 Ok(checksum == manifest.checksum)
627 }
628
629 async fn calculate_snapshot_checksum(&self, dir: &Path) -> Result<(u64, String)> {
631 let mut hasher = Sha256::new();
632 let mut total_size = 0u64;
633
634 let mut stack = vec![dir.to_path_buf()];
635 while let Some(current) = stack.pop() {
636 let mut entries = fs::read_dir(¤t).await?;
637 while let Some(entry) = entries.next_entry().await? {
638 let path = entry.path();
639 let metadata = fs::metadata(&path).await?;
640
641 if metadata.is_dir() {
642 if path
644 .file_name()
645 .and_then(|n| n.to_str())
646 .map(|s| s.starts_with('.'))
647 .unwrap_or(false)
648 {
649 continue;
650 }
651 stack.push(path);
652 } else if metadata.is_file() {
653 if path
655 .file_name()
656 .and_then(|n| n.to_str())
657 .map(|s| s == "manifest.json")
658 .unwrap_or(false)
659 {
660 continue;
661 }
662
663 let file_size = metadata.len();
664 total_size += file_size;
665
666 let content = fs::read(&path).await?;
667 hasher.update(&content);
668 hasher
669 .update(path.file_name().unwrap_or_default().to_string_lossy().as_bytes());
670 }
671 }
672 }
673
674 let checksum = format!("sha256:{:x}", hasher.finalize());
675 Ok((total_size, checksum))
676 }
677}
678
679impl Default for SnapshotManager {
680 fn default() -> Self {
681 Self::new(None)
682 }
683}