Skip to main content

oximedia_workflow/
step_condition.rs

1#![allow(dead_code)]
2//! Conditional step execution for workflow orchestration.
3//!
4//! Provides a flexible condition evaluation system that can be used to
5//! gate workflow steps based on runtime values, environment variables,
6//! previous step outputs, and logical combinations of sub-conditions.
7
8use std::collections::HashMap;
9
10/// A value that can appear in condition evaluations.
11#[derive(Debug, Clone, PartialEq)]
12pub enum ConditionValue {
13    /// A string value.
14    Str(String),
15    /// An integer value.
16    Int(i64),
17    /// A floating-point value.
18    Float(f64),
19    /// A boolean value.
20    Bool(bool),
21    /// No value / null.
22    Null,
23}
24
25impl ConditionValue {
26    /// Try to interpret the value as a boolean.
27    #[must_use]
28    pub fn as_bool(&self) -> Option<bool> {
29        match self {
30            Self::Bool(b) => Some(*b),
31            Self::Int(n) => Some(*n != 0),
32            Self::Str(s) => match s.to_lowercase().as_str() {
33                "true" | "yes" | "1" => Some(true),
34                "false" | "no" | "0" | "" => Some(false),
35                _ => None,
36            },
37            Self::Null => Some(false),
38            Self::Float(_) => None,
39        }
40    }
41
42    /// Try to interpret the value as an i64.
43    #[allow(clippy::cast_precision_loss)]
44    #[must_use]
45    pub fn as_int(&self) -> Option<i64> {
46        match self {
47            Self::Int(n) => Some(*n),
48            Self::Float(f) => Some(*f as i64),
49            Self::Str(s) => s.parse().ok(),
50            Self::Bool(b) => Some(i64::from(*b)),
51            Self::Null => None,
52        }
53    }
54
55    /// Try to interpret the value as f64.
56    #[allow(clippy::cast_precision_loss)]
57    #[must_use]
58    pub fn as_float(&self) -> Option<f64> {
59        match self {
60            Self::Float(f) => Some(*f),
61            Self::Int(n) => Some(*n as f64),
62            Self::Str(s) => s.parse().ok(),
63            Self::Bool(_) | Self::Null => None,
64        }
65    }
66
67    /// Convert to a string representation.
68    #[must_use]
69    pub fn to_string_repr(&self) -> String {
70        match self {
71            Self::Str(s) => s.clone(),
72            Self::Int(n) => n.to_string(),
73            Self::Float(f) => f.to_string(),
74            Self::Bool(b) => b.to_string(),
75            Self::Null => "null".to_string(),
76        }
77    }
78}
79
80/// Comparison operator for simple value comparisons.
81#[derive(Debug, Clone, PartialEq)]
82pub enum ComparisonOp {
83    /// Equal.
84    Eq,
85    /// Not equal.
86    Neq,
87    /// Less than.
88    Lt,
89    /// Less than or equal.
90    Lte,
91    /// Greater than.
92    Gt,
93    /// Greater than or equal.
94    Gte,
95    /// String contains.
96    Contains,
97    /// String starts with.
98    StartsWith,
99    /// String ends with.
100    EndsWith,
101    /// Value matches a regex-like pattern (simple glob).
102    Matches,
103}
104
105/// A condition that can be evaluated against a context.
106#[derive(Debug, Clone)]
107pub enum StepCondition {
108    /// Always true.
109    Always,
110    /// Always false.
111    Never,
112    /// Compare a named variable to a literal value.
113    Compare {
114        /// Variable name to look up in context.
115        variable: String,
116        /// Comparison operator.
117        op: ComparisonOp,
118        /// Value to compare against.
119        value: ConditionValue,
120    },
121    /// Check if a variable exists in the context.
122    Exists {
123        /// Variable name.
124        variable: String,
125    },
126    /// Logical AND of sub-conditions.
127    And(Vec<StepCondition>),
128    /// Logical OR of sub-conditions.
129    Or(Vec<StepCondition>),
130    /// Logical NOT.
131    Not(Box<StepCondition>),
132    /// Check that a previous step completed with a specific status.
133    StepStatus {
134        /// Name of the previous step.
135        step_name: String,
136        /// Expected status string (e.g. "completed", "failed").
137        expected_status: String,
138    },
139    /// Evaluate a simple expression string.
140    Expression(String),
141}
142
143/// Context used to evaluate conditions.
144#[derive(Debug, Clone)]
145pub struct ConditionContext {
146    /// Variables available for condition evaluation.
147    pub variables: HashMap<String, ConditionValue>,
148    /// Results from previous steps keyed by step name.
149    pub step_results: HashMap<String, String>,
150}
151
152impl Default for ConditionContext {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158impl ConditionContext {
159    /// Create an empty condition context.
160    #[must_use]
161    pub fn new() -> Self {
162        Self {
163            variables: HashMap::new(),
164            step_results: HashMap::new(),
165        }
166    }
167
168    /// Set a variable.
169    pub fn set_variable(&mut self, name: impl Into<String>, value: ConditionValue) {
170        self.variables.insert(name.into(), value);
171    }
172
173    /// Set a step result.
174    pub fn set_step_result(&mut self, step_name: impl Into<String>, status: impl Into<String>) {
175        self.step_results.insert(step_name.into(), status.into());
176    }
177
178    /// Get a variable by name.
179    #[must_use]
180    pub fn get_variable(&self, name: &str) -> Option<&ConditionValue> {
181        self.variables.get(name)
182    }
183
184    /// Get a step result by step name.
185    #[must_use]
186    pub fn get_step_result(&self, step_name: &str) -> Option<&str> {
187        self.step_results
188            .get(step_name)
189            .map(std::string::String::as_str)
190    }
191
192    /// Return the number of variables.
193    #[must_use]
194    pub fn variable_count(&self) -> usize {
195        self.variables.len()
196    }
197
198    /// Return the number of step results.
199    #[must_use]
200    pub fn step_result_count(&self) -> usize {
201        self.step_results.len()
202    }
203}
204
205/// Evaluate a condition against the given context.
206#[must_use]
207pub fn evaluate(condition: &StepCondition, ctx: &ConditionContext) -> bool {
208    match condition {
209        StepCondition::Always => true,
210        StepCondition::Never => false,
211        StepCondition::Compare {
212            variable,
213            op,
214            value,
215        } => {
216            let Some(actual) = ctx.get_variable(variable) else {
217                return false;
218            };
219            compare_values(actual, op, value)
220        }
221        StepCondition::Exists { variable } => ctx.variables.contains_key(variable),
222        StepCondition::And(conditions) => conditions.iter().all(|c| evaluate(c, ctx)),
223        StepCondition::Or(conditions) => conditions.iter().any(|c| evaluate(c, ctx)),
224        StepCondition::Not(inner) => !evaluate(inner, ctx),
225        StepCondition::StepStatus {
226            step_name,
227            expected_status,
228        } => ctx
229            .get_step_result(step_name)
230            .is_some_and(|s| s == expected_status),
231        StepCondition::Expression(expr) => evaluate_expression(expr, ctx),
232    }
233}
234
235/// Compare two condition values using the given operator.
236#[allow(clippy::cast_precision_loss)]
237fn compare_values(actual: &ConditionValue, op: &ComparisonOp, expected: &ConditionValue) -> bool {
238    match op {
239        ComparisonOp::Eq => actual == expected,
240        ComparisonOp::Neq => actual != expected,
241        ComparisonOp::Lt | ComparisonOp::Lte | ComparisonOp::Gt | ComparisonOp::Gte => {
242            if let (Some(a), Some(b)) = (actual.as_float(), expected.as_float()) {
243                match op {
244                    ComparisonOp::Lt => a < b,
245                    ComparisonOp::Lte => a <= b,
246                    ComparisonOp::Gt => a > b,
247                    ComparisonOp::Gte => a >= b,
248                    _ => false,
249                }
250            } else {
251                false
252            }
253        }
254        ComparisonOp::Contains => {
255            let a = actual.to_string_repr();
256            let b = expected.to_string_repr();
257            a.contains(&b)
258        }
259        ComparisonOp::StartsWith => {
260            let a = actual.to_string_repr();
261            let b = expected.to_string_repr();
262            a.starts_with(&b)
263        }
264        ComparisonOp::EndsWith => {
265            let a = actual.to_string_repr();
266            let b = expected.to_string_repr();
267            a.ends_with(&b)
268        }
269        ComparisonOp::Matches => {
270            let text = actual.to_string_repr();
271            let pattern = expected.to_string_repr();
272            simple_glob_match(&pattern, &text)
273        }
274    }
275}
276
277/// Simple glob matching: `*` matches any sequence, `?` matches any single char.
278fn simple_glob_match(pattern: &str, text: &str) -> bool {
279    let p: Vec<char> = pattern.chars().collect();
280    let t: Vec<char> = text.chars().collect();
281    glob_match_recursive(&p, &t, 0, 0)
282}
283
284/// Recursive glob matcher.
285fn glob_match_recursive(p: &[char], t: &[char], pi: usize, ti: usize) -> bool {
286    if pi == p.len() && ti == t.len() {
287        return true;
288    }
289    if pi == p.len() {
290        return false;
291    }
292    if p[pi] == '*' {
293        // Try matching zero or more characters
294        for i in ti..=t.len() {
295            if glob_match_recursive(p, t, pi + 1, i) {
296                return true;
297            }
298        }
299        false
300    } else if ti < t.len() && (p[pi] == '?' || p[pi] == t[ti]) {
301        glob_match_recursive(p, t, pi + 1, ti + 1)
302    } else {
303        false
304    }
305}
306
307/// Evaluate a simple expression string.
308fn evaluate_expression(expr: &str, ctx: &ConditionContext) -> bool {
309    let trimmed = expr.trim();
310    // Support simple "variable == value" style
311    if let Some(pos) = trimmed.find("==") {
312        let lhs = trimmed[..pos].trim();
313        let rhs = trimmed[pos + 2..].trim();
314        if let Some(val) = ctx.get_variable(lhs) {
315            return val.to_string_repr() == rhs;
316        }
317    }
318    // Support "variable != value"
319    if let Some(pos) = trimmed.find("!=") {
320        let lhs = trimmed[..pos].trim();
321        let rhs = trimmed[pos + 2..].trim();
322        if let Some(val) = ctx.get_variable(lhs) {
323            return val.to_string_repr() != rhs;
324        }
325    }
326    // If the expression is just a variable name, treat as truthy
327    if let Some(val) = ctx.get_variable(trimmed) {
328        return val.as_bool().unwrap_or(false);
329    }
330    false
331}
332
333/// Builder for constructing complex conditions fluently.
334#[derive(Debug)]
335pub struct ConditionBuilder {
336    /// The condition being built.
337    condition: StepCondition,
338}
339
340impl ConditionBuilder {
341    /// Start with an always-true condition.
342    #[must_use]
343    pub fn always() -> Self {
344        Self {
345            condition: StepCondition::Always,
346        }
347    }
348
349    /// Start with an always-false condition.
350    #[must_use]
351    pub fn never() -> Self {
352        Self {
353            condition: StepCondition::Never,
354        }
355    }
356
357    /// Create a variable comparison condition.
358    pub fn compare(variable: impl Into<String>, op: ComparisonOp, value: ConditionValue) -> Self {
359        Self {
360            condition: StepCondition::Compare {
361                variable: variable.into(),
362                op,
363                value,
364            },
365        }
366    }
367
368    /// Create an existence check.
369    pub fn exists(variable: impl Into<String>) -> Self {
370        Self {
371            condition: StepCondition::Exists {
372                variable: variable.into(),
373            },
374        }
375    }
376
377    /// Create a step status check.
378    pub fn step_status(step_name: impl Into<String>, expected: impl Into<String>) -> Self {
379        Self {
380            condition: StepCondition::StepStatus {
381                step_name: step_name.into(),
382                expected_status: expected.into(),
383            },
384        }
385    }
386
387    /// Combine with AND.
388    #[must_use]
389    pub fn and(self, other: ConditionBuilder) -> Self {
390        Self {
391            condition: StepCondition::And(vec![self.condition, other.condition]),
392        }
393    }
394
395    /// Combine with OR.
396    #[must_use]
397    pub fn or(self, other: ConditionBuilder) -> Self {
398        Self {
399            condition: StepCondition::Or(vec![self.condition, other.condition]),
400        }
401    }
402
403    /// Negate.
404    #[must_use]
405    pub fn not(self) -> Self {
406        Self {
407            condition: StepCondition::Not(Box::new(self.condition)),
408        }
409    }
410
411    /// Build the condition.
412    #[must_use]
413    pub fn build(self) -> StepCondition {
414        self.condition
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    fn ctx_with_vars() -> ConditionContext {
423        let mut ctx = ConditionContext::new();
424        ctx.set_variable("status", ConditionValue::Str("success".to_string()));
425        ctx.set_variable("count", ConditionValue::Int(42));
426        ctx.set_variable("ratio", ConditionValue::Float(0.95));
427        ctx.set_variable("enabled", ConditionValue::Bool(true));
428        ctx.set_variable("disabled", ConditionValue::Bool(false));
429        ctx.set_step_result("transcode", "completed");
430        ctx.set_step_result("qc", "failed");
431        ctx
432    }
433
434    #[test]
435    fn test_always_and_never() {
436        let ctx = ConditionContext::new();
437        assert!(evaluate(&StepCondition::Always, &ctx));
438        assert!(!evaluate(&StepCondition::Never, &ctx));
439    }
440
441    #[test]
442    fn test_compare_eq_string() {
443        let ctx = ctx_with_vars();
444        let cond = StepCondition::Compare {
445            variable: "status".to_string(),
446            op: ComparisonOp::Eq,
447            value: ConditionValue::Str("success".to_string()),
448        };
449        assert!(evaluate(&cond, &ctx));
450    }
451
452    #[test]
453    fn test_compare_neq() {
454        let ctx = ctx_with_vars();
455        let cond = StepCondition::Compare {
456            variable: "status".to_string(),
457            op: ComparisonOp::Neq,
458            value: ConditionValue::Str("failed".to_string()),
459        };
460        assert!(evaluate(&cond, &ctx));
461    }
462
463    #[test]
464    fn test_compare_gt_int() {
465        let ctx = ctx_with_vars();
466        let cond = StepCondition::Compare {
467            variable: "count".to_string(),
468            op: ComparisonOp::Gt,
469            value: ConditionValue::Int(10),
470        };
471        assert!(evaluate(&cond, &ctx));
472    }
473
474    #[test]
475    fn test_compare_lte_float() {
476        let ctx = ctx_with_vars();
477        let cond = StepCondition::Compare {
478            variable: "ratio".to_string(),
479            op: ComparisonOp::Lte,
480            value: ConditionValue::Float(1.0),
481        };
482        assert!(evaluate(&cond, &ctx));
483    }
484
485    #[test]
486    fn test_compare_contains() {
487        let ctx = ctx_with_vars();
488        let cond = StepCondition::Compare {
489            variable: "status".to_string(),
490            op: ComparisonOp::Contains,
491            value: ConditionValue::Str("ucc".to_string()),
492        };
493        assert!(evaluate(&cond, &ctx));
494    }
495
496    #[test]
497    fn test_compare_starts_with() {
498        let ctx = ctx_with_vars();
499        let cond = StepCondition::Compare {
500            variable: "status".to_string(),
501            op: ComparisonOp::StartsWith,
502            value: ConditionValue::Str("suc".to_string()),
503        };
504        assert!(evaluate(&cond, &ctx));
505    }
506
507    #[test]
508    fn test_compare_ends_with() {
509        let ctx = ctx_with_vars();
510        let cond = StepCondition::Compare {
511            variable: "status".to_string(),
512            op: ComparisonOp::EndsWith,
513            value: ConditionValue::Str("ess".to_string()),
514        };
515        assert!(evaluate(&cond, &ctx));
516    }
517
518    #[test]
519    fn test_glob_matches() {
520        let ctx = ctx_with_vars();
521        let cond = StepCondition::Compare {
522            variable: "status".to_string(),
523            op: ComparisonOp::Matches,
524            value: ConditionValue::Str("suc*".to_string()),
525        };
526        assert!(evaluate(&cond, &ctx));
527    }
528
529    #[test]
530    fn test_exists() {
531        let ctx = ctx_with_vars();
532        assert!(evaluate(
533            &StepCondition::Exists {
534                variable: "count".to_string()
535            },
536            &ctx
537        ));
538        assert!(!evaluate(
539            &StepCondition::Exists {
540                variable: "nope".to_string()
541            },
542            &ctx
543        ));
544    }
545
546    #[test]
547    fn test_and_condition() {
548        let ctx = ctx_with_vars();
549        let cond = StepCondition::And(vec![StepCondition::Always, StepCondition::Always]);
550        assert!(evaluate(&cond, &ctx));
551        let cond2 = StepCondition::And(vec![StepCondition::Always, StepCondition::Never]);
552        assert!(!evaluate(&cond2, &ctx));
553    }
554
555    #[test]
556    fn test_or_condition() {
557        let ctx = ctx_with_vars();
558        let cond = StepCondition::Or(vec![StepCondition::Never, StepCondition::Always]);
559        assert!(evaluate(&cond, &ctx));
560    }
561
562    #[test]
563    fn test_not_condition() {
564        let ctx = ctx_with_vars();
565        let cond = StepCondition::Not(Box::new(StepCondition::Never));
566        assert!(evaluate(&cond, &ctx));
567    }
568
569    #[test]
570    fn test_step_status() {
571        let ctx = ctx_with_vars();
572        let cond = StepCondition::StepStatus {
573            step_name: "transcode".to_string(),
574            expected_status: "completed".to_string(),
575        };
576        assert!(evaluate(&cond, &ctx));
577
578        let cond_fail = StepCondition::StepStatus {
579            step_name: "qc".to_string(),
580            expected_status: "completed".to_string(),
581        };
582        assert!(!evaluate(&cond_fail, &ctx));
583    }
584
585    #[test]
586    fn test_expression_eq() {
587        let ctx = ctx_with_vars();
588        let cond = StepCondition::Expression("status == success".to_string());
589        assert!(evaluate(&cond, &ctx));
590    }
591
592    #[test]
593    fn test_expression_neq() {
594        let ctx = ctx_with_vars();
595        let cond = StepCondition::Expression("status != failed".to_string());
596        assert!(evaluate(&cond, &ctx));
597    }
598
599    #[test]
600    fn test_expression_bool_variable() {
601        let ctx = ctx_with_vars();
602        assert!(evaluate(
603            &StepCondition::Expression("enabled".to_string()),
604            &ctx
605        ));
606        assert!(!evaluate(
607            &StepCondition::Expression("disabled".to_string()),
608            &ctx
609        ));
610    }
611
612    #[test]
613    fn test_condition_builder() {
614        let ctx = ctx_with_vars();
615        let cond = ConditionBuilder::compare("count", ComparisonOp::Gte, ConditionValue::Int(40))
616            .and(ConditionBuilder::step_status("transcode", "completed"))
617            .build();
618        assert!(evaluate(&cond, &ctx));
619    }
620
621    #[test]
622    fn test_condition_value_as_bool() {
623        assert_eq!(ConditionValue::Bool(true).as_bool(), Some(true));
624        assert_eq!(ConditionValue::Int(0).as_bool(), Some(false));
625        assert_eq!(ConditionValue::Str("yes".to_string()).as_bool(), Some(true));
626        assert_eq!(ConditionValue::Null.as_bool(), Some(false));
627    }
628
629    #[test]
630    fn test_condition_value_as_int() {
631        assert_eq!(ConditionValue::Int(5).as_int(), Some(5));
632        assert_eq!(ConditionValue::Float(3.7).as_int(), Some(3));
633        assert_eq!(ConditionValue::Str("10".to_string()).as_int(), Some(10));
634        assert_eq!(ConditionValue::Bool(true).as_int(), Some(1));
635    }
636
637    #[test]
638    fn test_condition_context_default() {
639        let ctx = ConditionContext::default();
640        assert_eq!(ctx.variable_count(), 0);
641        assert_eq!(ctx.step_result_count(), 0);
642    }
643
644    #[test]
645    fn test_missing_variable_compare_returns_false() {
646        let ctx = ConditionContext::new();
647        let cond = StepCondition::Compare {
648            variable: "nonexistent".to_string(),
649            op: ComparisonOp::Eq,
650            value: ConditionValue::Int(1),
651        };
652        assert!(!evaluate(&cond, &ctx));
653    }
654}