Skip to main content

ironflow_engine/
expression.rs

1//! A minimal boolean expression language for approval rules.
2//!
3//! An [`Expression`] is parsed once, when the workflow is built, and evaluated
4//! against a JSON context when an approval gate opens. The language is small on
5//! purpose: dotted paths, literals, comparisons, `&&`, `||`, `!` and
6//! parentheses.
7//!
8//! # Grammar
9//!
10//! ```text
11//! expr    := or
12//! or      := and ("||" and)*
13//! and     := unary ("&&" unary)*
14//! unary   := "!" unary | primary
15//! primary := "(" expr ")" | operand (cmp operand)?
16//! cmp     := == | != | > | >= | < | <=
17//! operand := path | literal
18//! path    := ident ( "." ident | "[" string "]" | "[" integer "]" )*
19//! ident   := [A-Za-z_][A-Za-z0-9_-]*
20//! literal := number | "str" | 'str' | true | false | null
21//! ```
22//!
23//! The root identifier of every path must be one of [`EXPRESSION_ROOTS`]:
24//! `output`, `payload`, `labels`, `metadata` or `steps`.
25//!
26//! # Evaluation semantics
27//!
28//! Evaluation is total: it never fails.
29//!
30//! - A path that does not resolve (missing key, out-of-range index, indexing
31//!   into a scalar) is `null`.
32//! - `==` and `!=` use JSON equality, with numbers compared as `f64`. When one
33//!   side is a number and the other a string that parses as a number, they are
34//!   compared numerically, because labels are always strings
35//!   (`labels.priority > 3` works with `priority = "5"`). `!=` is the negation
36//!   of `==`.
37//! - `>`, `>=`, `<` and `<=` compare numbers numerically (with the same
38//!   coercion) and strings lexicographically. Any other pair of types is
39//!   `false`.
40//! - A bare operand is tested for truthiness: `null`, `false`, `0`, `""`, `[]`
41//!   and `{}` are false, everything else is true.
42//!
43//! # Examples
44//!
45//! ```
46//! use ironflow_engine::expression::Expression;
47//! use serde_json::json;
48//!
49//! let expr = Expression::parse("payload.amount > 10000 && labels.env == 'production'")?;
50//! let ctx = json!({
51//!     "payload": {"amount": 15000},
52//!     "labels": {"env": "production"},
53//! });
54//! assert!(expr.evaluate(&ctx));
55//! # Ok::<(), ironflow_engine::expression::ExpressionError>(())
56//! ```
57
58use std::cmp::Ordering;
59use std::fmt;
60use std::str::FromStr;
61
62use serde::{Deserialize, Serialize};
63use serde_json::{Number, Value};
64use thiserror::Error;
65
66/// Maximum length of an expression source, in bytes.
67///
68/// # Examples
69///
70/// ```
71/// use ironflow_engine::expression::{Expression, ExpressionError, MAX_EXPRESSION_LEN};
72///
73/// let long = format!("payload.a == \"{}\"", "x".repeat(MAX_EXPRESSION_LEN));
74/// assert_eq!(Expression::parse(&long), Err(ExpressionError::TooLong));
75/// ```
76pub const MAX_EXPRESSION_LEN: usize = 4096;
77
78/// Maximum nesting depth of parentheses and `!` operators.
79///
80/// # Examples
81///
82/// ```
83/// use ironflow_engine::expression::{Expression, ExpressionError, MAX_EXPRESSION_DEPTH};
84///
85/// let deep = format!(
86///     "{}payload.a{}",
87///     "(".repeat(MAX_EXPRESSION_DEPTH + 1),
88///     ")".repeat(MAX_EXPRESSION_DEPTH + 1)
89/// );
90/// assert_eq!(Expression::parse(&deep), Err(ExpressionError::TooDeep));
91/// ```
92pub const MAX_EXPRESSION_DEPTH: usize = 64;
93
94/// Root identifiers a path may start with.
95///
96/// # Examples
97///
98/// ```
99/// use ironflow_engine::expression::EXPRESSION_ROOTS;
100///
101/// assert!(EXPRESSION_ROOTS.contains(&"payload"));
102/// ```
103pub const EXPRESSION_ROOTS: [&str; 5] = ["output", "payload", "labels", "metadata", "steps"];
104
105/// Error returned when an expression fails to parse.
106///
107/// Positions are byte offsets into the source.
108///
109/// # Examples
110///
111/// ```
112/// use ironflow_engine::expression::{Expression, ExpressionError};
113///
114/// let err = Expression::parse("foo.bar == 1").unwrap_err();
115/// assert_eq!(err, ExpressionError::UnknownRoot("foo".to_string()));
116/// ```
117#[derive(Debug, Clone, PartialEq, Eq, Error)]
118pub enum ExpressionError {
119    /// The source is empty or only whitespace.
120    #[error("expression is empty")]
121    Empty,
122    /// The source exceeds [`MAX_EXPRESSION_LEN`] bytes.
123    #[error("expression is longer than 4096 bytes")]
124    TooLong,
125    /// Parentheses or `!` are nested deeper than [`MAX_EXPRESSION_DEPTH`].
126    #[error("expression is nested deeper than 64 levels")]
127    TooDeep,
128    /// A character that starts no token.
129    #[error("unexpected character {ch:?} at position {pos}")]
130    UnexpectedChar {
131        /// Byte offset of the character.
132        pos: usize,
133        /// The offending character.
134        ch: char,
135    },
136    /// A string literal without its closing quote.
137    #[error("unterminated string starting at position {pos}")]
138    UnterminatedString {
139        /// Byte offset of the opening quote.
140        pos: usize,
141    },
142    /// A malformed number literal.
143    #[error("invalid number at position {pos}")]
144    InvalidNumber {
145        /// Byte offset of the number.
146        pos: usize,
147    },
148    /// A token that does not fit the grammar at this point.
149    #[error("unexpected token {found:?} at position {pos}")]
150    UnexpectedToken {
151        /// Byte offset of the token.
152        pos: usize,
153        /// Text of the token.
154        found: String,
155    },
156    /// The source ended where more input was expected.
157    #[error("unexpected end of expression")]
158    UnexpectedEnd,
159    /// A path starts with an identifier outside [`EXPRESSION_ROOTS`].
160    #[error("unknown root {0:?}, expected one of output, payload, labels, metadata, steps")]
161    UnknownRoot(String),
162}
163
164/// A parsed boolean expression over a JSON context.
165///
166/// See the [module documentation](crate::expression) for the grammar and the
167/// evaluation semantics. Serializes as its source string; deserializing parses
168/// the string and rejects invalid expressions. Two expressions are equal when
169/// their sources are equal.
170///
171/// # Examples
172///
173/// ```
174/// use ironflow_engine::expression::Expression;
175/// use serde_json::json;
176///
177/// let expr: Expression = "steps[\"risk-assessment\"].output.level == \"high\"".parse()?;
178/// let ctx = json!({"steps": {"risk-assessment": {"output": {"level": "high"}}}});
179/// assert!(expr.evaluate(&ctx));
180/// # Ok::<(), ironflow_engine::expression::ExpressionError>(())
181/// ```
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(try_from = "String", into = "String")]
184pub struct Expression {
185    source: String,
186    ast: Node,
187}
188
189impl Expression {
190    /// Parse an expression.
191    ///
192    /// # Errors
193    ///
194    /// Returns an [`ExpressionError`] when the source is empty, too long, too
195    /// deeply nested, lexically or syntactically invalid, or uses a path root
196    /// outside [`EXPRESSION_ROOTS`].
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// use ironflow_engine::expression::Expression;
202    ///
203    /// let expr = Expression::parse("output.amount > 10000")?;
204    /// assert_eq!(expr.source(), "output.amount > 10000");
205    /// assert!(Expression::parse("output.amount >").is_err());
206    /// # Ok::<(), ironflow_engine::expression::ExpressionError>(())
207    /// ```
208    pub fn parse(src: &str) -> Result<Self, ExpressionError> {
209        if src.trim().is_empty() {
210            return Err(ExpressionError::Empty);
211        }
212        if src.len() > MAX_EXPRESSION_LEN {
213            return Err(ExpressionError::TooLong);
214        }
215        let tokens = tokenize(src)?;
216        let mut parser = Parser {
217            tokens,
218            pos: 0,
219            depth: 0,
220        };
221        let ast = parser.parse_or()?;
222        if let Some(token) = parser.peek() {
223            return Err(token.unexpected());
224        }
225        Ok(Self {
226            source: src.to_string(),
227            ast,
228        })
229    }
230
231    /// Evaluate the expression against a JSON context.
232    ///
233    /// Never fails: missing paths resolve to `null` and type mismatches
234    /// evaluate to `false`.
235    ///
236    /// # Examples
237    ///
238    /// ```
239    /// use ironflow_engine::expression::Expression;
240    /// use serde_json::json;
241    ///
242    /// let expr = Expression::parse("labels.priority > 3")?;
243    /// assert!(expr.evaluate(&json!({"labels": {"priority": "5"}})));
244    /// assert!(!expr.evaluate(&json!({"labels": {}})));
245    /// # Ok::<(), ironflow_engine::expression::ExpressionError>(())
246    /// ```
247    pub fn evaluate(&self, ctx: &Value) -> bool {
248        self.ast.eval(ctx)
249    }
250
251    /// The source text the expression was parsed from.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use ironflow_engine::expression::Expression;
257    ///
258    /// let expr = Expression::parse("payload.urgent")?;
259    /// assert_eq!(expr.source(), "payload.urgent");
260    /// # Ok::<(), ironflow_engine::expression::ExpressionError>(())
261    /// ```
262    pub fn source(&self) -> &str {
263        &self.source
264    }
265}
266
267impl PartialEq for Expression {
268    fn eq(&self, other: &Self) -> bool {
269        self.source == other.source
270    }
271}
272
273impl Eq for Expression {}
274
275impl fmt::Display for Expression {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        f.write_str(&self.source)
278    }
279}
280
281impl FromStr for Expression {
282    type Err = ExpressionError;
283
284    fn from_str(s: &str) -> Result<Self, Self::Err> {
285        Self::parse(s)
286    }
287}
288
289impl TryFrom<String> for Expression {
290    type Error = ExpressionError;
291
292    fn try_from(value: String) -> Result<Self, Self::Error> {
293        Self::parse(&value)
294    }
295}
296
297impl From<Expression> for String {
298    fn from(expr: Expression) -> Self {
299        expr.source
300    }
301}
302
303// ---------------------------------------------------------------------------
304// AST and evaluation
305// ---------------------------------------------------------------------------
306
307#[derive(Debug, Clone)]
308enum Node {
309    Or(Vec<Node>),
310    And(Vec<Node>),
311    Not(Box<Node>),
312    Compare(Operand, CmpOp, Operand),
313    Truthy(Operand),
314}
315
316#[derive(Debug, Clone, Copy)]
317enum CmpOp {
318    Eq,
319    Ne,
320    Gt,
321    Ge,
322    Lt,
323    Le,
324}
325
326#[derive(Debug, Clone)]
327enum Operand {
328    Path(Vec<Segment>),
329    Literal(Value),
330}
331
332#[derive(Debug, Clone)]
333enum Segment {
334    Key(String),
335    Index(usize),
336}
337
338static NULL: Value = Value::Null;
339
340impl Node {
341    fn eval(&self, ctx: &Value) -> bool {
342        match self {
343            Node::Or(nodes) => nodes.iter().any(|n| n.eval(ctx)),
344            Node::And(nodes) => nodes.iter().all(|n| n.eval(ctx)),
345            Node::Not(node) => !node.eval(ctx),
346            Node::Compare(left, op, right) => {
347                let (a, b) = (left.resolve(ctx), right.resolve(ctx));
348                match op {
349                    CmpOp::Eq => loose_eq(a, b),
350                    CmpOp::Ne => !loose_eq(a, b),
351                    CmpOp::Gt => loose_cmp(a, b).is_some_and(Ordering::is_gt),
352                    CmpOp::Ge => loose_cmp(a, b).is_some_and(Ordering::is_ge),
353                    CmpOp::Lt => loose_cmp(a, b).is_some_and(Ordering::is_lt),
354                    CmpOp::Le => loose_cmp(a, b).is_some_and(Ordering::is_le),
355                }
356            }
357            Node::Truthy(operand) => truthy(operand.resolve(ctx)),
358        }
359    }
360}
361
362impl Operand {
363    fn resolve<'a>(&'a self, ctx: &'a Value) -> &'a Value {
364        match self {
365            Operand::Literal(value) => value,
366            Operand::Path(segments) => {
367                let mut current = ctx;
368                for segment in segments {
369                    let next = match segment {
370                        Segment::Key(key) => current.as_object().and_then(|o| o.get(key)),
371                        Segment::Index(idx) => current.as_array().and_then(|a| a.get(*idx)),
372                    };
373                    match next {
374                        Some(value) => current = value,
375                        None => return &NULL,
376                    }
377                }
378                current
379            }
380        }
381    }
382}
383
384/// The numeric value of `value`, coercing numeric strings.
385fn as_number(value: &Value) -> Option<f64> {
386    match value {
387        Value::Number(n) => n.as_f64(),
388        Value::String(s) => s.trim().parse::<f64>().ok(),
389        _ => None,
390    }
391}
392
393/// Both sides as numbers, when at least one is a JSON number and the other is
394/// a number or a numeric string.
395fn numeric_pair(a: &Value, b: &Value) -> Option<(f64, f64)> {
396    if !a.is_number() && !b.is_number() {
397        return None;
398    }
399    Some((as_number(a)?, as_number(b)?))
400}
401
402fn loose_eq(a: &Value, b: &Value) -> bool {
403    if a.is_number() || b.is_number() {
404        return numeric_pair(a, b).is_some_and(|(x, y)| x == y);
405    }
406    a == b
407}
408
409fn loose_cmp(a: &Value, b: &Value) -> Option<Ordering> {
410    if let Some((x, y)) = numeric_pair(a, b) {
411        return x.partial_cmp(&y);
412    }
413    match (a, b) {
414        (Value::String(x), Value::String(y)) => Some(x.cmp(y)),
415        _ => None,
416    }
417}
418
419fn truthy(value: &Value) -> bool {
420    match value {
421        Value::Null => false,
422        Value::Bool(b) => *b,
423        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
424        Value::String(s) => !s.is_empty(),
425        Value::Array(a) => !a.is_empty(),
426        Value::Object(o) => !o.is_empty(),
427    }
428}
429
430// ---------------------------------------------------------------------------
431// Tokenizer
432// ---------------------------------------------------------------------------
433
434#[derive(Debug, Clone, PartialEq)]
435enum Tok {
436    Ident(String),
437    Str(String),
438    Num(Number),
439    LParen,
440    RParen,
441    LBracket,
442    RBracket,
443    Dot,
444    AndAnd,
445    OrOr,
446    Bang,
447    Eq,
448    Ne,
449    Gt,
450    Ge,
451    Lt,
452    Le,
453}
454
455impl fmt::Display for Tok {
456    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457        match self {
458            Tok::Ident(s) => f.write_str(s),
459            Tok::Str(s) => write!(f, "{s:?}"),
460            Tok::Num(n) => write!(f, "{n}"),
461            Tok::LParen => f.write_str("("),
462            Tok::RParen => f.write_str(")"),
463            Tok::LBracket => f.write_str("["),
464            Tok::RBracket => f.write_str("]"),
465            Tok::Dot => f.write_str("."),
466            Tok::AndAnd => f.write_str("&&"),
467            Tok::OrOr => f.write_str("||"),
468            Tok::Bang => f.write_str("!"),
469            Tok::Eq => f.write_str("=="),
470            Tok::Ne => f.write_str("!="),
471            Tok::Gt => f.write_str(">"),
472            Tok::Ge => f.write_str(">="),
473            Tok::Lt => f.write_str("<"),
474            Tok::Le => f.write_str("<="),
475        }
476    }
477}
478
479#[derive(Debug, Clone)]
480struct Token {
481    kind: Tok,
482    pos: usize,
483}
484
485impl Token {
486    fn unexpected(&self) -> ExpressionError {
487        ExpressionError::UnexpectedToken {
488            pos: self.pos,
489            found: self.kind.to_string(),
490        }
491    }
492}
493
494fn is_ident_start(c: char) -> bool {
495    c.is_ascii_alphabetic() || c == '_'
496}
497
498fn is_ident_continue(c: char) -> bool {
499    c.is_ascii_alphanumeric() || c == '_' || c == '-'
500}
501
502fn tokenize(src: &str) -> Result<Vec<Token>, ExpressionError> {
503    let chars: Vec<(usize, char)> = src.char_indices().collect();
504    let mut tokens = Vec::new();
505    let mut i = 0;
506
507    while let Some(&(pos, c)) = chars.get(i) {
508        if c.is_whitespace() {
509            i += 1;
510            continue;
511        }
512
513        let next = chars.get(i + 1).map(|&(_, n)| n);
514        let (kind, len) = match (c, next) {
515            ('(', _) => (Tok::LParen, 1),
516            (')', _) => (Tok::RParen, 1),
517            ('[', _) => (Tok::LBracket, 1),
518            (']', _) => (Tok::RBracket, 1),
519            ('.', _) => (Tok::Dot, 1),
520            ('&', Some('&')) => (Tok::AndAnd, 2),
521            ('|', Some('|')) => (Tok::OrOr, 2),
522            ('=', Some('=')) => (Tok::Eq, 2),
523            ('!', Some('=')) => (Tok::Ne, 2),
524            ('!', _) => (Tok::Bang, 1),
525            ('>', Some('=')) => (Tok::Ge, 2),
526            ('>', _) => (Tok::Gt, 1),
527            ('<', Some('=')) => (Tok::Le, 2),
528            ('<', _) => (Tok::Lt, 1),
529            ('"' | '\'', _) => {
530                let (value, consumed) = lex_string(&chars, i)?;
531                (Tok::Str(value), consumed)
532            }
533            (c, _) if c.is_ascii_digit() || c == '-' => {
534                let (number, consumed) = lex_number(src, &chars, i)?;
535                (Tok::Num(number), consumed)
536            }
537            (c, _) if is_ident_start(c) => {
538                let mut j = i + 1;
539                while chars.get(j).is_some_and(|&(_, n)| is_ident_continue(n)) {
540                    j += 1;
541                }
542                let end = chars.get(j).map_or(src.len(), |&(p, _)| p);
543                (Tok::Ident(src[pos..end].to_string()), j - i)
544            }
545            (ch, _) => return Err(ExpressionError::UnexpectedChar { pos, ch }),
546        };
547
548        tokens.push(Token { kind, pos });
549        i += len;
550    }
551
552    Ok(tokens)
553}
554
555/// Lex a quoted string starting at `chars[start]`. Returns the unescaped value
556/// and the number of chars consumed, quotes included.
557fn lex_string(chars: &[(usize, char)], start: usize) -> Result<(String, usize), ExpressionError> {
558    let (open_pos, quote) = chars[start];
559    let mut value = String::new();
560    let mut j = start + 1;
561
562    while let Some(&(pos, c)) = chars.get(j) {
563        if c == quote {
564            return Ok((value, j - start + 1));
565        }
566        if c != '\\' {
567            value.push(c);
568            j += 1;
569            continue;
570        }
571        let Some(&(_, escaped)) = chars.get(j + 1) else {
572            break;
573        };
574        match escaped {
575            '"' | '\'' | '\\' => value.push(escaped),
576            'n' => value.push('\n'),
577            other => return Err(ExpressionError::UnexpectedChar { pos, ch: other }),
578        }
579        j += 2;
580    }
581
582    Err(ExpressionError::UnterminatedString { pos: open_pos })
583}
584
585/// Lex a number literal starting at `chars[start]`, with an optional leading
586/// `-` and an optional fractional part. Returns the number and the number of
587/// chars consumed.
588fn lex_number(
589    src: &str,
590    chars: &[(usize, char)],
591    start: usize,
592) -> Result<(Number, usize), ExpressionError> {
593    let pos = chars[start].0;
594    let invalid = ExpressionError::InvalidNumber { pos };
595    let is_digit = |j: usize| chars.get(j).is_some_and(|&(_, c)| c.is_ascii_digit());
596
597    let mut j = start;
598    if chars[j].1 == '-' {
599        j += 1;
600    }
601    if !is_digit(j) {
602        return Err(invalid);
603    }
604    while is_digit(j) {
605        j += 1;
606    }
607    let mut is_float = false;
608    if chars.get(j).is_some_and(|&(_, c)| c == '.') && is_digit(j + 1) {
609        is_float = true;
610        j += 1;
611        while is_digit(j) {
612            j += 1;
613        }
614    }
615    // A number glued to an identifier character (`12ab`) is malformed.
616    if chars.get(j).is_some_and(|&(_, c)| is_ident_start(c)) {
617        return Err(invalid);
618    }
619
620    let end = chars.get(j).map_or(src.len(), |&(p, _)| p);
621    let text = &src[pos..end];
622    let parsed = if is_float {
623        text.parse::<f64>().ok().and_then(Number::from_f64)
624    } else {
625        text.parse::<i64>().ok().map(Number::from)
626    };
627    parsed.map(|n| (n, j - start)).ok_or(invalid)
628}
629
630// ---------------------------------------------------------------------------
631// Parser
632// ---------------------------------------------------------------------------
633
634struct Parser {
635    tokens: Vec<Token>,
636    pos: usize,
637    depth: usize,
638}
639
640impl Parser {
641    fn peek(&self) -> Option<&Token> {
642        self.tokens.get(self.pos)
643    }
644
645    fn peek_kind(&self) -> Option<&Tok> {
646        self.peek().map(|t| &t.kind)
647    }
648
649    fn advance(&mut self) -> Result<Token, ExpressionError> {
650        let token = self
651            .tokens
652            .get(self.pos)
653            .cloned()
654            .ok_or(ExpressionError::UnexpectedEnd)?;
655        self.pos += 1;
656        Ok(token)
657    }
658
659    fn expect(&mut self, kind: &Tok) -> Result<(), ExpressionError> {
660        let token = self.advance()?;
661        if &token.kind == kind {
662            Ok(())
663        } else {
664            Err(token.unexpected())
665        }
666    }
667
668    fn enter(&mut self) -> Result<(), ExpressionError> {
669        self.depth += 1;
670        if self.depth > MAX_EXPRESSION_DEPTH {
671            return Err(ExpressionError::TooDeep);
672        }
673        Ok(())
674    }
675
676    fn leave(&mut self) {
677        self.depth -= 1;
678    }
679
680    fn parse_or(&mut self) -> Result<Node, ExpressionError> {
681        let mut nodes = vec![self.parse_and()?];
682        while self.peek_kind() == Some(&Tok::OrOr) {
683            self.pos += 1;
684            nodes.push(self.parse_and()?);
685        }
686        Ok(if nodes.len() == 1 {
687            nodes.remove(0)
688        } else {
689            Node::Or(nodes)
690        })
691    }
692
693    fn parse_and(&mut self) -> Result<Node, ExpressionError> {
694        let mut nodes = vec![self.parse_unary()?];
695        while self.peek_kind() == Some(&Tok::AndAnd) {
696            self.pos += 1;
697            nodes.push(self.parse_unary()?);
698        }
699        Ok(if nodes.len() == 1 {
700            nodes.remove(0)
701        } else {
702            Node::And(nodes)
703        })
704    }
705
706    fn parse_unary(&mut self) -> Result<Node, ExpressionError> {
707        if self.peek_kind() == Some(&Tok::Bang) {
708            self.pos += 1;
709            self.enter()?;
710            let inner = self.parse_unary()?;
711            self.leave();
712            return Ok(Node::Not(Box::new(inner)));
713        }
714        self.parse_primary()
715    }
716
717    fn parse_primary(&mut self) -> Result<Node, ExpressionError> {
718        if self.peek_kind() == Some(&Tok::LParen) {
719            self.pos += 1;
720            self.enter()?;
721            let inner = self.parse_or()?;
722            self.expect(&Tok::RParen)?;
723            self.leave();
724            return Ok(inner);
725        }
726
727        let left = self.parse_operand()?;
728        let op = match self.peek_kind() {
729            Some(Tok::Eq) => CmpOp::Eq,
730            Some(Tok::Ne) => CmpOp::Ne,
731            Some(Tok::Gt) => CmpOp::Gt,
732            Some(Tok::Ge) => CmpOp::Ge,
733            Some(Tok::Lt) => CmpOp::Lt,
734            Some(Tok::Le) => CmpOp::Le,
735            _ => return Ok(Node::Truthy(left)),
736        };
737        self.pos += 1;
738        let right = self.parse_operand()?;
739        Ok(Node::Compare(left, op, right))
740    }
741
742    fn parse_operand(&mut self) -> Result<Operand, ExpressionError> {
743        let token = self.advance()?;
744        match &token.kind {
745            Tok::Str(s) => Ok(Operand::Literal(Value::String(s.clone()))),
746            Tok::Num(n) => Ok(Operand::Literal(Value::Number(n.clone()))),
747            Tok::Ident(name) => match name.as_str() {
748                "true" => Ok(Operand::Literal(Value::Bool(true))),
749                "false" => Ok(Operand::Literal(Value::Bool(false))),
750                "null" => Ok(Operand::Literal(Value::Null)),
751                root if EXPRESSION_ROOTS.contains(&root) => {
752                    let mut segments = vec![Segment::Key(root.to_string())];
753                    self.parse_path_tail(&mut segments)?;
754                    Ok(Operand::Path(segments))
755                }
756                other => Err(ExpressionError::UnknownRoot(other.to_string())),
757            },
758            _ => Err(token.unexpected()),
759        }
760    }
761
762    fn parse_path_tail(&mut self, segments: &mut Vec<Segment>) -> Result<(), ExpressionError> {
763        loop {
764            match self.peek_kind() {
765                Some(Tok::Dot) => {
766                    self.pos += 1;
767                    let token = self.advance()?;
768                    match &token.kind {
769                        Tok::Ident(name) => segments.push(Segment::Key(name.clone())),
770                        _ => return Err(token.unexpected()),
771                    }
772                }
773                Some(Tok::LBracket) => {
774                    self.pos += 1;
775                    let token = self.advance()?;
776                    match &token.kind {
777                        Tok::Str(key) => segments.push(Segment::Key(key.clone())),
778                        Tok::Num(n) => match n.as_u64() {
779                            Some(idx) => segments.push(Segment::Index(idx as usize)),
780                            None => return Err(token.unexpected()),
781                        },
782                        _ => return Err(token.unexpected()),
783                    }
784                    self.expect(&Tok::RBracket)?;
785                }
786                _ => return Ok(()),
787            }
788        }
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use serde_json::{from_value, json, to_value};
795
796    use super::*;
797
798    fn eval(src: &str, ctx: &Value) -> bool {
799        Expression::parse(src)
800            .unwrap_or_else(|e| panic!("{src:?} should parse: {e}"))
801            .evaluate(ctx)
802    }
803
804    fn ctx() -> Value {
805        json!({
806            "output": {"amount": 15000, "ok": true},
807            "payload": {
808                "amount": 15000,
809                "ratio": 0.75,
810                "delta": -3,
811                "name": "café",
812                "items": ["first", "second"],
813                "empty": "",
814                "zero": 0,
815                "list": [],
816                "obj": {},
817                "flag": false,
818            },
819            "labels": {"env": "production", "priority": "5", "team": "ops"},
820            "metadata": {"attempt": 1, "workflow_name": "deploy"},
821            "steps": {
822                "risk_assessment": {"output": {"level": "high"}},
823                "risk-assessment": {"output": {"level": "high"}},
824            },
825        })
826    }
827
828    // ---- comparisons ----
829
830    #[test]
831    fn every_comparison_operator() {
832        let c = ctx();
833        assert!(eval("payload.amount == 15000", &c));
834        assert!(eval("payload.amount != 1", &c));
835        assert!(eval("payload.amount > 10000", &c));
836        assert!(eval("payload.amount >= 15000", &c));
837        assert!(eval("payload.amount < 20000", &c));
838        assert!(eval("payload.amount <= 15000", &c));
839        assert!(!eval("payload.amount < 15000", &c));
840        assert!(!eval("payload.amount >= 15001", &c));
841        assert!(!eval("payload.amount <= 14999", &c));
842    }
843
844    #[test]
845    fn output_amount_threshold() {
846        assert!(eval("output.amount > 10000", &ctx()));
847        let small = json!({"output": {"amount": 10}});
848        assert!(!eval("output.amount > 10000", &small));
849    }
850
851    #[test]
852    fn label_equality() {
853        assert!(eval("labels.env == \"production\"", &ctx()));
854        assert!(!eval("labels.env == \"staging\"", &ctx()));
855    }
856
857    #[test]
858    fn step_output_path() {
859        let c = ctx();
860        assert!(eval("steps.risk_assessment.output.level == \"high\"", &c));
861    }
862
863    #[test]
864    fn bracket_path_with_dash() {
865        let src = "steps[\"risk-assessment\"].output.level == 'high'";
866        assert!(eval(src, &ctx()));
867    }
868
869    #[test]
870    fn bracket_path_with_unicode_and_spaces() {
871        let c = json!({"steps": {"évaluation des risques": {"output": {"level": "élevé"}}}});
872        let src = "steps[\"évaluation des risques\"].output.level == \"élevé\"";
873        assert!(eval(src, &c));
874    }
875
876    #[test]
877    fn array_index() {
878        assert!(eval("payload.items[0] == \"first\"", &ctx()));
879        assert!(eval("payload.items[1] == \"second\"", &ctx()));
880        assert!(!eval("payload.items[5]", &ctx()));
881    }
882
883    // ---- boolean operators ----
884
885    #[test]
886    fn and_or_not() {
887        let c = ctx();
888        assert!(eval("payload.amount > 1 && labels.env == 'production'", &c));
889        assert!(!eval("payload.amount > 1 && labels.env == 'staging'", &c));
890        assert!(eval("payload.amount < 1 || labels.env == 'production'", &c));
891        assert!(!eval("payload.amount < 1 || labels.env == 'staging'", &c));
892        assert!(eval("!(labels.env == 'staging')", &c));
893        assert!(eval("!!payload.amount", &c));
894    }
895
896    #[test]
897    fn and_binds_tighter_than_or() {
898        // true || (false && false) == true; (true || false) && false == false
899        let c = ctx();
900        assert!(eval(
901            "labels.env == 'production' || labels.env == 'x' && labels.env == 'y'",
902            &c
903        ));
904        assert!(!eval(
905            "(labels.env == 'production' || labels.env == 'x') && labels.env == 'y'",
906            &c
907        ));
908    }
909
910    #[test]
911    fn not_applies_to_the_following_primary_only() {
912        let c = ctx();
913        // (!false) && true
914        assert!(eval("!payload.flag && payload.amount", &c));
915    }
916
917    // ---- truthiness ----
918
919    #[test]
920    fn bare_path_truthiness() {
921        let c = ctx();
922        assert!(eval("output.ok", &c));
923        assert!(eval("payload.amount", &c));
924        assert!(eval("payload.items", &c));
925        assert!(eval("labels", &c));
926        assert!(!eval("payload.flag", &c));
927        assert!(!eval("payload.zero", &c));
928        assert!(!eval("payload.empty", &c));
929        assert!(!eval("payload.list", &c));
930        assert!(!eval("payload.obj", &c));
931        assert!(!eval("payload.missing", &c));
932    }
933
934    #[test]
935    fn literal_truthiness() {
936        let c = ctx();
937        assert!(eval("true", &c));
938        assert!(!eval("false", &c));
939        assert!(!eval("null", &c));
940        assert!(eval("1", &c));
941        assert!(!eval("''", &c));
942    }
943
944    // ---- missing data and coercion ----
945
946    #[test]
947    fn missing_path_is_null() {
948        let c = ctx();
949        assert!(!eval("payload.nope.deeper > 1", &c));
950        assert!(!eval("payload.nope == 1", &c));
951        assert!(eval("payload.nope == null", &c));
952        assert!(!eval("payload.amount.inner", &c));
953        assert!(!eval("output.amount > 1", &json!({"output": null})));
954        assert!(!eval("output.amount > 1", &json!({})));
955    }
956
957    #[test]
958    fn not_equal_on_missing_path_is_true() {
959        assert!(eval("payload.nope != 'x'", &ctx()));
960    }
961
962    #[test]
963    fn label_strings_coerce_to_numbers() {
964        let c = ctx();
965        assert!(eval("labels.priority > 3", &c));
966        assert!(eval("labels.priority == 5", &c));
967        assert!(eval("5.0 == labels.priority", &c));
968        assert!(!eval("labels.priority < 3", &c));
969    }
970
971    #[test]
972    fn type_mismatch_is_false() {
973        let c = ctx();
974        assert!(!eval("labels.env > 3", &c));
975        assert!(!eval("labels.env == 3", &c));
976        assert!(!eval("payload.items > 1", &c));
977        assert!(!eval("payload.flag < 1", &c));
978        assert!(eval("labels.env != 3", &c));
979    }
980
981    #[test]
982    fn strings_compare_lexicographically() {
983        let c = ctx();
984        assert!(eval("labels.team < 'zzz'", &c));
985        assert!(eval("labels.team >= 'ops'", &c));
986        assert!(!eval("labels.team > 'ops'", &c));
987    }
988
989    // ---- literals ----
990
991    #[test]
992    fn floats_and_negatives() {
993        let c = ctx();
994        assert!(eval("payload.ratio == 0.75", &c));
995        assert!(eval("payload.ratio > 0.5", &c));
996        assert!(eval("payload.delta == -3", &c));
997        assert!(eval("payload.delta < -2.5", &c));
998        assert!(eval("payload.amount == 15000.0", &c));
999    }
1000
1001    #[test]
1002    fn single_quotes_escapes_and_unicode() {
1003        let c = json!({"payload": {"q": "it's \"quoted\"\\\n", "name": "café ☕"}});
1004        assert!(eval(r#"payload.q == 'it\'s "quoted"\\\n'"#, &c));
1005        assert!(eval(r#"payload.q == "it's \"quoted\"\\\n""#, &c));
1006        assert!(eval("payload.name == 'café ☕'", &c));
1007    }
1008
1009    #[test]
1010    fn whitespace_is_insignificant() {
1011        let src = "  payload.amount>10000&&labels.env=='production'  ";
1012        assert!(eval(src, &ctx()));
1013    }
1014
1015    // ---- errors ----
1016
1017    #[test]
1018    fn empty_source_is_rejected() {
1019        assert_eq!(Expression::parse(""), Err(ExpressionError::Empty));
1020        assert_eq!(Expression::parse("   \n"), Err(ExpressionError::Empty));
1021    }
1022
1023    #[test]
1024    fn unknown_root_is_rejected() {
1025        assert_eq!(
1026            Expression::parse("foo.bar == 1"),
1027            Err(ExpressionError::UnknownRoot("foo".to_string()))
1028        );
1029        assert_eq!(
1030            Expression::parse("payload.a == other"),
1031            Err(ExpressionError::UnknownRoot("other".to_string()))
1032        );
1033    }
1034
1035    #[test]
1036    fn unterminated_string_is_rejected() {
1037        assert_eq!(
1038            Expression::parse("labels.env == \"prod"),
1039            Err(ExpressionError::UnterminatedString { pos: 14 })
1040        );
1041        assert_eq!(
1042            Expression::parse("labels.env == 'prod\\"),
1043            Err(ExpressionError::UnterminatedString { pos: 14 })
1044        );
1045    }
1046
1047    #[test]
1048    fn trailing_token_is_rejected() {
1049        assert_eq!(
1050            Expression::parse("payload.a == 1 2"),
1051            Err(ExpressionError::UnexpectedToken {
1052                pos: 15,
1053                found: "2".to_string()
1054            })
1055        );
1056    }
1057
1058    #[test]
1059    fn unexpected_char_is_rejected() {
1060        assert_eq!(
1061            Expression::parse("payload.a # 1"),
1062            Err(ExpressionError::UnexpectedChar { pos: 10, ch: '#' })
1063        );
1064        assert_eq!(
1065            Expression::parse("payload.a = 1"),
1066            Err(ExpressionError::UnexpectedChar { pos: 10, ch: '=' })
1067        );
1068        assert_eq!(
1069            Expression::parse("payload.a & payload.b"),
1070            Err(ExpressionError::UnexpectedChar { pos: 10, ch: '&' })
1071        );
1072    }
1073
1074    #[test]
1075    fn invalid_number_is_rejected() {
1076        assert_eq!(
1077            Expression::parse("payload.a > -"),
1078            Err(ExpressionError::InvalidNumber { pos: 12 })
1079        );
1080        assert_eq!(
1081            Expression::parse("payload.a > 12ab"),
1082            Err(ExpressionError::InvalidNumber { pos: 12 })
1083        );
1084    }
1085
1086    #[test]
1087    fn unexpected_end_is_rejected() {
1088        assert_eq!(
1089            Expression::parse("payload.a >"),
1090            Err(ExpressionError::UnexpectedEnd)
1091        );
1092        assert_eq!(
1093            Expression::parse("(payload.a"),
1094            Err(ExpressionError::UnexpectedEnd)
1095        );
1096        assert_eq!(
1097            Expression::parse("payload."),
1098            Err(ExpressionError::UnexpectedEnd)
1099        );
1100    }
1101
1102    #[test]
1103    fn misplaced_tokens_are_rejected() {
1104        assert!(matches!(
1105            Expression::parse("payload.a == == 1"),
1106            Err(ExpressionError::UnexpectedToken { .. })
1107        ));
1108        assert!(matches!(
1109            Expression::parse("payload[-1]"),
1110            Err(ExpressionError::UnexpectedToken { .. })
1111        ));
1112        assert!(matches!(
1113            Expression::parse("payload[1.5]"),
1114            Err(ExpressionError::UnexpectedToken { .. })
1115        ));
1116        assert!(matches!(
1117            Expression::parse(")"),
1118            Err(ExpressionError::UnexpectedToken { .. })
1119        ));
1120        assert!(matches!(
1121            Expression::parse("payload.a > 1 > 2"),
1122            Err(ExpressionError::UnexpectedToken { .. })
1123        ));
1124    }
1125
1126    #[test]
1127    fn nesting_up_to_the_limit_is_accepted() {
1128        let src = format!(
1129            "{}payload.a{}",
1130            "(".repeat(MAX_EXPRESSION_DEPTH),
1131            ")".repeat(MAX_EXPRESSION_DEPTH)
1132        );
1133        assert!(Expression::parse(&src).is_ok());
1134        let bangs = format!("{}payload.a", "!".repeat(MAX_EXPRESSION_DEPTH));
1135        assert!(Expression::parse(&bangs).is_ok());
1136    }
1137
1138    #[test]
1139    fn nesting_beyond_the_limit_is_rejected() {
1140        let src = format!(
1141            "{}payload.a{}",
1142            "(".repeat(MAX_EXPRESSION_DEPTH + 1),
1143            ")".repeat(MAX_EXPRESSION_DEPTH + 1)
1144        );
1145        assert_eq!(Expression::parse(&src), Err(ExpressionError::TooDeep));
1146        let bangs = format!("{}payload.a", "!".repeat(MAX_EXPRESSION_DEPTH + 1));
1147        assert_eq!(Expression::parse(&bangs), Err(ExpressionError::TooDeep));
1148    }
1149
1150    #[test]
1151    fn source_longer_than_the_limit_is_rejected() {
1152        let src = format!("payload.a == '{}'", "x".repeat(MAX_EXPRESSION_LEN));
1153        assert_eq!(Expression::parse(&src), Err(ExpressionError::TooLong));
1154    }
1155
1156    // ---- traits and serde ----
1157
1158    #[test]
1159    fn display_from_str_and_conversions() {
1160        let expr: Expression = "payload.a > 1".parse().expect("parse");
1161        assert_eq!(expr.to_string(), "payload.a > 1");
1162        assert_eq!(expr.source(), "payload.a > 1");
1163
1164        let from_string = Expression::try_from(expr.to_string()).expect("parse");
1165        assert_eq!(from_string, expr);
1166        assert_ne!(
1167            from_string,
1168            Expression::parse("payload.a >= 1").expect("parse")
1169        );
1170
1171        let back: String = expr.into();
1172        assert_eq!(back, "payload.a > 1");
1173    }
1174
1175    #[test]
1176    fn serde_roundtrip_as_a_plain_string() {
1177        let expr = Expression::parse("labels.env == 'production'").expect("parse");
1178        let json = to_value(&expr).expect("serialize");
1179        assert_eq!(json, json!("labels.env == 'production'"));
1180
1181        let back: Expression = from_value(json).expect("deserialize");
1182        assert_eq!(back, expr);
1183        assert!(back.evaluate(&ctx()));
1184    }
1185
1186    #[test]
1187    fn serde_rejects_an_invalid_expression() {
1188        let err = from_value::<Expression>(json!("foo.bar == 1")).expect_err("invalid expression");
1189        assert!(err.to_string().contains("unknown root"));
1190        assert!(from_value::<Expression>(json!("")).is_err());
1191    }
1192}