Skip to main content

statum_core/
introspection.rs

1/// Static introspection surface emitted for a generated Statum machine.
2pub trait MachineIntrospection {
3    /// Machine-scoped state identifier emitted by `#[machine]`.
4    type StateId: Copy + Eq + core::hash::Hash + 'static;
5
6    /// Machine-scoped transition-site identifier emitted by `#[machine]`.
7    type TransitionId: Copy + Eq + core::hash::Hash + 'static;
8
9    /// Static graph descriptor for the machine family.
10    const GRAPH: &'static MachineGraph<Self::StateId, Self::TransitionId>;
11}
12
13/// Runtime accessor for transition descriptors that may be supplied by a
14/// distributed registration surface.
15#[derive(Clone, Copy)]
16pub struct TransitionInventory<S: 'static, T: 'static> {
17    get: fn() -> &'static [TransitionDescriptor<S, T>],
18}
19
20impl<S, T> TransitionInventory<S, T> {
21    /// Creates a transition inventory from a `'static` getter.
22    pub const fn new(get: fn() -> &'static [TransitionDescriptor<S, T>]) -> Self {
23        Self { get }
24    }
25
26    /// Returns the transition descriptors as a slice.
27    pub fn as_slice(&self) -> &'static [TransitionDescriptor<S, T>] {
28        (self.get)()
29    }
30}
31
32impl<S, T> core::ops::Deref for TransitionInventory<S, T> {
33    type Target = [TransitionDescriptor<S, T>];
34
35    fn deref(&self) -> &Self::Target {
36        self.as_slice()
37    }
38}
39
40impl<S, T> core::fmt::Debug for TransitionInventory<S, T> {
41    fn fmt(
42        &self,
43        formatter: &mut core::fmt::Formatter<'_>,
44    ) -> core::result::Result<(), core::fmt::Error> {
45        formatter.debug_tuple("TransitionInventory").finish()
46    }
47}
48
49impl<S, T> core::cmp::PartialEq for TransitionInventory<S, T> {
50    fn eq(&self, other: &Self) -> bool {
51        core::ptr::eq(self.as_slice(), other.as_slice())
52    }
53}
54
55impl<S, T> core::cmp::Eq for TransitionInventory<S, T> {}
56
57/// Identity for one concrete machine state.
58pub trait MachineStateIdentity: MachineIntrospection {
59    /// The state id for this concrete machine instantiation.
60    const STATE_ID: Self::StateId;
61}
62
63/// Optional human-facing metadata layered on top of a machine graph.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub struct MachinePresentation<
66    S: 'static,
67    T: 'static,
68    MachineMeta: 'static = (),
69    StateMeta: 'static = (),
70    TransitionMeta: 'static = (),
71> {
72    /// Optional machine-level presentation metadata.
73    pub machine: Option<MachinePresentationDescriptor<MachineMeta>>,
74    /// Optional state-level presentation metadata keyed by state id.
75    pub states: &'static [StatePresentation<S, StateMeta>],
76    /// Optional transition-level presentation metadata keyed by transition id.
77    pub transitions: &'static [TransitionPresentation<T, TransitionMeta>],
78}
79
80impl<S, T, MachineMeta, StateMeta, TransitionMeta>
81    MachinePresentation<S, T, MachineMeta, StateMeta, TransitionMeta>
82where
83    S: Copy + Eq + 'static,
84    T: Copy + Eq + 'static,
85{
86    /// Finds state presentation metadata by id.
87    pub fn state(&self, id: S) -> Option<&StatePresentation<S, StateMeta>> {
88        self.states.iter().find(|state| state.id == id)
89    }
90
91    /// Finds transition presentation metadata by id.
92    pub fn transition(&self, id: T) -> Option<&TransitionPresentation<T, TransitionMeta>> {
93        self.transitions
94            .iter()
95            .find(|transition| transition.id == id)
96    }
97}
98
99/// Optional machine-level presentation metadata.
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub struct MachinePresentationDescriptor<M: 'static = ()> {
102    /// Optional short human-facing machine label.
103    pub label: Option<&'static str>,
104    /// Optional longer human-facing machine description.
105    pub description: Option<&'static str>,
106    /// Consumer-owned typed machine metadata.
107    pub metadata: M,
108}
109
110/// Optional state-level presentation metadata.
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub struct StatePresentation<S: 'static, M: 'static = ()> {
113    /// Typed state identifier.
114    pub id: S,
115    /// Optional short human-facing state label.
116    pub label: Option<&'static str>,
117    /// Optional longer human-facing state description.
118    pub description: Option<&'static str>,
119    /// Consumer-owned typed state metadata.
120    pub metadata: M,
121}
122
123/// Optional transition-level presentation metadata.
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub struct TransitionPresentation<T: 'static, M: 'static = ()> {
126    /// Typed transition-site identifier.
127    pub id: T,
128    /// Optional short human-facing transition label.
129    pub label: Option<&'static str>,
130    /// Optional longer human-facing transition description.
131    pub description: Option<&'static str>,
132    /// Consumer-owned typed transition metadata.
133    pub metadata: M,
134}
135
136/// A runtime record of one chosen transition.
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub struct RecordedTransition<S: 'static, T: 'static> {
139    /// Rust-facing identity of the machine family.
140    pub machine: MachineDescriptor,
141    /// Exact source state where the transition was taken.
142    pub from: S,
143    /// Exact transition site that was chosen.
144    pub transition: T,
145    /// Exact target state that actually happened at runtime.
146    pub chosen: S,
147}
148
149impl<S, T> RecordedTransition<S, T>
150where
151    S: 'static,
152    T: 'static,
153{
154    /// Builds a runtime transition record from typed machine ids.
155    pub const fn new(machine: MachineDescriptor, from: S, transition: T, chosen: S) -> Self {
156        Self {
157            machine,
158            from,
159            transition,
160            chosen,
161        }
162    }
163
164    /// Finds the static transition descriptor for this runtime event.
165    pub fn transition_in<'a>(
166        &self,
167        graph: &'a MachineGraph<S, T>,
168    ) -> Option<&'a TransitionDescriptor<S, T>>
169    where
170        S: Copy + Eq,
171        T: Copy + Eq,
172    {
173        let descriptor = graph.transition(self.transition)?;
174        if descriptor.from == self.from && descriptor.to.contains(&self.chosen) {
175            Some(descriptor)
176        } else {
177            None
178        }
179    }
180
181    /// Finds the static source-state descriptor for this runtime event.
182    pub fn source_state_in<'a>(
183        &self,
184        graph: &'a MachineGraph<S, T>,
185    ) -> Option<&'a StateDescriptor<S>>
186    where
187        S: Copy + Eq,
188        T: Copy + Eq,
189    {
190        self.transition_in(graph)?;
191        graph.state(self.from)
192    }
193
194    /// Finds the static chosen-target descriptor for this runtime event.
195    pub fn chosen_state_in<'a>(
196        &self,
197        graph: &'a MachineGraph<S, T>,
198    ) -> Option<&'a StateDescriptor<S>>
199    where
200        S: Copy + Eq,
201        T: Copy + Eq,
202    {
203        self.transition_in(graph)?;
204        graph.state(self.chosen)
205    }
206}
207
208/// Runtime recording helpers layered on top of static machine introspection.
209pub trait MachineTransitionRecorder: MachineStateIdentity {
210    /// Records a runtime transition if `transition` is valid from `Self::STATE_ID`
211    /// and `chosen` is one of its legal target states.
212    fn try_record_transition(
213        transition: Self::TransitionId,
214        chosen: Self::StateId,
215    ) -> Option<RecordedTransition<Self::StateId, Self::TransitionId>> {
216        let graph = Self::GRAPH;
217        let descriptor = graph.transition(transition)?;
218        if descriptor.from != Self::STATE_ID || !descriptor.to.contains(&chosen) {
219            return None;
220        }
221
222        Some(RecordedTransition::new(
223            graph.machine,
224            Self::STATE_ID,
225            transition,
226            chosen,
227        ))
228    }
229
230    /// Records a runtime transition using a typed target machine state.
231    fn try_record_transition_to<Next>(
232        transition: Self::TransitionId,
233    ) -> Option<RecordedTransition<Self::StateId, Self::TransitionId>>
234    where
235        Next: MachineStateIdentity<StateId = Self::StateId, TransitionId = Self::TransitionId>,
236    {
237        Self::try_record_transition(transition, Next::STATE_ID)
238    }
239}
240
241impl<M> MachineTransitionRecorder for M where M: MachineStateIdentity {}
242
243/// Structural machine graph emitted from macro-generated metadata.
244#[derive(Clone, Copy, Debug, Eq, PartialEq)]
245pub struct MachineGraph<S: 'static, T: 'static> {
246    /// Rust-facing identity of the machine family.
247    pub machine: MachineDescriptor,
248    /// All states known to the machine.
249    pub states: &'static [StateDescriptor<S>],
250    /// All transition sites known to the machine.
251    pub transitions: TransitionInventory<S, T>,
252}
253
254impl<S, T> MachineGraph<S, T>
255where
256    S: Copy + Eq + 'static,
257    T: Copy + Eq + 'static,
258{
259    /// Finds a state descriptor by id.
260    pub fn state(&self, id: S) -> Option<&StateDescriptor<S>> {
261        self.states.iter().find(|state| state.id == id)
262    }
263
264    /// Finds a transition descriptor by id.
265    pub fn transition(&self, id: T) -> Option<&TransitionDescriptor<S, T>> {
266        self.transitions
267            .iter()
268            .find(|transition| transition.id == id)
269    }
270
271    /// Yields all transition sites originating from `state`.
272    pub fn transitions_from(
273        &self,
274        state: S,
275    ) -> impl Iterator<Item = &TransitionDescriptor<S, T>> + '_ {
276        self.transitions
277            .iter()
278            .filter(move |transition| transition.from == state)
279    }
280
281    /// Finds the transition site for `method_name` on `state`.
282    pub fn transition_from_method(
283        &self,
284        state: S,
285        method_name: &str,
286    ) -> Option<&TransitionDescriptor<S, T>> {
287        self.transitions
288            .iter()
289            .find(|transition| transition.from == state && transition.method_name == method_name)
290    }
291
292    /// Yields all transition sites that share the same method name.
293    pub fn transitions_named<'a>(
294        &'a self,
295        method_name: &'a str,
296    ) -> impl Iterator<Item = &'a TransitionDescriptor<S, T>> + 'a {
297        self.transitions
298            .iter()
299            .filter(move |transition| transition.method_name == method_name)
300    }
301
302    /// Returns the exact legal target states for a transition site.
303    pub fn legal_targets(&self, id: T) -> Option<&'static [S]> {
304        self.transition(id).map(|transition| transition.to)
305    }
306}
307
308/// Rust-facing identity for a machine family.
309#[derive(Clone, Copy, Debug, Eq, PartialEq)]
310pub struct MachineDescriptor {
311    /// `module_path!()` for the source module that owns the machine.
312    pub module_path: &'static str,
313    /// Fully qualified Rust type path for the machine family.
314    pub rust_type_path: &'static str,
315}
316
317/// Static descriptor for one generated state id.
318#[derive(Clone, Copy, Debug, Eq, PartialEq)]
319pub struct StateDescriptor<S: 'static> {
320    /// Typed state identifier.
321    pub id: S,
322    /// Rust variant name of the state marker.
323    pub rust_name: &'static str,
324    /// Whether the state carries `state_data`.
325    pub has_data: bool,
326}
327
328/// Static descriptor for one transition site.
329#[derive(Clone, Copy, Debug, Eq, PartialEq)]
330pub struct TransitionDescriptor<S: 'static, T: 'static> {
331    /// Typed transition-site identifier.
332    pub id: T,
333    /// Rust method name for the transition site.
334    pub method_name: &'static str,
335    /// Exact source state for the transition site.
336    pub from: S,
337    /// Exact legal target states for the transition site.
338    pub to: &'static [S],
339}
340
341#[cfg(test)]
342mod tests {
343    use super::{
344        MachineDescriptor, MachineGraph, MachineIntrospection, MachinePresentation,
345        MachinePresentationDescriptor, MachineStateIdentity, MachineTransitionRecorder,
346        RecordedTransition, StateDescriptor, StatePresentation, TransitionDescriptor,
347        TransitionInventory, TransitionPresentation,
348    };
349    use core::marker::PhantomData;
350
351    #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
352    enum StateId {
353        Draft,
354        Review,
355        Published,
356    }
357
358    #[derive(Clone, Copy)]
359    struct TransitionId(&'static crate::__private::TransitionToken);
360
361    impl TransitionId {
362        const fn from_token(token: &'static crate::__private::TransitionToken) -> Self {
363            Self(token)
364        }
365    }
366
367    impl core::fmt::Debug for TransitionId {
368        fn fmt(
369            &self,
370            formatter: &mut core::fmt::Formatter<'_>,
371        ) -> core::result::Result<(), core::fmt::Error> {
372            formatter.write_str("TransitionId(..)")
373        }
374    }
375
376    impl core::cmp::PartialEq for TransitionId {
377        fn eq(&self, other: &Self) -> bool {
378            core::ptr::eq(self.0, other.0)
379        }
380    }
381
382    impl core::cmp::Eq for TransitionId {}
383
384    impl core::hash::Hash for TransitionId {
385        fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
386            let ptr = core::ptr::from_ref(self.0) as usize;
387            <usize as core::hash::Hash>::hash(&ptr, state);
388        }
389    }
390
391    static REVIEW_TARGETS: [StateId; 1] = [StateId::Review];
392    static PUBLISH_TARGETS: [StateId; 1] = [StateId::Published];
393    static SUBMIT_FROM_DRAFT_TOKEN: crate::__private::TransitionToken =
394        crate::__private::TransitionToken::new();
395    static PUBLISH_FROM_REVIEW_TOKEN: crate::__private::TransitionToken =
396        crate::__private::TransitionToken::new();
397    const SUBMIT_FROM_DRAFT: TransitionId = TransitionId::from_token(&SUBMIT_FROM_DRAFT_TOKEN);
398    const PUBLISH_FROM_REVIEW: TransitionId = TransitionId::from_token(&PUBLISH_FROM_REVIEW_TOKEN);
399    static STATES: [StateDescriptor<StateId>; 3] = [
400        StateDescriptor {
401            id: StateId::Draft,
402            rust_name: "Draft",
403            has_data: false,
404        },
405        StateDescriptor {
406            id: StateId::Review,
407            rust_name: "Review",
408            has_data: true,
409        },
410        StateDescriptor {
411            id: StateId::Published,
412            rust_name: "Published",
413            has_data: false,
414        },
415    ];
416    static TRANSITIONS: [TransitionDescriptor<StateId, TransitionId>; 2] = [
417        TransitionDescriptor {
418            id: SUBMIT_FROM_DRAFT,
419            method_name: "submit",
420            from: StateId::Draft,
421            to: &REVIEW_TARGETS,
422        },
423        TransitionDescriptor {
424            id: PUBLISH_FROM_REVIEW,
425            method_name: "publish",
426            from: StateId::Review,
427            to: &PUBLISH_TARGETS,
428        },
429    ];
430
431    struct Workflow<S>(PhantomData<S>);
432    struct DraftMarker;
433    struct ReviewMarker;
434    struct PublishedMarker;
435
436    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
437    enum Phase {
438        Intake,
439        Review,
440        Output,
441    }
442
443    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
444    struct MachineMeta {
445        phase: Phase,
446    }
447
448    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
449    struct StateMeta {
450        phase: Phase,
451        term: &'static str,
452    }
453
454    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
455    struct TransitionMeta {
456        phase: Phase,
457        branch: bool,
458    }
459
460    static PRESENTATION: MachinePresentation<
461        StateId,
462        TransitionId,
463        MachineMeta,
464        StateMeta,
465        TransitionMeta,
466    > = MachinePresentation {
467        machine: Some(MachinePresentationDescriptor {
468            label: Some("Workflow"),
469            description: Some("Example presentation metadata for introspection."),
470            metadata: MachineMeta {
471                phase: Phase::Intake,
472            },
473        }),
474        states: &[
475            StatePresentation {
476                id: StateId::Draft,
477                label: Some("Draft"),
478                description: Some("Work has not been submitted yet."),
479                metadata: StateMeta {
480                    phase: Phase::Intake,
481                    term: "draft",
482                },
483            },
484            StatePresentation {
485                id: StateId::Review,
486                label: Some("Review"),
487                description: Some("Work is awaiting review."),
488                metadata: StateMeta {
489                    phase: Phase::Review,
490                    term: "review",
491                },
492            },
493            StatePresentation {
494                id: StateId::Published,
495                label: Some("Published"),
496                description: Some("Work is complete."),
497                metadata: StateMeta {
498                    phase: Phase::Output,
499                    term: "published",
500                },
501            },
502        ],
503        transitions: &[
504            TransitionPresentation {
505                id: SUBMIT_FROM_DRAFT,
506                label: Some("Submit"),
507                description: Some("Move work into review."),
508                metadata: TransitionMeta {
509                    phase: Phase::Review,
510                    branch: false,
511                },
512            },
513            TransitionPresentation {
514                id: PUBLISH_FROM_REVIEW,
515                label: Some("Publish"),
516                description: Some("Complete the workflow."),
517                metadata: TransitionMeta {
518                    phase: Phase::Output,
519                    branch: false,
520                },
521            },
522        ],
523    };
524
525    impl<S> MachineIntrospection for Workflow<S> {
526        type StateId = StateId;
527        type TransitionId = TransitionId;
528
529        const GRAPH: &'static MachineGraph<Self::StateId, Self::TransitionId> = &MachineGraph {
530            machine: MachineDescriptor {
531                module_path: "workflow",
532                rust_type_path: "workflow::Machine",
533            },
534            states: &STATES,
535            transitions: TransitionInventory::new(|| &TRANSITIONS),
536        };
537    }
538
539    impl MachineStateIdentity for Workflow<DraftMarker> {
540        const STATE_ID: Self::StateId = StateId::Draft;
541    }
542
543    impl MachineStateIdentity for Workflow<ReviewMarker> {
544        const STATE_ID: Self::StateId = StateId::Review;
545    }
546
547    impl MachineStateIdentity for Workflow<PublishedMarker> {
548        const STATE_ID: Self::StateId = StateId::Published;
549    }
550
551    #[test]
552    fn query_helpers_find_expected_items() {
553        let graph = MachineGraph {
554            machine: MachineDescriptor {
555                module_path: "workflow",
556                rust_type_path: "workflow::Machine",
557            },
558            states: &STATES,
559            transitions: TransitionInventory::new(|| &TRANSITIONS),
560        };
561
562        assert_eq!(
563            graph.state(StateId::Review).map(|state| state.rust_name),
564            Some("Review")
565        );
566        assert_eq!(
567            graph
568                .transition(PUBLISH_FROM_REVIEW)
569                .map(|transition| transition.method_name),
570            Some("publish")
571        );
572        assert_eq!(
573            graph
574                .transition_from_method(StateId::Draft, "submit")
575                .map(|transition| transition.id),
576            Some(SUBMIT_FROM_DRAFT)
577        );
578        assert_eq!(
579            graph.legal_targets(SUBMIT_FROM_DRAFT),
580            Some(REVIEW_TARGETS.as_slice())
581        );
582        assert_eq!(graph.transitions_from(StateId::Draft).count(), 1);
583        assert_eq!(graph.transitions_named("publish").count(), 1);
584    }
585
586    #[test]
587    fn runtime_transition_recording_joins_back_to_static_graph() {
588        let event = Workflow::<DraftMarker>::try_record_transition_to::<Workflow<ReviewMarker>>(
589            SUBMIT_FROM_DRAFT,
590        )
591        .expect("valid runtime transition");
592
593        assert_eq!(
594            event,
595            RecordedTransition::new(
596                MachineDescriptor {
597                    module_path: "workflow",
598                    rust_type_path: "workflow::Machine",
599                },
600                StateId::Draft,
601                SUBMIT_FROM_DRAFT,
602                StateId::Review,
603            )
604        );
605        assert_eq!(
606            Workflow::<DraftMarker>::GRAPH
607                .transition(event.transition)
608                .map(|transition| (transition.from, transition.to)),
609            Some((StateId::Draft, REVIEW_TARGETS.as_slice()))
610        );
611        assert_eq!(
612            event.source_state_in(Workflow::<DraftMarker>::GRAPH),
613            Some(&StateDescriptor {
614                id: StateId::Draft,
615                rust_name: "Draft",
616                has_data: false,
617            })
618        );
619    }
620
621    #[test]
622    fn runtime_transition_recording_rejects_illegal_target_or_site() {
623        assert!(Workflow::<DraftMarker>::try_record_transition(
624            PUBLISH_FROM_REVIEW,
625            StateId::Published,
626        )
627        .is_none());
628        assert!(
629            Workflow::<ReviewMarker>::try_record_transition_to::<Workflow<PublishedMarker>>(
630                SUBMIT_FROM_DRAFT,
631            )
632            .is_none()
633        );
634    }
635
636    #[test]
637    fn presentation_queries_join_with_runtime_transitions() {
638        let event = Workflow::<DraftMarker>::try_record_transition_to::<Workflow<ReviewMarker>>(
639            SUBMIT_FROM_DRAFT,
640        )
641        .expect("valid runtime transition");
642
643        assert_eq!(
644            PRESENTATION.machine,
645            Some(MachinePresentationDescriptor {
646                label: Some("Workflow"),
647                description: Some("Example presentation metadata for introspection."),
648                metadata: MachineMeta {
649                    phase: Phase::Intake,
650                },
651            })
652        );
653        assert_eq!(
654            PRESENTATION.transition(event.transition),
655            Some(&TransitionPresentation {
656                id: SUBMIT_FROM_DRAFT,
657                label: Some("Submit"),
658                description: Some("Move work into review."),
659                metadata: TransitionMeta {
660                    phase: Phase::Review,
661                    branch: false,
662                },
663            })
664        );
665        assert_eq!(
666            PRESENTATION.state(event.chosen),
667            Some(&StatePresentation {
668                id: StateId::Review,
669                label: Some("Review"),
670                description: Some("Work is awaiting review."),
671                metadata: StateMeta {
672                    phase: Phase::Review,
673                    term: "review",
674                },
675            })
676        );
677    }
678}