Skip to main content

nros_core/
lifecycle.rs

1//! Lifecycle state machine types (REP-2002)
2//!
3//! Provides the core types for ROS 2 lifecycle node management:
4//! - [`LifecycleState`] — the five lifecycle states
5//! - [`LifecycleTransition`] — transitions between states
6//! - [`TransitionResult`] — callback return values
7//!
8//! These types are shared between the Rust and C APIs. All enums use
9//! `#[repr(u8)]` for C interop.
10
11/// Lifecycle state (REP-2002)
12///
13/// A lifecycle node is always in exactly one of these states.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[repr(u8)]
16pub enum LifecycleState {
17    /// Initial state after construction. Node is not yet configured.
18    Unconfigured = 1,
19    /// Node is configured but not processing data.
20    Inactive = 2,
21    /// Node is fully operational and processing data.
22    Active = 3,
23    /// Terminal state. Node cannot be reused.
24    Finalized = 4,
25    /// Error occurred during a transition. Must recover or shut down.
26    ErrorProcessing = 5,
27}
28
29impl LifecycleState {
30    /// Returns true if this is a terminal state (Finalized).
31    pub const fn is_terminal(&self) -> bool {
32        matches!(self, Self::Finalized)
33    }
34
35    /// Returns true if this is a primary state (not a transition state).
36    ///
37    /// Primary states are: Unconfigured, Inactive, Active, Finalized.
38    /// ErrorProcessing is the only non-primary state.
39    pub const fn is_primary(&self) -> bool {
40        !matches!(self, Self::ErrorProcessing)
41    }
42
43    /// Try to convert from a u8 value.
44    pub const fn from_u8(value: u8) -> Option<Self> {
45        match value {
46            1 => Some(Self::Unconfigured),
47            2 => Some(Self::Inactive),
48            3 => Some(Self::Active),
49            4 => Some(Self::Finalized),
50            5 => Some(Self::ErrorProcessing),
51            _ => None,
52        }
53    }
54
55    /// Every reachable state, in `lifecycle_msgs` id order.
56    ///
57    /// phase-417 W4.f. `nros_node::lifecycle_services` held this list
58    /// privately, so the wire could enumerate the states and no in-process
59    /// caller in any of the three languages could. It is a CONSTANT, not
60    /// state — the point of moving it here is that there is one of it.
61    pub const ALL: [Self; 5] = [
62        Self::Unconfigured,
63        Self::Inactive,
64        Self::Active,
65        Self::Finalized,
66        Self::ErrorProcessing,
67    ];
68
69    /// The `lifecycle_msgs/msg/State.label` for this state, NUL-terminated.
70    ///
71    /// The C string is the SOURCE spelling and [`Self::label`] is derived from
72    /// it, not the other way round: the C API hands this pointer straight to a
73    /// caller (`rclc_lifecycle_state_label`), and a second set of literals for
74    /// the `&str` form is exactly the drift this move exists to remove.
75    pub const fn label_cstr(&self) -> &'static core::ffi::CStr {
76        match self {
77            Self::Unconfigured => c"unconfigured",
78            Self::Inactive => c"inactive",
79            Self::Active => c"active",
80            Self::Finalized => c"finalized",
81            Self::ErrorProcessing => c"errorprocessing",
82        }
83    }
84
85    /// The `lifecycle_msgs/msg/State.label` for this state.
86    ///
87    /// Infallible in practice — every label above is ASCII — but expressed
88    /// without an `unwrap`, because a panic on a `const` table is a panic
89    /// nobody can act on. The empty string is unreachable, and
90    /// `every_label_is_valid_utf8` is the test that keeps it so.
91    pub fn label(&self) -> &'static str {
92        self.label_cstr().to_str().unwrap_or_default()
93    }
94}
95
96/// Lifecycle transition (REP-2002)
97///
98/// Each transition has a specific source state. Shutdown has three variants
99/// because it can originate from Unconfigured, Inactive, or Active.
100///
101/// # The discriminants ARE `lifecycle_msgs/msg/Transition` (issue 1099)
102///
103/// These numbers are not ours to choose. They cross the C ABI as
104/// `NROS_LIFECYCLE_TRANSITION_*`, they are what
105/// `nros::LifecycleNode::trigger_transition(uint8_t)` accepts, and they are
106/// what a `ChangeState` request carries on the wire — so a caller reaching any
107/// one of those surfaces with a literal must get the transition ROS 2 would
108/// give them.
109///
110/// They did not, until issue 1099: `Activate`/`Deactivate`/`Cleanup` were
111/// `2`/`3`/`4` where upstream is `3`/`4`/`2`, so a ported
112/// `trigger_transition(2)` on an Inactive node ACTIVATED it and returned OK
113/// where ROS 2 cleans it up. Four of eight ids disagreed, and only the wire
114/// path translated — the C and C++ paths passed the raw byte through.
115///
116/// The one id with no upstream counterpart is [`Self::ErrorRecovery`]: upstream
117/// `8` is `TRANSITION_DESTROY`, which we do not implement (nothing is
118/// deallocated in a fixed arena), and upstream models error recovery as the
119/// IMPLICIT `TRANSITION_ON_ERROR_SUCCESS = 60`. So `ErrorRecovery` is `60` —
120/// the id `transition_wire()` in `nros-node` already put on the wire for it —
121/// and `8` is now rejected rather than silently meaning error recovery.
122///
123/// Do not renumber these. `nros_c::lifecycle` re-asserts every one against its
124/// `NROS_LIFECYCLE_TRANSITION_*` literal (issue 0792), and `nros-node`'s
125/// `transition_wire` / `from_msg_transition_id` are now identity functions that
126/// a test pins.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128#[repr(u8)]
129pub enum LifecycleTransition {
130    /// Unconfigured -> (configuring) -> Inactive
131    /// (`lifecycle_msgs` `TRANSITION_CONFIGURE`)
132    Configure = 1,
133    /// Inactive -> (cleaning up) -> Unconfigured
134    /// (`lifecycle_msgs` `TRANSITION_CLEANUP`)
135    Cleanup = 2,
136    /// Inactive -> (activating) -> Active
137    /// (`lifecycle_msgs` `TRANSITION_ACTIVATE`)
138    Activate = 3,
139    /// Active -> (deactivating) -> Inactive
140    /// (`lifecycle_msgs` `TRANSITION_DEACTIVATE`)
141    Deactivate = 4,
142    /// Unconfigured -> (shutting down) -> Finalized
143    /// (`lifecycle_msgs` `TRANSITION_UNCONFIGURED_SHUTDOWN`)
144    ShutdownUnconfigured = 5,
145    /// Inactive -> (shutting down) -> Finalized
146    /// (`lifecycle_msgs` `TRANSITION_INACTIVE_SHUTDOWN`)
147    ShutdownInactive = 6,
148    /// Active -> (shutting down) -> Finalized
149    /// (`lifecycle_msgs` `TRANSITION_ACTIVE_SHUTDOWN`)
150    ShutdownActive = 7,
151    /// ErrorProcessing -> (error recovery) -> Unconfigured
152    /// (`lifecycle_msgs` `TRANSITION_ON_ERROR_SUCCESS`; see the type doc for
153    /// why this is 60 and not 8 — 8 is upstream's `TRANSITION_DESTROY`.)
154    ErrorRecovery = 60,
155}
156
157impl LifecycleTransition {
158    /// Resolve a shorthand transition name from the current state.
159    ///
160    /// "shutdown" maps to the correct variant based on the current state.
161    /// Returns `None` if the shorthand is not valid from the given state.
162    pub fn from_shorthand(state: LifecycleState, name: &str) -> Option<Self> {
163        match name {
164            "configure" => Some(Self::Configure),
165            "activate" => Some(Self::Activate),
166            "deactivate" => Some(Self::Deactivate),
167            "cleanup" => Some(Self::Cleanup),
168            "shutdown" => match state {
169                LifecycleState::Unconfigured => Some(Self::ShutdownUnconfigured),
170                LifecycleState::Inactive => Some(Self::ShutdownInactive),
171                LifecycleState::Active => Some(Self::ShutdownActive),
172                _ => None,
173            },
174            "error_recovery" => Some(Self::ErrorRecovery),
175            _ => None,
176        }
177    }
178
179    /// Try to convert from a `lifecycle_msgs/msg/Transition` id.
180    ///
181    /// `0` (`TRANSITION_CREATE`) and `8` (`TRANSITION_DESTROY`) are upstream
182    /// ids we deliberately do not implement — construction and deallocation are
183    /// not transitions in a fixed arena — so both are `None` rather than
184    /// aliases for something else (issue 1099).
185    pub const fn from_u8(value: u8) -> Option<Self> {
186        match value {
187            1 => Some(Self::Configure),
188            2 => Some(Self::Cleanup),
189            3 => Some(Self::Activate),
190            4 => Some(Self::Deactivate),
191            5 => Some(Self::ShutdownUnconfigured),
192            6 => Some(Self::ShutdownInactive),
193            7 => Some(Self::ShutdownActive),
194            60 => Some(Self::ErrorRecovery),
195            _ => None,
196        }
197    }
198
199    /// Get the source state required for this transition.
200    pub const fn source_state(&self) -> LifecycleState {
201        match self {
202            Self::Configure => LifecycleState::Unconfigured,
203            Self::Activate => LifecycleState::Inactive,
204            Self::Deactivate => LifecycleState::Active,
205            Self::Cleanup => LifecycleState::Inactive,
206            Self::ShutdownUnconfigured => LifecycleState::Unconfigured,
207            Self::ShutdownInactive => LifecycleState::Inactive,
208            Self::ShutdownActive => LifecycleState::Active,
209            Self::ErrorRecovery => LifecycleState::ErrorProcessing,
210        }
211    }
212
213    /// Every transition in the REP-2002 graph, in `lifecycle_msgs` id order.
214    ///
215    /// phase-417 W4.f. The three shutdown variants are listed separately
216    /// because their [`source_state`](Self::source_state) differs, which is
217    /// also rclcpp's graph shape. `nros_node::lifecycle_services` served this
218    /// table over `~/get_transition_graph` while holding it privately, so a
219    /// node's own code could not read its transition table in ANY of our three
220    /// languages; this is the one table all of them now read.
221    pub const ALL: [Self; 8] = [
222        Self::Configure,
223        Self::Cleanup,
224        Self::Activate,
225        Self::Deactivate,
226        Self::ShutdownUnconfigured,
227        Self::ShutdownInactive,
228        Self::ShutdownActive,
229        Self::ErrorRecovery,
230    ];
231
232    /// The `lifecycle_msgs/msg/Transition.label` for this transition,
233    /// NUL-terminated. See [`LifecycleState::label_cstr`] for why the C
234    /// spelling is the source.
235    ///
236    /// The three shutdown variants share the label `"shutdown"` — that is
237    /// upstream's wire spelling, and `from_shorthand` is what resolves it back
238    /// against the current state.
239    pub const fn label_cstr(&self) -> &'static core::ffi::CStr {
240        match self {
241            Self::Configure => c"configure",
242            Self::Cleanup => c"cleanup",
243            Self::Activate => c"activate",
244            Self::Deactivate => c"deactivate",
245            Self::ShutdownUnconfigured => c"shutdown",
246            Self::ShutdownInactive => c"shutdown",
247            Self::ShutdownActive => c"shutdown",
248            Self::ErrorRecovery => c"error_recovery",
249        }
250    }
251
252    /// The `lifecycle_msgs/msg/Transition.label` for this transition.
253    pub fn label(&self) -> &'static str {
254        self.label_cstr().to_str().unwrap_or_default()
255    }
256
257    /// The state this transition ADVERTISES as its destination — rclcpp's
258    /// `Transition::goal_state()`.
259    ///
260    /// It is the state a SUCCEEDING callback reaches, which is what the
261    /// transition graph advertises. A failing callback routes to
262    /// `ErrorProcessing` or rolls back at runtime; that is
263    /// [`apply_transition`]'s business and orthogonal to the advertised graph.
264    pub const fn goal_state(&self) -> LifecycleState {
265        apply_transition(self.source_state(), *self, TransitionResult::Success)
266    }
267}
268
269/// Result of a lifecycle transition callback.
270///
271/// Matches the rclc convention where callbacks return a status
272/// indicating whether the transition should proceed.
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
274#[repr(u8)]
275pub enum TransitionResult {
276    /// Transition succeeded; move to the target state.
277    Success = 0,
278    /// Transition failed; roll back to the previous primary state.
279    Failure = 1,
280    /// An error occurred; move to ErrorProcessing state.
281    Error = 2,
282}
283
284impl TransitionResult {
285    /// Try to convert from a u8 value.
286    pub const fn from_u8(value: u8) -> Option<Self> {
287        match value {
288            0 => Some(Self::Success),
289            1 => Some(Self::Failure),
290            2 => Some(Self::Error),
291            _ => None,
292        }
293    }
294}
295
296/// Check whether a transition is valid from the given state.
297pub const fn can_transition(state: LifecycleState, transition: LifecycleTransition) -> bool {
298    matches!(
299        (state, transition),
300        (LifecycleState::Unconfigured, LifecycleTransition::Configure)
301            | (
302                LifecycleState::Unconfigured,
303                LifecycleTransition::ShutdownUnconfigured
304            )
305            | (LifecycleState::Inactive, LifecycleTransition::Activate)
306            | (LifecycleState::Inactive, LifecycleTransition::Cleanup)
307            | (
308                LifecycleState::Inactive,
309                LifecycleTransition::ShutdownInactive
310            )
311            | (LifecycleState::Active, LifecycleTransition::Deactivate)
312            | (LifecycleState::Active, LifecycleTransition::ShutdownActive)
313            | (
314                LifecycleState::ErrorProcessing,
315                LifecycleTransition::ErrorRecovery
316            )
317    )
318}
319
320/// Apply a transition given the callback result.
321///
322/// Implements the REP-2002 transition table:
323/// - **Success**: move to the target state
324/// - **Failure**: roll back to the previous primary state
325/// - **Error**: move to ErrorProcessing
326pub const fn apply_transition(
327    state: LifecycleState,
328    transition: LifecycleTransition,
329    result: TransitionResult,
330) -> LifecycleState {
331    match result {
332        TransitionResult::Error => LifecycleState::ErrorProcessing,
333        TransitionResult::Success => match transition {
334            LifecycleTransition::Configure => LifecycleState::Inactive,
335            LifecycleTransition::Activate => LifecycleState::Active,
336            LifecycleTransition::Deactivate => LifecycleState::Inactive,
337            LifecycleTransition::Cleanup => LifecycleState::Unconfigured,
338            LifecycleTransition::ShutdownUnconfigured
339            | LifecycleTransition::ShutdownInactive
340            | LifecycleTransition::ShutdownActive => LifecycleState::Finalized,
341            LifecycleTransition::ErrorRecovery => LifecycleState::Unconfigured,
342        },
343        TransitionResult::Failure => {
344            // Roll back to the source state (previous primary state)
345            // For error recovery, failure means we stay in ErrorProcessing
346            // since there's nowhere to roll back to
347            match transition {
348                LifecycleTransition::ErrorRecovery => LifecycleState::ErrorProcessing,
349                _ => state,
350            }
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_lifecycle_state_properties() {
361        assert!(!LifecycleState::Unconfigured.is_terminal());
362        assert!(!LifecycleState::Inactive.is_terminal());
363        assert!(!LifecycleState::Active.is_terminal());
364        assert!(LifecycleState::Finalized.is_terminal());
365        assert!(!LifecycleState::ErrorProcessing.is_terminal());
366
367        assert!(LifecycleState::Unconfigured.is_primary());
368        assert!(LifecycleState::Inactive.is_primary());
369        assert!(LifecycleState::Active.is_primary());
370        assert!(LifecycleState::Finalized.is_primary());
371        assert!(!LifecycleState::ErrorProcessing.is_primary());
372    }
373
374    #[test]
375    fn test_state_from_u8() {
376        assert_eq!(
377            LifecycleState::from_u8(1),
378            Some(LifecycleState::Unconfigured)
379        );
380        assert_eq!(LifecycleState::from_u8(2), Some(LifecycleState::Inactive));
381        assert_eq!(LifecycleState::from_u8(3), Some(LifecycleState::Active));
382        assert_eq!(LifecycleState::from_u8(4), Some(LifecycleState::Finalized));
383        assert_eq!(
384            LifecycleState::from_u8(5),
385            Some(LifecycleState::ErrorProcessing)
386        );
387        assert_eq!(LifecycleState::from_u8(0), None);
388        assert_eq!(LifecycleState::from_u8(6), None);
389    }
390
391    /// Issue 1099 — the discriminants ARE `lifecycle_msgs/msg/Transition`.
392    ///
393    /// The table below is transcribed from
394    /// `/opt/ros/humble/share/lifecycle_msgs/msg/Transition.msg`; it is the
395    /// contract every id-taking surface inherits (`NROS_LIFECYCLE_TRANSITION_*`,
396    /// `nros::LifecycleNode::trigger_transition`, `ChangeState.transition.id`).
397    ///
398    /// Before the fix this failed on FOUR of eight rows: `2` was `Activate`,
399    /// `3` was `Deactivate`, `4` was `Cleanup`, `8` was `ErrorRecovery`. The
400    /// `(2, Cleanup)` row is the dangerous one — `trigger_transition(2)` on an
401    /// Inactive node ACTIVATED it and returned OK.
402    #[test]
403    fn transition_discriminants_are_lifecycle_msgs_ids() {
404        // (upstream id, upstream constant name, our variant)
405        let table = [
406            (1u8, "TRANSITION_CONFIGURE", LifecycleTransition::Configure),
407            (2, "TRANSITION_CLEANUP", LifecycleTransition::Cleanup),
408            (3, "TRANSITION_ACTIVATE", LifecycleTransition::Activate),
409            (4, "TRANSITION_DEACTIVATE", LifecycleTransition::Deactivate),
410            (
411                5,
412                "TRANSITION_UNCONFIGURED_SHUTDOWN",
413                LifecycleTransition::ShutdownUnconfigured,
414            ),
415            (
416                6,
417                "TRANSITION_INACTIVE_SHUTDOWN",
418                LifecycleTransition::ShutdownInactive,
419            ),
420            (
421                7,
422                "TRANSITION_ACTIVE_SHUTDOWN",
423                LifecycleTransition::ShutdownActive,
424            ),
425            (
426                60,
427                "TRANSITION_ON_ERROR_SUCCESS",
428                LifecycleTransition::ErrorRecovery,
429            ),
430        ];
431        for (id, name, variant) in table {
432            assert_eq!(
433                variant as u8, id,
434                "{variant:?} must be lifecycle_msgs::{name} = {id}"
435            );
436            assert_eq!(
437                LifecycleTransition::from_u8(id),
438                Some(variant),
439                "lifecycle_msgs::{name} = {id} must decode to {variant:?}"
440            );
441        }
442    }
443
444    /// The two upstream ids we deliberately do not implement must be REJECTED,
445    /// not silently aliased onto a transition we do have (issue 1099).
446    #[test]
447    fn unimplemented_upstream_transition_ids_are_rejected() {
448        // TRANSITION_CREATE = 0 and TRANSITION_DESTROY = 8: construction and
449        // deallocation are not transitions in a fixed arena. `8` used to mean
450        // `ErrorRecovery` here.
451        assert_eq!(LifecycleTransition::from_u8(0), None);
452        assert_eq!(LifecycleTransition::from_u8(8), None);
453        // Not an id at all.
454        assert_eq!(LifecycleTransition::from_u8(9), None);
455    }
456
457    /// Every transition round-trips through its id, and no two share one.
458    #[test]
459    fn transition_ids_are_injective_and_round_trip() {
460        let all = [
461            LifecycleTransition::Configure,
462            LifecycleTransition::Cleanup,
463            LifecycleTransition::Activate,
464            LifecycleTransition::Deactivate,
465            LifecycleTransition::ShutdownUnconfigured,
466            LifecycleTransition::ShutdownInactive,
467            LifecycleTransition::ShutdownActive,
468            LifecycleTransition::ErrorRecovery,
469        ];
470        for (i, a) in all.iter().enumerate() {
471            assert_eq!(LifecycleTransition::from_u8(*a as u8), Some(*a));
472            for b in &all[i + 1..] {
473                assert_ne!(*a as u8, *b as u8, "{a:?} and {b:?} share an id");
474            }
475        }
476    }
477
478    #[test]
479    fn test_transition_result_from_u8() {
480        assert_eq!(
481            TransitionResult::from_u8(0),
482            Some(TransitionResult::Success)
483        );
484        assert_eq!(
485            TransitionResult::from_u8(1),
486            Some(TransitionResult::Failure)
487        );
488        assert_eq!(TransitionResult::from_u8(2), Some(TransitionResult::Error));
489        assert_eq!(TransitionResult::from_u8(3), None);
490    }
491
492    #[test]
493    fn test_valid_transitions() {
494        // From Unconfigured
495        assert!(can_transition(
496            LifecycleState::Unconfigured,
497            LifecycleTransition::Configure
498        ));
499        assert!(can_transition(
500            LifecycleState::Unconfigured,
501            LifecycleTransition::ShutdownUnconfigured
502        ));
503        assert!(!can_transition(
504            LifecycleState::Unconfigured,
505            LifecycleTransition::Activate
506        ));
507        assert!(!can_transition(
508            LifecycleState::Unconfigured,
509            LifecycleTransition::Deactivate
510        ));
511
512        // From Inactive
513        assert!(can_transition(
514            LifecycleState::Inactive,
515            LifecycleTransition::Activate
516        ));
517        assert!(can_transition(
518            LifecycleState::Inactive,
519            LifecycleTransition::Cleanup
520        ));
521        assert!(can_transition(
522            LifecycleState::Inactive,
523            LifecycleTransition::ShutdownInactive
524        ));
525        assert!(!can_transition(
526            LifecycleState::Inactive,
527            LifecycleTransition::Configure
528        ));
529        assert!(!can_transition(
530            LifecycleState::Inactive,
531            LifecycleTransition::Deactivate
532        ));
533
534        // From Active
535        assert!(can_transition(
536            LifecycleState::Active,
537            LifecycleTransition::Deactivate
538        ));
539        assert!(can_transition(
540            LifecycleState::Active,
541            LifecycleTransition::ShutdownActive
542        ));
543        assert!(!can_transition(
544            LifecycleState::Active,
545            LifecycleTransition::Activate
546        ));
547        assert!(!can_transition(
548            LifecycleState::Active,
549            LifecycleTransition::Configure
550        ));
551
552        // From Finalized (terminal — no transitions)
553        assert!(!can_transition(
554            LifecycleState::Finalized,
555            LifecycleTransition::Configure
556        ));
557        assert!(!can_transition(
558            LifecycleState::Finalized,
559            LifecycleTransition::ShutdownUnconfigured
560        ));
561
562        // From ErrorProcessing
563        assert!(can_transition(
564            LifecycleState::ErrorProcessing,
565            LifecycleTransition::ErrorRecovery
566        ));
567        assert!(!can_transition(
568            LifecycleState::ErrorProcessing,
569            LifecycleTransition::Configure
570        ));
571    }
572
573    #[test]
574    fn test_apply_transition_success() {
575        assert_eq!(
576            apply_transition(
577                LifecycleState::Unconfigured,
578                LifecycleTransition::Configure,
579                TransitionResult::Success
580            ),
581            LifecycleState::Inactive
582        );
583        assert_eq!(
584            apply_transition(
585                LifecycleState::Inactive,
586                LifecycleTransition::Activate,
587                TransitionResult::Success
588            ),
589            LifecycleState::Active
590        );
591        assert_eq!(
592            apply_transition(
593                LifecycleState::Active,
594                LifecycleTransition::Deactivate,
595                TransitionResult::Success
596            ),
597            LifecycleState::Inactive
598        );
599        assert_eq!(
600            apply_transition(
601                LifecycleState::Inactive,
602                LifecycleTransition::Cleanup,
603                TransitionResult::Success
604            ),
605            LifecycleState::Unconfigured
606        );
607        assert_eq!(
608            apply_transition(
609                LifecycleState::Unconfigured,
610                LifecycleTransition::ShutdownUnconfigured,
611                TransitionResult::Success
612            ),
613            LifecycleState::Finalized
614        );
615        assert_eq!(
616            apply_transition(
617                LifecycleState::Inactive,
618                LifecycleTransition::ShutdownInactive,
619                TransitionResult::Success
620            ),
621            LifecycleState::Finalized
622        );
623        assert_eq!(
624            apply_transition(
625                LifecycleState::Active,
626                LifecycleTransition::ShutdownActive,
627                TransitionResult::Success
628            ),
629            LifecycleState::Finalized
630        );
631        assert_eq!(
632            apply_transition(
633                LifecycleState::ErrorProcessing,
634                LifecycleTransition::ErrorRecovery,
635                TransitionResult::Success
636            ),
637            LifecycleState::Unconfigured
638        );
639    }
640
641    #[test]
642    fn test_apply_transition_failure_rolls_back() {
643        // Failure on configure -> stay Unconfigured
644        assert_eq!(
645            apply_transition(
646                LifecycleState::Unconfigured,
647                LifecycleTransition::Configure,
648                TransitionResult::Failure
649            ),
650            LifecycleState::Unconfigured
651        );
652        // Failure on activate -> stay Inactive
653        assert_eq!(
654            apply_transition(
655                LifecycleState::Inactive,
656                LifecycleTransition::Activate,
657                TransitionResult::Failure
658            ),
659            LifecycleState::Inactive
660        );
661        // Failure on deactivate -> stay Active
662        assert_eq!(
663            apply_transition(
664                LifecycleState::Active,
665                LifecycleTransition::Deactivate,
666                TransitionResult::Failure
667            ),
668            LifecycleState::Active
669        );
670        // Failure on error recovery -> stay ErrorProcessing
671        assert_eq!(
672            apply_transition(
673                LifecycleState::ErrorProcessing,
674                LifecycleTransition::ErrorRecovery,
675                TransitionResult::Failure
676            ),
677            LifecycleState::ErrorProcessing
678        );
679    }
680
681    #[test]
682    fn test_apply_transition_error_goes_to_error_processing() {
683        assert_eq!(
684            apply_transition(
685                LifecycleState::Unconfigured,
686                LifecycleTransition::Configure,
687                TransitionResult::Error
688            ),
689            LifecycleState::ErrorProcessing
690        );
691        assert_eq!(
692            apply_transition(
693                LifecycleState::Inactive,
694                LifecycleTransition::Activate,
695                TransitionResult::Error
696            ),
697            LifecycleState::ErrorProcessing
698        );
699        assert_eq!(
700            apply_transition(
701                LifecycleState::Active,
702                LifecycleTransition::ShutdownActive,
703                TransitionResult::Error
704            ),
705            LifecycleState::ErrorProcessing
706        );
707    }
708
709    #[test]
710    fn test_shutdown_shorthand_disambiguation() {
711        assert_eq!(
712            LifecycleTransition::from_shorthand(LifecycleState::Unconfigured, "shutdown"),
713            Some(LifecycleTransition::ShutdownUnconfigured)
714        );
715        assert_eq!(
716            LifecycleTransition::from_shorthand(LifecycleState::Inactive, "shutdown"),
717            Some(LifecycleTransition::ShutdownInactive)
718        );
719        assert_eq!(
720            LifecycleTransition::from_shorthand(LifecycleState::Active, "shutdown"),
721            Some(LifecycleTransition::ShutdownActive)
722        );
723        // Cannot shutdown from Finalized or ErrorProcessing
724        assert_eq!(
725            LifecycleTransition::from_shorthand(LifecycleState::Finalized, "shutdown"),
726            None
727        );
728        assert_eq!(
729            LifecycleTransition::from_shorthand(LifecycleState::ErrorProcessing, "shutdown"),
730            None
731        );
732    }
733
734    #[test]
735    fn test_shorthand_other_transitions() {
736        assert_eq!(
737            LifecycleTransition::from_shorthand(LifecycleState::Unconfigured, "configure"),
738            Some(LifecycleTransition::Configure)
739        );
740        assert_eq!(
741            LifecycleTransition::from_shorthand(LifecycleState::Active, "deactivate"),
742            Some(LifecycleTransition::Deactivate)
743        );
744        assert_eq!(
745            LifecycleTransition::from_shorthand(LifecycleState::Inactive, "cleanup"),
746            Some(LifecycleTransition::Cleanup)
747        );
748        assert_eq!(
749            LifecycleTransition::from_shorthand(LifecycleState::ErrorProcessing, "error_recovery"),
750            Some(LifecycleTransition::ErrorRecovery)
751        );
752        assert_eq!(
753            LifecycleTransition::from_shorthand(LifecycleState::Active, "unknown"),
754            None
755        );
756    }
757
758    #[test]
759    fn test_transition_source_state() {
760        assert_eq!(
761            LifecycleTransition::Configure.source_state(),
762            LifecycleState::Unconfigured
763        );
764        assert_eq!(
765            LifecycleTransition::Activate.source_state(),
766            LifecycleState::Inactive
767        );
768        assert_eq!(
769            LifecycleTransition::Deactivate.source_state(),
770            LifecycleState::Active
771        );
772        assert_eq!(
773            LifecycleTransition::Cleanup.source_state(),
774            LifecycleState::Inactive
775        );
776        assert_eq!(
777            LifecycleTransition::ShutdownUnconfigured.source_state(),
778            LifecycleState::Unconfigured
779        );
780        assert_eq!(
781            LifecycleTransition::ShutdownInactive.source_state(),
782            LifecycleState::Inactive
783        );
784        assert_eq!(
785            LifecycleTransition::ShutdownActive.source_state(),
786            LifecycleState::Active
787        );
788        assert_eq!(
789            LifecycleTransition::ErrorRecovery.source_state(),
790            LifecycleState::ErrorProcessing
791        );
792    }
793
794    #[test]
795    fn test_full_lifecycle_happy_path() {
796        let mut state = LifecycleState::Unconfigured;
797
798        // Configure
799        assert!(can_transition(state, LifecycleTransition::Configure));
800        state = apply_transition(
801            state,
802            LifecycleTransition::Configure,
803            TransitionResult::Success,
804        );
805        assert_eq!(state, LifecycleState::Inactive);
806
807        // Activate
808        assert!(can_transition(state, LifecycleTransition::Activate));
809        state = apply_transition(
810            state,
811            LifecycleTransition::Activate,
812            TransitionResult::Success,
813        );
814        assert_eq!(state, LifecycleState::Active);
815
816        // Deactivate
817        assert!(can_transition(state, LifecycleTransition::Deactivate));
818        state = apply_transition(
819            state,
820            LifecycleTransition::Deactivate,
821            TransitionResult::Success,
822        );
823        assert_eq!(state, LifecycleState::Inactive);
824
825        // Shutdown
826        assert!(can_transition(state, LifecycleTransition::ShutdownInactive));
827        state = apply_transition(
828            state,
829            LifecycleTransition::ShutdownInactive,
830            TransitionResult::Success,
831        );
832        assert_eq!(state, LifecycleState::Finalized);
833        assert!(state.is_terminal());
834    }
835
836    #[test]
837    fn test_error_recovery_path() {
838        let mut state = LifecycleState::Unconfigured;
839
840        // Configure with error
841        state = apply_transition(
842            state,
843            LifecycleTransition::Configure,
844            TransitionResult::Error,
845        );
846        assert_eq!(state, LifecycleState::ErrorProcessing);
847        assert!(!state.is_primary());
848
849        // Error recovery
850        assert!(can_transition(state, LifecycleTransition::ErrorRecovery));
851        state = apply_transition(
852            state,
853            LifecycleTransition::ErrorRecovery,
854            TransitionResult::Success,
855        );
856        assert_eq!(state, LifecycleState::Unconfigured);
857    }
858
859    // ── phase-417 W4.f — the transition table as a readable fact ───────────
860
861    /// Every label is ASCII, so the `&str` accessor never falls through to the
862    /// empty string. Pinned because `label()` reports the unreachable arm
863    /// SILENTLY rather than panicking: without this the fallback could become
864    /// live and nothing would say so.
865    #[test]
866    fn every_label_is_valid_utf8() {
867        for t in LifecycleTransition::ALL {
868            assert!(!t.label().is_empty(), "transition {t:?} has no label");
869            assert_eq!(t.label().as_bytes(), t.label_cstr().to_bytes());
870        }
871        for s in LifecycleState::ALL {
872            assert!(!s.label().is_empty(), "state {s:?} has no label");
873            assert_eq!(s.label().as_bytes(), s.label_cstr().to_bytes());
874        }
875    }
876
877    /// `ALL` is the graph, so it must hold every variant exactly once and in
878    /// id order. A table that silently lost a transition would serve a
879    /// `~/get_transition_graph` short by one and read as correct.
880    #[test]
881    fn all_is_complete_and_ordered() {
882        let ids: heapless::Vec<u8, 8> = LifecycleTransition::ALL
883            .iter()
884            .map(|t| *t as u8)
885            .collect::<heapless::Vec<u8, 8>>();
886        assert_eq!(ids.as_slice(), &[1, 2, 3, 4, 5, 6, 7, 60]);
887        for id in ids.iter() {
888            assert!(LifecycleTransition::from_u8(*id).is_some());
889        }
890        let state_ids: heapless::Vec<u8, 5> = LifecycleState::ALL
891            .iter()
892            .map(|s| *s as u8)
893            .collect::<heapless::Vec<u8, 5>>();
894        assert_eq!(state_ids.as_slice(), &[1, 2, 3, 4, 5]);
895    }
896
897    /// `goal_state` is the SUCCESS destination, and the three shutdowns all
898    /// reach `Finalized` from three different sources — the pair that makes
899    /// the graph worth publishing.
900    #[test]
901    fn goal_state_is_the_success_destination() {
902        use LifecycleState as S;
903        use LifecycleTransition as T;
904        assert_eq!(T::Configure.goal_state(), S::Inactive);
905        assert_eq!(T::Cleanup.goal_state(), S::Unconfigured);
906        assert_eq!(T::Activate.goal_state(), S::Active);
907        assert_eq!(T::Deactivate.goal_state(), S::Inactive);
908        assert_eq!(T::ErrorRecovery.goal_state(), S::Unconfigured);
909        for t in [
910            T::ShutdownUnconfigured,
911            T::ShutdownInactive,
912            T::ShutdownActive,
913        ] {
914            assert_eq!(t.goal_state(), S::Finalized);
915        }
916        assert_eq!(T::ShutdownUnconfigured.source_state(), S::Unconfigured);
917        assert_eq!(T::ShutdownInactive.source_state(), S::Inactive);
918        assert_eq!(T::ShutdownActive.source_state(), S::Active);
919    }
920}