Skip to main content

oximedia_workflow/
step_result.rs

1//! Step result types for workflow execution tracking.
2//!
3//! Provides `StepStatus`, `StepResult`, and `StepResultLog` for
4//! recording and querying per-step outcomes in a workflow run.
5
6#![allow(dead_code)]
7
8use std::collections::HashMap;
9
10// ---------------------------------------------------------------------------
11// StepStatus
12// ---------------------------------------------------------------------------
13
14/// The execution status of a single workflow step.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub enum StepStatus {
17    /// The step has not yet started.
18    Pending,
19    /// The step is currently executing.
20    Running,
21    /// The step completed successfully.
22    Success,
23    /// The step completed with an error.
24    Failed,
25    /// The step was intentionally skipped.
26    Skipped,
27}
28
29impl StepStatus {
30    /// Returns `true` if no further execution can follow from this status.
31    #[must_use]
32    pub fn is_terminal(&self) -> bool {
33        matches!(self, Self::Success | Self::Failed | Self::Skipped)
34    }
35
36    /// Human-readable label for the status.
37    #[must_use]
38    pub fn label(&self) -> &'static str {
39        match self {
40            Self::Pending => "pending",
41            Self::Running => "running",
42            Self::Success => "success",
43            Self::Failed => "failed",
44            Self::Skipped => "skipped",
45        }
46    }
47}
48
49// ---------------------------------------------------------------------------
50// StepResult
51// ---------------------------------------------------------------------------
52
53/// The outcome of a single step execution.
54#[derive(Debug, Clone)]
55pub struct StepResult {
56    /// Unique identifier for the step.
57    pub step_id: String,
58    /// Final status of the step.
59    pub status: StepStatus,
60    /// Wall-clock milliseconds from start to finish.
61    elapsed_ms: u64,
62    /// Optional human-readable message.
63    pub message: Option<String>,
64}
65
66impl StepResult {
67    /// Create a new `StepResult`.
68    #[must_use]
69    pub fn new(step_id: impl Into<String>, status: StepStatus, elapsed_ms: u64) -> Self {
70        Self {
71            step_id: step_id.into(),
72            status,
73            elapsed_ms,
74            message: None,
75        }
76    }
77
78    /// Attach a message to this result.
79    #[must_use]
80    pub fn with_message(mut self, msg: impl Into<String>) -> Self {
81        self.message = Some(msg.into());
82        self
83    }
84
85    /// Wall-clock elapsed time in milliseconds.
86    #[must_use]
87    pub fn elapsed_ms(&self) -> u64 {
88        self.elapsed_ms
89    }
90
91    /// Convenience: was this step successful?
92    #[must_use]
93    pub fn is_success(&self) -> bool {
94        self.status == StepStatus::Success
95    }
96
97    /// Convenience: did this step fail?
98    #[must_use]
99    pub fn is_failed(&self) -> bool {
100        self.status == StepStatus::Failed
101    }
102}
103
104// ---------------------------------------------------------------------------
105// StepResultLog
106// ---------------------------------------------------------------------------
107
108/// A collection of `StepResult`s grouped by workflow run identifier.
109#[derive(Debug, Default)]
110pub struct StepResultLog {
111    entries: HashMap<String, Vec<StepResult>>,
112}
113
114impl StepResultLog {
115    /// Create an empty log.
116    #[must_use]
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Append a result for the given `run_id`.
122    pub fn add(&mut self, run_id: impl Into<String>, result: StepResult) {
123        self.entries.entry(run_id.into()).or_default().push(result);
124    }
125
126    /// Fraction of terminal steps that succeeded for a given run.
127    ///
128    /// Returns `None` if the run is unknown or has no terminal steps.
129    #[allow(clippy::cast_precision_loss)]
130    #[must_use]
131    pub fn success_rate(&self, run_id: &str) -> Option<f64> {
132        let results = self.entries.get(run_id)?;
133        let terminal: Vec<_> = results.iter().filter(|r| r.status.is_terminal()).collect();
134        if terminal.is_empty() {
135            return None;
136        }
137        let successes = terminal.iter().filter(|r| r.is_success()).count();
138        Some(successes as f64 / terminal.len() as f64)
139    }
140
141    /// Return only the failed steps for a given run.
142    #[must_use]
143    pub fn failed_steps(&self, run_id: &str) -> Vec<&StepResult> {
144        self.entries
145            .get(run_id)
146            .map(|v| v.iter().filter(|r| r.is_failed()).collect())
147            .unwrap_or_default()
148    }
149
150    /// Total number of step results recorded across all runs.
151    #[must_use]
152    pub fn total_entries(&self) -> usize {
153        self.entries.values().map(Vec::len).sum()
154    }
155
156    /// All run identifiers currently in the log.
157    #[must_use]
158    pub fn run_ids(&self) -> Vec<&str> {
159        self.entries.keys().map(String::as_str).collect()
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Tests
165// ---------------------------------------------------------------------------
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_status_pending_not_terminal() {
173        assert!(!StepStatus::Pending.is_terminal());
174    }
175
176    #[test]
177    fn test_status_running_not_terminal() {
178        assert!(!StepStatus::Running.is_terminal());
179    }
180
181    #[test]
182    fn test_status_success_is_terminal() {
183        assert!(StepStatus::Success.is_terminal());
184    }
185
186    #[test]
187    fn test_status_failed_is_terminal() {
188        assert!(StepStatus::Failed.is_terminal());
189    }
190
191    #[test]
192    fn test_status_skipped_is_terminal() {
193        assert!(StepStatus::Skipped.is_terminal());
194    }
195
196    #[test]
197    fn test_status_labels() {
198        assert_eq!(StepStatus::Pending.label(), "pending");
199        assert_eq!(StepStatus::Running.label(), "running");
200        assert_eq!(StepStatus::Success.label(), "success");
201        assert_eq!(StepStatus::Failed.label(), "failed");
202        assert_eq!(StepStatus::Skipped.label(), "skipped");
203    }
204
205    #[test]
206    fn test_step_result_elapsed_ms() {
207        let r = StepResult::new("step1", StepStatus::Success, 250);
208        assert_eq!(r.elapsed_ms(), 250);
209    }
210
211    #[test]
212    fn test_step_result_is_success() {
213        let r = StepResult::new("step1", StepStatus::Success, 10);
214        assert!(r.is_success());
215        assert!(!r.is_failed());
216    }
217
218    #[test]
219    fn test_step_result_is_failed() {
220        let r = StepResult::new("step2", StepStatus::Failed, 5);
221        assert!(r.is_failed());
222        assert!(!r.is_success());
223    }
224
225    #[test]
226    fn test_step_result_with_message() {
227        let r = StepResult::new("s", StepStatus::Failed, 0).with_message("oops");
228        assert_eq!(r.message.as_deref(), Some("oops"));
229    }
230
231    #[test]
232    fn test_log_add_and_total_entries() {
233        let mut log = StepResultLog::new();
234        log.add("run1", StepResult::new("a", StepStatus::Success, 1));
235        log.add("run1", StepResult::new("b", StepStatus::Failed, 2));
236        log.add("run2", StepResult::new("c", StepStatus::Skipped, 0));
237        assert_eq!(log.total_entries(), 3);
238    }
239
240    #[test]
241    fn test_success_rate_all_success() {
242        let mut log = StepResultLog::new();
243        log.add("r", StepResult::new("a", StepStatus::Success, 1));
244        log.add("r", StepResult::new("b", StepStatus::Success, 2));
245        let rate = log.success_rate("r").expect("should succeed in test");
246        assert!((rate - 1.0).abs() < f64::EPSILON);
247    }
248
249    #[test]
250    fn test_success_rate_mixed() {
251        let mut log = StepResultLog::new();
252        log.add("r", StepResult::new("a", StepStatus::Success, 1));
253        log.add("r", StepResult::new("b", StepStatus::Failed, 2));
254        let rate = log.success_rate("r").expect("should succeed in test");
255        assert!((rate - 0.5).abs() < f64::EPSILON);
256    }
257
258    #[test]
259    fn test_success_rate_unknown_run() {
260        let log = StepResultLog::new();
261        assert!(log.success_rate("unknown").is_none());
262    }
263
264    #[test]
265    fn test_failed_steps() {
266        let mut log = StepResultLog::new();
267        log.add("r", StepResult::new("ok", StepStatus::Success, 1));
268        log.add("r", StepResult::new("bad", StepStatus::Failed, 2));
269        let failed = log.failed_steps("r");
270        assert_eq!(failed.len(), 1);
271        assert_eq!(failed[0].step_id, "bad");
272    }
273
274    #[test]
275    fn test_run_ids() {
276        let mut log = StepResultLog::new();
277        log.add("alpha", StepResult::new("x", StepStatus::Success, 0));
278        log.add("beta", StepResult::new("y", StepStatus::Success, 0));
279        let mut ids = log.run_ids();
280        ids.sort_unstable();
281        assert_eq!(ids, vec!["alpha", "beta"]);
282    }
283
284    #[test]
285    fn test_success_rate_no_terminal_steps() {
286        let mut log = StepResultLog::new();
287        log.add("r", StepResult::new("x", StepStatus::Running, 0));
288        assert!(log.success_rate("r").is_none());
289    }
290}