made_core/value_objects/ceremony/
step_result.rs1use serde::{Deserialize, Serialize};
2
3use crate::error::DomainError;
4
5use super::{StepErrorMessage, StepFailureKind, StepOutput, StepStatus};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct StepResult {
9 status: StepStatus,
10 output: StepOutput,
11 error_message: Option<StepErrorMessage>,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 failure_kind: Option<StepFailureKind>,
14}
15
16impl StepResult {
17 pub fn new(
18 status: StepStatus,
19 output: StepOutput,
20 error_message: Option<StepErrorMessage>,
21 ) -> Result<Self, DomainError> {
22 if matches!(status, StepStatus::Pending | StepStatus::InProgress) {
23 return Err(DomainError::InvariantViolated {
24 reason: "step result status must be observable",
25 });
26 }
27 if status == StepStatus::Failed && error_message.is_none() {
28 return Err(DomainError::EmptyField {
29 field: "step_result.error_message",
30 });
31 }
32 if status != StepStatus::Failed && error_message.is_some() {
33 return Err(DomainError::InvariantViolated {
34 reason: "only failed step results may carry an error message",
35 });
36 }
37 Ok(Self {
38 status,
39 output,
40 error_message,
41 failure_kind: None,
42 })
43 }
44
45 pub fn completed(output: StepOutput) -> Result<Self, DomainError> {
46 Self::new(StepStatus::Completed, output, None)
47 }
48
49 pub fn waiting_for_human(output: StepOutput) -> Result<Self, DomainError> {
50 Self::new(StepStatus::WaitingForHuman, output, None)
51 }
52
53 pub fn failed(error_message: StepErrorMessage) -> Result<Self, DomainError> {
54 Self::new(StepStatus::Failed, StepOutput::empty(), Some(error_message))
55 }
56
57 pub fn from_handler_error(error: &DomainError) -> Result<Self, DomainError> {
59 let mut result = Self::failed(StepErrorMessage::new(error.to_string())?)?;
60 if matches!(error, DomainError::NoValidProposal { .. }) {
61 result.failure_kind = Some(StepFailureKind::NoValidProposal);
62 }
63 Ok(result)
64 }
65
66 pub fn timed_out() -> Result<Self, DomainError> {
67 let mut result = Self::failed(StepErrorMessage::new("step deadline exceeded")?)?;
68 result.failure_kind = Some(StepFailureKind::Timeout);
69 Ok(result)
70 }
71
72 #[must_use]
73 pub fn failure_kind(&self) -> Option<StepFailureKind> {
74 self.failure_kind
75 }
76
77 #[must_use]
78 pub fn status(&self) -> StepStatus {
79 self.status
80 }
81
82 #[must_use]
83 pub fn output(&self) -> &StepOutput {
84 &self.output
85 }
86
87 #[must_use]
88 pub fn error_message(&self) -> Option<&StepErrorMessage> {
89 self.error_message.as_ref()
90 }
91
92 #[must_use]
93 pub fn into_parts(self) -> (StepStatus, StepOutput, Option<StepErrorMessage>) {
94 (self.status, self.output, self.error_message)
95 }
96
97 #[must_use]
98 pub fn is_success(&self) -> bool {
99 self.status.is_success()
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::entities::{ceremony_events::StepFailed, CeremonyEvent, CeremonyEventReader};
107 use crate::value_objects::{
108 AuditEventType, EventSchemaVersion, RoleId, StateIteration, StateVisit, StepAttempt,
109 StepId, StepIteration,
110 };
111
112 #[test]
113 fn only_typed_domain_failure_is_classified() {
114 let typed = StepResult::from_handler_error(&DomainError::NoValidProposal {
115 contract_id: "contract".to_owned(),
116 })
117 .unwrap();
118 assert_eq!(typed.failure_kind(), Some(StepFailureKind::NoValidProposal));
119 let prose =
120 StepResult::failed(StepErrorMessage::new("NoValidProposal no_valid_proposal").unwrap())
121 .unwrap();
122 assert_eq!(prose.failure_kind(), None);
123 let bytes = serde_json::to_string(&prose).unwrap();
124 assert_eq!(
125 bytes,
126 r#"{"status":"FAILED","output":{},"error_message":"NoValidProposal no_valid_proposal"}"#
127 );
128 assert_eq!(serde_json::from_str::<StepResult>(&bytes).unwrap(), prose);
129 }
130
131 #[test]
132 fn classified_failure_requires_its_new_event_version_and_legacy_stays_readable() {
133 let mut failed = StepFailed {
134 step_id: StepId::new("review").unwrap(),
135 state_visit: Some(StateVisit::FIRST),
136 state_iteration: Some(StateIteration::FIRST),
137 iteration: StepIteration::FIRST,
138 attempt: StepAttempt::FIRST,
139 result: StepResult::from_handler_error(&DomainError::NoValidProposal {
140 contract_id: "contract".to_owned(),
141 })
142 .unwrap(),
143 finished_by: RoleId::new("reviewer").unwrap(),
144 finished_at: time::OffsetDateTime::UNIX_EPOCH,
145 };
146 let event = CeremonyEvent::StepFailed(failed.clone());
147 assert_eq!(event.schema_version(), EventSchemaVersion::V4);
148 let raw = serde_json::to_value(&event).unwrap();
149 assert!(CeremonyEventReader::read(
150 AuditEventType::StepFailed,
151 EventSchemaVersion::V3,
152 raw.clone()
153 )
154 .is_err());
155 assert_eq!(
156 CeremonyEventReader::read(AuditEventType::StepFailed, EventSchemaVersion::V4, raw)
157 .unwrap(),
158 event
159 );
160 failed.result = StepResult::failed(StepErrorMessage::new("old failure").unwrap()).unwrap();
161 let legacy = CeremonyEvent::StepFailed(failed);
162 assert_eq!(legacy.schema_version(), EventSchemaVersion::V3);
163 let raw = serde_json::to_value(&legacy).unwrap();
164 assert_eq!(
165 CeremonyEventReader::read(AuditEventType::StepFailed, EventSchemaVersion::V3, raw)
166 .unwrap(),
167 legacy
168 );
169 }
170}