1use 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 #[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 #[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 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
295fn 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}