Skip to main content

libpetri_debug/
debug_response.rs

1//! Responses sent from server to debug UI client via WebSocket.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use crate::debug_command::{BreakpointConfig, EventFilter};
7
8/// Summary of a debug session.
9///
10/// `tags`, `end_time`, and `duration_ms` were added in libpetri 1.6.0. Older clients that
11/// do not know these fields ignore them (serde tolerance).
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13#[serde(rename_all = "camelCase")]
14pub struct SessionSummary {
15    pub session_id: String,
16    pub net_name: String,
17    pub start_time: String,
18    pub active: bool,
19    pub event_count: usize,
20    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
21    pub tags: HashMap<String, String>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub end_time: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub duration_ms: Option<u64>,
26}
27
28/// Serializable token information.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct TokenInfo {
32    pub id: Option<String>,
33    #[serde(rename = "type")]
34    pub token_type: String,
35    pub value: Option<String>,
36    pub timestamp: Option<String>,
37}
38
39/// Serializable event information.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct NetEventInfo {
43    #[serde(rename = "type")]
44    pub event_type: String,
45    pub timestamp: String,
46    pub transition_name: Option<String>,
47    pub place_name: Option<String>,
48    pub details: HashMap<String, serde_json::Value>,
49}
50
51/// Information about a place in the net structure.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct PlaceInfo {
55    pub name: String,
56    pub graph_id: String,
57    pub token_type: String,
58    pub is_start: bool,
59    pub is_end: bool,
60    pub is_environment: bool,
61}
62
63/// Information about a transition in the net structure.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct TransitionInfo {
67    pub name: String,
68    pub graph_id: String,
69}
70
71/// Structure of a Petri net for the debug UI.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct NetStructure {
75    pub places: Vec<PlaceInfo>,
76    pub transitions: Vec<TransitionInfo>,
77}
78
79/// Summary of an archived session.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct ArchiveSummary {
83    pub session_id: String,
84    pub key: String,
85    pub size_bytes: u64,
86    pub last_modified: String,
87}
88
89/// Responses from server to debug UI client.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
92pub enum DebugResponse {
93    SessionList {
94        sessions: Vec<SessionSummary>,
95    },
96    Subscribed {
97        session_id: String,
98        net_name: String,
99        dot_diagram: String,
100        structure: NetStructure,
101        current_marking: HashMap<String, Vec<TokenInfo>>,
102        enabled_transitions: Vec<String>,
103        in_flight_transitions: Vec<String>,
104        event_count: usize,
105        mode: String,
106    },
107    Unsubscribed {
108        session_id: String,
109    },
110    Event {
111        session_id: String,
112        index: usize,
113        event: NetEventInfo,
114    },
115    EventBatch {
116        session_id: String,
117        start_index: usize,
118        events: Vec<NetEventInfo>,
119        has_more: bool,
120    },
121    MarkingSnapshot {
122        session_id: String,
123        marking: HashMap<String, Vec<TokenInfo>>,
124        enabled_transitions: Vec<String>,
125        in_flight_transitions: Vec<String>,
126    },
127    PlaybackStateChanged {
128        session_id: String,
129        paused: bool,
130        speed: f64,
131        current_index: usize,
132    },
133    FilterApplied {
134        session_id: String,
135        filter: EventFilter,
136    },
137    BreakpointHit {
138        session_id: String,
139        breakpoint_id: String,
140        event: NetEventInfo,
141        event_index: usize,
142    },
143    BreakpointList {
144        session_id: String,
145        breakpoints: Vec<BreakpointConfig>,
146    },
147    BreakpointSet {
148        session_id: String,
149        breakpoint: BreakpointConfig,
150    },
151    BreakpointCleared {
152        session_id: String,
153        breakpoint_id: String,
154    },
155    Error {
156        code: String,
157        message: String,
158        session_id: Option<String>,
159    },
160    ArchiveList {
161        archives: Vec<ArchiveSummary>,
162        storage_available: bool,
163    },
164    ArchiveImported {
165        session_id: String,
166        net_name: String,
167        event_count: usize,
168    },
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn serde_round_trip_session_list() {
177        let resp = DebugResponse::SessionList {
178            sessions: vec![SessionSummary {
179                session_id: "s1".into(),
180                net_name: "test".into(),
181                start_time: "2025-01-01T00:00:00Z".into(),
182                active: true,
183                event_count: 42,
184                ..Default::default()
185            }],
186        };
187        let json = serde_json::to_string(&resp).unwrap();
188        assert!(json.contains("\"type\":\"sessionList\""));
189        // New 1.6.0 fields are omitted when unset (skip_serializing_if)
190        assert!(!json.contains("\"tags\""));
191        assert!(!json.contains("\"endTime\""));
192        assert!(!json.contains("\"durationMs\""));
193        let back: DebugResponse = serde_json::from_str(&json).unwrap();
194        match back {
195            DebugResponse::SessionList { sessions } => {
196                assert_eq!(sessions.len(), 1);
197                assert_eq!(sessions[0].session_id, "s1");
198                assert!(sessions[0].tags.is_empty());
199                assert!(sessions[0].end_time.is_none());
200                assert!(sessions[0].duration_ms.is_none());
201            }
202            _ => panic!("wrong variant"),
203        }
204    }
205
206    #[test]
207    fn serde_round_trip_session_summary_with_1_6_0_fields() {
208        let mut tags = HashMap::new();
209        tags.insert("channel".to_string(), "voice".to_string());
210        tags.insert("env".to_string(), "staging".to_string());
211
212        let summary = SessionSummary {
213            session_id: "s1".into(),
214            net_name: "test".into(),
215            start_time: "1000".into(),
216            active: false,
217            event_count: 42,
218            tags,
219            end_time: Some("2500".into()),
220            duration_ms: Some(1500),
221        };
222        let json = serde_json::to_string(&summary).unwrap();
223
224        assert!(json.contains("\"tags\""));
225        assert!(json.contains("\"channel\":\"voice\""));
226        assert!(json.contains("\"endTime\":\"2500\""));
227        assert!(json.contains("\"durationMs\":1500"));
228
229        let back: SessionSummary = serde_json::from_str(&json).unwrap();
230        assert_eq!(back.session_id, "s1");
231        assert_eq!(back.end_time, Some("2500".into()));
232        assert_eq!(back.duration_ms, Some(1500));
233        assert_eq!(back.tags.get("channel"), Some(&"voice".to_string()));
234        assert_eq!(back.tags.get("env"), Some(&"staging".to_string()));
235    }
236
237    #[test]
238    fn serde_deserialize_session_summary_tolerates_missing_1_6_0_fields() {
239        // An old-format payload (pre-1.6.0) must deserialize cleanly.
240        let json = r#"{
241            "sessionId": "s1",
242            "netName": "test",
243            "startTime": "1000",
244            "active": true,
245            "eventCount": 0
246        }"#;
247        let summary: SessionSummary = serde_json::from_str(json).unwrap();
248        assert_eq!(summary.session_id, "s1");
249        assert!(summary.tags.is_empty());
250        assert!(summary.end_time.is_none());
251        assert!(summary.duration_ms.is_none());
252    }
253
254    #[test]
255    fn serde_round_trip_subscribed() {
256        let resp = DebugResponse::Subscribed {
257            session_id: "s1".into(),
258            net_name: "test".into(),
259            dot_diagram: "digraph {}".into(),
260            structure: NetStructure {
261                places: vec![PlaceInfo {
262                    name: "p1".into(),
263                    graph_id: "p_p1".into(),
264                    token_type: "i32".into(),
265                    is_start: true,
266                    is_end: false,
267                    is_environment: false,
268                }],
269                transitions: vec![TransitionInfo {
270                    name: "t1".into(),
271                    graph_id: "t_t1".into(),
272                }],
273            },
274            current_marking: HashMap::new(),
275            enabled_transitions: vec!["t1".into()],
276            in_flight_transitions: vec![],
277            event_count: 5,
278            mode: "live".into(),
279        };
280        let json = serde_json::to_string(&resp).unwrap();
281        assert!(json.contains("\"type\":\"subscribed\""));
282        let _back: DebugResponse = serde_json::from_str(&json).unwrap();
283    }
284
285    #[test]
286    fn serde_round_trip_error() {
287        let resp = DebugResponse::Error {
288            code: "NOT_FOUND".into(),
289            message: "Session not found".into(),
290            session_id: Some("s1".into()),
291        };
292        let json = serde_json::to_string(&resp).unwrap();
293        let back: DebugResponse = serde_json::from_str(&json).unwrap();
294        match back {
295            DebugResponse::Error {
296                code,
297                message,
298                session_id,
299            } => {
300                assert_eq!(code, "NOT_FOUND");
301                assert_eq!(message, "Session not found");
302                assert_eq!(session_id, Some("s1".into()));
303            }
304            _ => panic!("wrong variant"),
305        }
306    }
307
308    #[test]
309    fn serde_response_inline_fields_use_camelcase() {
310        // Cross-language interop: inline enum-variant fields must serialize as camelCase
311        // so the TypeScript debug-ui (and Java client) can parse them.
312        let resp = DebugResponse::Subscribed {
313            session_id: "s1".into(),
314            net_name: "test".into(),
315            dot_diagram: "digraph {}".into(),
316            structure: NetStructure {
317                places: vec![],
318                transitions: vec![],
319            },
320            current_marking: HashMap::new(),
321            enabled_transitions: vec!["t1".into()],
322            in_flight_transitions: vec![],
323            event_count: 5,
324            mode: "live".into(),
325        };
326        let json = serde_json::to_string(&resp).unwrap();
327        assert!(json.contains("\"sessionId\":\"s1\""));
328        assert!(json.contains("\"netName\":\"test\""));
329        assert!(json.contains("\"dotDiagram\""));
330        assert!(json.contains("\"currentMarking\""));
331        assert!(json.contains("\"enabledTransitions\""));
332        assert!(json.contains("\"inFlightTransitions\""));
333        assert!(json.contains("\"eventCount\":5"));
334        assert!(!json.contains("\"session_id\""));
335        assert!(!json.contains("\"net_name\""));
336        assert!(!json.contains("\"event_count\""));
337    }
338
339    #[test]
340    fn serde_event_batch_camelcase_interop() {
341        // Cross-language interop: a Java/TS-shaped EventBatch payload must deserialize.
342        let json = r#"{"type":"eventBatch","sessionId":"s1","startIndex":0,"events":[],"hasMore":true}"#;
343        let resp: DebugResponse = serde_json::from_str(json).unwrap();
344        match resp {
345            DebugResponse::EventBatch {
346                session_id,
347                start_index,
348                has_more,
349                ..
350            } => {
351                assert_eq!(session_id, "s1");
352                assert_eq!(start_index, 0);
353                assert!(has_more);
354            }
355            _ => panic!("wrong variant"),
356        }
357    }
358
359    #[test]
360    fn serde_all_response_variants() {
361        let responses: Vec<DebugResponse> = vec![
362            DebugResponse::SessionList { sessions: vec![] },
363            DebugResponse::Unsubscribed {
364                session_id: "s1".into(),
365            },
366            DebugResponse::Event {
367                session_id: "s1".into(),
368                index: 0,
369                event: NetEventInfo {
370                    event_type: "TransitionStarted".into(),
371                    timestamp: "2025-01-01T00:00:00Z".into(),
372                    transition_name: Some("t1".into()),
373                    place_name: None,
374                    details: HashMap::new(),
375                },
376            },
377            DebugResponse::EventBatch {
378                session_id: "s1".into(),
379                start_index: 0,
380                events: vec![],
381                has_more: false,
382            },
383            DebugResponse::MarkingSnapshot {
384                session_id: "s1".into(),
385                marking: HashMap::new(),
386                enabled_transitions: vec![],
387                in_flight_transitions: vec![],
388            },
389            DebugResponse::PlaybackStateChanged {
390                session_id: "s1".into(),
391                paused: true,
392                speed: 1.0,
393                current_index: 0,
394            },
395            DebugResponse::FilterApplied {
396                session_id: "s1".into(),
397                filter: EventFilter::all(),
398            },
399            DebugResponse::BreakpointList {
400                session_id: "s1".into(),
401                breakpoints: vec![],
402            },
403            DebugResponse::BreakpointCleared {
404                session_id: "s1".into(),
405                breakpoint_id: "bp1".into(),
406            },
407            DebugResponse::Error {
408                code: "ERR".into(),
409                message: "msg".into(),
410                session_id: None,
411            },
412            DebugResponse::ArchiveList {
413                archives: vec![],
414                storage_available: false,
415            },
416            DebugResponse::ArchiveImported {
417                session_id: "s1".into(),
418                net_name: "test".into(),
419                event_count: 10,
420            },
421        ];
422        for resp in responses {
423            let json = serde_json::to_string(&resp).unwrap();
424            let _back: DebugResponse = serde_json::from_str(&json).unwrap();
425        }
426    }
427}