use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotMetadata {
pub id: String,
pub name: String,
pub source_box_id: String,
pub image: String,
pub vcpus: u32,
pub memory_mb: u32,
pub volumes: Vec<String>,
pub env: HashMap<String, String>,
pub cmd: Vec<String>,
#[serde(default)]
pub entrypoint: Option<Vec<String>>,
#[serde(default)]
pub workdir: Option<String>,
#[serde(default)]
pub port_map: Vec<String>,
#[serde(default)]
pub labels: HashMap<String, String>,
#[serde(default)]
pub network_mode: Option<String>,
#[serde(default)]
pub rootfs_cache_key: Option<String>,
#[serde(default)]
pub size_bytes: u64,
#[serde(default = "epoch")]
pub created_at: DateTime<Utc>,
#[serde(default)]
pub description: String,
}
fn epoch() -> DateTime<Utc> {
DateTime::<Utc>::UNIX_EPOCH
}
impl SnapshotMetadata {
pub fn new(id: String, name: String, source_box_id: String, image: String) -> Self {
Self {
id,
name,
source_box_id,
image,
vcpus: 2,
memory_mb: 512,
volumes: Vec::new(),
env: HashMap::new(),
cmd: Vec::new(),
entrypoint: None,
workdir: None,
port_map: Vec::new(),
labels: HashMap::new(),
network_mode: None,
rootfs_cache_key: None,
size_bytes: 0,
created_at: Utc::now(),
description: String::new(),
}
}
pub fn with_description(mut self, desc: &str) -> Self {
self.description = desc.to_string();
self
}
pub fn with_resources(mut self, vcpus: u32, memory_mb: u32) -> Self {
self.vcpus = vcpus;
self.memory_mb = memory_mb;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotConfig {
pub enabled: bool,
pub snapshot_dir: Option<PathBuf>,
pub max_snapshots: usize,
pub max_total_bytes: u64,
}
impl Default for SnapshotConfig {
fn default() -> Self {
Self {
enabled: true,
snapshot_dir: None,
max_snapshots: 0,
max_total_bytes: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snapshot_metadata_new() {
let meta = SnapshotMetadata::new(
"snap-001".to_string(),
"my-snapshot".to_string(),
"box-abc".to_string(),
"alpine:latest".to_string(),
);
assert_eq!(meta.id, "snap-001");
assert_eq!(meta.name, "my-snapshot");
assert_eq!(meta.source_box_id, "box-abc");
assert_eq!(meta.image, "alpine:latest");
assert_eq!(meta.vcpus, 2);
assert_eq!(meta.memory_mb, 512);
assert!(meta.volumes.is_empty());
assert!(meta.env.is_empty());
assert!(meta.description.is_empty());
}
#[test]
fn test_snapshot_metadata_with_description() {
let meta = SnapshotMetadata::new(
"snap-002".to_string(),
"test".to_string(),
"box-xyz".to_string(),
"ubuntu:22.04".to_string(),
)
.with_description("Before migration");
assert_eq!(meta.description, "Before migration");
}
#[test]
fn test_snapshot_metadata_with_resources() {
let meta = SnapshotMetadata::new(
"snap-003".to_string(),
"test".to_string(),
"box-xyz".to_string(),
"python:3.12".to_string(),
)
.with_resources(4, 2048);
assert_eq!(meta.vcpus, 4);
assert_eq!(meta.memory_mb, 2048);
}
#[test]
fn test_snapshot_metadata_tolerates_missing_size_and_created_at() {
let json = r#"{
"id": "snap-old",
"name": "old",
"source_box_id": "box-1",
"image": "alpine:latest",
"vcpus": 2,
"memory_mb": 512,
"volumes": [],
"env": {},
"cmd": []
}"#;
let parsed: SnapshotMetadata =
serde_json::from_str(json).expect("must tolerate missing size_bytes/created_at");
assert_eq!(parsed.id, "snap-old");
assert_eq!(parsed.size_bytes, 0);
assert_eq!(parsed.created_at, DateTime::<Utc>::UNIX_EPOCH);
}
#[test]
fn test_snapshot_metadata_serde_roundtrip() {
let mut meta = SnapshotMetadata::new(
"snap-rt".to_string(),
"roundtrip".to_string(),
"box-123".to_string(),
"nginx:latest".to_string(),
);
meta.vcpus = 4;
meta.memory_mb = 1024;
meta.volumes = vec!["/data:/data".to_string()];
meta.env.insert("FOO".to_string(), "bar".to_string());
meta.cmd = vec!["nginx".to_string(), "-g".to_string()];
meta.entrypoint = Some(vec!["/docker-entrypoint.sh".to_string()]);
meta.workdir = Some("/app".to_string());
meta.port_map = vec!["8080:80".to_string()];
meta.labels.insert("env".to_string(), "prod".to_string());
meta.network_mode = Some("bridge".to_string());
meta.rootfs_cache_key = Some("abc123".to_string());
meta.size_bytes = 1024 * 1024;
meta.description = "test snapshot".to_string();
let json = serde_json::to_string(&meta).unwrap();
let parsed: SnapshotMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, "snap-rt");
assert_eq!(parsed.name, "roundtrip");
assert_eq!(parsed.source_box_id, "box-123");
assert_eq!(parsed.image, "nginx:latest");
assert_eq!(parsed.vcpus, 4);
assert_eq!(parsed.memory_mb, 1024);
assert_eq!(parsed.volumes, vec!["/data:/data"]);
assert_eq!(parsed.env.get("FOO").unwrap(), "bar");
assert_eq!(parsed.cmd, vec!["nginx", "-g"]);
assert_eq!(
parsed.entrypoint,
Some(vec!["/docker-entrypoint.sh".to_string()])
);
assert_eq!(parsed.workdir, Some("/app".to_string()));
assert_eq!(parsed.port_map, vec!["8080:80"]);
assert_eq!(parsed.labels.get("env").unwrap(), "prod");
assert_eq!(parsed.network_mode, Some("bridge".to_string()));
assert_eq!(parsed.rootfs_cache_key, Some("abc123".to_string()));
assert_eq!(parsed.size_bytes, 1024 * 1024);
assert_eq!(parsed.description, "test snapshot");
}
#[test]
fn test_snapshot_metadata_deserialize_minimal() {
let json = r#"{
"id": "snap-min",
"name": "minimal",
"source_box_id": "box-1",
"image": "alpine:latest",
"vcpus": 1,
"memory_mb": 256,
"volumes": [],
"env": {},
"cmd": [],
"size_bytes": 0,
"created_at": "2024-01-01T00:00:00Z",
"description": ""
}"#;
let meta: SnapshotMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.id, "snap-min");
assert!(meta.entrypoint.is_none());
assert!(meta.workdir.is_none());
assert!(meta.port_map.is_empty());
assert!(meta.labels.is_empty());
assert!(meta.network_mode.is_none());
assert!(meta.rootfs_cache_key.is_none());
}
#[test]
fn test_snapshot_config_default() {
let config = SnapshotConfig::default();
assert!(config.enabled);
assert!(config.snapshot_dir.is_none());
assert_eq!(config.max_snapshots, 0);
assert_eq!(config.max_total_bytes, 0);
}
#[test]
fn test_snapshot_config_serde_roundtrip() {
let config = SnapshotConfig {
enabled: true,
snapshot_dir: Some(PathBuf::from("/custom/snapshots")),
max_snapshots: 10,
max_total_bytes: 5 * 1024 * 1024 * 1024,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: SnapshotConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.enabled);
assert_eq!(
parsed.snapshot_dir,
Some(PathBuf::from("/custom/snapshots"))
);
assert_eq!(parsed.max_snapshots, 10);
assert_eq!(parsed.max_total_bytes, 5 * 1024 * 1024 * 1024);
}
#[test]
fn test_snapshot_metadata_clone() {
let meta = SnapshotMetadata::new(
"snap-clone".to_string(),
"clone-test".to_string(),
"box-c".to_string(),
"redis:7".to_string(),
);
let cloned = meta.clone();
assert_eq!(cloned.id, meta.id);
assert_eq!(cloned.name, meta.name);
assert_eq!(cloned.source_box_id, meta.source_box_id);
}
}