Skip to main content

turnframe_test/explore/
model.rs

1//! What a domain must tell the explorer about its own transitions.
2
3use turnframe_core::error::DomainRejection;
4use turnframe_core::flow::WorkflowDefinition;
5
6/// The result of applying one candidate command to one state (spec §8.5).
7///
8/// A transition is either applied — producing the next state and the events the
9/// executor would commit — or refused by the domain. A refusal carries the
10/// [`DomainRejection`] the real executor would return, so the explorer can check
11/// it against [`WorkflowDefinition::validate_command`].
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum SimulatedTransition<S, E> {
14    /// The command was applied.
15    Applied {
16        /// State after the command, or `None` when the case ceased to exist.
17        state: Option<S>,
18        /// Events the executor would commit.
19        events: Vec<E>,
20    },
21    /// The domain refused the command; the input state is unchanged.
22    Rejected(DomainRejection),
23}
24
25impl<S, E> SimulatedTransition<S, E> {
26    /// A transition that produced a new state.
27    #[must_use]
28    pub fn applied(state: S, events: Vec<E>) -> Self {
29        Self::Applied {
30            state: Some(state),
31            events,
32        }
33    }
34
35    /// A transition that removed the case, which
36    /// [`explore`](crate::explore::explore) reports as
37    /// [`TransitionRemovesCase`](crate::explore::ExplorationViolationKind::TransitionRemovesCase).
38    ///
39    /// A case's identity outlives its content, so a domain whose working
40    /// document is consumed on success moves the case to a terminal *status*
41    /// instead of dropping it, the way the traveler sample's `Deleted` does.
42    /// This constructor exists so that a model ported from a delete-on-completion
43    /// design produces a reported violation rather than a shape the explorer
44    /// cannot express — and so the check itself can be falsified.
45    #[must_use]
46    pub fn removed(events: Vec<E>) -> Self {
47        Self::Applied {
48            state: None,
49            events,
50        }
51    }
52
53    /// A refusal.
54    #[must_use]
55    pub fn rejected(rejection: DomainRejection) -> Self {
56        Self::Rejected(rejection)
57    }
58
59    /// Returns `true` when the command was applied.
60    #[must_use]
61    pub fn is_applied(&self) -> bool {
62        matches!(self, Self::Applied { .. })
63    }
64
65    /// The state after the command, when the command applied and the case still
66    /// exists.
67    #[must_use]
68    pub fn next_state(&self) -> Option<&S> {
69        match self {
70            Self::Applied { state, .. } => state.as_ref(),
71            Self::Rejected(_) => None,
72        }
73    }
74
75    /// The events the command would commit; empty for a refusal.
76    #[must_use]
77    pub fn events(&self) -> &[E] {
78        match self {
79            Self::Applied { events, .. } => events,
80            Self::Rejected(_) => &[],
81        }
82    }
83
84    /// The rejection, when the command was refused.
85    #[must_use]
86    pub fn rejection(&self) -> Option<&DomainRejection> {
87        match self {
88            Self::Rejected(rejection) => Some(rejection),
89            Self::Applied { .. } => None,
90        }
91    }
92}
93
94/// The transition model of a workflow, used for bounded exploration (spec §8.5).
95///
96/// A [`WorkflowDefinition`] says how state *projects*; a `WorkflowModel` says
97/// how state *moves*. Keeping them apart means the explorer never has to run an
98/// executor, a store or a clock.
99///
100/// Implementations must be deterministic and side-effect free: the explorer
101/// deduplicates states by their canonical JSON, so a model that mints a fresh
102/// identifier on every call turns a small workflow into an infinite one. Derive
103/// identifiers from the state instead (for example, the n-th line always gets
104/// the n-th identifier of a fixed table).
105pub trait WorkflowModel<W: WorkflowDefinition> {
106    /// The states exploration starts from. `None` means "the case does not
107    /// exist yet", which is where most workflows begin — and it never means
108    /// "the case is over", because a case's identity outlives its content.
109    fn initial_states(&self) -> Vec<Option<W::State>>;
110
111    /// Commands worth trying in this state, in a stable order. Include commands
112    /// you expect to be refused: the explorer checks that a refusal changes
113    /// nothing.
114    fn candidate_commands(&self, state: Option<&W::State>) -> Vec<W::Command>;
115
116    /// Applies one candidate command purely.
117    fn simulate(
118        &self,
119        state: Option<&W::State>,
120        command: &W::Command,
121    ) -> SimulatedTransition<W::State, W::Event>;
122
123    /// Outcomes the workflow claims it can reach. The explorer reports every
124    /// declared outcome no reachable state projects. The default is empty,
125    /// which disables the check.
126    fn declared_outcomes(&self) -> Vec<W::Outcome> {
127        Vec::new()
128    }
129}