use crate::consistency::ConsistencyEngine;
use crate::snapshots::state_exporter::ProtocolStateExporter;
use crate::snapshots::types::{SnapshotComponents, SnapshotManifest, SnapshotMetadata};
use crate::workspace_persistence::WorkspacePersistence;
use crate::Result;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tracing::{debug, info, warn};
pub struct SnapshotManager {
base_dir: PathBuf,
}
impl SnapshotManager {
pub fn new(base_dir: Option<PathBuf>) -> Self {
let base_dir = base_dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".mockforge")
.join("snapshots")
});
Self { base_dir }
}
fn workspace_dir(&self, workspace_id: &str) -> PathBuf {
self.base_dir.join(workspace_id)
}
fn snapshot_dir(&self, workspace_id: &str, snapshot_name: &str) -> PathBuf {
self.workspace_dir(workspace_id).join(snapshot_name)
}
#[allow(clippy::too_many_arguments)]
pub async fn save_snapshot(
&self,
name: String,
description: Option<String>,
workspace_id: String,
components: SnapshotComponents,
consistency_engine: Option<&ConsistencyEngine>,
workspace_persistence: Option<&WorkspacePersistence>,
vbr_state: Option<serde_json::Value>,
recorder_state: Option<serde_json::Value>,
) -> Result<SnapshotManifest> {
self.save_snapshot_with_exporters(
name,
description,
workspace_id,
components,
consistency_engine,
workspace_persistence,
vbr_state,
recorder_state,
HashMap::new(),
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn save_snapshot_with_exporters(
&self,
name: String,
description: Option<String>,
workspace_id: String,
components: SnapshotComponents,
consistency_engine: Option<&ConsistencyEngine>,
workspace_persistence: Option<&WorkspacePersistence>,
vbr_state: Option<serde_json::Value>,
recorder_state: Option<serde_json::Value>,
protocol_exporters: HashMap<String, Arc<dyn ProtocolStateExporter>>,
) -> Result<SnapshotManifest> {
info!("Saving snapshot '{}' for workspace '{}'", name, workspace_id);
let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
fs::create_dir_all(&snapshot_dir).await?;
let temp_dir = snapshot_dir.join(".tmp");
fs::create_dir_all(&temp_dir).await?;
let mut manifest =
SnapshotManifest::new(name.clone(), workspace_id.clone(), components.clone());
if components.unified_state {
if let Some(engine) = consistency_engine {
let unified_state = engine.get_state(&workspace_id).await;
if let Some(state) = unified_state {
let state_path = temp_dir.join("unified_state.json");
let state_json = serde_json::to_string_pretty(&state)?;
fs::write(&state_path, &state_json).await?;
debug!("Saved unified state to {}", state_path.display());
} else {
warn!("No unified state found for workspace {}", workspace_id);
}
}
}
if components.workspace_config {
let config_path = temp_dir.join("workspace_config.yaml");
if let Some(persistence) = workspace_persistence {
match persistence.load_workspace(&workspace_id).await {
Ok(workspace) => {
let config_yaml = serde_yaml::to_string(&workspace).map_err(|e| {
crate::Error::io_with_context("serialize workspace", e.to_string())
})?;
fs::write(&config_path, config_yaml).await?;
debug!("Saved workspace config to {}", config_path.display());
}
Err(e) => {
warn!("Failed to load workspace config for snapshot: {}. Saving empty config.", e);
let empty_config = serde_yaml::to_string(&serde_json::json!({}))?;
fs::write(&config_path, empty_config).await?;
}
}
} else {
warn!("Workspace persistence not provided, saving empty workspace config");
let empty_config = serde_yaml::to_string(&serde_json::json!({}))?;
fs::write(&config_path, empty_config).await?;
}
}
if components.vbr_state {
let vbr_path = temp_dir.join("vbr_state.json");
if let Some(state) = vbr_state {
let state_json = serde_json::to_string_pretty(&state)?;
fs::write(&vbr_path, state_json).await?;
debug!("Saved VBR state to {}", vbr_path.display());
} else {
warn!("VBR state requested but not provided, saving empty state");
let empty_state = serde_json::json!({});
fs::write(&vbr_path, serde_json::to_string_pretty(&empty_state)?).await?;
}
}
if components.recorder_state {
let recorder_path = temp_dir.join("recorder_state.json");
if let Some(state) = recorder_state {
let state_json = serde_json::to_string_pretty(&state)?;
fs::write(&recorder_path, state_json).await?;
debug!("Saved Recorder state to {}", recorder_path.display());
} else {
warn!("Recorder state requested but not provided, saving empty state");
let empty_state = serde_json::json!({});
fs::write(&recorder_path, serde_json::to_string_pretty(&empty_state)?).await?;
}
}
if !components.protocols.is_empty() || !protocol_exporters.is_empty() {
let protocols_dir = temp_dir.join("protocols");
fs::create_dir_all(&protocols_dir).await?;
let protocols_to_save: Vec<String> = if components.protocols.is_empty() {
protocol_exporters.keys().cloned().collect()
} else {
components.protocols.clone()
};
for protocol_name in protocols_to_save {
let protocol_path = protocols_dir.join(format!("{}.json", protocol_name));
if let Some(exporter) = protocol_exporters.get(&protocol_name) {
match exporter.export_state().await {
Ok(state) => {
let summary = exporter.state_summary().await;
fs::write(&protocol_path, serde_json::to_string_pretty(&state)?)
.await?;
debug!(
"Saved {} protocol state to {}: {}",
protocol_name,
protocol_path.display(),
summary
);
}
Err(e) => {
warn!(
"Failed to export {} protocol state: {}. Saving empty state.",
protocol_name, e
);
let empty_state = serde_json::json!({
"error": format!("Failed to export state: {}", e),
"protocol": protocol_name
});
fs::write(&protocol_path, serde_json::to_string_pretty(&empty_state)?)
.await?;
}
}
} else {
debug!(
"No exporter available for protocol {}, saving placeholder",
protocol_name
);
let placeholder_state = serde_json::json!({
"protocol": protocol_name,
"state": "no_exporter_available"
});
fs::write(&protocol_path, serde_json::to_string_pretty(&placeholder_state)?)
.await?;
}
}
}
let (size, checksum) = self.calculate_snapshot_checksum(&temp_dir).await?;
manifest.size_bytes = size;
manifest.checksum = checksum;
manifest.description = description;
let manifest_path = temp_dir.join("manifest.json");
let manifest_json = serde_json::to_string_pretty(&manifest)?;
fs::write(&manifest_path, &manifest_json).await?;
if snapshot_dir.exists() && snapshot_dir != temp_dir {
let old_backup = snapshot_dir.with_extension("old");
if old_backup.exists() {
fs::remove_dir_all(&old_backup).await?;
}
fs::rename(&snapshot_dir, &old_backup).await?;
}
if temp_dir.exists() {
let mut entries = fs::read_dir(&temp_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let dest = snapshot_dir.join(entry.file_name());
fs::rename(entry.path(), &dest).await?;
}
fs::remove_dir(&temp_dir).await?;
}
info!("Snapshot '{}' saved successfully ({} bytes)", name, size);
Ok(manifest)
}
pub async fn load_snapshot(
&self,
name: String,
workspace_id: String,
components: Option<SnapshotComponents>,
consistency_engine: Option<&ConsistencyEngine>,
workspace_persistence: Option<&WorkspacePersistence>,
) -> Result<(SnapshotManifest, Option<serde_json::Value>, Option<serde_json::Value>)> {
info!("Loading snapshot '{}' for workspace '{}'", name, workspace_id);
let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
if !snapshot_dir.exists() {
return Err(crate::Error::from(format!(
"Snapshot '{}' not found for workspace '{}'",
name, workspace_id
)));
}
let manifest_path = snapshot_dir.join("manifest.json");
let manifest_json = fs::read_to_string(&manifest_path).await?;
let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
let (_size, checksum) = self.calculate_snapshot_checksum(&snapshot_dir).await?;
if checksum != manifest.checksum {
warn!("Snapshot checksum mismatch: expected {}, got {}", manifest.checksum, checksum);
}
let components_to_restore = components.unwrap_or_else(|| manifest.components.clone());
if components_to_restore.unified_state && manifest.components.unified_state {
if let Some(engine) = consistency_engine {
let state_path = snapshot_dir.join("unified_state.json");
if state_path.exists() {
let state_json = fs::read_to_string(&state_path).await?;
let unified_state: crate::consistency::UnifiedState =
serde_json::from_str(&state_json)?;
engine.restore_state(unified_state).await?;
debug!("Restored unified state from {}", state_path.display());
}
}
}
if components_to_restore.workspace_config && manifest.components.workspace_config {
let config_path = snapshot_dir.join("workspace_config.yaml");
if config_path.exists() {
if let Some(persistence) = workspace_persistence {
let config_yaml = fs::read_to_string(&config_path).await?;
let workspace: crate::workspace::Workspace = serde_yaml::from_str(&config_yaml)
.map_err(|e| {
crate::Error::io_with_context("deserialize workspace", e.to_string())
})?;
persistence.save_workspace(&workspace).await?;
debug!("Restored workspace config from {}", config_path.display());
} else {
warn!(
"Workspace persistence not provided, skipping workspace config restoration"
);
}
} else {
warn!("Workspace config file not found in snapshot: {}", config_path.display());
}
}
let vbr_state = if components_to_restore.vbr_state && manifest.components.vbr_state {
let vbr_path = snapshot_dir.join("vbr_state.json");
if vbr_path.exists() {
let vbr_json = fs::read_to_string(&vbr_path).await?;
let state: serde_json::Value = serde_json::from_str(&vbr_json)
.map_err(|e| crate::Error::io_with_context("parse VBR state", e.to_string()))?;
debug!("Loaded VBR state from {}", vbr_path.display());
Some(state)
} else {
warn!("VBR state file not found in snapshot: {}", vbr_path.display());
None
}
} else {
None
};
let recorder_state =
if components_to_restore.recorder_state && manifest.components.recorder_state {
let recorder_path = snapshot_dir.join("recorder_state.json");
if recorder_path.exists() {
let recorder_json = fs::read_to_string(&recorder_path).await?;
let state: serde_json::Value =
serde_json::from_str(&recorder_json).map_err(|e| {
crate::Error::io_with_context("parse Recorder state", e.to_string())
})?;
debug!("Loaded Recorder state from {}", recorder_path.display());
Some(state)
} else {
warn!("Recorder state file not found in snapshot: {}", recorder_path.display());
None
}
} else {
None
};
info!("Snapshot '{}' loaded successfully", name);
Ok((manifest, vbr_state, recorder_state))
}
pub async fn load_snapshot_with_exporters(
&self,
name: String,
workspace_id: String,
components: Option<SnapshotComponents>,
consistency_engine: Option<&ConsistencyEngine>,
workspace_persistence: Option<&WorkspacePersistence>,
protocol_exporters: HashMap<String, Arc<dyn ProtocolStateExporter>>,
) -> Result<(SnapshotManifest, Option<serde_json::Value>, Option<serde_json::Value>)> {
let (manifest, vbr_state, recorder_state) = self
.load_snapshot(
name.clone(),
workspace_id.clone(),
components.clone(),
consistency_engine,
workspace_persistence,
)
.await?;
let components_to_restore = components.unwrap_or_else(|| manifest.components.clone());
if !protocol_exporters.is_empty()
&& (!components_to_restore.protocols.is_empty()
|| !manifest.components.protocols.is_empty())
{
let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
let protocols_dir = snapshot_dir.join("protocols");
if protocols_dir.exists() {
let protocols_to_restore: Vec<String> =
if components_to_restore.protocols.is_empty() {
manifest.components.protocols.clone()
} else {
components_to_restore.protocols.clone()
};
for protocol_name in protocols_to_restore {
let protocol_path = protocols_dir.join(format!("{}.json", protocol_name));
if protocol_path.exists() {
if let Some(exporter) = protocol_exporters.get(&protocol_name) {
match fs::read_to_string(&protocol_path).await {
Ok(state_json) => {
match serde_json::from_str::<serde_json::Value>(&state_json) {
Ok(state) => {
if state.get("state")
== Some(&serde_json::json!("no_exporter_available"))
{
debug!(
"Skipping {} protocol restore - no exporter was available during save",
protocol_name
);
continue;
}
if state.get("error").is_some() {
warn!(
"Skipping {} protocol restore - state contains error from save",
protocol_name
);
continue;
}
match exporter.import_state(state).await {
Ok(_) => {
debug!(
"Restored {} protocol state from {}",
protocol_name,
protocol_path.display()
);
}
Err(e) => {
warn!(
"Failed to restore {} protocol state: {}",
protocol_name, e
);
}
}
}
Err(e) => {
warn!(
"Failed to parse {} protocol state: {}",
protocol_name, e
);
}
}
}
Err(e) => {
warn!(
"Failed to read {} protocol state file: {}",
protocol_name, e
);
}
}
} else {
debug!(
"No exporter provided for protocol {}, skipping restore",
protocol_name
);
}
} else {
debug!(
"Protocol state file not found for {}: {}",
protocol_name,
protocol_path.display()
);
}
}
}
}
Ok((manifest, vbr_state, recorder_state))
}
pub async fn list_snapshots(&self, workspace_id: &str) -> Result<Vec<SnapshotMetadata>> {
let workspace_dir = self.workspace_dir(workspace_id);
if !workspace_dir.exists() {
return Ok(Vec::new());
}
let mut snapshots = Vec::new();
let mut entries = fs::read_dir(&workspace_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let snapshot_name = entry.file_name().to_string_lossy().to_string();
if snapshot_name.starts_with('.') {
continue;
}
let manifest_path = entry.path().join("manifest.json");
if manifest_path.exists() {
match fs::read_to_string(&manifest_path).await {
Ok(manifest_json) => {
match serde_json::from_str::<SnapshotManifest>(&manifest_json) {
Ok(manifest) => {
snapshots.push(SnapshotMetadata::from(manifest));
}
Err(e) => {
warn!(
"Failed to parse manifest for snapshot {}: {}",
snapshot_name, e
);
}
}
}
Err(e) => {
warn!("Failed to read manifest for snapshot {}: {}", snapshot_name, e);
}
}
}
}
snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(snapshots)
}
pub async fn delete_snapshot(&self, name: String, workspace_id: String) -> Result<()> {
info!("Deleting snapshot '{}' for workspace '{}'", name, workspace_id);
let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
if snapshot_dir.exists() {
fs::remove_dir_all(&snapshot_dir).await?;
info!("Snapshot '{}' deleted successfully", name);
} else {
return Err(crate::Error::from(format!(
"Snapshot '{}' not found for workspace '{}'",
name, workspace_id
)));
}
Ok(())
}
pub async fn get_snapshot_info(
&self,
name: String,
workspace_id: String,
) -> Result<SnapshotManifest> {
let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
let manifest_path = snapshot_dir.join("manifest.json");
if !manifest_path.exists() {
return Err(crate::Error::from(format!(
"Snapshot '{}' not found for workspace '{}'",
name, workspace_id
)));
}
let manifest_json = fs::read_to_string(&manifest_path).await?;
let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
Ok(manifest)
}
pub async fn validate_snapshot(&self, name: String, workspace_id: String) -> Result<bool> {
let snapshot_dir = self.snapshot_dir(&workspace_id, &name);
let manifest_path = snapshot_dir.join("manifest.json");
if !manifest_path.exists() {
return Err(crate::Error::from(format!(
"Snapshot '{}' not found for workspace '{}'",
name, workspace_id
)));
}
let manifest_json = fs::read_to_string(&manifest_path).await?;
let manifest: SnapshotManifest = serde_json::from_str(&manifest_json)?;
let (_, checksum) = self.calculate_snapshot_checksum(&snapshot_dir).await?;
Ok(checksum == manifest.checksum)
}
async fn calculate_snapshot_checksum(&self, dir: &Path) -> Result<(u64, String)> {
let mut hasher = Sha256::new();
let mut total_size = 0u64;
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let mut entries = fs::read_dir(¤t).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let metadata = fs::metadata(&path).await?;
if metadata.is_dir() {
if path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.starts_with('.'))
.unwrap_or(false)
{
continue;
}
stack.push(path);
} else if metadata.is_file() {
if path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s == "manifest.json")
.unwrap_or(false)
{
continue;
}
let file_size = metadata.len();
total_size += file_size;
let content = fs::read(&path).await?;
hasher.update(&content);
hasher
.update(path.file_name().unwrap_or_default().to_string_lossy().as_bytes());
}
}
}
let checksum = format!("sha256:{:x}", hasher.finalize());
Ok((total_size, checksum))
}
}
impl Default for SnapshotManager {
fn default() -> Self {
Self::new(None)
}
}