Skip to main content

tui_dispatch_debug/
snapshot.rs

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