1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::DomainError;
12use crate::value_objects::{
13 CeremonyContext, CeremonyDefinitionDigest, CeremonyDescription, CeremonyGuard,
14 CeremonyInputDefinition, CeremonyName, CeremonyOutputDefinition, CeremonyRole, CeremonyState,
15 CeremonyStep, CeremonyTimeout, CeremonyTransition, CeremonyValidationReport, CeremonyVersion,
16 GuardName, InputName, MaxBounces, MaxParallel, MaxTransitions, OutputName, RoleAction, RoleId,
17 StateId, StateTimeout, StepExecutionRecord, StepId, TransitionTrigger,
18};
19
20use super::ceremony_definition_analysis::CeremonyDefinitionParts;
21
22mod collections;
23mod guards;
24
25use collections::{
26 collect_guards, collect_inputs, collect_outputs, collect_roles, collect_states, collect_steps,
27};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct CeremonyDefinition {
31 name: CeremonyName,
32 version: CeremonyVersion,
33 description: Option<CeremonyDescription>,
34 inputs: BTreeMap<InputName, CeremonyInputDefinition>,
35 outputs: BTreeMap<OutputName, CeremonyOutputDefinition>,
36 states: BTreeMap<StateId, CeremonyState>,
37 transitions: Vec<CeremonyTransition>,
38 steps: BTreeMap<StepId, CeremonyStep>,
39 step_order: Vec<StepId>,
40 guards: BTreeMap<GuardName, CeremonyGuard>,
41 roles: BTreeMap<RoleId, CeremonyRole>,
42 #[serde(default, skip_serializing_if = "MaxParallel::is_default")]
43 max_parallel: MaxParallel,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 max_transitions: Option<MaxTransitions>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 max_bounces: Option<MaxBounces>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 ceremony_timeout: Option<CeremonyTimeout>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 state_timeout: Option<StateTimeout>,
52}
53
54impl CeremonyDefinition {
55 pub fn new(
56 name: CeremonyName,
57 version: CeremonyVersion,
58 description: Option<CeremonyDescription>,
59 inputs: impl IntoIterator<Item = CeremonyInputDefinition>,
60 outputs: impl IntoIterator<Item = CeremonyOutputDefinition>,
61 states: impl IntoIterator<Item = CeremonyState>,
62 transitions: impl IntoIterator<Item = CeremonyTransition>,
63 steps: impl IntoIterator<Item = CeremonyStep>,
64 guards: impl IntoIterator<Item = CeremonyGuard>,
65 roles: impl IntoIterator<Item = CeremonyRole>,
66 ) -> Result<Self, DomainError> {
67 Self::new_with_transition_budgets(
68 name,
69 version,
70 description,
71 inputs,
72 outputs,
73 states,
74 transitions,
75 steps,
76 guards,
77 roles,
78 None,
79 None,
80 )
81 }
82
83 #[allow(clippy::too_many_arguments)]
88 pub fn new_with_transition_budgets(
89 name: CeremonyName,
90 version: CeremonyVersion,
91 description: Option<CeremonyDescription>,
92 inputs: impl IntoIterator<Item = CeremonyInputDefinition>,
93 outputs: impl IntoIterator<Item = CeremonyOutputDefinition>,
94 states: impl IntoIterator<Item = CeremonyState>,
95 transitions: impl IntoIterator<Item = CeremonyTransition>,
96 steps: impl IntoIterator<Item = CeremonyStep>,
97 guards: impl IntoIterator<Item = CeremonyGuard>,
98 roles: impl IntoIterator<Item = CeremonyRole>,
99 max_transitions: Option<MaxTransitions>,
100 max_bounces: Option<MaxBounces>,
101 ) -> Result<Self, DomainError> {
102 let inputs = collect_inputs(inputs)?;
103 let outputs = collect_outputs(outputs)?;
104 let states = collect_states(states)?;
105 let transitions = transitions.into_iter().collect::<Vec<_>>();
106 let (steps, step_order) = collect_steps(steps)?;
107 let guards = collect_guards(guards)?;
108 let roles = collect_roles(roles)?;
109
110 let definition = Self {
111 name,
112 version,
113 description,
114 inputs,
115 outputs,
116 states,
117 transitions,
118 steps,
119 step_order,
120 guards,
121 roles,
122 max_parallel: MaxParallel::default(),
123 max_transitions,
124 max_bounces,
125 ceremony_timeout: None,
126 state_timeout: None,
127 };
128 definition.validate()?;
129 Ok(definition)
130 }
131
132 #[must_use]
133 pub fn name(&self) -> &CeremonyName {
134 &self.name
135 }
136
137 #[must_use]
138 pub fn version(&self) -> &CeremonyVersion {
139 &self.version
140 }
141
142 #[must_use]
143 pub fn description(&self) -> Option<&CeremonyDescription> {
144 self.description.as_ref()
145 }
146
147 #[must_use]
148 pub fn inputs(&self) -> &BTreeMap<InputName, CeremonyInputDefinition> {
149 &self.inputs
150 }
151
152 #[must_use]
153 pub fn outputs(&self) -> &BTreeMap<OutputName, CeremonyOutputDefinition> {
154 &self.outputs
155 }
156
157 #[must_use]
158 pub fn states(&self) -> &BTreeMap<StateId, CeremonyState> {
159 &self.states
160 }
161
162 #[must_use]
163 pub fn transitions(&self) -> &[CeremonyTransition] {
164 &self.transitions
165 }
166
167 #[must_use]
168 pub fn steps(&self) -> &BTreeMap<StepId, CeremonyStep> {
169 &self.steps
170 }
171
172 pub fn steps_in_declaration_order(&self) -> impl Iterator<Item = &CeremonyStep> + '_ {
177 self.step_order.iter().map(|step_id| {
178 self.steps
179 .get(step_id)
180 .expect("ceremony step order must reference an indexed step")
181 })
182 }
183
184 #[must_use]
185 pub fn guards(&self) -> &BTreeMap<GuardName, CeremonyGuard> {
186 &self.guards
187 }
188
189 #[must_use]
190 pub fn roles(&self) -> &BTreeMap<RoleId, CeremonyRole> {
191 &self.roles
192 }
193
194 #[must_use]
195 pub fn with_max_parallel(mut self, max_parallel: MaxParallel) -> Self {
196 self.max_parallel = max_parallel;
197 self
198 }
199
200 #[must_use]
201 pub fn max_parallel(&self) -> MaxParallel {
202 self.max_parallel
203 }
204
205 #[must_use]
206 pub const fn max_transitions(&self) -> Option<MaxTransitions> {
207 self.max_transitions
208 }
209
210 #[must_use]
211 pub const fn max_bounces(&self) -> Option<MaxBounces> {
212 self.max_bounces
213 }
214
215 #[must_use]
216 pub const fn with_ceremony_timeout(mut self, timeout: CeremonyTimeout) -> Self {
217 self.ceremony_timeout = Some(timeout);
218 self
219 }
220 #[must_use]
221 pub const fn with_state_timeout(mut self, timeout: StateTimeout) -> Self {
222 self.state_timeout = Some(timeout);
223 self
224 }
225 #[must_use]
226 pub const fn ceremony_timeout(&self) -> Option<CeremonyTimeout> {
227 self.ceremony_timeout
228 }
229 #[must_use]
230 pub const fn state_timeout(&self) -> Option<StateTimeout> {
231 self.state_timeout
232 }
233
234 #[must_use]
235 pub fn initial_state_id(&self) -> &StateId {
236 self.states
237 .values()
238 .find(|state| state.is_initial())
239 .map(CeremonyState::id)
240 .expect("ceremony definition invariant requires one initial state")
241 }
242
243 #[must_use]
244 pub fn state(&self, state_id: &StateId) -> Option<&CeremonyState> {
245 self.states.get(state_id)
246 }
247
248 #[must_use]
249 pub fn step(&self, step_id: &StepId) -> Option<&CeremonyStep> {
250 self.steps.get(step_id)
251 }
252
253 #[must_use]
254 pub fn role(&self, role_id: &RoleId) -> Option<&CeremonyRole> {
255 self.roles.get(role_id)
256 }
257
258 pub fn steps_for_state(&self, state_id: &StateId) -> impl Iterator<Item = &CeremonyStep> + '_ {
259 let state_id = state_id.clone();
260 self.steps_in_declaration_order()
261 .filter(move |step| step.state_id() == &state_id)
262 }
263
264 #[must_use]
265 pub fn is_terminal_state(&self, state_id: &StateId) -> bool {
266 self.states
267 .get(state_id)
268 .is_some_and(CeremonyState::is_terminal)
269 }
270
271 #[must_use]
272 pub fn transition_for_trigger(
273 &self,
274 state_id: &StateId,
275 trigger: &TransitionTrigger,
276 ) -> Option<&CeremonyTransition> {
277 self.transitions
278 .iter()
279 .find(|transition| transition.from() == state_id && transition.trigger() == trigger)
280 }
281
282 pub fn available_transitions(
283 &self,
284 state_id: &StateId,
285 ) -> impl Iterator<Item = &CeremonyTransition> + '_ {
286 let state_id = state_id.clone();
287 self.transitions
288 .iter()
289 .filter(move |transition| transition.from() == &state_id)
290 }
291
292 #[must_use]
293 pub fn role_allows(&self, role_id: &RoleId, action: &RoleAction) -> bool {
294 self.roles
295 .get(role_id)
296 .is_some_and(|role| role.allows(action))
297 }
298
299 #[must_use]
305 pub fn role_for_action(&self, action: &RoleAction) -> Option<&CeremonyRole> {
306 self.roles.values().find(|role| role.allows(action))
307 }
308
309 pub fn role_id_for_step(&self, step_id: &StepId) -> Result<RoleId, DomainError> {
315 self.role_for_action(&RoleAction::step(step_id.clone()))
316 .map(|role| role.id().clone())
317 .ok_or(DomainError::InvariantViolated {
318 reason: "no ceremony role can execute step",
319 })
320 }
321
322 pub fn role_id_for_transition(
329 &self,
330 trigger: &TransitionTrigger,
331 ) -> Result<RoleId, DomainError> {
332 self.role_for_action(&RoleAction::transition(trigger.clone()))
333 .map(|role| role.id().clone())
334 .ok_or(DomainError::InvariantViolated {
335 reason: "no ceremony role can apply transition",
336 })
337 }
338
339 #[must_use]
348 pub fn next_satisfied_transition(
349 &self,
350 state_id: &StateId,
351 records: &BTreeMap<StepId, StepExecutionRecord>,
352 context: &CeremonyContext,
353 ) -> Option<&CeremonyTransition> {
354 self.available_transitions(state_id)
355 .find(|transition| self.guards_are_satisfied(transition, records, context))
356 }
357
358 pub fn digest(&self) -> Result<CeremonyDefinitionDigest, DomainError> {
376 let canonical = self.canonical_form()?;
377 Ok(CeremonyDefinitionDigest::of_canonical_form(&canonical))
378 }
379
380 fn canonical_form(&self) -> Result<Vec<u8>, DomainError> {
381 serde_json::to_vec(self).map_err(|_| DomainError::InvariantViolated {
382 reason: "ceremony definition cannot be rendered canonically",
383 })
384 }
385
386 #[must_use]
396 pub fn analyze(&self) -> CeremonyValidationReport {
397 let mut findings = Vec::new();
398 self.parts().collect_findings(&mut findings);
399 CeremonyValidationReport::new(findings)
400 }
401
402 fn parts(&self) -> CeremonyDefinitionParts<'_> {
403 CeremonyDefinitionParts {
404 states: &self.states,
405 transitions: &self.transitions,
406 steps: &self.steps,
407 step_order: &self.step_order,
408 guards: &self.guards,
409 roles: &self.roles,
410 max_transitions: self.max_transitions,
411 max_bounces: self.max_bounces,
412 }
413 }
414
415 fn validate(&self) -> Result<(), DomainError> {
416 match self.analyze().first_error() {
417 Some(finding) => Err(finding.defect().clone()),
418 None => Ok(()),
419 }
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::value_objects::{
427 CeremonyStateKind, CeremonyValidationLocus, GuardCondition, RetryPolicy, StepHandlerConfig,
428 StepHandlerKind, StepStatus,
429 };
430
431 fn name() -> CeremonyName {
432 CeremonyName::new("planning_ceremony").unwrap()
433 }
434
435 fn state_id(raw: &str) -> StateId {
436 StateId::new(raw).unwrap()
437 }
438
439 fn step_id(raw: &str) -> StepId {
440 StepId::new(raw).unwrap()
441 }
442
443 fn guard_name(raw: &str) -> GuardName {
444 GuardName::new(raw).unwrap()
445 }
446
447 fn trigger(raw: &str) -> TransitionTrigger {
448 TransitionTrigger::new(raw).unwrap()
449 }
450
451 fn handler_kind() -> StepHandlerKind {
452 StepHandlerKind::new("manual_review").unwrap()
453 }
454
455 fn step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
456 CeremonyStep::new(
457 step_id(raw_step_id),
458 state_id(raw_state_id),
459 handler_kind(),
460 StepHandlerConfig::empty(),
461 RetryPolicy::single_attempt(),
462 None,
463 )
464 }
465
466 fn role(actions: Vec<RoleAction>) -> CeremonyRole {
467 CeremonyRole::new(RoleId::new("facilitator").unwrap(), actions).unwrap()
468 }
469
470 fn definition(
471 states: Vec<CeremonyState>,
472 transitions: Vec<CeremonyTransition>,
473 steps: Vec<CeremonyStep>,
474 guards: Vec<CeremonyGuard>,
475 roles: Vec<CeremonyRole>,
476 ) -> Result<CeremonyDefinition, DomainError> {
477 definition_with_budgets(states, transitions, steps, guards, roles, None, None)
478 }
479
480 fn definition_with_budgets(
481 states: Vec<CeremonyState>,
482 transitions: Vec<CeremonyTransition>,
483 steps: Vec<CeremonyStep>,
484 guards: Vec<CeremonyGuard>,
485 roles: Vec<CeremonyRole>,
486 max_transitions: Option<MaxTransitions>,
487 max_bounces: Option<MaxBounces>,
488 ) -> Result<CeremonyDefinition, DomainError> {
489 CeremonyDefinition::new_with_transition_budgets(
490 name(),
491 CeremonyVersion::v1(),
492 None,
493 Vec::new(),
494 Vec::new(),
495 states,
496 transitions,
497 steps,
498 guards,
499 roles,
500 max_transitions,
501 max_bounces,
502 )
503 }
504
505 fn valid_definition() -> CeremonyDefinition {
506 let plan_step = step("plan", "drafting");
507 let guard = CeremonyGuard::new(
508 guard_name("plan_done"),
509 GuardCondition::StepStatus {
510 step_id: plan_step.id().clone(),
511 status: StepStatus::Completed,
512 },
513 );
514 let transition = CeremonyTransition::new(
515 state_id("drafting"),
516 state_id("done"),
517 trigger("finish"),
518 vec![guard.name().clone()],
519 )
520 .unwrap();
521 let role = role(vec![
522 RoleAction::step(plan_step.id().clone()),
523 RoleAction::transition(transition.trigger().clone()),
524 ]);
525
526 definition(
527 vec![
528 CeremonyState::initial(state_id("drafting")),
529 CeremonyState::terminal(state_id("done")),
530 ],
531 vec![transition],
532 vec![plan_step],
533 vec![guard],
534 vec![role],
535 )
536 .unwrap()
537 }
538
539 #[test]
540 fn accepts_valid_declarative_state_machine() {
541 let definition = valid_definition();
542
543 assert_eq!(definition.initial_state_id(), &state_id("drafting"));
544 assert_eq!(definition.steps_for_state(&state_id("drafting")).count(), 1);
545 assert!(definition.role_allows(
546 &RoleId::new("facilitator").unwrap(),
547 &RoleAction::transition(trigger("finish"))
548 ));
549 }
550
551 #[test]
552 fn preserves_step_declaration_order_within_a_state() {
553 let definition = definition(
554 vec![
555 CeremonyState::initial(state_id("drafting")),
556 CeremonyState::terminal(state_id("done")),
557 ],
558 Vec::new(),
559 vec![
560 step("write_plan", "drafting"),
561 step("challenge_plan", "drafting"),
562 step("archive_plan", "drafting"),
563 ],
564 Vec::new(),
565 Vec::new(),
566 )
567 .unwrap();
568
569 let step_ids = definition
570 .steps_for_state(&state_id("drafting"))
571 .map(CeremonyStep::id)
572 .cloned()
573 .collect::<Vec<_>>();
574
575 assert_eq!(
576 step_ids,
577 vec![
578 step_id("write_plan"),
579 step_id("challenge_plan"),
580 step_id("archive_plan"),
581 ]
582 );
583 }
584
585 #[test]
586 fn rejects_definitions_without_exactly_one_initial_state() {
587 let err = definition(
588 vec![
589 CeremonyState::new(state_id("one"), CeremonyStateKind::Initial),
590 CeremonyState::new(state_id("two"), CeremonyStateKind::Initial),
591 CeremonyState::terminal(state_id("done")),
592 ],
593 Vec::new(),
594 Vec::new(),
595 Vec::new(),
596 Vec::new(),
597 )
598 .unwrap_err();
599
600 assert!(matches!(err, DomainError::InvariantViolated { .. }));
601 }
602
603 #[test]
604 fn rejects_terminal_states_with_outgoing_transitions() {
605 let transition = CeremonyTransition::new(
606 state_id("done"),
607 state_id("drafting"),
608 trigger("restart"),
609 Vec::new(),
610 )
611 .unwrap();
612
613 let err = definition(
614 vec![
615 CeremonyState::initial(state_id("drafting")),
616 CeremonyState::terminal(state_id("done")),
617 ],
618 vec![transition],
619 Vec::new(),
620 Vec::new(),
621 Vec::new(),
622 )
623 .unwrap_err();
624
625 assert!(matches!(err, DomainError::InvariantViolated { .. }));
626 }
627
628 #[test]
629 fn rejects_roles_that_reference_unknown_steps() {
630 let role = role(vec![RoleAction::step(step_id("missing"))]);
631
632 let err = definition(
633 vec![
634 CeremonyState::initial(state_id("drafting")),
635 CeremonyState::terminal(state_id("done")),
636 ],
637 Vec::new(),
638 Vec::new(),
639 Vec::new(),
640 vec![role],
641 )
642 .unwrap_err();
643
644 assert!(matches!(
645 err,
646 DomainError::NotFound {
647 what: "ceremony_role.step_action"
648 }
649 ));
650 }
651
652 #[test]
653 fn rejects_empty_states_collection() {
654 let err =
655 definition(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()).unwrap_err();
656
657 assert!(matches!(
658 err,
659 DomainError::EmptyCollection {
660 field: "ceremony_definition.states"
661 }
662 ));
663 }
664
665 #[test]
666 fn rejects_transition_referencing_unknown_from_state() {
667 let transition = CeremonyTransition::new(
668 state_id("ghost"),
669 state_id("done"),
670 trigger("finish"),
671 Vec::new(),
672 )
673 .unwrap();
674
675 let err = definition(
676 vec![
677 CeremonyState::initial(state_id("drafting")),
678 CeremonyState::terminal(state_id("done")),
679 ],
680 vec![transition],
681 Vec::new(),
682 Vec::new(),
683 Vec::new(),
684 )
685 .unwrap_err();
686
687 assert!(matches!(
688 err,
689 DomainError::NotFound {
690 what: "ceremony_transition.from_state"
691 }
692 ));
693 }
694
695 #[test]
696 fn rejects_transition_referencing_unknown_to_state() {
697 let transition = CeremonyTransition::new(
698 state_id("drafting"),
699 state_id("ghost"),
700 trigger("finish"),
701 Vec::new(),
702 )
703 .unwrap();
704
705 let err = definition(
706 vec![
707 CeremonyState::initial(state_id("drafting")),
708 CeremonyState::terminal(state_id("done")),
709 ],
710 vec![transition],
711 Vec::new(),
712 Vec::new(),
713 Vec::new(),
714 )
715 .unwrap_err();
716
717 assert!(matches!(
718 err,
719 DomainError::NotFound {
720 what: "ceremony_transition.to_state"
721 }
722 ));
723 }
724
725 #[test]
726 fn rejects_duplicate_state_trigger_pairs() {
727 let first = CeremonyTransition::new(
728 state_id("drafting"),
729 state_id("done"),
730 trigger("finish"),
731 Vec::new(),
732 )
733 .unwrap();
734 let duplicate = CeremonyTransition::new(
735 state_id("drafting"),
736 state_id("done"),
737 trigger("finish"),
738 Vec::new(),
739 )
740 .unwrap();
741
742 let err = definition(
743 vec![
744 CeremonyState::initial(state_id("drafting")),
745 CeremonyState::terminal(state_id("done")),
746 ],
747 vec![first, duplicate],
748 Vec::new(),
749 Vec::new(),
750 Vec::new(),
751 )
752 .unwrap_err();
753
754 assert!(matches!(
755 err,
756 DomainError::AlreadyExists {
757 what: "ceremony_transition.state_trigger"
758 }
759 ));
760 }
761
762 #[test]
763 fn rejects_transition_referencing_unknown_guard() {
764 let transition = CeremonyTransition::new(
765 state_id("drafting"),
766 state_id("done"),
767 trigger("finish"),
768 vec![guard_name("absent")],
769 )
770 .unwrap();
771
772 let err = definition(
773 vec![
774 CeremonyState::initial(state_id("drafting")),
775 CeremonyState::terminal(state_id("done")),
776 ],
777 vec![transition],
778 Vec::new(),
779 Vec::new(),
780 Vec::new(),
781 )
782 .unwrap_err();
783
784 assert!(matches!(
785 err,
786 DomainError::NotFound {
787 what: "ceremony_transition.guard"
788 }
789 ));
790 }
791
792 #[test]
793 fn rejects_step_referencing_unknown_state() {
794 let err = definition(
795 vec![
796 CeremonyState::initial(state_id("drafting")),
797 CeremonyState::terminal(state_id("done")),
798 ],
799 Vec::new(),
800 vec![step("plan", "ghost")],
801 Vec::new(),
802 Vec::new(),
803 )
804 .unwrap_err();
805
806 assert!(matches!(
807 err,
808 DomainError::NotFound {
809 what: "ceremony_step.state"
810 }
811 ));
812 }
813
814 #[test]
815 fn rejects_role_that_references_unknown_transition_trigger() {
816 let role = role(vec![RoleAction::transition(trigger("ghost"))]);
817
818 let err = definition(
819 vec![
820 CeremonyState::initial(state_id("drafting")),
821 CeremonyState::terminal(state_id("done")),
822 ],
823 Vec::new(),
824 Vec::new(),
825 Vec::new(),
826 vec![role],
827 )
828 .unwrap_err();
829
830 assert!(matches!(
831 err,
832 DomainError::NotFound {
833 what: "ceremony_role.transition_action"
834 }
835 ));
836 }
837
838 #[test]
839 fn resolves_role_authorised_for_a_step() {
840 let definition = valid_definition();
841
842 assert_eq!(
843 definition.role_id_for_step(&step_id("plan")).unwrap(),
844 RoleId::new("facilitator").unwrap()
845 );
846 }
847
848 #[test]
849 fn rejects_step_with_no_authorised_role() {
850 let definition = valid_definition();
851
852 let err = definition
853 .role_id_for_step(&step_id("unowned"))
854 .unwrap_err();
855
856 assert!(matches!(
857 err,
858 DomainError::InvariantViolated {
859 reason: "no ceremony role can execute step"
860 }
861 ));
862 }
863
864 #[test]
865 fn resolves_role_authorised_for_a_transition() {
866 let definition = valid_definition();
867
868 assert_eq!(
869 definition
870 .role_id_for_transition(&trigger("finish"))
871 .unwrap(),
872 RoleId::new("facilitator").unwrap()
873 );
874 }
875
876 #[test]
877 fn rejects_transition_with_no_authorised_role() {
878 let definition = valid_definition();
879
880 let err = definition
881 .role_id_for_transition(&trigger("unowned"))
882 .unwrap_err();
883
884 assert!(matches!(
885 err,
886 DomainError::InvariantViolated {
887 reason: "no ceremony role can apply transition"
888 }
889 ));
890 }
891
892 #[test]
893 fn selects_guardless_transition_as_immediately_enabled() {
894 let transition = CeremonyTransition::new(
895 state_id("open"),
896 state_id("closed"),
897 trigger("go"),
898 Vec::new(),
899 )
900 .unwrap();
901 let definition = definition_with_budgets(
902 vec![
903 CeremonyState::initial(state_id("open")),
904 CeremonyState::terminal(state_id("closed")),
905 ],
906 vec![transition],
907 Vec::new(),
908 Vec::new(),
909 Vec::new(),
910 None,
911 Some(MaxBounces::new(1).unwrap()),
912 )
913 .unwrap();
914
915 let selected = definition
916 .next_satisfied_transition(
917 &state_id("open"),
918 &BTreeMap::new(),
919 &CeremonyContext::empty(),
920 )
921 .expect("guardless transition is always enabled");
922
923 assert_eq!(selected.trigger(), &trigger("go"));
924 }
925
926 #[test]
927 fn skips_transition_whose_guards_are_unsatisfied() {
928 let definition = valid_definition();
929
930 assert!(definition
931 .next_satisfied_transition(
932 &state_id("drafting"),
933 &BTreeMap::new(),
934 &CeremonyContext::empty(),
935 )
936 .is_none());
937 }
938
939 #[test]
940 fn yields_no_transition_out_of_a_terminal_state() {
941 let definition = valid_definition();
942
943 assert!(definition
944 .next_satisfied_transition(
945 &state_id("done"),
946 &BTreeMap::new(),
947 &CeremonyContext::empty(),
948 )
949 .is_none());
950 }
951
952 #[test]
953 fn rejects_guards_that_reference_unknown_steps() {
954 let guard = CeremonyGuard::new(
955 guard_name("unknown_step_done"),
956 GuardCondition::StepStatus {
957 step_id: step_id("missing"),
958 status: StepStatus::Completed,
959 },
960 );
961
962 let err = definition(
963 vec![
964 CeremonyState::initial(state_id("drafting")),
965 CeremonyState::terminal(state_id("done")),
966 ],
967 Vec::new(),
968 Vec::new(),
969 vec![guard],
970 Vec::new(),
971 )
972 .unwrap_err();
973
974 assert!(matches!(
975 err,
976 DomainError::NotFound {
977 what: "ceremony_guard.step"
978 }
979 ));
980 }
981
982 #[test]
988 fn serde_json_emits_sorted_keys() {
989 let mut out_of_order = serde_json::Map::new();
990 out_of_order.insert("zulu".to_owned(), serde_json::Value::from(1));
991 out_of_order.insert("alpha".to_owned(), serde_json::Value::from(2));
992
993 assert_eq!(
994 serde_json::to_string(&serde_json::Value::Object(out_of_order)).unwrap(),
995 r#"{"alpha":2,"zulu":1}"#,
996 "serde_json is no longer emitting sorted keys, so the definition digest is not canonical"
997 );
998 }
999
1000 #[test]
1001 fn the_same_definition_always_digests_the_same() {
1002 assert_eq!(
1003 valid_definition().digest().unwrap(),
1004 valid_definition().digest().unwrap()
1005 );
1006 }
1007
1008 #[test]
1009 fn a_material_difference_changes_the_digest() {
1010 let baseline = valid_definition().digest().unwrap();
1011 let renamed = definition(
1012 vec![
1013 CeremonyState::initial(state_id("drafting")),
1014 CeremonyState::terminal(state_id("finished")),
1015 ],
1016 vec![CeremonyTransition::new(
1017 state_id("drafting"),
1018 state_id("finished"),
1019 trigger("finish"),
1020 Vec::new(),
1021 )
1022 .unwrap()],
1023 Vec::new(),
1024 Vec::new(),
1025 Vec::new(),
1026 )
1027 .unwrap()
1028 .digest()
1029 .unwrap();
1030
1031 assert_ne!(baseline, renamed);
1032 }
1033
1034 #[test]
1035 fn transition_order_is_material_to_the_digest() {
1036 let states = || {
1040 vec![
1041 CeremonyState::initial(state_id("drafting")),
1042 CeremonyState::terminal(state_id("done")),
1043 CeremonyState::terminal(state_id("cancelled")),
1044 ]
1045 };
1046 let finish = || {
1047 CeremonyTransition::new(
1048 state_id("drafting"),
1049 state_id("done"),
1050 trigger("finish"),
1051 Vec::new(),
1052 )
1053 .unwrap()
1054 };
1055 let cancel = || {
1056 CeremonyTransition::new(
1057 state_id("drafting"),
1058 state_id("cancelled"),
1059 trigger("cancel"),
1060 Vec::new(),
1061 )
1062 .unwrap()
1063 };
1064
1065 let first = definition(
1066 states(),
1067 vec![finish(), cancel()],
1068 Vec::new(),
1069 Vec::new(),
1070 Vec::new(),
1071 )
1072 .unwrap();
1073 let swapped = definition(
1074 states(),
1075 vec![cancel(), finish()],
1076 Vec::new(),
1077 Vec::new(),
1078 Vec::new(),
1079 )
1080 .unwrap();
1081
1082 assert_ne!(first.digest().unwrap(), swapped.digest().unwrap());
1083 }
1084
1085 #[test]
1086 fn a_valid_definition_reports_no_findings_at_all() {
1087 let report = valid_definition().analyze();
1088
1089 assert!(report.is_valid());
1090 assert!(report.findings().is_empty());
1091 }
1092
1093 #[test]
1094 fn an_unreachable_state_is_warned_about_without_blocking_construction() {
1095 let definition = definition(
1096 vec![
1097 CeremonyState::initial(state_id("drafting")),
1098 CeremonyState::intermediate(state_id("orphan")),
1099 CeremonyState::terminal(state_id("done")),
1100 ],
1101 vec![
1102 CeremonyTransition::new(
1103 state_id("drafting"),
1104 state_id("done"),
1105 trigger("finish"),
1106 Vec::new(),
1107 )
1108 .unwrap(),
1109 CeremonyTransition::new(
1110 state_id("orphan"),
1111 state_id("done"),
1112 trigger("rescue"),
1113 Vec::new(),
1114 )
1115 .unwrap(),
1116 ],
1117 Vec::new(),
1118 Vec::new(),
1119 Vec::new(),
1120 )
1121 .unwrap();
1122
1123 let report = definition.analyze();
1124 let warnings = report.warnings().collect::<Vec<_>>();
1125
1126 assert!(report.is_valid());
1127 assert_eq!(warnings.len(), 1);
1128 assert_eq!(
1129 warnings[0].locus(),
1130 &CeremonyValidationLocus::state(state_id("orphan"))
1131 );
1132 }
1133
1134 #[test]
1135 fn a_state_that_cannot_reach_a_terminal_is_warned_about() {
1136 let definition = definition_with_budgets(
1137 vec![
1138 CeremonyState::initial(state_id("drafting")),
1139 CeremonyState::intermediate(state_id("stuck")),
1140 CeremonyState::terminal(state_id("done")),
1141 ],
1142 vec![
1143 CeremonyTransition::new(
1144 state_id("drafting"),
1145 state_id("done"),
1146 trigger("finish"),
1147 Vec::new(),
1148 )
1149 .unwrap(),
1150 CeremonyTransition::new(
1151 state_id("drafting"),
1152 state_id("stuck"),
1153 trigger("stall"),
1154 Vec::new(),
1155 )
1156 .unwrap(),
1157 CeremonyTransition::new(
1158 state_id("stuck"),
1159 state_id("stuck"),
1160 trigger("spin"),
1161 Vec::new(),
1162 )
1163 .unwrap(),
1164 ],
1165 Vec::new(),
1166 Vec::new(),
1167 Vec::new(),
1168 None,
1169 Some(MaxBounces::new(1).unwrap()),
1170 )
1171 .unwrap();
1172
1173 let report = definition.analyze();
1174 let warnings = report.warnings().collect::<Vec<_>>();
1175
1176 assert!(warnings.iter().any(|warning| {
1177 warning.locus() == &CeremonyValidationLocus::state(state_id("stuck"))
1178 && matches!(
1179 warning.defect(),
1180 DomainError::InvariantViolated {
1181 reason: "no terminal state is reachable from this ceremony state"
1182 }
1183 )
1184 }));
1185 }
1186
1187 #[test]
1188 fn a_definition_without_any_terminal_state_is_warned_about() {
1189 let definition = definition(
1190 vec![CeremonyState::initial(state_id("drafting"))],
1191 Vec::new(),
1192 Vec::new(),
1193 Vec::new(),
1194 Vec::new(),
1195 )
1196 .unwrap();
1197
1198 let report = definition.analyze();
1199 let warnings = report.warnings().collect::<Vec<_>>();
1200
1201 assert_eq!(warnings.len(), 1);
1202 assert_eq!(warnings[0].locus(), &CeremonyValidationLocus::Definition);
1203 }
1204
1205 #[test]
1206 fn structural_errors_suppress_reachability_noise() {
1207 let report = definition(
1208 vec![
1209 CeremonyState::initial(state_id("drafting")),
1210 CeremonyState::initial(state_id("also_drafting")),
1211 CeremonyState::terminal(state_id("done")),
1212 ],
1213 Vec::new(),
1214 Vec::new(),
1215 Vec::new(),
1216 Vec::new(),
1217 )
1218 .unwrap_err();
1219
1220 assert!(matches!(
1221 report,
1222 DomainError::InvariantViolated {
1223 reason: "ceremony definition must have exactly one initial state"
1224 }
1225 ));
1226 }
1227}