Skip to main content

flow_execution/
condition.rs

1//! Runtime evaluator for edge `when <expr>` conditions.
2//!
3//! The DSL parses `when <expr>` into [`flow_domain::FlowEdge::condition`] as an
4//! opaque string and serializes it back verbatim; this module gives that string
5//! runtime meaning. The executor first substitutes `{{...}}` references against
6//! prior node outputs (reusing its interpolation machinery), then hands the
7//! resolved literal string here.
8//!
9//! The grammar is intentionally flat and total - there are no parentheses and
10//! evaluation never panics. Any malformed input yields an [`EvalError`]; the
11//! executor treats that (and any unresolved reference) as **fail-closed**: the
12//! edge does not fire.
13//!
14//! ```text
15//! expr   := or
16//! or     := and ( "||" and )*
17//! and    := cmp ( "&&" cmp )*
18//! cmp    := value ( cmpop value )?
19//! cmpop  := "==" | "!=" | "<" | "<=" | ">" | ">="
20//! value  := number | "'...'" | "\"...\"" | bare-word
21//! ```
22//!
23//! A lone `value` (no comparison) is truthy when it is `true` (any case), a
24//! non-zero number, or a non-empty string that is not `false`. Comparisons use
25//! numeric ordering when both sides parse as `f64`, otherwise string ordering.
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum CmpOp {
29    Eq,
30    Ne,
31    Lt,
32    Le,
33    Gt,
34    Ge,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38enum Tok {
39    Val(String),
40    Op(CmpOp),
41    And,
42    Or,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum EvalError {
47    /// The expression had no tokens (e.g. an empty `when`).
48    Empty,
49    /// A value was expected but an operator (or end of input) was found.
50    ExpectedValue,
51    /// Tokens remained after a complete parse.
52    Trailing,
53    /// A quoted string was never closed.
54    UnterminatedString,
55}
56
57/// Evaluate a fully-interpolated `when` expression to a boolean.
58pub fn eval(expr: &str) -> Result<bool, EvalError> {
59    let toks = tokenize(expr)?;
60    if toks.is_empty() {
61        return Err(EvalError::Empty);
62    }
63    let mut p = Parser { toks, pos: 0 };
64    let value = p.parse_or()?;
65    if p.pos != p.toks.len() {
66        return Err(EvalError::Trailing);
67    }
68    Ok(value)
69}
70
71fn tokenize(expr: &str) -> Result<Vec<Tok>, EvalError> {
72    let chars: Vec<char> = expr.chars().collect();
73    let mut toks = Vec::new();
74    let mut i = 0;
75    while i < chars.len() {
76        let c = chars[i];
77        if c.is_whitespace() {
78            i += 1;
79            continue;
80        }
81        match c {
82            '\'' | '"' => {
83                let quote = c;
84                let mut s = String::new();
85                i += 1;
86                let mut closed = false;
87                while i < chars.len() {
88                    if chars[i] == quote {
89                        closed = true;
90                        i += 1;
91                        break;
92                    }
93                    s.push(chars[i]);
94                    i += 1;
95                }
96                if !closed {
97                    return Err(EvalError::UnterminatedString);
98                }
99                toks.push(Tok::Val(s));
100            }
101            '=' if peek(&chars, i + 1) == Some('=') => {
102                toks.push(Tok::Op(CmpOp::Eq));
103                i += 2;
104            }
105            '!' if peek(&chars, i + 1) == Some('=') => {
106                toks.push(Tok::Op(CmpOp::Ne));
107                i += 2;
108            }
109            '<' if peek(&chars, i + 1) == Some('=') => {
110                toks.push(Tok::Op(CmpOp::Le));
111                i += 2;
112            }
113            '>' if peek(&chars, i + 1) == Some('=') => {
114                toks.push(Tok::Op(CmpOp::Ge));
115                i += 2;
116            }
117            '<' => {
118                toks.push(Tok::Op(CmpOp::Lt));
119                i += 1;
120            }
121            '>' => {
122                toks.push(Tok::Op(CmpOp::Gt));
123                i += 1;
124            }
125            '&' if peek(&chars, i + 1) == Some('&') => {
126                toks.push(Tok::And);
127                i += 2;
128            }
129            '|' if peek(&chars, i + 1) == Some('|') => {
130                toks.push(Tok::Or);
131                i += 2;
132            }
133            _ => {
134                // Bare word: read until whitespace or the start of an operator.
135                let start = i;
136                while i < chars.len() {
137                    let d = chars[i];
138                    if d.is_whitespace() || matches!(d, '=' | '!' | '<' | '>' | '&' | '|') {
139                        break;
140                    }
141                    i += 1;
142                }
143                toks.push(Tok::Val(chars[start..i].iter().collect()));
144            }
145        }
146    }
147    Ok(toks)
148}
149
150fn peek(chars: &[char], i: usize) -> Option<char> {
151    chars.get(i).copied()
152}
153
154struct Parser {
155    toks: Vec<Tok>,
156    pos: usize,
157}
158
159impl Parser {
160    fn parse_or(&mut self) -> Result<bool, EvalError> {
161        let mut value = self.parse_and()?;
162        while matches!(self.toks.get(self.pos), Some(Tok::Or)) {
163            self.pos += 1;
164            let rhs = self.parse_and()?;
165            value = value || rhs;
166        }
167        Ok(value)
168    }
169
170    fn parse_and(&mut self) -> Result<bool, EvalError> {
171        let mut value = self.parse_cmp()?;
172        while matches!(self.toks.get(self.pos), Some(Tok::And)) {
173            self.pos += 1;
174            let rhs = self.parse_cmp()?;
175            value = value && rhs;
176        }
177        Ok(value)
178    }
179
180    fn parse_cmp(&mut self) -> Result<bool, EvalError> {
181        let lhs = self.expect_value()?;
182        if let Some(Tok::Op(op)) = self.toks.get(self.pos).cloned() {
183            self.pos += 1;
184            let rhs = self.expect_value()?;
185            Ok(compare(op, &lhs, &rhs))
186        } else {
187            Ok(truthy(&lhs))
188        }
189    }
190
191    fn expect_value(&mut self) -> Result<String, EvalError> {
192        match self.toks.get(self.pos) {
193            Some(Tok::Val(s)) => {
194                let s = s.clone();
195                self.pos += 1;
196                Ok(s)
197            }
198            _ => Err(EvalError::ExpectedValue),
199        }
200    }
201}
202
203fn compare(op: CmpOp, a: &str, b: &str) -> bool {
204    if let (Ok(x), Ok(y)) = (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
205        match op {
206            CmpOp::Eq => x == y,
207            CmpOp::Ne => x != y,
208            CmpOp::Lt => x < y,
209            CmpOp::Le => x <= y,
210            CmpOp::Gt => x > y,
211            CmpOp::Ge => x >= y,
212        }
213    } else {
214        match op {
215            CmpOp::Eq => a == b,
216            CmpOp::Ne => a != b,
217            CmpOp::Lt => a < b,
218            CmpOp::Le => a <= b,
219            CmpOp::Gt => a > b,
220            CmpOp::Ge => a >= b,
221        }
222    }
223}
224
225fn truthy(s: &str) -> bool {
226    let t = s.trim();
227    if t.eq_ignore_ascii_case("true") {
228        return true;
229    }
230    if t.eq_ignore_ascii_case("false") || t.is_empty() {
231        return false;
232    }
233    if let Ok(n) = t.parse::<f64>() {
234        return n != 0.0;
235    }
236    true
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn numeric_comparisons() {
245        assert_eq!(eval("5 > 0"), Ok(true));
246        assert_eq!(eval("0 > 0"), Ok(false));
247        assert_eq!(eval("3 == 3"), Ok(true));
248        assert_eq!(eval("3 >= 3"), Ok(true));
249        assert_eq!(eval("2 <= 1"), Ok(false));
250        assert_eq!(eval("2 != 3"), Ok(true));
251    }
252
253    #[test]
254    fn string_equality() {
255        assert_eq!(eval("done == 'done'"), Ok(true));
256        assert_eq!(eval("'a' == \"b\""), Ok(false));
257        assert_eq!(eval("ok != fail"), Ok(true));
258    }
259
260    #[test]
261    fn bare_value_truthiness() {
262        assert_eq!(eval("true"), Ok(true));
263        assert_eq!(eval("false"), Ok(false));
264        assert_eq!(eval("1"), Ok(true));
265        assert_eq!(eval("0"), Ok(false));
266        assert_eq!(eval("anything"), Ok(true));
267    }
268
269    #[test]
270    fn and_binds_tighter_than_or() {
271        // false && false || true  ==  (false && false) || true  ==  true
272        assert_eq!(eval("0 > 1 && 1 > 0 || 5 == 5"), Ok(true));
273        // true && false  ==  false
274        assert_eq!(eval("1 == 1 && 2 == 3"), Ok(false));
275        // true || (anything)  ==  true
276        assert_eq!(eval("1 == 1 || 2 == 3"), Ok(true));
277    }
278
279    #[test]
280    fn empty_resolved_reference_fails_closed() {
281        // `{{missing.field}}` interpolates to "" upstream, leaving a dangling
282        // operator; the parser rejects it so the edge will not fire.
283        assert_eq!(eval(" > 0"), Err(EvalError::ExpectedValue));
284        assert_eq!(eval(""), Err(EvalError::Empty));
285        assert_eq!(eval("   "), Err(EvalError::Empty));
286    }
287
288    #[test]
289    fn malformed_expressions_error() {
290        assert_eq!(eval("5 5"), Err(EvalError::Trailing));
291        assert_eq!(eval("5 =="), Err(EvalError::ExpectedValue));
292        assert_eq!(eval("'unclosed"), Err(EvalError::UnterminatedString));
293    }
294}