Skip to main content

tui_dispatch_debug/
snapshot.rs

1use 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// JSON Schema generation (requires "json-schema" feature)
122
123#[cfg(feature = "json-schema")]
124pub use schemars::JsonSchema;
125
126/// Generate a JSON schema for type T.
127#[cfg(feature = "json-schema")]
128pub fn generate_schema<T: schemars::JsonSchema>() -> schemars::schema::RootSchema {
129    schemars::schema_for!(T)
130}
131
132/// Generate a JSON schema as a pretty-printed JSON string.
133#[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/// Save a JSON schema to a file.
140#[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/// Generate and save a replay schema for `Vec<ReplayItem<A>>`.
152///
153/// This includes:
154/// - The full schema for replay items (actions + await markers)
155/// - An `awaitable_actions` list extracted from Did* action names
156#[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    // Generate schema for Vec<ReplayItem<A>>
165    let mut schema = schemars::schema_for!(Vec<ReplayItem<A>>);
166
167    // Extract Did* action names from the schema definitions
168    let awaitable = extract_awaitable_actions(&schema);
169
170    // Add awaitable_actions to the schema's extensions
171    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    // Add awaitable_actions as a custom extension
181    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/// Extract action names containing "Did" from a schema's definitions.
197#[cfg(feature = "json-schema")]
198fn extract_awaitable_actions(schema: &schemars::schema::RootSchema) -> Vec<String> {
199    let mut awaitable = Vec::new();
200
201    // Look through definitions for action enum variants
202    for (name, def) in &schema.definitions {
203        // Skip non-action definitions
204        if !name.ends_with("Action") && name != "Action" {
205            continue;
206        }
207
208        // Extract enum variant names from oneOf
209        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    // Check for enum values (unit variants like "WeatherDidLoad")
226    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    // Check for object properties (struct variants like {"WeatherDidLoad": data})
237    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}