Skip to main content

libpetri_debug/
debug_command.rs

1//! Commands sent from debug UI client to server via WebSocket.
2
3use serde::{Deserialize, Serialize};
4
5/// Subscription mode for a debug session.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub enum SubscriptionMode {
9    Live,
10    Replay,
11}
12
13/// Breakpoint trigger types.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
16pub enum BreakpointType {
17    TransitionEnabled,
18    TransitionStart,
19    TransitionComplete,
20    TransitionFail,
21    TokenAdded,
22    TokenRemoved,
23}
24
25/// Configuration for a single breakpoint.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct BreakpointConfig {
29    pub id: String,
30    #[serde(rename = "type")]
31    pub bp_type: BreakpointType,
32    pub target: Option<String>,
33    pub enabled: bool,
34}
35
36/// Event filter for restricting which events are delivered.
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct EventFilter {
40    pub event_types: Option<Vec<String>>,
41    pub transition_names: Option<Vec<String>>,
42    pub place_names: Option<Vec<String>>,
43    #[serde(default)]
44    pub exclude_event_types: Option<Vec<String>>,
45    #[serde(default)]
46    pub exclude_transition_names: Option<Vec<String>>,
47    #[serde(default)]
48    pub exclude_place_names: Option<Vec<String>>,
49}
50
51impl EventFilter {
52    /// Creates a filter that matches all events.
53    pub fn all() -> Self {
54        Self::default()
55    }
56}
57
58/// Commands from debug UI client to server.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
61pub enum DebugCommand {
62    ListSessions {
63        limit: Option<usize>,
64        active_only: Option<bool>,
65        /// Optional tag filter (AND semantics). Empty or missing matches all. (libpetri 1.6.0+)
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        tag_filter: Option<std::collections::HashMap<String, String>>,
68    },
69    Subscribe {
70        session_id: String,
71        mode: SubscriptionMode,
72        from_index: Option<usize>,
73    },
74    Unsubscribe {
75        session_id: String,
76    },
77    Seek {
78        session_id: String,
79        timestamp: String,
80    },
81    PlaybackSpeed {
82        session_id: String,
83        speed: f64,
84    },
85    Filter {
86        session_id: String,
87        filter: EventFilter,
88    },
89    Pause {
90        session_id: String,
91    },
92    Resume {
93        session_id: String,
94    },
95    StepForward {
96        session_id: String,
97    },
98    StepBackward {
99        session_id: String,
100    },
101    SetBreakpoint {
102        session_id: String,
103        breakpoint: BreakpointConfig,
104    },
105    ClearBreakpoint {
106        session_id: String,
107        breakpoint_id: String,
108    },
109    ListBreakpoints {
110        session_id: String,
111    },
112    ListArchives {
113        limit: Option<usize>,
114        prefix: Option<String>,
115    },
116    ImportArchive {
117        session_id: String,
118    },
119    UploadArchive {
120        file_name: String,
121        data: String,
122    },
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn serde_round_trip_subscribe() {
131        let cmd = DebugCommand::Subscribe {
132            session_id: "s1".into(),
133            mode: SubscriptionMode::Live,
134            from_index: Some(10),
135        };
136        let json = serde_json::to_string(&cmd).unwrap();
137        assert!(json.contains("\"type\":\"subscribe\""));
138        let back: DebugCommand = serde_json::from_str(&json).unwrap();
139        match back {
140            DebugCommand::Subscribe {
141                session_id,
142                mode,
143                from_index,
144            } => {
145                assert_eq!(session_id, "s1");
146                assert_eq!(mode, SubscriptionMode::Live);
147                assert_eq!(from_index, Some(10));
148            }
149            _ => panic!("wrong variant"),
150        }
151    }
152
153    #[test]
154    fn serde_round_trip_list_sessions() {
155        let cmd = DebugCommand::ListSessions {
156            limit: None,
157            active_only: Some(true),
158            tag_filter: None,
159        };
160        let json = serde_json::to_string(&cmd).unwrap();
161        assert!(json.contains("\"type\":\"listSessions\""));
162        // `tag_filter: None` must be omitted from the wire so older clients parse it.
163        assert!(!json.contains("\"tagFilter\""));
164        let back: DebugCommand = serde_json::from_str(&json).unwrap();
165        match back {
166            DebugCommand::ListSessions {
167                limit,
168                active_only,
169                tag_filter,
170            } => {
171                assert!(limit.is_none());
172                assert_eq!(active_only, Some(true));
173                assert!(tag_filter.is_none());
174            }
175            _ => panic!("wrong variant"),
176        }
177    }
178
179    #[test]
180    fn serde_list_sessions_with_tag_filter() {
181        let mut filter = std::collections::HashMap::new();
182        filter.insert("channel".to_string(), "voice".to_string());
183        let cmd = DebugCommand::ListSessions {
184            limit: Some(10),
185            active_only: Some(false),
186            tag_filter: Some(filter),
187        };
188        let json = serde_json::to_string(&cmd).unwrap();
189        assert!(json.contains("\"tagFilter\""));
190        assert!(json.contains("\"activeOnly\""));
191        assert!(json.contains("\"channel\":\"voice\""));
192        let back: DebugCommand = serde_json::from_str(&json).unwrap();
193        if let DebugCommand::ListSessions { tag_filter, .. } = back {
194            let f = tag_filter.expect("tag_filter should be Some");
195            assert_eq!(f.get("channel"), Some(&"voice".to_string()));
196        } else {
197            panic!("wrong variant");
198        }
199    }
200
201    #[test]
202    fn serde_list_sessions_without_tag_filter() {
203        // Payloads without the 1.6.0 tag_filter field must still deserialize cleanly.
204        let json = r#"{"type":"listSessions","limit":10,"activeOnly":false}"#;
205        let cmd: DebugCommand = serde_json::from_str(json).unwrap();
206        if let DebugCommand::ListSessions {
207            limit,
208            active_only,
209            tag_filter,
210        } = cmd
211        {
212            assert_eq!(limit, Some(10));
213            assert_eq!(active_only, Some(false));
214            assert!(tag_filter.is_none());
215        } else {
216            panic!("wrong variant");
217        }
218    }
219
220    #[test]
221    fn serde_list_sessions_camelcase_interop() {
222        // Cross-language interop: a literal Java/TS-shaped payload must deserialize.
223        let json = r#"{"type":"listSessions","limit":10,"activeOnly":true,"tagFilter":{"channel":"voice","env":"staging"}}"#;
224        let cmd: DebugCommand = serde_json::from_str(json).unwrap();
225        if let DebugCommand::ListSessions {
226            limit,
227            active_only,
228            tag_filter,
229        } = cmd
230        {
231            assert_eq!(limit, Some(10));
232            assert_eq!(active_only, Some(true));
233            let f = tag_filter.expect("tag_filter should be Some");
234            assert_eq!(f.get("channel"), Some(&"voice".to_string()));
235            assert_eq!(f.get("env"), Some(&"staging".to_string()));
236        } else {
237            panic!("wrong variant");
238        }
239    }
240
241    #[test]
242    fn serde_subscribe_camelcase_interop() {
243        // Cross-language interop: Subscribe payload from Java/TS uses camelCase fields.
244        let json = r#"{"type":"subscribe","sessionId":"s1","mode":"live","fromIndex":10}"#;
245        let cmd: DebugCommand = serde_json::from_str(json).unwrap();
246        if let DebugCommand::Subscribe {
247            session_id,
248            mode,
249            from_index,
250        } = cmd
251        {
252            assert_eq!(session_id, "s1");
253            assert_eq!(mode, SubscriptionMode::Live);
254            assert_eq!(from_index, Some(10));
255        } else {
256            panic!("wrong variant");
257        }
258    }
259
260    #[test]
261    fn serde_breakpoint_config() {
262        let bp = BreakpointConfig {
263            id: "bp1".into(),
264            bp_type: BreakpointType::TransitionStart,
265            target: Some("t1".into()),
266            enabled: true,
267        };
268        let json = serde_json::to_string(&bp).unwrap();
269        assert!(json.contains("\"type\":\"TRANSITION_START\""));
270        let back: BreakpointConfig = serde_json::from_str(&json).unwrap();
271        assert_eq!(back.bp_type, BreakpointType::TransitionStart);
272    }
273
274    #[test]
275    fn serde_event_filter_all() {
276        let filter = EventFilter::all();
277        let json = serde_json::to_string(&filter).unwrap();
278        let back: EventFilter = serde_json::from_str(&json).unwrap();
279        assert!(back.event_types.is_none());
280        assert!(back.transition_names.is_none());
281        assert!(back.place_names.is_none());
282        assert!(back.exclude_event_types.is_none());
283        assert!(back.exclude_transition_names.is_none());
284        assert!(back.exclude_place_names.is_none());
285    }
286
287    #[test]
288    fn serde_event_filter_backward_compat() {
289        let json = r#"{"eventTypes":["TransitionStarted"],"transitionNames":null,"placeNames":null}"#;
290        let filter: EventFilter = serde_json::from_str(json).unwrap();
291        assert!(filter.exclude_event_types.is_none());
292        assert!(filter.exclude_transition_names.is_none());
293        assert!(filter.exclude_place_names.is_none());
294    }
295
296    #[test]
297    fn serde_event_filter_with_exclusions() {
298        let filter = EventFilter {
299            event_types: None,
300            transition_names: None,
301            place_names: None,
302            exclude_event_types: Some(vec!["LogMessage".into()]),
303            exclude_transition_names: Some(vec!["t1".into()]),
304            exclude_place_names: None,
305        };
306        let json = serde_json::to_string(&filter).unwrap();
307        let back: EventFilter = serde_json::from_str(&json).unwrap();
308        assert_eq!(back.exclude_event_types, Some(vec!["LogMessage".into()]));
309        assert_eq!(back.exclude_transition_names, Some(vec!["t1".into()]));
310        assert!(back.exclude_place_names.is_none());
311    }
312
313    #[test]
314    fn serde_all_command_variants() {
315        let cmds = vec![
316            DebugCommand::ListSessions {
317                limit: Some(10),
318                active_only: None,
319                tag_filter: None,
320            },
321            DebugCommand::Subscribe {
322                session_id: "s1".into(),
323                mode: SubscriptionMode::Replay,
324                from_index: None,
325            },
326            DebugCommand::Unsubscribe {
327                session_id: "s1".into(),
328            },
329            DebugCommand::Seek {
330                session_id: "s1".into(),
331                timestamp: "2025-01-01T00:00:00Z".into(),
332            },
333            DebugCommand::PlaybackSpeed {
334                session_id: "s1".into(),
335                speed: 2.0,
336            },
337            DebugCommand::Filter {
338                session_id: "s1".into(),
339                filter: EventFilter::all(),
340            },
341            DebugCommand::Pause {
342                session_id: "s1".into(),
343            },
344            DebugCommand::Resume {
345                session_id: "s1".into(),
346            },
347            DebugCommand::StepForward {
348                session_id: "s1".into(),
349            },
350            DebugCommand::StepBackward {
351                session_id: "s1".into(),
352            },
353            DebugCommand::SetBreakpoint {
354                session_id: "s1".into(),
355                breakpoint: BreakpointConfig {
356                    id: "bp1".into(),
357                    bp_type: BreakpointType::TokenAdded,
358                    target: None,
359                    enabled: true,
360                },
361            },
362            DebugCommand::ClearBreakpoint {
363                session_id: "s1".into(),
364                breakpoint_id: "bp1".into(),
365            },
366            DebugCommand::ListBreakpoints {
367                session_id: "s1".into(),
368            },
369            DebugCommand::ListArchives {
370                limit: None,
371                prefix: None,
372            },
373            DebugCommand::ImportArchive {
374                session_id: "s1".into(),
375            },
376            DebugCommand::UploadArchive {
377                file_name: "test.gz".into(),
378                data: "base64data".into(),
379            },
380        ];
381        for cmd in cmds {
382            let json = serde_json::to_string(&cmd).unwrap();
383            let _back: DebugCommand = serde_json::from_str(&json).unwrap();
384        }
385    }
386}