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