1use crate::criterion::CriterionError;
9use crate::expression::ExpressionError;
10use crate::http::ClientError;
11use crate::operation::OperationError;
12use crate::select::SelectError;
13use serde_json::Value;
14use std::collections::BTreeMap;
15use std::fmt;
16use std::time::Duration;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Outcome {
22 #[default]
24 Succeeded,
25 Failed,
27 Ended,
29}
30
31impl fmt::Display for Outcome {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 f.write_str(match self {
34 Outcome::Succeeded => "succeeded",
35 Outcome::Failed => "failed",
36 Outcome::Ended => "ended early",
37 })
38 }
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct CriterionOutcome {
45 pub condition: String,
47 pub passed: bool,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum Performed {
56 Request {
58 method: String,
60 url: String,
62 status: u16,
64 },
65 Workflow {
67 workflow_id: String,
69 outcome: Outcome,
71 },
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76#[non_exhaustive]
77pub struct StepRecord {
78 pub workflow_id: String,
80 pub step_id: String,
82 pub attempt: u32,
84 pub performed: Performed,
86 pub criteria: Vec<CriterionOutcome>,
88 pub passed: bool,
90 pub outputs: BTreeMap<String, Value>,
92 pub action: Option<String>,
94 pub elapsed: Duration,
96}
97
98impl StepRecord {
99 #[must_use]
102 pub fn status(&self) -> Option<u16> {
103 match &self.performed {
104 Performed::Request { status, .. } => Some(*status),
105 Performed::Workflow { .. } => None,
106 }
107 }
108
109 #[must_use]
111 pub fn method(&self) -> Option<&str> {
112 match &self.performed {
113 Performed::Request { method, .. } => Some(method),
114 Performed::Workflow { .. } => None,
115 }
116 }
117
118 #[must_use]
120 pub fn url(&self) -> Option<&str> {
121 match &self.performed {
122 Performed::Request { url, .. } => Some(url),
123 Performed::Workflow { .. } => None,
124 }
125 }
126}
127
128#[derive(Clone, Debug, Default, PartialEq, Eq)]
130#[non_exhaustive]
131pub struct ExecutionReport {
132 pub workflow_id: String,
134 pub outcome: Outcome,
136 pub outputs: BTreeMap<String, Value>,
138 pub steps: Vec<StepRecord>,
141}
142
143impl ExecutionReport {
144 #[must_use]
146 pub fn is_success(&self) -> bool {
147 self.outcome != Outcome::Failed
148 }
149}
150
151impl fmt::Display for ExecutionReport {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 writeln!(f, "workflow `{}` {}", self.workflow_id, self.outcome)?;
154 for step in &self.steps {
155 match &step.performed {
156 Performed::Request {
157 method,
158 url,
159 status,
160 } => write!(f, "- {} {method} {url} → {status}", step.step_id)?,
161 Performed::Workflow {
162 workflow_id,
163 outcome,
164 } => write!(f, "- {} → workflow `{workflow_id}` {outcome}", step.step_id)?,
165 }
166 if step.attempt > 1 {
167 write!(f, " (attempt {})", step.attempt)?;
168 }
169 if !step.passed {
170 write!(f, " — failed")?;
171 }
172 if let Some(action) = &step.action {
173 write!(f, " — {action}")?;
174 }
175 writeln!(f)?;
176 }
177 for (name, value) in &self.outputs {
178 writeln!(f, " {name} = {value}")?;
179 }
180 Ok(())
181 }
182}
183
184#[derive(Debug, thiserror::Error)]
186#[non_exhaustive]
187pub enum ExecutionError {
188 #[error("the description has no workflow `{0}`")]
190 UnknownWorkflow(String),
191 #[error("workflow `{workflow}` has no step `{step}` to go to")]
193 UnknownStep {
194 workflow: String,
196 step: String,
198 },
199 #[error("`dependsOn` is circular: {0}")]
201 Circular(String),
202 #[error(transparent)]
204 Operation(#[from] OperationError),
205 #[error(transparent)]
207 Expression(#[from] ExpressionError),
208 #[error(transparent)]
210 Select(#[from] SelectError),
211 #[error(transparent)]
213 Criterion(#[from] CriterionError),
214 #[error("the request could not be sent: {0}")]
216 Client(#[from] ClientError),
217 #[error("step `{step}` cannot be turned into a request: {reason}")]
219 BadRequest {
220 step: String,
222 reason: String,
224 },
225 #[error("the run stopped after reaching its {limit} limit of {at}")]
227 Limit {
228 limit: &'static str,
230 at: usize,
232 },
233 #[error("{0}")]
235 Unsupported(String),
236 #[error("a response arrived when no request was outstanding")]
238 NotWaiting,
239 #[error("the run is waiting for a response to `{method} {url}` — supply it before advancing")]
242 Awaiting {
243 method: String,
245 url: String,
247 },
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use serde_json::json;
254
255 fn record(step_id: &str, status: u16, passed: bool) -> StepRecord {
256 StepRecord {
257 workflow_id: "buyPet".to_owned(),
258 step_id: step_id.to_owned(),
259 attempt: 1,
260 performed: Performed::Request {
261 method: "GET".to_owned(),
262 url: format!("https://api.example.com/{step_id}"),
263 status,
264 },
265 criteria: vec![CriterionOutcome {
266 condition: "$statusCode == 200".to_owned(),
267 passed,
268 }],
269 passed,
270 outputs: BTreeMap::new(),
271 action: None,
272 elapsed: Duration::from_millis(12),
273 }
274 }
275
276 #[test]
277 fn a_report_reads_as_what_happened() {
278 let report = ExecutionReport {
279 workflow_id: "buyPet".to_owned(),
280 outcome: Outcome::Succeeded,
281 outputs: BTreeMap::from([("pet".to_owned(), json!({ "id": 7 }))]),
282 steps: vec![record("findPet", 200, true)],
283 };
284 assert_eq!(
285 report.to_string(),
286 "workflow `buyPet` succeeded\n\
287 - findPet GET https://api.example.com/findPet → 200\n \
288 pet = {\"id\":7}\n"
289 );
290 assert!(report.is_success());
291 }
292
293 #[test]
294 fn a_failure_and_a_retry_show_in_the_line() {
295 let mut failed = record("findPet", 503, false);
296 failed.attempt = 2;
297 failed.action = Some("retry".to_owned());
298 let report = ExecutionReport {
299 workflow_id: "buyPet".to_owned(),
300 outcome: Outcome::Failed,
301 outputs: BTreeMap::new(),
302 steps: vec![failed],
303 };
304 let text = report.to_string();
305 assert!(text.contains("workflow `buyPet` failed"), "{text}");
306 assert!(text.contains("(attempt 2)"), "{text}");
307 assert!(text.contains("— failed"), "{text}");
308 assert!(text.contains("— retry"), "{text}");
309 assert!(!report.is_success());
310 }
311
312 #[test]
313 fn a_step_that_called_a_workflow_reads_as_what_it_called() {
314 let record = StepRecord {
315 workflow_id: "buyPet".to_owned(),
316 step_id: "authenticate".to_owned(),
317 attempt: 1,
318 performed: Performed::Workflow {
319 workflow_id: "login".to_owned(),
320 outcome: Outcome::Succeeded,
321 },
322 criteria: Vec::new(),
323 passed: true,
324 outputs: BTreeMap::from([("token".to_owned(), json!("t-1"))]),
325 action: None,
326 elapsed: Duration::from_millis(30),
327 };
328 assert_eq!(record.status(), None);
331 assert_eq!(record.method(), None);
332 assert_eq!(record.url(), None);
333
334 let report = ExecutionReport {
335 workflow_id: "buyPet".to_owned(),
336 outcome: Outcome::Succeeded,
337 outputs: BTreeMap::new(),
338 steps: vec![record],
339 };
340 assert_eq!(
341 report.to_string(),
342 "workflow `buyPet` succeeded\n\
343 - authenticate → workflow `login` succeeded\n"
344 );
345 }
346
347 #[test]
348 fn a_step_that_sent_a_request_says_what_it_sent() {
349 let record = record("findPet", 200, true);
350 assert_eq!(record.status(), Some(200));
351 assert_eq!(record.method(), Some("GET"));
352 assert_eq!(record.url(), Some("https://api.example.com/findPet"));
353 }
354
355 #[test]
356 fn a_run_waiting_for_a_response_says_which_one() {
357 assert_eq!(
358 ExecutionError::Awaiting {
359 method: "GET".to_owned(),
360 url: "https://api.example.com/pets".to_owned(),
361 }
362 .to_string(),
363 "the run is waiting for a response to `GET https://api.example.com/pets` — supply it before advancing"
364 );
365 }
366
367 #[test]
368 fn an_outcome_reads_as_a_word() {
369 assert_eq!(Outcome::Succeeded.to_string(), "succeeded");
370 assert_eq!(Outcome::Failed.to_string(), "failed");
371 assert_eq!(Outcome::Ended.to_string(), "ended early");
372 }
373
374 #[test]
375 fn an_error_says_which_thing_was_missing() {
376 assert_eq!(
377 ExecutionError::UnknownWorkflow("nope".to_owned()).to_string(),
378 "the description has no workflow `nope`"
379 );
380 assert_eq!(
381 ExecutionError::UnknownStep {
382 workflow: "buyPet".to_owned(),
383 step: "nope".to_owned(),
384 }
385 .to_string(),
386 "workflow `buyPet` has no step `nope` to go to"
387 );
388 assert_eq!(
389 ExecutionError::Limit {
390 limit: "step",
391 at: 1000
392 }
393 .to_string(),
394 "the run stopped after reaching its step limit of 1000"
395 );
396 }
397}