Skip to main content

metalcraft_flows/
eval.rs

1//! Deterministic predicate evaluation for [`conditional`](crate::CoreNodeType::Conditional)
2//! nodes.
3//!
4//! Pure and side-effect free: given an operator, the actual value read from flow
5//! state, and an expected value, decide whether the predicate holds. `gt`/`lt`
6//! compare **numerically** when both operands parse as numbers — unlike a naive
7//! string compare where `"18" > "50"` would be true.
8
9use serde_json::Value;
10
11/// A comparison operator on a [`crate::nodes::Condition`].
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Operator {
14    /// Equal (type-aware: numbers compare numerically, else string-equal).
15    Equals,
16    /// Not equal.
17    NotEquals,
18    /// `actual` (as string) contains `expected` (as string).
19    Contains,
20    /// `actual` (as string) starts with `expected` (as string).
21    StartsWith,
22    /// `actual` (as string) ends with `expected` (as string).
23    EndsWith,
24    /// Numeric greater-than.
25    Gt,
26    /// Numeric less-than.
27    Lt,
28    /// `actual` is present and not null.
29    Exists,
30    /// `actual` is truthy (non-empty string, non-zero number, `true`, non-empty
31    /// array/object).
32    Truthy,
33    /// `actual` (as string) matches `expected` as a regular expression.
34    ///
35    /// Requires the `regex` crate feature; without it this always evaluates to
36    /// `false`.
37    Matches,
38}
39
40impl Operator {
41    /// Parse an operator's wire-format string.
42    pub fn from_wire(s: &str) -> Option<Self> {
43        match s {
44            "equals" => Some(Operator::Equals),
45            "not_equals" => Some(Operator::NotEquals),
46            "contains" => Some(Operator::Contains),
47            "starts_with" => Some(Operator::StartsWith),
48            "ends_with" => Some(Operator::EndsWith),
49            "gt" => Some(Operator::Gt),
50            "lt" => Some(Operator::Lt),
51            "exists" => Some(Operator::Exists),
52            "truthy" => Some(Operator::Truthy),
53            "matches" => Some(Operator::Matches),
54            _ => None,
55        }
56    }
57
58    /// The operator's wire-format string.
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Operator::Equals => "equals",
62            Operator::NotEquals => "not_equals",
63            Operator::Contains => "contains",
64            Operator::StartsWith => "starts_with",
65            Operator::EndsWith => "ends_with",
66            Operator::Gt => "gt",
67            Operator::Lt => "lt",
68            Operator::Exists => "exists",
69            Operator::Truthy => "truthy",
70            Operator::Matches => "matches",
71        }
72    }
73}
74
75/// Coerce a JSON value to `f64` if it is a number or a numeric string.
76fn as_number(v: &Value) -> Option<f64> {
77    match v {
78        Value::Number(n) => n.as_f64(),
79        Value::String(s) => s.trim().parse::<f64>().ok(),
80        _ => None,
81    }
82}
83
84/// Render a JSON value as a plain string for string-oriented operators
85/// (strings pass through unquoted; other scalars use their JSON form).
86fn as_text(v: &Value) -> String {
87    match v {
88        Value::String(s) => s.clone(),
89        Value::Null => String::new(),
90        other => other.to_string(),
91    }
92}
93
94/// Whether a JSON value is "truthy".
95fn is_truthy(v: &Value) -> bool {
96    match v {
97        Value::Null => false,
98        Value::Bool(b) => *b,
99        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
100        Value::String(s) => !s.is_empty(),
101        Value::Array(a) => !a.is_empty(),
102        Value::Object(o) => !o.is_empty(),
103    }
104}
105
106/// Evaluate `op` over `actual` (the value read from state; `None` if the
107/// variable is absent) and `expected` (the condition's right-hand `value`,
108/// `None` if omitted).
109///
110/// Unary operators (`exists`, `truthy`) ignore `expected`. `gt`/`lt` require
111/// both sides to be numeric and return `false` otherwise.
112pub fn evaluate(op: Operator, actual: Option<&Value>, expected: Option<&Value>) -> bool {
113    let null = Value::Null;
114    let a = actual.unwrap_or(&null);
115
116    match op {
117        Operator::Exists => actual.is_some() && !a.is_null(),
118        Operator::Truthy => is_truthy(a),
119        Operator::Equals | Operator::NotEquals => {
120            let eq = match (as_number(a), expected.and_then(as_number)) {
121                (Some(x), Some(y)) => x == y,
122                _ => match expected {
123                    Some(e) => as_text(a) == as_text(e),
124                    None => a.is_null(),
125                },
126            };
127            if op == Operator::Equals { eq } else { !eq }
128        }
129        Operator::Gt | Operator::Lt => {
130            match (as_number(a), expected.and_then(as_number)) {
131                (Some(x), Some(y)) => {
132                    if op == Operator::Gt { x > y } else { x < y }
133                }
134                _ => false,
135            }
136        }
137        Operator::Contains => match expected {
138            Some(e) => as_text(a).contains(&as_text(e)),
139            None => false,
140        },
141        Operator::StartsWith => match expected {
142            Some(e) => as_text(a).starts_with(&as_text(e)),
143            None => false,
144        },
145        Operator::EndsWith => match expected {
146            Some(e) => as_text(a).ends_with(&as_text(e)),
147            None => false,
148        },
149        Operator::Matches => match expected {
150            Some(e) => matches_regex(&as_text(a), &as_text(e)),
151            None => false,
152        },
153    }
154}
155
156#[cfg(feature = "regex")]
157fn matches_regex(text: &str, pattern: &str) -> bool {
158    regex::Regex::new(pattern).map(|re| re.is_match(text)).unwrap_or(false)
159}
160
161#[cfg(not(feature = "regex"))]
162fn matches_regex(_text: &str, _pattern: &str) -> bool {
163    false
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use serde_json::json;
170
171    #[test]
172    fn numeric_gt_lt_not_lexicographic() {
173        // The vix bug: "18" > "50" is true as strings. Must be false numerically.
174        assert!(!evaluate(Operator::Gt, Some(&json!(18)), Some(&json!(50))));
175        assert!(evaluate(Operator::Lt, Some(&json!(18)), Some(&json!(50))));
176        assert!(evaluate(Operator::Gt, Some(&json!(75)), Some(&json!(50))));
177        // numeric strings coerce too
178        assert!(evaluate(Operator::Gt, Some(&json!("75")), Some(&json!("50"))));
179        assert!(!evaluate(Operator::Gt, Some(&json!("18")), Some(&json!("50"))));
180    }
181
182    #[test]
183    fn gt_on_non_numbers_is_false() {
184        assert!(!evaluate(Operator::Gt, Some(&json!("hot")), Some(&json!("cold"))));
185        assert!(!evaluate(Operator::Gt, None, Some(&json!(1))));
186    }
187
188    #[test]
189    fn equals_type_aware() {
190        assert!(evaluate(Operator::Equals, Some(&json!(5)), Some(&json!("5"))));
191        assert!(evaluate(Operator::Equals, Some(&json!("P0")), Some(&json!("P0"))));
192        assert!(evaluate(Operator::NotEquals, Some(&json!("P0")), Some(&json!("P1"))));
193    }
194
195    #[test]
196    fn string_ops() {
197        assert!(evaluate(Operator::Contains, Some(&json!("hello world")), Some(&json!("wor"))));
198        assert!(evaluate(Operator::StartsWith, Some(&json!("hello")), Some(&json!("he"))));
199        assert!(evaluate(Operator::EndsWith, Some(&json!("hello")), Some(&json!("lo"))));
200    }
201
202    #[test]
203    fn exists_and_truthy() {
204        assert!(evaluate(Operator::Exists, Some(&json!("x")), None));
205        assert!(!evaluate(Operator::Exists, Some(&json!(null)), None));
206        assert!(!evaluate(Operator::Exists, None, None));
207        assert!(evaluate(Operator::Truthy, Some(&json!(1)), None));
208        assert!(!evaluate(Operator::Truthy, Some(&json!(0)), None));
209        assert!(!evaluate(Operator::Truthy, Some(&json!("")), None));
210    }
211
212    #[cfg(feature = "regex")]
213    #[test]
214    fn regex_matches() {
215        assert!(evaluate(Operator::Matches, Some(&json!("abc123")), Some(&json!(r"\d+"))));
216        assert!(!evaluate(Operator::Matches, Some(&json!("abc")), Some(&json!(r"\d+"))));
217    }
218
219    #[test]
220    fn operator_round_trips() {
221        for op in [
222            Operator::Equals, Operator::NotEquals, Operator::Contains,
223            Operator::StartsWith, Operator::EndsWith, Operator::Gt, Operator::Lt,
224            Operator::Exists, Operator::Truthy, Operator::Matches,
225        ] {
226            assert_eq!(Operator::from_wire(op.as_str()), Some(op));
227        }
228    }
229}