Skip to main content

oximedia_workflow/
step_conditions.rs

1//! Conditional step execution for workflow orchestration.
2//!
3//! [`StepCondition`] variants cover static conditions (`Always`), success/failure
4//! guards, direct field lookups, expression strings (`"field op value"`), and
5//! recursive logical combinators (`And`, `Or`, `Not`).
6//!
7//! Use [`ConditionEvaluator::evaluate`] to test a condition against a
8//! [`StepContext`] gathered at runtime.
9
10#![allow(dead_code)]
11
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15// ── StepCondition ─────────────────────────────────────────────────────────────
16
17/// A condition that gates whether a workflow step should execute.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub enum StepCondition {
20    /// Always execute — the step is unconditional.
21    Always,
22    /// Execute only when the immediately preceding step succeeded.
23    OnPreviousSuccess,
24    /// Execute only when the immediately preceding step failed.
25    OnPreviousFailure,
26    /// Evaluate a simple expression of the form `"field op value"`.
27    ///
28    /// Supported operators: `>`, `<`, `==`, `!=`, `>=`, `<=`, `contains`.
29    Expression(String),
30    /// Execute when `ctx.previous_output[field] == value`.
31    FieldEquals {
32        /// Output field key.
33        field: String,
34        /// Expected value.
35        value: String,
36    },
37    /// Execute when `ctx.previous_output[field]` contains `substring`.
38    FieldContains {
39        /// Output field key.
40        field: String,
41        /// Required substring.
42        substring: String,
43    },
44    /// All sub-conditions must be true (short-circuit).
45    And(Vec<StepCondition>),
46    /// At least one sub-condition must be true (short-circuit).
47    Or(Vec<StepCondition>),
48    /// Negation of the inner condition.
49    Not(Box<StepCondition>),
50}
51
52// ── StepContext ───────────────────────────────────────────────────────────────
53
54/// Runtime context supplied to [`ConditionEvaluator::evaluate`].
55#[derive(Debug, Clone)]
56pub struct StepContext {
57    /// Identifier of the step whose condition is being evaluated.
58    pub step_id: String,
59    /// Whether the immediately preceding step completed successfully.
60    pub previous_success: bool,
61    /// Key/value output produced by the preceding step.
62    pub previous_output: HashMap<String, String>,
63}
64
65impl StepContext {
66    /// Create a context representing a successful previous step with no output.
67    #[must_use]
68    pub fn new(step_id: impl Into<String>) -> Self {
69        Self {
70            step_id: step_id.into(),
71            previous_success: true,
72            previous_output: HashMap::new(),
73        }
74    }
75
76    /// Override the success flag.
77    #[must_use]
78    pub fn with_success(mut self, success: bool) -> Self {
79        self.previous_success = success;
80        self
81    }
82
83    /// Insert an output key/value pair.
84    #[must_use]
85    pub fn with_output(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
86        self.previous_output.insert(key.into(), value.into());
87        self
88    }
89}
90
91// ── ConditionEvaluator ───────────────────────────────────────────────────────
92
93/// Evaluates [`StepCondition`]s against a [`StepContext`].
94pub struct ConditionEvaluator;
95
96impl ConditionEvaluator {
97    /// Evaluate `condition` using the runtime values in `ctx`.
98    ///
99    /// `And` and `Or` short-circuit: evaluation stops as soon as the result
100    /// is determined.
101    #[must_use]
102    pub fn evaluate(condition: &StepCondition, ctx: &StepContext) -> bool {
103        match condition {
104            StepCondition::Always => true,
105            StepCondition::OnPreviousSuccess => ctx.previous_success,
106            StepCondition::OnPreviousFailure => !ctx.previous_success,
107            StepCondition::Expression(expr) => evaluate_expression(expr, ctx),
108            StepCondition::FieldEquals { field, value } => {
109                ctx.previous_output.get(field).map_or(false, |v| v == value)
110            }
111            StepCondition::FieldContains { field, substring } => ctx
112                .previous_output
113                .get(field)
114                .map_or(false, |v| v.contains(substring.as_str())),
115            StepCondition::And(conditions) => conditions.iter().all(|c| Self::evaluate(c, ctx)),
116            StepCondition::Or(conditions) => conditions.iter().any(|c| Self::evaluate(c, ctx)),
117            StepCondition::Not(inner) => !Self::evaluate(inner, ctx),
118        }
119    }
120}
121
122// ── Expression parser / evaluator ────────────────────────────────────────────
123
124/// Parse and evaluate a simple expression `"field op value"` against `ctx`.
125///
126/// Supported `op` tokens: `>=`, `<=`, `!=`, `==`, `>`, `<`, `contains`.
127/// The `field` is looked up in `ctx.previous_output`; the comparison is
128/// attempted numerically first, then as a string equality/containment.
129fn evaluate_expression(expr: &str, ctx: &StepContext) -> bool {
130    let expr = expr.trim();
131
132    // Ordered by length (longest first) so `>=` is matched before `>`.
133    const OPS: &[&str] = &[">=", "<=", "!=", "==", ">", "<", "contains"];
134
135    for &op in OPS {
136        if let Some(op_pos) = find_op(expr, op) {
137            let field = expr[..op_pos].trim();
138            let rhs = expr[op_pos + op.len()..].trim();
139
140            let lhs_raw = match ctx.previous_output.get(field) {
141                Some(v) => v.as_str(),
142                None => return false,
143            };
144
145            return compare_str(lhs_raw, op, rhs);
146        }
147    }
148
149    // No operator found — treat as boolean field test.
150    ctx.previous_output
151        .get(expr)
152        .map_or(false, |v| is_truthy(v))
153}
154
155/// Find the byte offset of `op` in `expr`, skipping occurrences inside longer
156/// operators (e.g. avoid matching `>` inside `>=`).
157fn find_op(expr: &str, op: &str) -> Option<usize> {
158    // We search left-to-right and ensure the character immediately following
159    // the found position is not part of a longer operator.
160    let bytes = expr.as_bytes();
161    let op_bytes = op.as_bytes();
162    let len = op_bytes.len();
163
164    let mut i = 0usize;
165    while i + len <= bytes.len() {
166        if &bytes[i..i + len] == op_bytes {
167            // For single-char ops like `>` or `<`, ensure the next char is
168            // not `=` (which would make it `>=` / `<=`).
169            let next_ok = if len == 1 {
170                bytes.get(i + 1) != Some(&b'=')
171            } else {
172                true
173            };
174            if next_ok {
175                return Some(i);
176            }
177        }
178        i += 1;
179    }
180    None
181}
182
183/// Compare `lhs` (raw string from output) to `rhs` (literal from expression)
184/// using operator `op`.
185fn compare_str(lhs: &str, op: &str, rhs: &str) -> bool {
186    // Attempt numeric comparison first.
187    if let (Ok(l), Ok(r)) = (lhs.parse::<f64>(), rhs.parse::<f64>()) {
188        return match op {
189            "==" => (l - r).abs() < f64::EPSILON,
190            "!=" => (l - r).abs() >= f64::EPSILON,
191            ">" => l > r,
192            "<" => l < r,
193            ">=" => l >= r,
194            "<=" => l <= r,
195            "contains" => lhs.contains(rhs),
196            _ => false,
197        };
198    }
199
200    // String comparison.
201    match op {
202        "==" => lhs == rhs,
203        "!=" => lhs != rhs,
204        "contains" => lhs.contains(rhs),
205        // Lexicographic ordering for non-numeric strings.
206        ">" => lhs > rhs,
207        "<" => lhs < rhs,
208        ">=" => lhs >= rhs,
209        "<=" => lhs <= rhs,
210        _ => false,
211    }
212}
213
214/// Interpret a string value as boolean.
215fn is_truthy(v: &str) -> bool {
216    matches!(v.to_lowercase().as_str(), "true" | "yes" | "1")
217}
218
219// ── StepCondition::parse ─────────────────────────────────────────────────────
220
221impl StepCondition {
222    /// Parse a [`StepCondition`] from its string representation.
223    ///
224    /// Supported forms:
225    /// - `"always"` → [`StepCondition::Always`]
226    /// - `"on_success"` / `"on_previous_success"` → [`StepCondition::OnPreviousSuccess`]
227    /// - `"on_failure"` / `"on_previous_failure"` → [`StepCondition::OnPreviousFailure`]
228    /// - `"expr: <expression>"` → [`StepCondition::Expression`]
229    /// - `"field_equals: <field>=<value>"` → [`StepCondition::FieldEquals`]
230    /// - `"field_contains: <field>=<substring>"` → [`StepCondition::FieldContains`]
231    ///
232    /// # Errors
233    ///
234    /// Returns an error string for unrecognised or malformed input.
235    pub fn parse(s: &str) -> Result<Self, String> {
236        let s = s.trim();
237
238        if s.eq_ignore_ascii_case("always") {
239            return Ok(Self::Always);
240        }
241        if s.eq_ignore_ascii_case("on_success") || s.eq_ignore_ascii_case("on_previous_success") {
242            return Ok(Self::OnPreviousSuccess);
243        }
244        if s.eq_ignore_ascii_case("on_failure") || s.eq_ignore_ascii_case("on_previous_failure") {
245            return Ok(Self::OnPreviousFailure);
246        }
247
248        if let Some(rest) = s.strip_prefix("expr:") {
249            return Ok(Self::Expression(rest.trim().to_string()));
250        }
251
252        if let Some(rest) = s.strip_prefix("field_equals:") {
253            return parse_field_value_pair(rest.trim())
254                .map(|(f, v)| Self::FieldEquals { field: f, value: v });
255        }
256
257        if let Some(rest) = s.strip_prefix("field_contains:") {
258            return parse_field_value_pair(rest.trim()).map(|(f, v)| Self::FieldContains {
259                field: f,
260                substring: v,
261            });
262        }
263
264        Err(format!("Unrecognised condition string: '{s}'"))
265    }
266}
267
268/// Split `"field=value"` into `(field, value)`.
269fn parse_field_value_pair(s: &str) -> Result<(String, String), String> {
270    if let Some(eq_pos) = s.find('=') {
271        let field = s[..eq_pos].trim().to_string();
272        let value = s[eq_pos + 1..].trim().to_string();
273        if field.is_empty() {
274            return Err("Field name must not be empty".to_string());
275        }
276        Ok((field, value))
277    } else {
278        Err(format!("Expected 'field=value' in: '{s}'"))
279    }
280}
281
282// ── Tests ─────────────────────────────────────────────────────────────────────
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn ctx_success() -> StepContext {
289        StepContext::new("step-a")
290            .with_success(true)
291            .with_output("size_mb", "250")
292            .with_output("status", "ok")
293            .with_output("codec", "h264")
294    }
295
296    fn ctx_failure() -> StepContext {
297        StepContext::new("step-b").with_success(false)
298    }
299
300    // ── Basic variants ────────────────────────────────────────────────────────
301
302    #[test]
303    fn test_always_is_true() {
304        let ctx = ctx_failure();
305        assert!(ConditionEvaluator::evaluate(&StepCondition::Always, &ctx));
306    }
307
308    #[test]
309    fn test_on_previous_success_true_when_succeeded() {
310        assert!(ConditionEvaluator::evaluate(
311            &StepCondition::OnPreviousSuccess,
312            &ctx_success()
313        ));
314    }
315
316    #[test]
317    fn test_on_previous_success_false_when_failed() {
318        assert!(!ConditionEvaluator::evaluate(
319            &StepCondition::OnPreviousSuccess,
320            &ctx_failure()
321        ));
322    }
323
324    #[test]
325    fn test_on_previous_failure_true_when_failed() {
326        assert!(ConditionEvaluator::evaluate(
327            &StepCondition::OnPreviousFailure,
328            &ctx_failure()
329        ));
330    }
331
332    #[test]
333    fn test_on_previous_failure_false_when_succeeded() {
334        assert!(!ConditionEvaluator::evaluate(
335            &StepCondition::OnPreviousFailure,
336            &ctx_success()
337        ));
338    }
339
340    // ── FieldEquals / FieldContains ───────────────────────────────────────────
341
342    #[test]
343    fn test_field_equals_match() {
344        let cond = StepCondition::FieldEquals {
345            field: "status".into(),
346            value: "ok".into(),
347        };
348        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
349    }
350
351    #[test]
352    fn test_field_equals_no_match() {
353        let cond = StepCondition::FieldEquals {
354            field: "status".into(),
355            value: "fail".into(),
356        };
357        assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
358    }
359
360    #[test]
361    fn test_field_equals_missing_field() {
362        let cond = StepCondition::FieldEquals {
363            field: "nonexistent".into(),
364            value: "x".into(),
365        };
366        assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
367    }
368
369    #[test]
370    fn test_field_contains_match() {
371        let cond = StepCondition::FieldContains {
372            field: "codec".into(),
373            substring: "264".into(),
374        };
375        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
376    }
377
378    #[test]
379    fn test_field_contains_no_match() {
380        let cond = StepCondition::FieldContains {
381            field: "codec".into(),
382            substring: "vp9".into(),
383        };
384        assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
385    }
386
387    // ── Expression evaluation ─────────────────────────────────────────────────
388
389    #[test]
390    fn test_expression_gt_numeric() {
391        let cond = StepCondition::Expression("size_mb > 100".into());
392        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
393    }
394
395    #[test]
396    fn test_expression_lte_numeric_false() {
397        let cond = StepCondition::Expression("size_mb <= 100".into());
398        assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
399    }
400
401    #[test]
402    fn test_expression_eq_string() {
403        let cond = StepCondition::Expression("status == ok".into());
404        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
405    }
406
407    #[test]
408    fn test_expression_neq_string() {
409        let cond = StepCondition::Expression("status != fail".into());
410        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
411    }
412
413    #[test]
414    fn test_expression_contains_op() {
415        let cond = StepCondition::Expression("codec contains 264".into());
416        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
417    }
418
419    #[test]
420    fn test_expression_missing_field_returns_false() {
421        let cond = StepCondition::Expression("no_such_field > 0".into());
422        assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
423    }
424
425    // ── And / Or / Not ────────────────────────────────────────────────────────
426
427    #[test]
428    fn test_and_all_true() {
429        let cond = StepCondition::And(vec![
430            StepCondition::Always,
431            StepCondition::OnPreviousSuccess,
432        ]);
433        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
434    }
435
436    #[test]
437    fn test_and_short_circuits_on_false() {
438        let cond = StepCondition::And(vec![
439            StepCondition::OnPreviousFailure, // false for success ctx
440            StepCondition::Always,
441        ]);
442        assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
443    }
444
445    #[test]
446    fn test_or_true_when_any_true() {
447        let cond = StepCondition::Or(vec![
448            StepCondition::OnPreviousFailure, // false
449            StepCondition::OnPreviousSuccess, // true
450        ]);
451        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
452    }
453
454    #[test]
455    fn test_or_false_when_all_false() {
456        let cond = StepCondition::Or(vec![
457            StepCondition::OnPreviousFailure,
458            StepCondition::OnPreviousFailure,
459        ]);
460        assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
461    }
462
463    #[test]
464    fn test_not_negates() {
465        let cond = StepCondition::Not(Box::new(StepCondition::OnPreviousSuccess));
466        assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
467        assert!(ConditionEvaluator::evaluate(&cond, &ctx_failure()));
468    }
469
470    #[test]
471    fn test_nested_and_or_not() {
472        // (size_mb > 100 AND status == ok) OR NOT(on_failure)
473        let cond = StepCondition::Or(vec![
474            StepCondition::And(vec![
475                StepCondition::Expression("size_mb > 100".into()),
476                StepCondition::FieldEquals {
477                    field: "status".into(),
478                    value: "ok".into(),
479                },
480            ]),
481            StepCondition::Not(Box::new(StepCondition::OnPreviousFailure)),
482        ]);
483        assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
484    }
485
486    // ── StepCondition::parse ──────────────────────────────────────────────────
487
488    #[test]
489    fn test_parse_always() {
490        let c = StepCondition::parse("always").expect("parse should succeed");
491        assert!(matches!(c, StepCondition::Always));
492    }
493
494    #[test]
495    fn test_parse_on_success_short_form() {
496        let c = StepCondition::parse("on_success").expect("parse should succeed");
497        assert!(matches!(c, StepCondition::OnPreviousSuccess));
498    }
499
500    #[test]
501    fn test_parse_on_failure_long_form() {
502        let c = StepCondition::parse("on_previous_failure").expect("parse should succeed");
503        assert!(matches!(c, StepCondition::OnPreviousFailure));
504    }
505
506    #[test]
507    fn test_parse_expr() {
508        let c = StepCondition::parse("expr: size_mb > 100").expect("parse should succeed");
509        assert!(matches!(c, StepCondition::Expression(_)));
510    }
511
512    #[test]
513    fn test_parse_field_equals() {
514        let c = StepCondition::parse("field_equals: status=ok").expect("parse should succeed");
515        if let StepCondition::FieldEquals { field, value } = c {
516            assert_eq!(field, "status");
517            assert_eq!(value, "ok");
518        } else {
519            panic!("expected FieldEquals");
520        }
521    }
522
523    #[test]
524    fn test_parse_field_contains() {
525        let c = StepCondition::parse("field_contains: codec=264").expect("parse should succeed");
526        if let StepCondition::FieldContains { field, substring } = c {
527            assert_eq!(field, "codec");
528            assert_eq!(substring, "264");
529        } else {
530            panic!("expected FieldContains");
531        }
532    }
533
534    #[test]
535    fn test_parse_unknown_returns_err() {
536        let r = StepCondition::parse("nonsense_condition");
537        assert!(r.is_err());
538    }
539}