1#![deny(unsafe_code)]
4#![warn(missing_docs)]
5
6use chrono::{DateTime, Utc};
7use parallax_core::{
8 check_snapshot_format, ErrorCode, ExecutionState, ParallaxError, RuntimeKind, SnapshotId,
9 SNAPSHOT_FORMAT_VERSION,
10};
11use parallax_ir::{content_hash, from_json_bytes, to_json_bytes_compact, PirDocument};
12use serde::{Deserialize, Serialize};
13use std::fs;
14use std::path::Path;
15
16pub const SNAPSHOT_MAGIC: &str = "PARALLAX_PLX";
18
19#[derive(Clone, Debug, Serialize, Deserialize)]
21pub struct Snapshot {
22 pub magic: String,
24 pub format_version: u32,
26 pub id: SnapshotId,
28 pub created_at: DateTime<Utc>,
30 pub runtime: RuntimeKind,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub label: Option<String>,
35 pub state: ExecutionState,
37 pub pir: PirDocument,
39 pub content_hash: String,
41}
42
43impl Snapshot {
44 pub fn create(
46 runtime: RuntimeKind,
47 state: ExecutionState,
48 pir: PirDocument,
49 label: Option<String>,
50 ) -> parallax_core::Result<Self> {
51 pir.validate()?;
52 let mut snap = Self {
53 magic: SNAPSHOT_MAGIC.into(),
54 format_version: SNAPSHOT_FORMAT_VERSION,
55 id: SnapshotId::new(),
56 created_at: Utc::now(),
57 runtime,
58 label,
59 state,
60 pir,
61 content_hash: String::new(),
62 };
63 snap.content_hash = snap.compute_hash()?;
64 Ok(snap)
65 }
66
67 fn compute_hash(&self) -> parallax_core::Result<String> {
68 let payload = serde_json::json!({
69 "format_version": self.format_version,
70 "runtime": self.runtime,
71 "state": self.state,
72 "pir": self.pir,
73 });
74 let bytes = serde_json::to_vec(&payload).map_err(ParallaxError::from)?;
75 Ok(content_hash(&bytes))
76 }
77
78 pub fn validate(&self) -> parallax_core::Result<()> {
80 if self.magic != SNAPSHOT_MAGIC {
81 return Err(ParallaxError::new(
82 ErrorCode::InvalidSnapshot,
83 format!("invalid snapshot magic: {}", self.magic),
84 )
85 .with_source("parallax-snapshot")
86 .with_operation("validate"));
87 }
88 check_snapshot_format(self.format_version).map_err(|e| {
89 e.with_source("parallax-snapshot")
90 .with_operation("validate")
91 })?;
92 self.pir.validate()?;
93 let expected = self.compute_hash()?;
94 if expected != self.content_hash {
95 return Err(ParallaxError::new(
96 ErrorCode::InvalidSnapshot,
97 "snapshot content hash mismatch",
98 )
99 .with_source("parallax-snapshot")
100 .with_operation("validate")
101 .context("expected", expected)
102 .context("actual", self.content_hash.clone()));
103 }
104 Ok(())
105 }
106
107 pub fn write_to_path(&self, path: &Path) -> parallax_core::Result<()> {
109 self.validate()?;
110 let bytes = serde_json::to_vec_pretty(self).map_err(ParallaxError::from)?;
111 if let Some(parent) = path.parent() {
112 if !parent.as_os_str().is_empty() {
113 fs::create_dir_all(parent)?;
114 }
115 }
116 fs::write(path, bytes)?;
117 Ok(())
118 }
119
120 pub fn read_from_path(path: &Path) -> parallax_core::Result<Self> {
122 let bytes = fs::read(path)?;
123 let snap: Self = serde_json::from_slice(&bytes).map_err(|e| {
124 ParallaxError::new(ErrorCode::InvalidSnapshot, e.to_string())
125 .with_source("parallax-snapshot")
126 .with_operation("read_from_path")
127 })?;
128 snap.validate()?;
129 Ok(snap)
130 }
131
132 pub fn inspect_summary(&self) -> SnapshotInspect {
134 SnapshotInspect {
135 id: self.id.to_string(),
136 runtime: self.runtime.clone(),
137 created_at: self.created_at,
138 label: self.label.clone(),
139 format_version: self.format_version,
140 content_hash: self.content_hash.clone(),
141 binding_names: self.pir.bindings.keys().cloned().collect(),
142 value_count: self.pir.value_count(),
143 pir_schema: self.pir.schema,
144 }
145 }
146}
147
148#[derive(Clone, Debug, Serialize, Deserialize)]
150pub struct SnapshotInspect {
151 pub id: String,
153 pub runtime: RuntimeKind,
155 pub created_at: DateTime<Utc>,
157 pub label: Option<String>,
159 pub format_version: u32,
161 pub content_hash: String,
163 pub binding_names: Vec<String>,
165 pub value_count: usize,
167 pub pir_schema: u32,
169}
170
171pub fn encode_pir(doc: &PirDocument) -> parallax_core::Result<Vec<u8>> {
173 to_json_bytes_compact(doc)
174}
175
176pub fn decode_pir(bytes: &[u8]) -> parallax_core::Result<PirDocument> {
178 from_json_bytes(bytes)
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use parallax_core::{RuntimeCapabilities, RuntimeKind};
185 use parallax_ir::{PirInteger, PirValue};
186 use tempfile::tempdir;
187
188 #[test]
189 fn snapshot_round_trip() {
190 let mut pir = PirDocument::new();
191 pir.set_binding("state", PirValue::int_i64(42));
192 let state = ExecutionState::empty(RuntimeKind::Python, RuntimeCapabilities::python());
193 let snap = Snapshot::create(RuntimeKind::Python, state, pir, Some("t".into())).unwrap();
194 let dir = tempdir().unwrap();
195 let path = dir.path().join("t.plx");
196 snap.write_to_path(&path).unwrap();
197 let loaded = Snapshot::read_from_path(&path).unwrap();
198 assert_eq!(loaded.content_hash, snap.content_hash);
199 assert_eq!(
200 loaded.pir.binding("state"),
201 Some(&PirValue::Int {
202 v: PirInteger::from_i64(42)
203 })
204 );
205 }
206
207 #[test]
208 fn rejects_future_format_version() {
209 let mut pir = PirDocument::new();
210 pir.set_binding("state", PirValue::int_i64(1));
211 let state = ExecutionState::empty(RuntimeKind::Python, RuntimeCapabilities::python());
212 let mut snap = Snapshot::create(RuntimeKind::Python, state, pir, None).unwrap();
213 snap.format_version = SNAPSHOT_FORMAT_VERSION + 1;
214 let err = snap.validate().unwrap_err();
215 assert_eq!(err.code, ErrorCode::InvalidSnapshot);
216 assert!(err.message.contains("unsupported snapshot format"));
217 }
218}