1use super::schema::{Playbook, Transition};
8use std::collections::{HashMap, HashSet, VecDeque};
9
10#[derive(Debug, Clone)]
12pub struct ValidationResult {
13 pub is_valid: bool,
15 pub issues: Vec<ValidationIssue>,
17 pub reachability: ReachabilityInfo,
19 pub determinism: DeterminismInfo,
21}
22
23#[derive(Debug, Clone, Default)]
25pub struct ReachabilityInfo {
26 pub reachable_states: HashSet<String>,
28 pub orphaned_states: HashSet<String>,
30 pub reachable_final_states: HashSet<String>,
32 pub can_reach_final: bool,
34}
35
36#[derive(Debug, Clone, Default)]
38pub struct DeterminismInfo {
39 pub is_deterministic: bool,
41 pub non_deterministic_pairs: Vec<(String, String)>,
43}
44
45#[derive(Debug, Clone)]
47pub enum ValidationIssue {
48 OrphanedState { state_id: String },
50 NoPathToFinal { from_state: String },
52 DeadEndState { state_id: String },
54 NonDeterministic {
56 state_id: String,
57 event: String,
58 transitions: Vec<String>,
59 },
60 UnhandledEvent { state_id: String, event: String },
62 UnguardedSelfLoop { transition_id: String },
64}
65
66impl ValidationIssue {
67 pub fn severity(&self) -> IssueSeverity {
69 match self {
70 ValidationIssue::OrphanedState { .. } => IssueSeverity::Error,
71 ValidationIssue::NoPathToFinal { .. } => IssueSeverity::Warning,
72 ValidationIssue::DeadEndState { .. } => IssueSeverity::Error,
73 ValidationIssue::NonDeterministic { .. } => IssueSeverity::Warning,
74 ValidationIssue::UnhandledEvent { .. } => IssueSeverity::Info,
75 ValidationIssue::UnguardedSelfLoop { .. } => IssueSeverity::Warning,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
82pub enum IssueSeverity {
83 Info,
84 Warning,
85 Error,
86}
87
88pub struct StateMachineValidator<'a> {
90 playbook: &'a Playbook,
91}
92
93impl<'a> StateMachineValidator<'a> {
94 pub fn new(playbook: &'a Playbook) -> Self {
96 Self { playbook }
97 }
98
99 pub fn validate(&self) -> ValidationResult {
101 contract_pre_playbook_state_machine!();
102 let mut issues = Vec::new();
103
104 let reachability = self.compute_reachability();
106
107 for state_id in &reachability.orphaned_states {
109 issues.push(ValidationIssue::OrphanedState {
110 state_id: state_id.clone(),
111 });
112 }
113
114 self.check_dead_ends(&reachability, &mut issues);
116
117 let determinism = self.check_determinism(&mut issues);
119
120 self.check_self_loops(&mut issues);
122
123 self.check_final_reachability(&reachability, &mut issues);
125
126 let has_errors = issues.iter().any(|i| i.severity() == IssueSeverity::Error);
127
128 ValidationResult {
129 is_valid: !has_errors,
130 issues,
131 reachability,
132 determinism,
133 }
134 }
135
136 fn compute_reachability(&self) -> ReachabilityInfo {
138 contract_pre_playbook_state_machine!();
139 let mut reachable = HashSet::new();
140 let mut queue = VecDeque::new();
141
142 queue.push_back(self.playbook.machine.initial.clone());
144 reachable.insert(self.playbook.machine.initial.clone());
145
146 while let Some(current) = queue.pop_front() {
148 for transition in &self.playbook.machine.transitions {
149 if transition.from == current && !reachable.contains(&transition.to) {
150 reachable.insert(transition.to.clone());
151 queue.push_back(transition.to.clone());
152 }
153 }
154 }
155
156 let all_states: HashSet<_> = self.playbook.machine.states.keys().cloned().collect();
158 let orphaned: HashSet<_> = all_states.difference(&reachable).cloned().collect();
159
160 let final_states: HashSet<_> = reachable
162 .iter()
163 .filter(|id| {
164 self.playbook
165 .machine
166 .states
167 .get(*id)
168 .is_some_and(|s| s.final_state)
169 })
170 .cloned()
171 .collect();
172
173 ReachabilityInfo {
174 reachable_states: reachable,
175 orphaned_states: orphaned,
176 can_reach_final: !final_states.is_empty(),
177 reachable_final_states: final_states,
178 }
179 }
180
181 fn check_dead_ends(&self, reachability: &ReachabilityInfo, issues: &mut Vec<ValidationIssue>) {
183 let mut outgoing: HashMap<&str, Vec<&Transition>> = HashMap::new();
185 for transition in &self.playbook.machine.transitions {
186 outgoing
187 .entry(transition.from.as_str())
188 .or_default()
189 .push(transition);
190 }
191
192 for state_id in &reachability.reachable_states {
194 if let Some(state) = self.playbook.machine.states.get(state_id) {
195 if !state.final_state && !outgoing.contains_key(state_id.as_str()) {
196 issues.push(ValidationIssue::DeadEndState {
197 state_id: state_id.clone(),
198 });
199 }
200 }
201 }
202 }
203
204 fn check_determinism(&self, issues: &mut Vec<ValidationIssue>) -> DeterminismInfo {
206 contract_pre_playbook_state_machine!();
207 let mut transition_map: HashMap<(String, String), Vec<&Transition>> = HashMap::new();
209
210 for transition in &self.playbook.machine.transitions {
211 transition_map
212 .entry((transition.from.clone(), transition.event.clone()))
213 .or_default()
214 .push(transition);
215 }
216
217 let mut non_deterministic_pairs: Vec<(String, String)> = Vec::new();
218
219 for (key, trans_vec) in &transition_map {
221 let (state_id, event) = key;
222 if trans_vec.len() > 1 {
223 let all_guarded = trans_vec.iter().all(|t| t.guard.is_some());
225 if !all_guarded {
226 non_deterministic_pairs.push((state_id.clone(), event.clone()));
227 issues.push(ValidationIssue::NonDeterministic {
228 state_id: state_id.clone(),
229 event: event.clone(),
230 transitions: trans_vec.iter().map(|t| t.id.clone()).collect(),
231 });
232 }
233 }
234 }
235
236 DeterminismInfo {
237 is_deterministic: non_deterministic_pairs.is_empty(),
238 non_deterministic_pairs,
239 }
240 }
241
242 fn check_self_loops(&self, issues: &mut Vec<ValidationIssue>) {
244 for transition in &self.playbook.machine.transitions {
245 if transition.from == transition.to && transition.guard.is_none() {
246 issues.push(ValidationIssue::UnguardedSelfLoop {
247 transition_id: transition.id.clone(),
248 });
249 }
250 }
251 }
252
253 fn check_final_reachability(
255 &self,
256 reachability: &ReachabilityInfo,
257 issues: &mut Vec<ValidationIssue>,
258 ) {
259 if reachability.reachable_final_states.is_empty() {
260 return; }
262
263 let mut can_reach_final = reachability.reachable_final_states.clone();
265 let mut changed = true;
266
267 while changed {
268 changed = false;
269 for transition in &self.playbook.machine.transitions {
270 if can_reach_final.contains(&transition.to)
271 && !can_reach_final.contains(&transition.from)
272 {
273 can_reach_final.insert(transition.from.clone());
274 changed = true;
275 }
276 }
277 }
278
279 for state_id in &reachability.reachable_states {
281 if !can_reach_final.contains(state_id) {
282 issues.push(ValidationIssue::NoPathToFinal {
283 from_state: state_id.clone(),
284 });
285 }
286 }
287 }
288}
289
290pub fn to_dot(playbook: &Playbook) -> String {
292 let mut dot = String::new();
293 dot.push_str("digraph StateMachine {\n");
294 dot.push_str(" rankdir=LR;\n");
295 dot.push_str(" node [shape=ellipse];\n");
296
297 dot.push_str(&format!(
299 " __start [shape=point];\n __start -> \"{}\";\n",
300 playbook.machine.initial
301 ));
302
303 for (id, state) in &playbook.machine.states {
305 let shape = if state.final_state {
306 "doublecircle"
307 } else {
308 "ellipse"
309 };
310 dot.push_str(&format!(" \"{}\" [shape={}];\n", id, shape));
311 }
312
313 for transition in &playbook.machine.transitions {
315 let label = if let Some(guard) = &transition.guard {
316 format!("{} [{}]", transition.event, guard)
317 } else {
318 transition.event.clone()
319 };
320 dot.push_str(&format!(
321 " \"{}\" -> \"{}\" [label=\"{}\"];\n",
322 transition.from, transition.to, label
323 ));
324 }
325
326 dot.push_str("}\n");
327 dot
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::playbook::schema::Playbook;
334
335 const VALID_PLAYBOOK: &str = r#"
336version: "1.0"
337machine:
338 id: "test"
339 initial: "start"
340 states:
341 start:
342 id: "start"
343 middle:
344 id: "middle"
345 end:
346 id: "end"
347 final_state: true
348 transitions:
349 - id: "t1"
350 from: "start"
351 to: "middle"
352 event: "next"
353 - id: "t2"
354 from: "middle"
355 to: "end"
356 event: "finish"
357"#;
358
359 #[test]
360 fn test_valid_state_machine() {
361 let playbook = Playbook::from_yaml(VALID_PLAYBOOK).expect("parse");
362 let validator = StateMachineValidator::new(&playbook);
363 let result = validator.validate();
364
365 assert!(result.is_valid);
366 assert!(result.reachability.orphaned_states.is_empty());
367 assert!(result.reachability.can_reach_final);
368 assert!(result.determinism.is_deterministic);
369 }
370
371 #[test]
372 fn test_detect_orphaned_state() {
373 let yaml = r#"
374version: "1.0"
375machine:
376 id: "test"
377 initial: "start"
378 states:
379 start:
380 id: "start"
381 orphan:
382 id: "orphan"
383 end:
384 id: "end"
385 final_state: true
386 transitions:
387 - id: "t1"
388 from: "start"
389 to: "end"
390 event: "finish"
391"#;
392 let playbook = Playbook::from_yaml(yaml).expect("parse");
393 let validator = StateMachineValidator::new(&playbook);
394 let result = validator.validate();
395
396 assert!(!result.is_valid);
397 assert!(result.reachability.orphaned_states.contains("orphan"));
398 assert!(result.issues.iter().any(
399 |i| matches!(i, ValidationIssue::OrphanedState { state_id } if state_id == "orphan")
400 ));
401 }
402
403 #[test]
404 fn test_detect_dead_end() {
405 let yaml = r#"
406version: "1.0"
407machine:
408 id: "test"
409 initial: "start"
410 states:
411 start:
412 id: "start"
413 dead_end:
414 id: "dead_end"
415 transitions:
416 - id: "t1"
417 from: "start"
418 to: "dead_end"
419 event: "go"
420"#;
421 let playbook = Playbook::from_yaml(yaml).expect("parse");
422 let validator = StateMachineValidator::new(&playbook);
423 let result = validator.validate();
424
425 assert!(!result.is_valid);
426 assert!(result.issues.iter().any(
427 |i| matches!(i, ValidationIssue::DeadEndState { state_id } if state_id == "dead_end")
428 ));
429 }
430
431 #[test]
432 fn test_detect_non_deterministic() {
433 let yaml = r#"
434version: "1.0"
435machine:
436 id: "test"
437 initial: "start"
438 states:
439 start:
440 id: "start"
441 end1:
442 id: "end1"
443 final_state: true
444 end2:
445 id: "end2"
446 final_state: true
447 transitions:
448 - id: "t1"
449 from: "start"
450 to: "end1"
451 event: "go"
452 - id: "t2"
453 from: "start"
454 to: "end2"
455 event: "go"
456"#;
457 let playbook = Playbook::from_yaml(yaml).expect("parse");
458 let validator = StateMachineValidator::new(&playbook);
459 let result = validator.validate();
460
461 assert!(!result.determinism.is_deterministic);
462 assert!(result.issues.iter().any(
463 |i| matches!(i, ValidationIssue::NonDeterministic { state_id, event, .. } if state_id == "start" && event == "go")
464 ));
465 }
466
467 #[test]
468 fn test_dot_generation() {
469 let playbook = Playbook::from_yaml(VALID_PLAYBOOK).expect("parse");
470 let dot = to_dot(&playbook);
471
472 assert!(dot.contains("digraph StateMachine"));
473 assert!(dot.contains("__start"));
474 assert!(dot.contains("doublecircle")); assert!(dot.contains("\"start\" -> \"middle\""));
476 }
477}