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