1pub trait MachineIntrospection {
3 type StateId: Copy + Eq + core::hash::Hash + 'static;
5
6 type TransitionId: Copy + Eq + core::hash::Hash + 'static;
8
9 const GRAPH: &'static MachineGraph<Self::StateId, Self::TransitionId>;
11}
12
13#[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 pub const fn new(get: fn() -> &'static [TransitionDescriptor<S, T>]) -> Self {
23 Self { get }
24 }
25
26 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#[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 pub const fn new(get: fn() -> &'static [TransitionPresentation<T, M>]) -> Self {
67 Self { get }
68 }
69
70 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
103pub trait MachineStateIdentity: MachineIntrospection {
105 const STATE_ID: Self::StateId;
107}
108
109#[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 pub machine: Option<MachinePresentationDescriptor<MachineMeta>>,
120 pub states: &'static [StatePresentation<S, StateMeta>],
122 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 pub fn state(&self, id: S) -> Option<&StatePresentation<S, StateMeta>> {
134 self.states.iter().find(|state| state.id == id)
135 }
136
137 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
147pub struct MachinePresentationDescriptor<M: 'static = ()> {
148 pub label: Option<&'static str>,
150 pub description: Option<&'static str>,
152 pub metadata: M,
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub struct StatePresentation<S: 'static, M: 'static = ()> {
159 pub id: S,
161 pub label: Option<&'static str>,
163 pub description: Option<&'static str>,
165 pub metadata: M,
167}
168
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
171pub struct TransitionPresentation<T: 'static, M: 'static = ()> {
172 pub id: T,
174 pub label: Option<&'static str>,
176 pub description: Option<&'static str>,
178 pub metadata: M,
180}
181
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub struct RecordedTransition<S: 'static, T: 'static> {
185 pub machine: MachineDescriptor,
187 pub from: S,
189 pub transition: T,
191 pub chosen: S,
193}
194
195impl<S, T> RecordedTransition<S, T>
196where
197 S: 'static,
198 T: 'static,
199{
200 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 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 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 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
254pub trait MachineTransitionRecorder: MachineStateIdentity {
256 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
291pub struct MachineGraph<S: 'static, T: 'static> {
292 pub machine: MachineDescriptor,
294 pub states: &'static [StateDescriptor<S>],
296 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 pub fn state(&self, id: S) -> Option<&StateDescriptor<S>> {
307 self.states.iter().find(|state| state.id == id)
308 }
309
310 pub fn transition(&self, id: T) -> Option<&TransitionDescriptor<S, T>> {
312 self.transitions
313 .iter()
314 .find(|transition| transition.id == id)
315 }
316
317 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 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 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 pub fn legal_targets(&self, id: T) -> Option<&'static [S]> {
350 self.transition(id).map(|transition| transition.to)
351 }
352}
353
354#[derive(Clone, Copy, Debug, Eq, PartialEq)]
356pub struct MachineDescriptor {
357 pub module_path: &'static str,
359 pub rust_type_path: &'static str,
361}
362
363#[derive(Clone, Copy, Debug, Eq, PartialEq)]
365pub struct StateDescriptor<S: 'static> {
366 pub id: S,
368 pub rust_name: &'static str,
370 pub has_data: bool,
372}
373
374#[derive(Clone, Copy, Debug, Eq, PartialEq)]
376pub struct TransitionDescriptor<S: 'static, T: 'static> {
377 pub id: T,
379 pub method_name: &'static str,
381 pub from: S,
383 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}