Skip to main content

gwr_components/
state_machine.rs

1// Copyright (c) 2026 Graphcore Ltd. All rights reserved.
2
3//! State-machine builders.
4
5#[doc(hidden)]
6pub use paste::paste;
7
8/// Build a state enum, transition metadata, runtime transition checks, and
9/// typestate proof helpers.
10///
11/// The macro takes a list of states, the default state, and a set of named
12/// transitions. Each transition declares one or more source states and a single
13/// destination state.
14///
15/// ```rust
16/// gwr_components::create_state_machine!(
17///     pub JobMachine {
18///         states: [Queued, Running, Complete, Failed],
19///         default: Queued,
20///         transitions: [
21///             start: [Queued] => Running,
22///             finish: [Running] => Complete,
23///             fail: [Queued, Running] => Failed,
24///             retry: [Failed] => Queued,
25///         ],
26///     }
27/// );
28/// ```
29///
30/// The generated state enum can be used as a runtime state machine. Transition
31/// marker types are named from the machine, action, and `Transition` suffix.
32///
33/// ```rust
34/// # gwr_components::create_state_machine!(
35/// #     JobMachine {
36/// #         states: [Queued, Running, Complete, Failed],
37/// #         default: Queued,
38/// #         transitions: [
39/// #             start: [Queued] => Running,
40/// #             finish: [Running] => Complete,
41/// #             fail: [Queued, Running] => Failed,
42/// #             retry: [Failed] => Queued,
43/// #         ],
44/// #     }
45/// # );
46/// #
47/// let mut state = JobMachine::default();
48/// assert_eq!(state, JobMachine::Queued);
49/// assert!(state.apply::<JobMachineStartTransition>());
50/// assert_eq!(state, JobMachine::Running);
51/// assert!(!state.apply::<JobMachineRetryTransition>());
52/// assert_eq!(state, JobMachine::Running);
53/// ```
54///
55/// The macro also generates typestate wrappers. Valid transition methods are
56/// only implemented for the source states listed in the transition declaration,
57/// so invalid transition chains fail to compile.
58///
59/// ```rust
60/// # gwr_components::create_state_machine!(
61/// #     JobMachine {
62/// #         states: [Queued, Running, Complete, Failed],
63/// #         default: Queued,
64/// #         transitions: [
65/// #             start: [Queued] => Running,
66/// #             finish: [Running] => Complete,
67/// #             fail: [Queued, Running] => Failed,
68/// #             retry: [Failed] => Queued,
69/// #         ],
70/// #     }
71/// # );
72/// #
73/// let queued = JobMachineTypestate::default();
74/// let running = queued.start();
75/// let complete = running.finish();
76/// assert_eq!(complete.into_state(), JobMachineComplete);
77/// ```
78///
79/// `cfg_attr` is rejected because it can expand to a `cfg` attribute after
80/// macro parsing, which would allow the generated enum to be disabled without
81/// disabling the rest of the generated state-machine items.
82///
83/// ```compile_fail
84/// gwr_components::create_state_machine!(
85///     #[cfg_attr(all(), cfg(any()))]
86///     pub DisabledByCfgAttr {
87///         states: [Idle, Busy],
88///         default: Idle,
89///         transitions: [
90///             start: [Idle] => Busy,
91///         ],
92///     }
93/// );
94/// ```
95#[macro_export]
96macro_rules! create_state_machine {
97    (
98        @impl
99        [$cfg:meta]
100        [$(#[$($machine_attrs:tt)*])*]
101        $vis:vis $machine:ident {
102            states: [ $( $state:ident ),+ $(,)? ],
103            default: $default:ident,
104            transitions: [
105                $(
106                    $action:ident: [ $( $from:ident ),+ $(,)? ] => $to:ident
107                ),+ $(,)?
108            ],
109        }
110    ) => {
111        $crate::state_machine::paste! {
112            #[cfg($cfg)]
113            $(#[$($machine_attrs)*])*
114            #[derive(Clone, Copy, Debug, Eq, PartialEq)]
115            $vis enum $machine {
116                $( $state, )+
117            }
118
119            #[cfg($cfg)]
120            impl ::std::default::Default for $machine {
121                fn default() -> Self {
122                    Self::$default
123                }
124            }
125
126            #[cfg($cfg)]
127            impl ::std::fmt::Display for $machine {
128                fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
129                    f.write_str(self.name())
130                }
131            }
132
133            #[cfg($cfg)]
134            #[allow(dead_code)]
135            impl $machine {
136                $vis const STATES: &'static [&'static str] = &[
137                    $( stringify!($state), )+
138                ];
139
140                $vis const TRANSITIONS: &'static [(&'static str, &'static str)] = &[
141                    $(
142                        $( (stringify!($from), stringify!($to)), )+
143                    )+
144                ];
145
146                #[must_use]
147                $vis fn name(self) -> &'static str {
148                    match self {
149                        $( Self::$state => stringify!($state), )+
150                    }
151                }
152
153                #[must_use]
154                $vis fn states() -> &'static [&'static str] {
155                    Self::STATES
156                }
157
158                #[must_use]
159                $vis fn transitions() -> &'static [(&'static str, &'static str)] {
160                    Self::TRANSITIONS
161                }
162
163                #[must_use]
164                #[allow(unreachable_patterns)]
165                $vis fn transition_allowed(from: Self, to: Self) -> bool {
166                    matches!(
167                        (from, to),
168                        $(
169                            $( (Self::$from, Self::$to) )|+
170                        )|+
171                    )
172                }
173
174                $vis fn apply<TTransition>(&mut self) -> bool
175                where
176                    TTransition: [< $machine Transition >],
177                {
178                    if !TTransition::FROM.contains(self)
179                        || !Self::transition_allowed(*self, TTransition::TO)
180                    {
181                        return false;
182                    }
183
184                    *self = TTransition::TO;
185                    true
186                }
187
188                #[must_use]
189                $vis fn as_str(self) -> &'static str {
190                    self.name()
191                }
192
193                #[must_use]
194                $vis fn mermaid() -> ::std::string::String {
195                    let mut diagram = ::std::string::String::from("stateDiagram-v2\n");
196                    diagram.push_str("    [*] --> ");
197                    diagram.push_str(stringify!($default));
198                    diagram.push('\n');
199
200                    $(
201                        $(
202                            diagram.push_str("    ");
203                            diagram.push_str(stringify!($from));
204                            diagram.push_str(" --> ");
205                            diagram.push_str(stringify!($to));
206                            diagram.push_str(": ");
207                            diagram.push_str(stringify!($action));
208                            diagram.push('\n');
209                        )+
210                    )+
211
212                    diagram
213                }
214            }
215
216            $(
217                #[cfg($cfg)]
218                #[allow(non_camel_case_types)]
219                $vis struct [< $machine $action:camel Transition >];
220            )+
221
222            #[cfg($cfg)]
223            mod [< $machine:snake _transition_seal >] {
224                pub trait Sealed {}
225            }
226
227            #[cfg($cfg)]
228            #[allow(private_bounds)]
229            $vis trait [< $machine Transition >]: [< $machine:snake _transition_seal >]::Sealed {
230                const FROM: &'static [$machine];
231                const TO: $machine;
232            }
233
234            $(
235                #[cfg($cfg)]
236                impl [< $machine:snake _transition_seal >]::Sealed
237                    for [< $machine $action:camel Transition >]
238                {
239                }
240
241                #[cfg($cfg)]
242                impl [< $machine Transition >] for [< $machine $action:camel Transition >] {
243                    const FROM: &'static [$machine] = &[
244                        $( $machine::$from, )+
245                    ];
246                    const TO: $machine = $machine::$to;
247                }
248            )+
249
250            $(
251                #[cfg($cfg)]
252                #[derive(Clone, Copy, Debug, Eq, PartialEq)]
253                $vis struct [< $machine $state >];
254            )+
255
256            #[cfg($cfg)]
257            #[derive(Clone, Copy, Debug, Eq, PartialEq)]
258            $vis struct [< $machine Typestate >]<S> {
259                state: S,
260            }
261
262            #[cfg($cfg)]
263            #[allow(dead_code)]
264            impl<S> [< $machine Typestate >]<S> {
265                #[must_use]
266                $vis const fn new(state: S) -> Self {
267                    Self {
268                        state,
269                    }
270                }
271
272                #[must_use]
273                $vis fn state(&self) -> &S {
274                    &self.state
275                }
276
277                #[must_use]
278                $vis fn into_state(self) -> S {
279                    self.state
280                }
281            }
282
283            #[cfg($cfg)]
284            impl ::std::default::Default for [< $machine Typestate >]<[< $machine $default >]> {
285                fn default() -> Self {
286                    Self::new([< $machine $default >])
287                }
288            }
289
290            #[cfg($cfg)]
291            const _: () = {
292                $(
293                    let _ = [< $machine $state >];
294                    let _ = [< $machine Typestate >]::new([< $machine $state >]);
295                )+
296
297                $(
298                    let _ = [< $machine $action:camel Transition >];
299                )+
300            };
301
302            $(
303                $(
304                    #[cfg($cfg)]
305                    #[allow(dead_code, non_snake_case)]
306                    impl [< $machine Typestate >]<[< $machine $from >]> {
307                        #[must_use]
308                        $vis fn $action(self) -> [< $machine Typestate >]<[< $machine $to >]> {
309                            [< $machine Typestate >]::new([< $machine $to >])
310                        }
311                    }
312                )+
313            )+
314        }
315    };
316
317    (
318        @parse
319        [$($cfgs:meta),*]
320        [$($machine_attrs:tt)*]
321        #[cfg($cfg:meta)]
322        $($rest:tt)*
323    ) => {
324        $crate::create_state_machine!(
325            @parse
326            [$($cfgs,)* $cfg]
327            [$($machine_attrs)*]
328            $($rest)*
329        );
330    };
331
332    (
333        @parse
334        [$($cfgs:meta),*]
335        [$($machine_attrs:tt)*]
336        #[cfg_attr($($cfg_attr:tt)*)]
337        $($rest:tt)*
338    ) => {
339        ::std::compile_error!(
340            "`create_state_machine!` does not support `cfg_attr`; use explicit `cfg` attributes instead"
341        );
342    };
343
344    (
345        @parse
346        [$($cfgs:meta),*]
347        [$($machine_attrs:tt)*]
348        #[$($machine_attr:tt)*]
349        $($rest:tt)*
350    ) => {
351        $crate::create_state_machine!(
352            @parse
353            [$($cfgs),*]
354            [$($machine_attrs)* #[$($machine_attr)*]]
355            $($rest)*
356        );
357    };
358
359    (
360        @parse
361        [$($cfgs:meta),*]
362        [$($machine_attrs:tt)*]
363        $vis:vis $machine:ident {
364            states: [ $( $state:ident ),+ $(,)? ],
365            default: $default:ident,
366            transitions: [
367                $(
368                    $action:ident: [ $( $from:ident ),+ $(,)? ] => $to:ident
369                ),+ $(,)?
370            ],
371        }
372    ) => {
373        $crate::create_state_machine!(
374            @impl
375            [all($($cfgs),*)]
376            [$($machine_attrs)*]
377            $vis $machine {
378                states: [ $( $state ),+ ],
379                default: $default,
380                transitions: [
381                    $(
382                        $action: [ $( $from ),+ ] => $to
383                    ),+
384                ],
385            }
386        );
387    };
388
389    ($($input:tt)*) => {
390        $crate::create_state_machine!(@parse [] [] $($input)*);
391    };
392}
393
394#[cfg(test)]
395mod tests {
396    create_state_machine!(
397        TestMachine {
398            states: [Idle, Busy, Fault],
399            default: Idle,
400            transitions: [
401                start: [Idle] => Busy,
402                fail: [Idle, Busy] => Fault,
403                finish: [Busy] => Idle,
404                reset: [Fault] => Idle,
405            ],
406        }
407    );
408
409    create_state_machine!(
410        pub(crate) DoorMachine {
411            states: [Closed, Open, Locked],
412            default: Closed,
413            transitions: [
414                open: [Closed] => Open,
415                close: [Open] => Closed,
416                lock: [Closed] => Locked,
417                reset: [Open, Locked] => Closed,
418            ],
419        }
420    );
421
422    create_state_machine!(
423        pub(crate) DeviceMachine {
424            states: [Reset, Ready],
425            default: Reset,
426            transitions: [
427                reset: [Ready] => Reset,
428                ready: [Reset] => Ready,
429            ],
430        }
431    );
432
433    #[test]
434    fn generated_state_machine_exports_metadata() {
435        assert_eq!(TestMachine::states(), &["Idle", "Busy", "Fault"]);
436        assert_eq!(TestMachine::default(), TestMachine::Idle);
437        assert_eq!(TestMachine::Busy.name(), "Busy");
438        assert_eq!(TestMachine::Fault.as_str(), "Fault");
439        assert_eq!(TestMachine::Idle.to_string(), "Idle");
440        assert_eq!(
441            TestMachine::transitions(),
442            &[
443                ("Idle", "Busy"),
444                ("Idle", "Fault"),
445                ("Busy", "Fault"),
446                ("Busy", "Idle"),
447                ("Fault", "Idle"),
448            ]
449        );
450    }
451
452    #[test]
453    fn generated_state_machine_checks_transition_matrix() {
454        assert!(TestMachine::transition_allowed(
455            TestMachine::Idle,
456            TestMachine::Busy
457        ));
458        assert!(TestMachine::transition_allowed(
459            TestMachine::Idle,
460            TestMachine::Fault
461        ));
462        assert!(TestMachine::transition_allowed(
463            TestMachine::Busy,
464            TestMachine::Fault
465        ));
466        assert!(TestMachine::transition_allowed(
467            TestMachine::Busy,
468            TestMachine::Idle
469        ));
470        assert!(TestMachine::transition_allowed(
471            TestMachine::Fault,
472            TestMachine::Idle
473        ));
474
475        assert!(!TestMachine::transition_allowed(
476            TestMachine::Fault,
477            TestMachine::Busy
478        ));
479        assert!(!TestMachine::transition_allowed(
480            TestMachine::Idle,
481            TestMachine::Idle
482        ));
483        assert!(!TestMachine::transition_allowed(
484            TestMachine::Busy,
485            TestMachine::Busy
486        ));
487    }
488
489    #[test]
490    fn generated_transition_markers_support_runtime_state_updates() {
491        let mut state = TestMachine::Idle;
492
493        assert!(!state.apply::<TestMachineFinishTransition>());
494        assert_eq!(state, TestMachine::Idle);
495
496        assert!(state.apply::<TestMachineStartTransition>());
497        assert_eq!(state, TestMachine::Busy);
498
499        assert!(state.apply::<TestMachineFailTransition>());
500        assert_eq!(state, TestMachine::Fault);
501
502        assert!(state.apply::<TestMachineResetTransition>());
503        assert_eq!(state, TestMachine::Idle);
504    }
505
506    #[test]
507    fn generated_transition_markers_export_source_and_target_metadata() {
508        assert_eq!(TestMachineStartTransition::FROM, &[TestMachine::Idle]);
509        assert_eq!(TestMachineStartTransition::TO, TestMachine::Busy);
510
511        assert_eq!(
512            TestMachineFailTransition::FROM,
513            &[TestMachine::Idle, TestMachine::Busy]
514        );
515        assert_eq!(TestMachineFailTransition::TO, TestMachine::Fault);
516    }
517
518    #[test]
519    fn generated_typestate_helpers_chain_valid_transitions() {
520        fn expect_idle(_: TestMachineTypestate<TestMachineIdle>) {}
521        fn expect_busy(_: TestMachineTypestate<TestMachineBusy>) {}
522        fn expect_fault(_: TestMachineTypestate<TestMachineFault>) {}
523
524        let idle = TestMachineTypestate::default();
525        assert_eq!(*idle.state(), TestMachineIdle);
526
527        let busy = idle.start();
528        expect_busy(busy);
529
530        let busy = TestMachineTypestate::new(TestMachineBusy);
531        let fault = busy.fail();
532        expect_fault(fault);
533
534        let fault = TestMachineTypestate::new(TestMachineFault);
535        let idle = fault.reset();
536        assert_eq!(idle.into_state(), TestMachineIdle);
537        expect_idle(idle);
538    }
539
540    #[test]
541    fn generated_names_do_not_collide_when_events_repeat_across_machines() {
542        assert_eq!(
543            DoorMachineResetTransition::FROM,
544            &[DoorMachine::Open, DoorMachine::Locked]
545        );
546        assert_eq!(DoorMachineResetTransition::TO, DoorMachine::Closed);
547        assert_eq!(DeviceMachineResetTransition::FROM, &[DeviceMachine::Ready]);
548        assert_eq!(DeviceMachineResetTransition::TO, DeviceMachine::Reset);
549        assert!(DeviceMachine::transition_allowed(
550            DeviceMachine::Ready,
551            DeviceMachine::Reset
552        ));
553    }
554
555    #[test]
556    fn generated_public_visibility_exports_machine_api() {
557        assert_eq!(
558            DoorMachine::transitions(),
559            &[
560                ("Closed", "Open"),
561                ("Open", "Closed"),
562                ("Closed", "Locked"),
563                ("Open", "Closed"),
564                ("Locked", "Closed"),
565            ]
566        );
567        assert_eq!(DoorMachine::default(), DoorMachine::Closed);
568        assert_eq!(DoorMachine::Locked.to_string(), "Locked");
569    }
570
571    #[test]
572    fn generated_mermaid_diagram_uses_default_state_and_action_labels() {
573        assert_eq!(
574            TestMachine::mermaid(),
575            concat!(
576                "stateDiagram-v2\n",
577                "    [*] --> Idle\n",
578                "    Idle --> Busy: start\n",
579                "    Idle --> Fault: fail\n",
580                "    Busy --> Fault: fail\n",
581                "    Busy --> Idle: finish\n",
582                "    Fault --> Idle: reset\n",
583            )
584        );
585    }
586
587    #[test]
588    fn generated_mermaid_diagram_expands_shared_events_in_declaration_order() {
589        assert_eq!(
590            DoorMachine::mermaid(),
591            concat!(
592                "stateDiagram-v2\n",
593                "    [*] --> Closed\n",
594                "    Closed --> Open: open\n",
595                "    Open --> Closed: close\n",
596                "    Closed --> Locked: lock\n",
597                "    Open --> Closed: reset\n",
598                "    Locked --> Closed: reset\n",
599            )
600        );
601    }
602}