Skip to main content

made_core/value_objects/ceremony/
step_result.rs

1use 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    /// Preserve a known domain failure without classifying its prose.
58    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}