Skip to main content

jugar_probar/playbook/
mutation.rs

1//! Mutation testing support for playbook validation.
2//!
3//! Implements M1-M5 mutation classes for falsification protocol.
4//! Reference: Fabbri et al., "Mutation Testing Applied to Validate
5//! Specifications Based on Statecharts" (ISSRE 1999)
6
7use super::schema::Playbook;
8use std::collections::HashMap;
9
10/// Mutation classes for state machine testing.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum MutationClass {
13    /// M1: State removal - remove a state
14    StateRemoval,
15    /// M2: Transition removal - remove a transition
16    TransitionRemoval,
17    /// M3: Event swap - swap event triggers between transitions
18    EventSwap,
19    /// M4: Target swap - change transition target to different state
20    TargetSwap,
21    /// M5: Guard negation - negate guard conditions
22    GuardNegation,
23}
24
25impl MutationClass {
26    /// Get all mutation classes.
27    pub fn all() -> Vec<MutationClass> {
28        vec![
29            MutationClass::StateRemoval,
30            MutationClass::TransitionRemoval,
31            MutationClass::EventSwap,
32            MutationClass::TargetSwap,
33            MutationClass::GuardNegation,
34        ]
35    }
36
37    /// Get the mutation class identifier (M1-M5).
38    pub fn id(&self) -> &'static str {
39        match self {
40            MutationClass::StateRemoval => "M1",
41            MutationClass::TransitionRemoval => "M2",
42            MutationClass::EventSwap => "M3",
43            MutationClass::TargetSwap => "M4",
44            MutationClass::GuardNegation => "M5",
45        }
46    }
47
48    /// Get a description of the mutation class.
49    pub fn description(&self) -> &'static str {
50        match self {
51            MutationClass::StateRemoval => "Remove a state from the machine",
52            MutationClass::TransitionRemoval => "Remove a transition from the machine",
53            MutationClass::EventSwap => "Swap event triggers between two transitions",
54            MutationClass::TargetSwap => "Change a transition's target to a different state",
55            MutationClass::GuardNegation => "Negate a transition's guard condition",
56        }
57    }
58}
59
60/// A mutant is a modified version of the original playbook.
61#[derive(Debug, Clone)]
62pub struct Mutant {
63    /// Unique identifier for this mutant
64    pub id: String,
65    /// Mutation class applied
66    pub class: MutationClass,
67    /// Description of the mutation
68    pub description: String,
69    /// The mutated playbook
70    pub playbook: Playbook,
71}
72
73/// Result of running tests against a mutant.
74#[derive(Debug, Clone)]
75pub struct MutantResult {
76    /// Mutant identifier
77    pub mutant_id: String,
78    /// Mutation class
79    pub class: MutationClass,
80    /// Whether the mutant was killed (test detected the mutation)
81    pub killed: bool,
82    /// How the mutant was killed (if killed)
83    pub kill_reason: Option<String>,
84}
85
86/// Mutation score summary.
87#[derive(Debug, Clone)]
88pub struct MutationScore {
89    /// Total mutants generated
90    pub total_mutants: usize,
91    /// Mutants killed by tests
92    pub killed: usize,
93    /// Mutants that survived
94    pub survived: usize,
95    /// Mutation score (killed / total)
96    pub score: f64,
97    /// Results by mutation class
98    pub by_class: HashMap<MutationClass, ClassScore>,
99}
100
101/// Score for a single mutation class.
102#[derive(Debug, Clone, Default)]
103pub struct ClassScore {
104    pub total: usize,
105    pub killed: usize,
106    pub score: f64,
107}
108
109/// Mutation generator for playbooks.
110pub struct MutationGenerator<'a> {
111    playbook: &'a Playbook,
112}
113
114impl<'a> MutationGenerator<'a> {
115    /// Create a new mutation generator for the given playbook.
116    pub fn new(playbook: &'a Playbook) -> Self {
117        Self { playbook }
118    }
119
120    /// Generate all possible mutants across all mutation classes.
121    pub fn generate_all(&self) -> Vec<Mutant> {
122        let mut mutants = Vec::new();
123        mutants.extend(self.generate_state_removals());
124        mutants.extend(self.generate_transition_removals());
125        mutants.extend(self.generate_event_swaps());
126        mutants.extend(self.generate_target_swaps());
127        mutants.extend(self.generate_guard_negations());
128        mutants
129    }
130
131    /// Generate mutants for a specific class.
132    pub fn generate(&self, class: MutationClass) -> Vec<Mutant> {
133        match class {
134            MutationClass::StateRemoval => self.generate_state_removals(),
135            MutationClass::TransitionRemoval => self.generate_transition_removals(),
136            MutationClass::EventSwap => self.generate_event_swaps(),
137            MutationClass::TargetSwap => self.generate_target_swaps(),
138            MutationClass::GuardNegation => self.generate_guard_negations(),
139        }
140    }
141
142    /// M1: Generate state removal mutants.
143    fn generate_state_removals(&self) -> Vec<Mutant> {
144        let mut mutants = Vec::new();
145
146        for state_id in self.playbook.machine.states.keys() {
147            // Skip initial state (would make playbook invalid)
148            if *state_id == self.playbook.machine.initial {
149                continue;
150            }
151
152            let mut mutated = self.playbook.clone();
153            mutated.machine.states.remove(state_id);
154
155            // Also remove transitions involving this state
156            mutated
157                .machine
158                .transitions
159                .retain(|t| t.from != *state_id && t.to != *state_id);
160
161            mutants.push(Mutant {
162                id: format!("M1_{}", state_id),
163                class: MutationClass::StateRemoval,
164                description: format!("Remove state '{}'", state_id),
165                playbook: mutated,
166            });
167        }
168
169        mutants
170    }
171
172    /// M2: Generate transition removal mutants.
173    fn generate_transition_removals(&self) -> Vec<Mutant> {
174        let mut mutants = Vec::new();
175
176        for (idx, transition) in self.playbook.machine.transitions.iter().enumerate() {
177            let mut mutated = self.playbook.clone();
178            mutated.machine.transitions.remove(idx);
179
180            // Only generate if still valid (at least one transition remains)
181            if !mutated.machine.transitions.is_empty() {
182                mutants.push(Mutant {
183                    id: format!("M2_{}", transition.id),
184                    class: MutationClass::TransitionRemoval,
185                    description: format!("Remove transition '{}'", transition.id),
186                    playbook: mutated,
187                });
188            }
189        }
190
191        mutants
192    }
193
194    /// M3: Generate event swap mutants.
195    fn generate_event_swaps(&self) -> Vec<Mutant> {
196        let mut mutants = Vec::new();
197        let transitions = &self.playbook.machine.transitions;
198
199        for i in 0..transitions.len() {
200            for j in (i + 1)..transitions.len() {
201                // Only swap if events are different
202                if transitions[i].event != transitions[j].event {
203                    let mut mutated = self.playbook.clone();
204
205                    // Swap events
206                    let event_i = transitions[i].event.clone();
207                    let event_j = transitions[j].event.clone();
208                    mutated.machine.transitions[i].event = event_j;
209                    mutated.machine.transitions[j].event = event_i;
210
211                    mutants.push(Mutant {
212                        id: format!("M3_{}_{}", transitions[i].id, transitions[j].id),
213                        class: MutationClass::EventSwap,
214                        description: format!(
215                            "Swap events between '{}' and '{}'",
216                            transitions[i].id, transitions[j].id
217                        ),
218                        playbook: mutated,
219                    });
220                }
221            }
222        }
223
224        mutants
225    }
226
227    /// M4: Generate target swap mutants.
228    fn generate_target_swaps(&self) -> Vec<Mutant> {
229        let mut mutants = Vec::new();
230        let state_ids: Vec<_> = self.playbook.machine.states.keys().collect();
231
232        for (idx, transition) in self.playbook.machine.transitions.iter().enumerate() {
233            for state_id in &state_ids {
234                // Skip if same as original target
235                if **state_id == transition.to {
236                    continue;
237                }
238
239                let mut mutated = self.playbook.clone();
240                mutated.machine.transitions[idx].to = (*state_id).clone();
241
242                mutants.push(Mutant {
243                    id: format!("M4_{}_{}", transition.id, state_id),
244                    class: MutationClass::TargetSwap,
245                    description: format!(
246                        "Change target of '{}' from '{}' to '{}'",
247                        transition.id, transition.to, state_id
248                    ),
249                    playbook: mutated,
250                });
251            }
252        }
253
254        mutants
255    }
256
257    /// M5: Generate guard negation mutants.
258    fn generate_guard_negations(&self) -> Vec<Mutant> {
259        let mut mutants = Vec::new();
260
261        for (idx, transition) in self.playbook.machine.transitions.iter().enumerate() {
262            if let Some(guard) = &transition.guard {
263                let mut mutated = self.playbook.clone();
264
265                // Negate the guard condition
266                let negated = format!("!({})", guard);
267                mutated.machine.transitions[idx].guard = Some(negated.clone());
268
269                mutants.push(Mutant {
270                    id: format!("M5_{}", transition.id),
271                    class: MutationClass::GuardNegation,
272                    description: format!(
273                        "Negate guard of '{}': '{}' → '{}'",
274                        transition.id, guard, negated
275                    ),
276                    playbook: mutated,
277                });
278            }
279        }
280
281        mutants
282    }
283}
284
285/// Calculate mutation score from results.
286pub fn calculate_mutation_score(results: &[MutantResult]) -> MutationScore {
287    let total_mutants = results.len();
288    let killed = results.iter().filter(|r| r.killed).count();
289    let survived = total_mutants - killed;
290    let score = if total_mutants > 0 {
291        killed as f64 / total_mutants as f64
292    } else {
293        1.0
294    };
295
296    // Calculate per-class scores
297    let mut by_class: HashMap<MutationClass, ClassScore> = HashMap::new();
298
299    for class in MutationClass::all() {
300        let class_results: Vec<_> = results.iter().filter(|r| r.class == class).collect();
301        let class_total = class_results.len();
302        let class_killed = class_results.iter().filter(|r| r.killed).count();
303
304        by_class.insert(
305            class,
306            ClassScore {
307                total: class_total,
308                killed: class_killed,
309                score: if class_total > 0 {
310                    class_killed as f64 / class_total as f64
311                } else {
312                    1.0
313                },
314            },
315        );
316    }
317
318    MutationScore {
319        total_mutants,
320        killed,
321        survived,
322        score,
323        by_class,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::playbook::schema::Playbook;
331
332    const TEST_PLAYBOOK: &str = r#"
333version: "1.0"
334machine:
335  id: "test"
336  initial: "start"
337  states:
338    start:
339      id: "start"
340    middle:
341      id: "middle"
342    end:
343      id: "end"
344      final_state: true
345  transitions:
346    - id: "t1"
347      from: "start"
348      to: "middle"
349      event: "next"
350    - id: "t2"
351      from: "middle"
352      to: "end"
353      event: "finish"
354      guard: "user.isLoggedIn"
355"#;
356
357    #[test]
358    fn test_generate_state_removals() {
359        let playbook = Playbook::from_yaml(TEST_PLAYBOOK).expect("parse");
360        let generator = MutationGenerator::new(&playbook);
361        let mutants = generator.generate(MutationClass::StateRemoval);
362
363        // Should generate 2 mutants (middle and end, not start)
364        assert_eq!(mutants.len(), 2);
365        assert!(mutants
366            .iter()
367            .all(|m| m.class == MutationClass::StateRemoval));
368    }
369
370    #[test]
371    fn test_generate_transition_removals() {
372        let playbook = Playbook::from_yaml(TEST_PLAYBOOK).expect("parse");
373        let generator = MutationGenerator::new(&playbook);
374        let mutants = generator.generate(MutationClass::TransitionRemoval);
375
376        // Should generate 2 mutants (one for each transition)
377        // But only 1 is valid (removing one leaves at least one)
378        assert!(!mutants.is_empty());
379        assert!(mutants
380            .iter()
381            .all(|m| m.class == MutationClass::TransitionRemoval));
382    }
383
384    #[test]
385    fn test_generate_event_swaps() {
386        let playbook = Playbook::from_yaml(TEST_PLAYBOOK).expect("parse");
387        let generator = MutationGenerator::new(&playbook);
388        let mutants = generator.generate(MutationClass::EventSwap);
389
390        // Should generate 1 mutant (swap "next" and "finish")
391        assert_eq!(mutants.len(), 1);
392        assert_eq!(mutants[0].class, MutationClass::EventSwap);
393    }
394
395    #[test]
396    fn test_generate_target_swaps() {
397        let playbook = Playbook::from_yaml(TEST_PLAYBOOK).expect("parse");
398        let generator = MutationGenerator::new(&playbook);
399        let mutants = generator.generate(MutationClass::TargetSwap);
400
401        // Each transition can target 2 other states
402        assert_eq!(mutants.len(), 4);
403        assert!(mutants.iter().all(|m| m.class == MutationClass::TargetSwap));
404    }
405
406    #[test]
407    fn test_generate_guard_negations() {
408        let playbook = Playbook::from_yaml(TEST_PLAYBOOK).expect("parse");
409        let generator = MutationGenerator::new(&playbook);
410        let mutants = generator.generate(MutationClass::GuardNegation);
411
412        // Only t2 has a guard
413        assert_eq!(mutants.len(), 1);
414        assert_eq!(mutants[0].class, MutationClass::GuardNegation);
415        assert!(mutants[0]
416            .playbook
417            .machine
418            .transitions
419            .iter()
420            .any(|t| t.guard.as_deref() == Some("!(user.isLoggedIn)")));
421    }
422
423    #[test]
424    fn test_generate_all() {
425        let playbook = Playbook::from_yaml(TEST_PLAYBOOK).expect("parse");
426        let generator = MutationGenerator::new(&playbook);
427        let mutants = generator.generate_all();
428
429        // Should have mutants from all classes
430        let has_m1 = mutants
431            .iter()
432            .any(|m| m.class == MutationClass::StateRemoval);
433        let has_m2 = mutants
434            .iter()
435            .any(|m| m.class == MutationClass::TransitionRemoval);
436        let has_m3 = mutants.iter().any(|m| m.class == MutationClass::EventSwap);
437        let has_m4 = mutants.iter().any(|m| m.class == MutationClass::TargetSwap);
438        let has_m5 = mutants
439            .iter()
440            .any(|m| m.class == MutationClass::GuardNegation);
441
442        assert!(has_m1);
443        assert!(has_m2);
444        assert!(has_m3);
445        assert!(has_m4);
446        assert!(has_m5);
447    }
448
449    #[test]
450    fn test_calculate_mutation_score() {
451        let results = vec![
452            MutantResult {
453                mutant_id: "M1_1".to_string(),
454                class: MutationClass::StateRemoval,
455                killed: true,
456                kill_reason: Some("Validation failed".to_string()),
457            },
458            MutantResult {
459                mutant_id: "M2_1".to_string(),
460                class: MutationClass::TransitionRemoval,
461                killed: true,
462                kill_reason: Some("Test failed".to_string()),
463            },
464            MutantResult {
465                mutant_id: "M3_1".to_string(),
466                class: MutationClass::EventSwap,
467                killed: false,
468                kill_reason: None,
469            },
470        ];
471
472        let score = calculate_mutation_score(&results);
473
474        assert_eq!(score.total_mutants, 3);
475        assert_eq!(score.killed, 2);
476        assert_eq!(score.survived, 1);
477        assert!((score.score - 0.666).abs() < 0.01);
478    }
479
480    #[test]
481    fn test_mutation_class_metadata() {
482        assert_eq!(MutationClass::StateRemoval.id(), "M1");
483        assert_eq!(MutationClass::TransitionRemoval.id(), "M2");
484        assert_eq!(MutationClass::EventSwap.id(), "M3");
485        assert_eq!(MutationClass::TargetSwap.id(), "M4");
486        assert_eq!(MutationClass::GuardNegation.id(), "M5");
487    }
488}