tui_dispatch_debug/
snapshot.rs1use serde::{de::DeserializeOwned, Deserialize, Serialize};
2use std::fs;
3use std::path::Path;
4
5pub type SnapshotResult<T> = Result<T, SnapshotError>;
6
7#[derive(Debug)]
8pub enum SnapshotError {
9 Io(std::io::Error),
10 Json(serde_json::Error),
11}
12
13impl From<std::io::Error> for SnapshotError {
14 fn from(error: std::io::Error) -> Self {
15 Self::Io(error)
16 }
17}
18
19impl From<serde_json::Error> for SnapshotError {
20 fn from(error: serde_json::Error) -> Self {
21 Self::Json(error)
22 }
23}
24
25#[derive(Clone, Debug, Deserialize, Serialize)]
26pub struct StateSnapshot<S> {
27 pub state: S,
28}
29
30impl<S> StateSnapshot<S> {
31 pub fn new(state: S) -> Self {
32 Self { state }
33 }
34
35 pub fn state(&self) -> &S {
36 &self.state
37 }
38
39 pub fn into_state(self) -> S {
40 self.state
41 }
42}
43
44impl<S> StateSnapshot<S>
45where
46 S: Serialize,
47{
48 pub fn save_json<P: AsRef<Path>>(&self, path: P) -> SnapshotResult<()> {
49 save_json(path, &self.state)
50 }
51}
52
53impl<S> StateSnapshot<S>
54where
55 S: DeserializeOwned,
56{
57 pub fn load_json<P: AsRef<Path>>(path: P) -> SnapshotResult<Self> {
58 let state = load_json(path)?;
59 Ok(Self { state })
60 }
61}
62
63#[derive(Clone, Debug, Deserialize, Serialize)]
64pub struct ActionSnapshot<A> {
65 pub actions: Vec<A>,
66}
67
68impl<A> ActionSnapshot<A> {
69 pub fn new(actions: Vec<A>) -> Self {
70 Self { actions }
71 }
72
73 pub fn actions(&self) -> &[A] {
74 &self.actions
75 }
76
77 pub fn into_actions(self) -> Vec<A> {
78 self.actions
79 }
80}
81
82impl<A> ActionSnapshot<A>
83where
84 A: Serialize,
85{
86 pub fn save_json<P: AsRef<Path>>(&self, path: P) -> SnapshotResult<()> {
87 save_json(path, &self.actions)
88 }
89}
90
91impl<A> ActionSnapshot<A>
92where
93 A: DeserializeOwned,
94{
95 pub fn load_json<P: AsRef<Path>>(path: P) -> SnapshotResult<Self> {
96 let actions = load_json(path)?;
97 Ok(Self { actions })
98 }
99}
100
101pub fn load_json<T, P>(path: P) -> SnapshotResult<T>
102where
103 T: DeserializeOwned,
104 P: AsRef<Path>,
105{
106 let contents = fs::read_to_string(path)?;
107 let value = serde_json::from_str(&contents)?;
108 Ok(value)
109}
110
111pub fn save_json<T, P>(path: P, value: &T) -> SnapshotResult<()>
112where
113 T: Serialize,
114 P: AsRef<Path>,
115{
116 let data = serde_json::to_string_pretty(value)?;
117 fs::write(path, data)?;
118 Ok(())
119}
120
121#[cfg(feature = "json-schema")]
124pub use schemars::JsonSchema;
125
126#[cfg(feature = "json-schema")]
128pub fn generate_schema<T: schemars::JsonSchema>() -> schemars::schema::RootSchema {
129 schemars::schema_for!(T)
130}
131
132#[cfg(feature = "json-schema")]
134pub fn schema_json<T: schemars::JsonSchema>() -> String {
135 let schema = generate_schema::<T>();
136 serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
137}
138
139#[cfg(feature = "json-schema")]
141pub fn save_schema<T, P>(path: P) -> SnapshotResult<()>
142where
143 T: schemars::JsonSchema,
144 P: AsRef<Path>,
145{
146 let json = schema_json::<T>();
147 fs::write(path, json)?;
148 Ok(())
149}
150
151#[cfg(feature = "json-schema")]
157pub fn save_replay_schema<A, P>(path: P) -> SnapshotResult<()>
158where
159 A: schemars::JsonSchema,
160 P: AsRef<Path>,
161{
162 use crate::replay::ReplayItem;
163
164 let mut schema = schemars::schema_for!(Vec<ReplayItem<A>>);
166
167 let awaitable = extract_awaitable_actions(&schema);
169
170 if let Some(metadata) = schema.schema.metadata.as_mut() {
172 metadata.description = Some(
173 "Replay items: actions and await markers for async coordination.\n\n\
174 Use `_await` or `_await_any` to pause replay until async effects complete.\n\
175 Only actions listed in `awaitable_actions` should be awaited (Did* pattern)."
176 .to_string(),
177 );
178 }
179
180 schema.schema.extensions.insert(
182 "awaitable_actions".to_string(),
183 serde_json::Value::Array(
184 awaitable
185 .into_iter()
186 .map(serde_json::Value::String)
187 .collect(),
188 ),
189 );
190
191 let json = serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string());
192 fs::write(path, json)?;
193 Ok(())
194}
195
196#[cfg(feature = "json-schema")]
198fn extract_awaitable_actions(schema: &schemars::schema::RootSchema) -> Vec<String> {
199 let mut awaitable = Vec::new();
200
201 for (name, def) in &schema.definitions {
203 if !name.ends_with("Action") && name != "Action" {
205 continue;
206 }
207
208 if let Some(subschemas) = def.clone().into_object().subschemas {
210 if let Some(one_of) = subschemas.one_of {
211 for variant_schema in one_of {
212 extract_did_variants(&variant_schema.into_object(), &mut awaitable);
213 }
214 }
215 }
216 }
217
218 awaitable.sort();
219 awaitable.dedup();
220 awaitable
221}
222
223#[cfg(feature = "json-schema")]
224fn extract_did_variants(schema: &schemars::schema::SchemaObject, awaitable: &mut Vec<String>) {
225 if let Some(enum_values) = &schema.enum_values {
227 for val in enum_values {
228 if let Some(name) = val.as_str() {
229 if name.contains("Did") {
230 awaitable.push(name.to_string());
231 }
232 }
233 }
234 }
235
236 if let Some(obj) = &schema.object {
238 for prop_name in obj.properties.keys() {
239 if prop_name.contains("Did") {
240 awaitable.push(prop_name.clone());
241 }
242 }
243 for prop_name in obj.required.iter() {
244 if prop_name.contains("Did") && !awaitable.contains(prop_name) {
245 awaitable.push(prop_name.clone());
246 }
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use serde::{Deserialize, Serialize};
255 use std::path::PathBuf;
256 use std::time::{SystemTime, UNIX_EPOCH};
257
258 fn temp_path(label: &str) -> PathBuf {
259 let mut path = std::env::temp_dir();
260 let nanos = SystemTime::now()
261 .duration_since(UNIX_EPOCH)
262 .unwrap()
263 .as_nanos();
264 path.push(format!("tui-dispatch-debug-{label}-{nanos}.json"));
265 path
266 }
267
268 #[test]
269 fn test_state_snapshot_round_trip() {
270 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
271 struct TestState {
272 name: String,
273 count: usize,
274 flags: Vec<bool>,
275 }
276
277 let state = TestState {
278 name: "alpha".to_string(),
279 count: 42,
280 flags: vec![true, false, true],
281 };
282
283 let path = temp_path("state");
284 StateSnapshot::new(state.clone())
285 .save_json(&path)
286 .expect("save state snapshot");
287
288 let loaded = StateSnapshot::<TestState>::load_json(&path)
289 .expect("load state snapshot")
290 .into_state();
291
292 assert_eq!(loaded, state);
293 let _ = std::fs::remove_file(&path);
294 }
295
296 #[test]
297 fn test_action_snapshot_round_trip() {
298 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
299 enum TestAction {
300 Tick,
301 Set { value: i32 },
302 }
303
304 let actions = vec![TestAction::Tick, TestAction::Set { value: 7 }];
305 let path = temp_path("actions");
306
307 ActionSnapshot::new(actions.clone())
308 .save_json(&path)
309 .expect("save action snapshot");
310
311 let loaded = ActionSnapshot::<TestAction>::load_json(&path)
312 .expect("load action snapshot")
313 .into_actions();
314
315 assert_eq!(loaded, actions);
316 let _ = std::fs::remove_file(&path);
317 }
318
319 #[test]
320 fn test_load_json_missing_file() {
321 let path = temp_path("missing");
322 let _ = std::fs::remove_file(&path);
323
324 match load_json::<u32, _>(&path) {
325 Err(SnapshotError::Io(_)) => {}
326 other => panic!("expected io error, got {other:?}"),
327 }
328 }
329}