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
57pub trait MachineStateIdentity: MachineIntrospection {
59 const STATE_ID: Self::StateId;
61}
62
63#[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 pub machine: Option<MachinePresentationDescriptor<MachineMeta>>,
74 pub states: &'static [StatePresentation<S, StateMeta>],
76 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 pub fn state(&self, id: S) -> Option<&StatePresentation<S, StateMeta>> {
88 self.states.iter().find(|state| state.id == id)
89 }
90
91 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub struct MachinePresentationDescriptor<M: 'static = ()> {
102 pub label: Option<&'static str>,
104 pub description: Option<&'static str>,
106 pub metadata: M,
108}
109
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub struct StatePresentation<S: 'static, M: 'static = ()> {
113 pub id: S,
115 pub label: Option<&'static str>,
117 pub description: Option<&'static str>,
119 pub metadata: M,
121}
122
123#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub struct TransitionPresentation<T: 'static, M: 'static = ()> {
126 pub id: T,
128 pub label: Option<&'static str>,
130 pub description: Option<&'static str>,
132 pub metadata: M,
134}
135
136#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub struct RecordedTransition<S: 'static, T: 'static> {
139 pub machine: MachineDescriptor,
141 pub from: S,
143 pub transition: T,
145 pub chosen: S,
147}
148
149impl<S, T> RecordedTransition<S, T>
150where
151 S: 'static,
152 T: 'static,
153{
154 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 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 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 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
208pub trait MachineTransitionRecorder: MachineStateIdentity {
210 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
245pub struct MachineGraph<S: 'static, T: 'static> {
246 pub machine: MachineDescriptor,
248 pub states: &'static [StateDescriptor<S>],
250 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 pub fn state(&self, id: S) -> Option<&StateDescriptor<S>> {
261 self.states.iter().find(|state| state.id == id)
262 }
263
264 pub fn transition(&self, id: T) -> Option<&TransitionDescriptor<S, T>> {
266 self.transitions
267 .iter()
268 .find(|transition| transition.id == id)
269 }
270
271 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 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 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 pub fn legal_targets(&self, id: T) -> Option<&'static [S]> {
304 self.transition(id).map(|transition| transition.to)
305 }
306}
307
308#[derive(Clone, Copy, Debug, Eq, PartialEq)]
310pub struct MachineDescriptor {
311 pub module_path: &'static str,
313 pub rust_type_path: &'static str,
315}
316
317#[derive(Clone, Copy, Debug, Eq, PartialEq)]
319pub struct StateDescriptor<S: 'static> {
320 pub id: S,
322 pub rust_name: &'static str,
324 pub has_data: bool,
326}
327
328#[derive(Clone, Copy, Debug, Eq, PartialEq)]
330pub struct TransitionDescriptor<S: 'static, T: 'static> {
331 pub id: T,
333 pub method_name: &'static str,
335 pub from: S,
337 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}