Skip to main content

made_core/entities/
ceremony_definition_draft.rs

1//! [`CeremonyDefinitionDraft`] — a ceremony definition under authoring.
2//!
3//! A [`CeremonyDefinition`] is always valid: it cannot be constructed
4//! otherwise. That is the right guarantee for execution and the wrong
5//! one for authoring, because the definition an author most needs
6//! feedback about is precisely the one that does not construct.
7//!
8//! The draft holds the same declarations without the invariants, so it
9//! can be analysed, corrected and only then published.
10
11use std::collections::BTreeMap;
12
13use crate::error::DomainError;
14use crate::value_objects::{
15    CeremonyDescription, CeremonyGuard, CeremonyInputDefinition, CeremonyName,
16    CeremonyOutputDefinition, CeremonyRole, CeremonyState, CeremonyStep, CeremonyTransition,
17    CeremonyValidationFinding, CeremonyValidationLocus, CeremonyValidationReport, CeremonyVersion,
18};
19
20use super::ceremony_definition_analysis::CeremonyDefinitionParts;
21use super::CeremonyDefinition;
22
23#[derive(Debug, Clone, PartialEq)]
24pub struct CeremonyDefinitionDraft {
25    name: CeremonyName,
26    version: CeremonyVersion,
27    description: Option<CeremonyDescription>,
28    inputs: Vec<CeremonyInputDefinition>,
29    outputs: Vec<CeremonyOutputDefinition>,
30    states: Vec<CeremonyState>,
31    transitions: Vec<CeremonyTransition>,
32    steps: Vec<CeremonyStep>,
33    guards: Vec<CeremonyGuard>,
34    roles: Vec<CeremonyRole>,
35}
36
37impl CeremonyDefinitionDraft {
38    /// Accept the declarations as given.
39    ///
40    /// Construction never fails: a draft exists in order to describe
41    /// what is wrong with it.
42    #[allow(clippy::too_many_arguments)]
43    #[must_use]
44    pub fn new(
45        name: CeremonyName,
46        version: CeremonyVersion,
47        description: Option<CeremonyDescription>,
48        inputs: impl IntoIterator<Item = CeremonyInputDefinition>,
49        outputs: impl IntoIterator<Item = CeremonyOutputDefinition>,
50        states: impl IntoIterator<Item = CeremonyState>,
51        transitions: impl IntoIterator<Item = CeremonyTransition>,
52        steps: impl IntoIterator<Item = CeremonyStep>,
53        guards: impl IntoIterator<Item = CeremonyGuard>,
54        roles: impl IntoIterator<Item = CeremonyRole>,
55    ) -> Self {
56        Self {
57            name,
58            version,
59            description,
60            inputs: inputs.into_iter().collect(),
61            outputs: outputs.into_iter().collect(),
62            states: states.into_iter().collect(),
63            transitions: transitions.into_iter().collect(),
64            steps: steps.into_iter().collect(),
65            guards: guards.into_iter().collect(),
66            roles: roles.into_iter().collect(),
67        }
68    }
69
70    #[must_use]
71    pub fn name(&self) -> &CeremonyName {
72        &self.name
73    }
74
75    #[must_use]
76    pub fn version(&self) -> &CeremonyVersion {
77        &self.version
78    }
79
80    #[must_use]
81    pub fn description(&self) -> Option<&CeremonyDescription> {
82        self.description.as_ref()
83    }
84
85    #[must_use]
86    pub fn states(&self) -> &[CeremonyState] {
87        &self.states
88    }
89
90    #[must_use]
91    pub fn transitions(&self) -> &[CeremonyTransition] {
92        &self.transitions
93    }
94
95    #[must_use]
96    pub fn steps(&self) -> &[CeremonyStep] {
97        &self.steps
98    }
99
100    #[must_use]
101    pub fn guards(&self) -> &[CeremonyGuard] {
102        &self.guards
103    }
104
105    #[must_use]
106    pub fn roles(&self) -> &[CeremonyRole] {
107        &self.roles
108    }
109
110    /// Report every defect that would prevent publication.
111    ///
112    /// Duplicate declarations are reported first, in the order
113    /// [`CeremonyDefinition::new`] assembles them, followed by the
114    /// structural analysis of the resulting state machine. Duplicates
115    /// do not stop the structural pass: an author fixing a draft wants
116    /// the whole picture, not the first obstacle.
117    #[must_use]
118    pub fn analyze(&self) -> CeremonyValidationReport {
119        let mut findings = Vec::new();
120
121        let (_, duplicate_inputs) = index(&self.inputs, CeremonyInputDefinition::name);
122        push_duplicates(
123            &mut findings,
124            duplicate_inputs,
125            "ceremony_input",
126            CeremonyValidationLocus::input,
127        );
128
129        let (_, duplicate_outputs) = index(&self.outputs, CeremonyOutputDefinition::name);
130        push_duplicates(
131            &mut findings,
132            duplicate_outputs,
133            "ceremony_output",
134            CeremonyValidationLocus::output,
135        );
136
137        let (states, duplicate_states) = index(&self.states, CeremonyState::id);
138        push_duplicates(
139            &mut findings,
140            duplicate_states,
141            "ceremony_state",
142            CeremonyValidationLocus::state,
143        );
144
145        let (steps, duplicate_steps) = index(&self.steps, CeremonyStep::id);
146        push_duplicates(
147            &mut findings,
148            duplicate_steps,
149            "ceremony_step",
150            CeremonyValidationLocus::step,
151        );
152
153        let (guards, duplicate_guards) = index(&self.guards, CeremonyGuard::name);
154        push_duplicates(
155            &mut findings,
156            duplicate_guards,
157            "ceremony_guard",
158            CeremonyValidationLocus::guard,
159        );
160
161        let (roles, duplicate_roles) = index(&self.roles, CeremonyRole::id);
162        push_duplicates(
163            &mut findings,
164            duplicate_roles,
165            "ceremony_role",
166            CeremonyValidationLocus::role,
167        );
168
169        CeremonyDefinitionParts {
170            states: &states,
171            transitions: &self.transitions,
172            steps: &steps,
173            guards: &guards,
174            roles: &roles,
175        }
176        .collect_findings(&mut findings);
177
178        CeremonyValidationReport::new(findings)
179    }
180
181    /// Promote the draft into an always-valid definition.
182    ///
183    /// Publication goes through [`CeremonyDefinition::new`] so there is
184    /// exactly one place where the invariants are enforced.
185    pub fn publish(self) -> Result<CeremonyDefinition, DomainError> {
186        CeremonyDefinition::new(
187            self.name,
188            self.version,
189            self.description,
190            self.inputs,
191            self.outputs,
192            self.states,
193            self.transitions,
194            self.steps,
195            self.guards,
196            self.roles,
197        )
198    }
199}
200
201/// Index declarations by identity, keeping the first occurrence and
202/// reporting every later one as a duplicate.
203fn index<'a, T, K>(items: &'a [T], key: impl Fn(&'a T) -> &'a K) -> (BTreeMap<K, T>, Vec<K>)
204where
205    T: Clone,
206    K: Clone + Ord + 'a,
207{
208    let mut indexed = BTreeMap::new();
209    let mut duplicates = Vec::new();
210    for item in items {
211        let item_key = key(item).clone();
212        if indexed.contains_key(&item_key) {
213            duplicates.push(item_key);
214            continue;
215        }
216        indexed.insert(item_key, item.clone());
217    }
218    (indexed, duplicates)
219}
220
221fn push_duplicates<K>(
222    findings: &mut Vec<CeremonyValidationFinding>,
223    duplicates: Vec<K>,
224    what: &'static str,
225    locus: impl Fn(K) -> CeremonyValidationLocus,
226) {
227    for key in duplicates {
228        findings.push(CeremonyValidationFinding::error(
229            locus(key),
230            DomainError::AlreadyExists { what },
231        ));
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::value_objects::{
239        GuardCondition, RetryPolicy, RoleAction, RoleId, StateId, StepHandlerConfig,
240        StepHandlerKind, StepId, StepStatus, TransitionTrigger,
241    };
242
243    fn state_id(raw: &str) -> StateId {
244        StateId::new(raw).unwrap()
245    }
246
247    fn step_id(raw: &str) -> StepId {
248        StepId::new(raw).unwrap()
249    }
250
251    fn trigger(raw: &str) -> TransitionTrigger {
252        TransitionTrigger::new(raw).unwrap()
253    }
254
255    fn step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
256        CeremonyStep::new(
257            step_id(raw_step_id),
258            state_id(raw_state_id),
259            StepHandlerKind::new("manual_review").unwrap(),
260            StepHandlerConfig::empty(),
261            RetryPolicy::single_attempt(),
262            None,
263        )
264    }
265
266    fn draft(
267        states: Vec<CeremonyState>,
268        transitions: Vec<CeremonyTransition>,
269        steps: Vec<CeremonyStep>,
270        guards: Vec<CeremonyGuard>,
271        roles: Vec<CeremonyRole>,
272    ) -> CeremonyDefinitionDraft {
273        CeremonyDefinitionDraft::new(
274            CeremonyName::new("planning_ceremony").unwrap(),
275            CeremonyVersion::v1(),
276            None,
277            Vec::new(),
278            Vec::new(),
279            states,
280            transitions,
281            steps,
282            guards,
283            roles,
284        )
285    }
286
287    fn three_defect_draft() -> CeremonyDefinitionDraft {
288        draft(
289            vec![
290                CeremonyState::initial(state_id("drafting")),
291                CeremonyState::terminal(state_id("done")),
292            ],
293            vec![CeremonyTransition::new(
294                state_id("drafting"),
295                state_id("nowhere"),
296                trigger("finish"),
297                Vec::new(),
298            )
299            .unwrap()],
300            Vec::new(),
301            vec![CeremonyGuard::new(
302                crate::value_objects::GuardName::new("plan_done").unwrap(),
303                GuardCondition::StepStatus {
304                    step_id: step_id("missing"),
305                    status: StepStatus::Completed,
306                },
307            )],
308            vec![CeremonyRole::new(
309                RoleId::new("facilitator").unwrap(),
310                vec![RoleAction::step(step_id("missing"))],
311            )
312            .unwrap()],
313        )
314    }
315
316    #[test]
317    fn a_draft_reports_every_defect_at_once() {
318        let report = three_defect_draft().analyze();
319        let errors = report.errors().collect::<Vec<_>>();
320
321        assert!(!report.is_valid());
322        assert_eq!(errors.len(), 3, "found: {errors:?}");
323        assert_eq!(
324            errors
325                .iter()
326                .map(|finding| finding.defect().clone())
327                .collect::<Vec<_>>(),
328            vec![
329                DomainError::NotFound {
330                    what: "ceremony_transition.to_state"
331                },
332                DomainError::NotFound {
333                    what: "ceremony_guard.step"
334                },
335                DomainError::NotFound {
336                    what: "ceremony_role.step_action"
337                },
338            ]
339        );
340    }
341
342    #[test]
343    fn duplicate_declarations_are_reported_instead_of_aborting_the_analysis() {
344        let report = draft(
345            vec![
346                CeremonyState::initial(state_id("drafting")),
347                CeremonyState::terminal(state_id("done")),
348            ],
349            vec![CeremonyTransition::new(
350                state_id("drafting"),
351                state_id("done"),
352                trigger("finish"),
353                Vec::new(),
354            )
355            .unwrap()],
356            vec![step("plan", "drafting"), step("plan", "drafting")],
357            Vec::new(),
358            Vec::new(),
359        )
360        .analyze();
361        let errors = report.errors().collect::<Vec<_>>();
362
363        assert_eq!(errors.len(), 1);
364        assert_eq!(
365            errors[0].defect(),
366            &DomainError::AlreadyExists {
367                what: "ceremony_step"
368            }
369        );
370        assert_eq!(
371            errors[0].locus(),
372            &CeremonyValidationLocus::step(step_id("plan"))
373        );
374    }
375
376    #[test]
377    fn publishing_fails_with_exactly_the_first_blocking_finding() {
378        let draft = three_defect_draft();
379        let expected = draft
380            .analyze()
381            .first_error()
382            .expect("a blocking finding")
383            .defect()
384            .clone();
385
386        let error = draft.publish().unwrap_err();
387
388        assert_eq!(error, expected);
389    }
390
391    #[test]
392    fn a_clean_draft_publishes() {
393        let draft = draft(
394            vec![
395                CeremonyState::initial(state_id("drafting")),
396                CeremonyState::terminal(state_id("done")),
397            ],
398            vec![CeremonyTransition::new(
399                state_id("drafting"),
400                state_id("done"),
401                trigger("finish"),
402                Vec::new(),
403            )
404            .unwrap()],
405            vec![step("plan", "drafting")],
406            Vec::new(),
407            Vec::new(),
408        );
409
410        assert!(draft.analyze().is_valid());
411
412        let definition = draft.publish().expect("a clean draft must publish");
413
414        assert_eq!(definition.initial_state_id(), &state_id("drafting"));
415        assert!(definition.analyze().findings().is_empty());
416    }
417}