Skip to main content

webui_expressions/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI expression evaluation module.
5//!
6//! This module handles the evaluation of condition expressions in WebUI templates.
7
8use std::borrow::Cow;
9
10use serde_json::Value;
11use thiserror::Error;
12use webui_protocol::{
13    condition_expr, ComparisonOperator, CompoundCondition, ConditionExpr, LogicalOperator,
14    Predicate,
15};
16use webui_state::find_value_by_dotted_path_ref;
17
18/// Error types for expression evaluation.
19#[derive(Debug, Error)]
20pub enum ExpressionError {
21    #[error("Evaluation error: {0}")]
22    Evaluation(String),
23
24    #[error("Missing value: {0}")]
25    MissingValue(String),
26
27    #[error("Type error: {0}")]
28    TypeError(String),
29
30    #[error("Mixed logical operators: Cannot mix AND and OR operators")]
31    MixedOperators,
32
33    #[error("Too many logical operators: Maximum of 5 allowed, found {0}")]
34    TooManyOperators(usize),
35
36    #[error("Value comparison error: {0}")]
37    Comparison(String),
38}
39
40pub type Result<T> = std::result::Result<T, ExpressionError>;
41
42/// Evaluate a condition expression with the given state.
43///
44/// Missing identifier paths are falsy operands. Missing values used by
45/// comparison predicates remain evaluation errors.
46pub fn evaluate(condition: &ConditionExpr, state: &Value) -> Result<bool> {
47    evaluate_with_resolver(condition, |path| find_value_by_dotted_path_ref(path, state))
48}
49
50/// Evaluate a condition expression using a custom resolver for value lookups.
51///
52/// The `resolver` closure takes a dotted path (e.g., `"contact.name"`) and
53/// returns the resolved value. This allows callers to provide merged views
54/// (e.g., local variables overlaid on global state) without cloning the
55/// entire state tree.
56pub fn evaluate_with_resolver<'a, F>(condition: &ConditionExpr, resolver: F) -> Result<bool>
57where
58    F: Fn(&str) -> Option<Cow<'a, Value>>,
59{
60    // Single-term conditions carry no logical operators, so the traversal in
61    // `count_logical_operators` is pure overhead for the most common template
62    // conditions.
63    if matches!(
64        condition.expr,
65        Some(condition_expr::Expr::Identifier(_)) | Some(condition_expr::Expr::Predicate(_))
66    ) {
67        return evaluate_expr(condition, &resolver);
68    }
69
70    let (logical_op_count, has_mixed_ops) = count_logical_operators(condition);
71
72    if logical_op_count > 5 {
73        return Err(ExpressionError::TooManyOperators(logical_op_count));
74    }
75
76    if has_mixed_ops {
77        return Err(ExpressionError::MixedOperators);
78    }
79
80    evaluate_expr(condition, &resolver)
81}
82
83/// Inline capacity of [`PendingExprs`].
84///
85/// The depth-first walk keeps at most one pending node per open compound plus
86/// the node being visited, so any condition within the five-operator limit
87/// fits inline and only trees that will be rejected anyway reach the heap.
88const INLINE_PENDING_EXPRS: usize = 8;
89
90/// LIFO stack of sub-expressions still to visit, kept on the call stack.
91///
92/// Compound conditions are evaluated on every render of every `<if>` and
93/// boolean attribute, so the operator check must not allocate per call.
94struct PendingExprs<'a> {
95    inline: [Option<&'a ConditionExpr>; INLINE_PENDING_EXPRS],
96    inline_len: usize,
97    overflow: Vec<&'a ConditionExpr>,
98}
99
100impl<'a> PendingExprs<'a> {
101    fn new(root: &'a ConditionExpr) -> Self {
102        let mut inline = [None; INLINE_PENDING_EXPRS];
103        inline[0] = Some(root);
104        Self {
105            inline,
106            inline_len: 1,
107            overflow: Vec::new(),
108        }
109    }
110
111    fn push(&mut self, expr: &'a ConditionExpr) {
112        if self.inline_len < INLINE_PENDING_EXPRS {
113            self.inline[self.inline_len] = Some(expr);
114            self.inline_len += 1;
115        } else {
116            self.overflow.push(expr);
117        }
118    }
119
120    fn pop(&mut self) -> Option<&'a ConditionExpr> {
121        if let Some(expr) = self.overflow.pop() {
122            return Some(expr);
123        }
124        if self.inline_len == 0 {
125            return None;
126        }
127        self.inline_len -= 1;
128        self.inline[self.inline_len].take()
129    }
130}
131
132// Helper function to count logical operators and check if they're mixed
133fn count_logical_operators(condition: &ConditionExpr) -> (usize, bool) {
134    let mut count = 0;
135    let mut last_op: Option<i32> = None;
136    let mut has_mixed = false;
137
138    // We need to use a stack to avoid recursion
139    let mut stack = PendingExprs::new(condition);
140
141    while let Some(expr) = stack.pop() {
142        match &expr.expr {
143            Some(condition_expr::Expr::Compound(compound)) => {
144                count += 1;
145
146                // Check if we're mixing operators
147                if let Some(last) = last_op {
148                    if last != compound.op {
149                        has_mixed = true;
150                    }
151                } else {
152                    last_op = Some(compound.op);
153                }
154
155                // Push sub-expressions to stack
156                if let Some(right) = compound.right.as_ref() {
157                    stack.push(right);
158                }
159                if let Some(left) = compound.left.as_ref() {
160                    stack.push(left);
161                }
162            }
163            Some(condition_expr::Expr::Not(not_cond)) => {
164                if let Some(inner) = not_cond.condition.as_ref() {
165                    stack.push(inner);
166                }
167            }
168            _ => {} // Predicates and identifiers don't have logical operators
169        }
170    }
171
172    (count, has_mixed)
173}
174
175// Iterative evaluation of expressions using a resolver closure
176fn evaluate_expr<'a, F>(condition: &ConditionExpr, resolver: &F) -> Result<bool>
177where
178    F: Fn(&str) -> Option<Cow<'a, Value>>,
179{
180    match &condition.expr {
181        Some(condition_expr::Expr::Predicate(pred)) => evaluate_predicate(pred, resolver),
182        Some(condition_expr::Expr::Not(not_cond)) => {
183            let inner = not_cond.condition.as_ref().ok_or_else(|| {
184                ExpressionError::Evaluation("Not condition missing inner expression".to_string())
185            })?;
186            let result = evaluate_expr(inner, resolver)?;
187            Ok(!result)
188        }
189        Some(condition_expr::Expr::Compound(compound)) => evaluate_compound(compound, resolver),
190        Some(condition_expr::Expr::Identifier(id)) if id.value == "true" => Ok(true),
191        Some(condition_expr::Expr::Identifier(id)) if id.value == "false" => Ok(false),
192        Some(condition_expr::Expr::Identifier(id)) => {
193            if let Some(val) = resolver(&id.value) {
194                match val.as_ref() {
195                    Value::Bool(b) => Ok(*b),
196                    Value::Null => Ok(false),
197                    Value::Number(n) => Ok(!(n.as_f64() == Some(0.0))),
198                    Value::String(s) => Ok(!s.is_empty()),
199                    Value::Array(a) => Ok(!a.is_empty()),
200                    Value::Object(o) => Ok(!o.is_empty()),
201                }
202            } else {
203                Ok(false)
204            }
205        }
206        None => Err(ExpressionError::Evaluation(
207            "Empty condition expression".to_string(),
208        )),
209    }
210}
211
212fn evaluate_compound<'a, F>(compound: &CompoundCondition, resolver: &F) -> Result<bool>
213where
214    F: Fn(&str) -> Option<Cow<'a, Value>>,
215{
216    let left = compound.left.as_ref().ok_or_else(|| {
217        ExpressionError::Evaluation("Compound missing left expression".to_string())
218    })?;
219    let right = compound.right.as_ref().ok_or_else(|| {
220        ExpressionError::Evaluation("Compound missing right expression".to_string())
221    })?;
222
223    let left_result = evaluate_expr(left, resolver)?;
224    let op = LogicalOperator::try_from(compound.op).map_err(|_| {
225        ExpressionError::Evaluation(format!("Invalid logical operator: {}", compound.op))
226    })?;
227
228    match op {
229        LogicalOperator::And => {
230            if !left_result {
231                return Ok(false);
232            }
233            evaluate_expr(right, resolver)
234        }
235        LogicalOperator::Or => {
236            if left_result {
237                return Ok(true);
238            }
239            evaluate_expr(right, resolver)
240        }
241        LogicalOperator::Unspecified => Err(ExpressionError::Evaluation(
242            "Unspecified logical operator".to_string(),
243        )),
244    }
245}
246
247fn evaluate_predicate<'a, F>(predicate: &Predicate, resolver: &F) -> Result<bool>
248where
249    F: Fn(&str) -> Option<Cow<'a, Value>>,
250{
251    let left_val = match resolver(&predicate.left) {
252        Some(val) => val,
253        None => return Err(ExpressionError::MissingValue(predicate.left.clone())),
254    };
255
256    let right_val = if is_literal(&predicate.right) {
257        Cow::Owned(parse_literal(&predicate.right)?)
258    } else {
259        match resolver(&predicate.right) {
260            Some(val) => val,
261            None => return Err(ExpressionError::MissingValue(predicate.right.clone())),
262        }
263    };
264
265    let op = ComparisonOperator::try_from(predicate.operator).map_err(|_| {
266        ExpressionError::Evaluation(format!(
267            "Invalid comparison operator: {}",
268            predicate.operator
269        ))
270    })?;
271
272    compare_values(left_val.as_ref(), &op, right_val.as_ref())
273}
274
275// Check if a string is a literal value
276fn is_literal(s: &str) -> bool {
277    // A string is a literal if:
278    // - It starts with a quote (single or double)
279    // - It's a number (starts with a digit or negative sign followed by a digit)
280    // - It's a boolean ("true" or "false")
281    s.starts_with('"')
282        || s.starts_with('\'')
283        || s.starts_with(|c: char| {
284            c.is_ascii_digit()
285                || (c == '-' && s.len() > 1 && s.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
286        })
287        || s == "true"
288        || s == "false"
289}
290
291// Parse a literal string into a JSON value
292fn parse_literal(s: &str) -> Result<Value> {
293    // Handle quoted strings
294    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
295        let content = &s[1..s.len() - 1];
296        return Ok(Value::String(content.to_string()));
297    }
298
299    // Handle booleans
300    if s == "true" {
301        return Ok(Value::Bool(true));
302    }
303    if s == "false" {
304        return Ok(Value::Bool(false));
305    }
306
307    // Handle numbers
308    if let Ok(num) = s.parse::<i64>() {
309        return Ok(Value::Number(num.into()));
310    }
311
312    if let Ok(num) = s.parse::<f64>() {
313        // Create a JSON number from f64, handling error if it's not representable
314        match serde_json::Number::from_f64(num) {
315            Some(n) => return Ok(Value::Number(n)),
316            None => {
317                return Err(ExpressionError::TypeError(format!(
318                    "Cannot convert {} to JSON number",
319                    s
320                )))
321            }
322        }
323    }
324
325    // If we get here, it's not a recognized literal
326    Err(ExpressionError::TypeError(format!(
327        "Invalid literal: {}",
328        s
329    )))
330}
331
332// Compare two JSON values based on the comparison operator
333fn compare_values(left: &Value, op: &ComparisonOperator, right: &Value) -> Result<bool> {
334    match op {
335        ComparisonOperator::Equal => Ok(left == right),
336        ComparisonOperator::NotEqual => Ok(left != right),
337
338        // Handle numeric comparisons
339        ComparisonOperator::GreaterThan => compare_ordered(left, right, |a, b| a > b),
340        ComparisonOperator::LessThan => compare_ordered(left, right, |a, b| a < b),
341        ComparisonOperator::GreaterThanOrEqual => compare_ordered(left, right, |a, b| a >= b),
342        ComparisonOperator::LessThanOrEqual => compare_ordered(left, right, |a, b| a <= b),
343        ComparisonOperator::Unspecified => Err(ExpressionError::Evaluation(
344            "Unspecified comparison operator".to_string(),
345        )),
346    }
347}
348
349// Helper for ordered comparisons
350fn compare_ordered<F>(left: &Value, right: &Value, compare_fn: F) -> Result<bool>
351where
352    F: Fn(&f64, &f64) -> bool,
353{
354    // Extract numeric values
355    let left_num = extract_number(left)?;
356    let right_num = extract_number(right)?;
357
358    Ok(compare_fn(&left_num, &right_num))
359}
360
361// Extract a numeric value from a JSON value
362fn extract_number(val: &Value) -> Result<f64> {
363    match val {
364        Value::Number(n) => {
365            if let Some(f) = n.as_f64() {
366                Ok(f)
367            } else {
368                Err(ExpressionError::TypeError(format!(
369                    "Cannot convert number to f64: {:?}",
370                    n
371                )))
372            }
373        }
374        Value::String(s) => match s.parse::<f64>() {
375            Ok(num) => Ok(num),
376            Err(_) => Err(ExpressionError::TypeError(format!(
377                "Cannot convert string to number: {}",
378                s
379            ))),
380        },
381        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
382        _ => Err(ExpressionError::TypeError(format!(
383            "Cannot convert to number: {:?}",
384            val
385        ))),
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use std::borrow::Cow;
393    use webui_protocol::{ComparisonOperator, ConditionExpr, LogicalOperator};
394    use webui_test_utils::test_json;
395
396    #[test]
397    fn boolean_literals_do_not_resolve_state_keys() {
398        let state = test_json!({"true": false, "false": true});
399        assert_eq!(
400            evaluate(&ConditionExpr::identifier("true"), &state).unwrap(),
401            true
402        );
403        assert_eq!(
404            evaluate(&ConditionExpr::identifier("false"), &state).unwrap(),
405            false
406        );
407    }
408
409    #[test]
410    fn test_simple_identifier() {
411        // Test true identifier
412        let condition = ConditionExpr::identifier("flag");
413
414        let state = test_json!({
415            "flag": true
416        });
417
418        let result = evaluate(&condition, &state);
419        assert!(
420            matches!(result, Ok(true)),
421            "Expected Ok(true), got {:?}",
422            result
423        );
424
425        // Test false identifier
426        let condition = ConditionExpr::identifier("flag");
427
428        let state = test_json!({
429            "flag": false
430        });
431
432        let result = evaluate(&condition, &state);
433        assert!(
434            matches!(result, Ok(false)),
435            "Expected Ok(false), got {:?}",
436            result
437        );
438
439        // Test non-boolean identifier treated as boolean
440        let condition = ConditionExpr::identifier("name");
441
442        let state = test_json!({
443            "name": "John"
444        });
445
446        let result = evaluate(&condition, &state);
447        assert!(
448            matches!(result, Ok(true)),
449            "Expected Ok(true), got {:?}",
450            result
451        ); // Non-empty string is truthy
452    }
453
454    #[test]
455    fn test_predicate() {
456        let condition = ConditionExpr::predicate("age", ComparisonOperator::GreaterThan, "18");
457
458        // Test with age > 18
459        let state = test_json!({
460            "age": 21
461        });
462
463        let result = evaluate(&condition, &state);
464        assert!(
465            matches!(result, Ok(true)),
466            "Expected Ok(true), got {:?}",
467            result
468        );
469
470        // Test with age < 18
471        let state = test_json!({
472            "age": 16
473        });
474
475        let result = evaluate(&condition, &state);
476        assert!(
477            matches!(result, Ok(false)),
478            "Expected Ok(false), got {:?}",
479            result
480        );
481    }
482
483    #[test]
484    fn test_not_expression() {
485        let condition = ConditionExpr::negated(ConditionExpr::identifier("flag"));
486
487        // Test with flag = true
488        let state = test_json!({
489            "flag": true
490        });
491
492        let result = evaluate(&condition, &state);
493        assert!(
494            matches!(result, Ok(false)),
495            "Expected Ok(false), got {:?}",
496            result
497        );
498
499        // Test with flag = false
500        let state = test_json!({
501            "flag": false
502        });
503
504        let result = evaluate(&condition, &state);
505        assert!(
506            matches!(result, Ok(true)),
507            "Expected Ok(true), got {:?}",
508            result
509        );
510    }
511
512    #[test]
513    fn test_compound_expression() {
514        // Test AND
515        let condition = ConditionExpr::compound(
516            ConditionExpr::identifier("isAdmin"),
517            LogicalOperator::And,
518            ConditionExpr::identifier("isActive"),
519        );
520
521        let state = test_json!({
522            "isAdmin": true,
523            "isActive": true
524        });
525
526        let result = evaluate(&condition, &state);
527        assert!(
528            matches!(result, Ok(true)),
529            "Expected Ok(true), got {:?}",
530            result
531        );
532
533        // Test OR
534        let condition = ConditionExpr::compound(
535            ConditionExpr::identifier("isAdmin"),
536            LogicalOperator::Or,
537            ConditionExpr::identifier("isEditor"),
538        );
539
540        let state = test_json!({
541            "isAdmin": false,
542            "isEditor": true
543        });
544
545        let result = evaluate(&condition, &state);
546        assert!(
547            matches!(result, Ok(true)),
548            "Expected Ok(true), got {:?}",
549            result
550        );
551    }
552
553    #[test]
554    fn test_mixed_operators_error() {
555        // Create a condition with mixed operators (AND and OR)
556        let condition = ConditionExpr::compound(
557            ConditionExpr::compound(
558                ConditionExpr::identifier("a"),
559                LogicalOperator::And,
560                ConditionExpr::identifier("b"),
561            ),
562            LogicalOperator::Or,
563            ConditionExpr::identifier("c"),
564        );
565
566        let state = test_json!({
567            "a": true, "b": true, "c": true
568        });
569
570        let result = evaluate(&condition, &state);
571        assert!(matches!(result, Err(ExpressionError::MixedOperators)));
572    }
573
574    #[test]
575    fn test_too_many_operators_error() {
576        // Create a condition with more than 5 operators
577        let mut condition = ConditionExpr::identifier("a");
578
579        // Add 6 logical operators (exceeding the limit of 5)
580        for i in 0..6 {
581            condition = ConditionExpr::compound(
582                condition,
583                LogicalOperator::And,
584                ConditionExpr::identifier(format!("var{}", i)),
585            );
586        }
587
588        let state = test_json!({
589            "a": true, "var0": true, "var1": true,
590            "var2": true, "var3": true, "var4": true, "var5": true
591        });
592
593        let result = evaluate(&condition, &state);
594        assert!(matches!(result, Err(ExpressionError::TooManyOperators(_))));
595    }
596
597    #[test]
598    fn test_comparison_operators() {
599        let state = test_json!({
600            "value": 10
601        });
602
603        // Test each comparison operator
604        let ops = [
605            (ComparisonOperator::GreaterThan, "5", true),
606            (ComparisonOperator::GreaterThan, "15", false),
607            (ComparisonOperator::LessThan, "15", true),
608            (ComparisonOperator::LessThan, "5", false),
609            (ComparisonOperator::Equal, "10", true),
610            (ComparisonOperator::Equal, "11", false),
611            (ComparisonOperator::NotEqual, "11", true),
612            (ComparisonOperator::NotEqual, "10", false),
613            (ComparisonOperator::GreaterThanOrEqual, "10", true),
614            (ComparisonOperator::GreaterThanOrEqual, "11", false),
615            (ComparisonOperator::LessThanOrEqual, "10", true),
616            (ComparisonOperator::LessThanOrEqual, "9", false),
617        ];
618
619        for (op, right, expected) in ops.iter() {
620            let condition = ConditionExpr::predicate("value", *op, *right);
621
622            let result = evaluate(&condition, &state);
623            assert!(
624                matches!(result, Ok(val) if val == *expected),
625                "Failed for operator {:?} with right value {}: expected Ok({}), got {:?}",
626                op,
627                right,
628                expected,
629                result
630            );
631        }
632    }
633
634    #[test]
635    fn test_string_comparison() {
636        let state = test_json!({
637            "name": "John"
638        });
639
640        // Test string equality
641        let condition = ConditionExpr::predicate("name", ComparisonOperator::Equal, "\"John\"");
642
643        let result = evaluate(&condition, &state);
644        assert!(
645            matches!(result, Ok(true)),
646            "Expected Ok(true), got {:?}",
647            result
648        );
649
650        // Test string inequality
651        let condition = ConditionExpr::predicate("name", ComparisonOperator::NotEqual, "\"Jane\"");
652
653        let result = evaluate(&condition, &state);
654        assert!(
655            matches!(result, Ok(true)),
656            "Expected Ok(true), got {:?}",
657            result
658        );
659    }
660
661    #[test]
662    fn test_dotted_path() {
663        let state = test_json!({
664            "user": {
665                "profile": {
666                    "age": 25,
667                    "name": "John"
668                }
669            }
670        });
671
672        // Test nested property access
673        let condition =
674            ConditionExpr::predicate("user.profile.age", ComparisonOperator::GreaterThan, "18");
675
676        let result = evaluate(&condition, &state);
677        assert!(
678            matches!(result, Ok(true)),
679            "Expected Ok(true), got {:?}",
680            result
681        );
682    }
683
684    #[test]
685    fn test_borrowed_resolver_value() {
686        let condition = ConditionExpr::identifier("flag");
687        let value = Value::Bool(true);
688
689        let result = evaluate_with_resolver(&condition, |path| {
690            if path == "flag" {
691                Some(Cow::Borrowed(&value))
692            } else {
693                None
694            }
695        });
696
697        assert!(matches!(result, Ok(true)));
698    }
699
700    #[test]
701    fn test_missing_value() {
702        let state = test_json!({
703            "user": {
704                "name": "John"
705            }
706        });
707
708        // Test with a missing value
709        let condition = ConditionExpr::predicate("user.age", ComparisonOperator::GreaterThan, "18");
710
711        let result = evaluate(&condition, &state);
712        assert!(matches!(result, Err(ExpressionError::MissingValue(_))));
713    }
714
715    // === Identifier Edge Cases ===
716
717    #[test]
718    fn test_missing_identifier_is_falsy_before_negation() {
719        let condition = ConditionExpr::identifier("notExist");
720        let negated = ConditionExpr::negated(condition.clone());
721        let state = test_json!({ "flag": true });
722
723        assert!(
724            matches!(evaluate(&condition, &state), Ok(false)),
725            "a missing identifier must be a falsy operand"
726        );
727        assert!(
728            matches!(evaluate(&negated, &state), Ok(true)),
729            "negating a missing identifier must evaluate to true"
730        );
731    }
732
733    #[test]
734    fn test_zero_field() {
735        let condition = ConditionExpr::identifier("zero");
736        let state = test_json!({ "zero": 0 });
737
738        let result = evaluate(&condition, &state);
739        assert!(
740            matches!(result, Ok(false)),
741            "Expected Ok(false), got {:?}",
742            result
743        );
744    }
745
746    #[test]
747    fn test_empty_string_field() {
748        let condition = ConditionExpr::identifier("empty");
749        let state = test_json!({ "empty": "" });
750
751        let result = evaluate(&condition, &state);
752        assert!(
753            matches!(result, Ok(false)),
754            "Expected Ok(false), got {:?}",
755            result
756        );
757    }
758
759    #[test]
760    fn test_nonempty_array() {
761        let condition = ConditionExpr::identifier("myList");
762        let state = test_json!({ "myList": [1, 2, 3] });
763
764        let result = evaluate(&condition, &state);
765        assert!(
766            matches!(result, Ok(true)),
767            "Expected Ok(true), got {:?}",
768            result
769        );
770    }
771
772    #[test]
773    fn test_empty_array() {
774        let condition = ConditionExpr::identifier("emptyList");
775        let state = test_json!({ "emptyList": [] });
776
777        let result = evaluate(&condition, &state);
778        assert!(
779            matches!(result, Ok(false)),
780            "Expected Ok(false), got {:?}",
781            result
782        );
783    }
784
785    // === Deep Dotted Path ===
786
787    #[test]
788    fn test_dotted_path_deep() {
789        let condition = ConditionExpr::identifier("outer.nested.deep.value");
790        let state = test_json!({
791            "outer": { "nested": { "deep": { "value": true } } }
792        });
793
794        let result = evaluate(&condition, &state);
795        assert!(
796            matches!(result, Ok(true)),
797            "Expected Ok(true), got {:?}",
798            result
799        );
800    }
801
802    // === Comparison Edge Cases ===
803
804    #[test]
805    fn test_string_eq() {
806        let condition =
807            ConditionExpr::predicate("appearance", ComparisonOperator::Equal, "\"hub\"");
808        let state = test_json!({ "appearance": "hub" });
809
810        let result = evaluate(&condition, &state);
811        assert!(
812            matches!(result, Ok(true)),
813            "Expected Ok(true), got {:?}",
814            result
815        );
816    }
817
818    #[test]
819    fn test_string_ne() {
820        let condition =
821            ConditionExpr::predicate("appearance", ComparisonOperator::NotEqual, "\"full-page\"");
822        let state = test_json!({ "appearance": "hub" });
823
824        let result = evaluate(&condition, &state);
825        assert!(
826            matches!(result, Ok(true)),
827            "Expected Ok(true), got {:?}",
828            result
829        );
830    }
831
832    #[test]
833    fn test_number_eq() {
834        let condition = ConditionExpr::predicate("x", ComparisonOperator::Equal, "5");
835        let state = test_json!({ "x": 5 });
836
837        let result = evaluate(&condition, &state);
838        assert!(
839            matches!(result, Ok(true)),
840            "Expected Ok(true), got {:?}",
841            result
842        );
843    }
844
845    #[test]
846    fn test_less_than() {
847        let condition = ConditionExpr::predicate("x", ComparisonOperator::LessThan, "y");
848        let state = test_json!({ "x": 5, "y": 10 });
849
850        let result = evaluate(&condition, &state);
851        assert!(
852            matches!(result, Ok(true)),
853            "Expected Ok(true), got {:?}",
854            result
855        );
856    }
857
858    #[test]
859    fn test_greater_than() {
860        let condition = ConditionExpr::predicate("y", ComparisonOperator::GreaterThan, "x");
861        let state = test_json!({ "x": 5, "y": 10 });
862
863        let result = evaluate(&condition, &state);
864        assert!(
865            matches!(result, Ok(true)),
866            "Expected Ok(true), got {:?}",
867            result
868        );
869    }
870
871    #[test]
872    fn test_less_equal() {
873        let condition = ConditionExpr::predicate("x", ComparisonOperator::LessThanOrEqual, "5");
874        let state = test_json!({ "x": 5 });
875
876        let result = evaluate(&condition, &state);
877        assert!(
878            matches!(result, Ok(true)),
879            "Expected Ok(true), got {:?}",
880            result
881        );
882    }
883
884    #[test]
885    fn test_greater_equal() {
886        let condition = ConditionExpr::predicate("y", ComparisonOperator::GreaterThanOrEqual, "10");
887        let state = test_json!({ "y": 10 });
888
889        let result = evaluate(&condition, &state);
890        assert!(
891            matches!(result, Ok(true)),
892            "Expected Ok(true), got {:?}",
893            result
894        );
895    }
896
897    #[test]
898    fn test_nested_eq() {
899        let condition = ConditionExpr::predicate("outer.inner", ComparisonOperator::Equal, "42");
900        let state = test_json!({ "outer": { "inner": 42 } });
901
902        let result = evaluate(&condition, &state);
903        assert!(
904            matches!(result, Ok(true)),
905            "Expected Ok(true), got {:?}",
906            result
907        );
908    }
909
910    // === Short-Circuit Evaluation ===
911
912    #[test]
913    fn test_and_true_true() {
914        let condition = ConditionExpr::compound(
915            ConditionExpr::identifier("isEnabled"),
916            LogicalOperator::And,
917            ConditionExpr::identifier("x"),
918        );
919        let state = test_json!({ "isEnabled": true, "x": 5 });
920
921        let result = evaluate(&condition, &state);
922        assert!(
923            matches!(result, Ok(true)),
924            "Expected Ok(true), got {:?}",
925            result
926        );
927    }
928
929    #[test]
930    fn test_and_true_false() {
931        let condition = ConditionExpr::compound(
932            ConditionExpr::identifier("isEnabled"),
933            LogicalOperator::And,
934            ConditionExpr::identifier("isDisabled"),
935        );
936        let state = test_json!({ "isEnabled": true, "isDisabled": false });
937
938        let result = evaluate(&condition, &state);
939        assert!(
940            matches!(result, Ok(false)),
941            "Expected Ok(false), got {:?}",
942            result
943        );
944    }
945
946    #[test]
947    fn test_or_false_false() {
948        let condition = ConditionExpr::compound(
949            ConditionExpr::identifier("isDisabled"),
950            LogicalOperator::Or,
951            ConditionExpr::identifier("zero"),
952        );
953        let state = test_json!({ "isDisabled": false, "zero": 0 });
954
955        let result = evaluate(&condition, &state);
956        assert!(
957            matches!(result, Ok(false)),
958            "Expected Ok(false), got {:?}",
959            result
960        );
961    }
962
963    #[test]
964    fn test_or_false_true() {
965        let condition = ConditionExpr::compound(
966            ConditionExpr::identifier("isDisabled"),
967            LogicalOperator::Or,
968            ConditionExpr::identifier("isEnabled"),
969        );
970        let state = test_json!({ "isDisabled": false, "isEnabled": true });
971
972        let result = evaluate(&condition, &state);
973        assert!(
974            matches!(result, Ok(true)),
975            "Expected Ok(true), got {:?}",
976            result
977        );
978    }
979
980    // === Complex Expressions ===
981
982    #[test]
983    fn test_appearance_and_actions() {
984        let condition = ConditionExpr::compound(
985            ConditionExpr::predicate("appearance", ComparisonOperator::Equal, "\"hub\""),
986            LogicalOperator::And,
987            ConditionExpr::identifier("actions.trailing"),
988        );
989        let state = test_json!({
990            "appearance": "hub",
991            "actions": { "trailing": true }
992        });
993
994        let result = evaluate(&condition, &state);
995        assert!(
996            matches!(result, Ok(true)),
997            "Expected Ok(true), got {:?}",
998            result
999        );
1000    }
1001
1002    #[test]
1003    fn test_negated_binary() {
1004        let condition = ConditionExpr::negated(ConditionExpr::predicate(
1005            "appearance",
1006            ComparisonOperator::Equal,
1007            "\"hub\"",
1008        ));
1009        let state = test_json!({ "appearance": "sidepanel" });
1010
1011        let result = evaluate(&condition, &state);
1012        assert!(
1013            matches!(result, Ok(true)),
1014            "Expected Ok(true), got {:?}",
1015            result
1016        );
1017    }
1018
1019    // === Single-term fast path ===
1020
1021    #[test]
1022    fn test_single_term_matches_operator_guard_semantics() {
1023        let state = test_json!({
1024            "flag": true,
1025            "off": false,
1026            "count": 0,
1027            "name": "Alice",
1028            "user": { "age": 25 }
1029        });
1030
1031        // Single identifiers and predicates carry no logical operators, so they
1032        // must evaluate normally and never surface an operator-limit error.
1033        let cases: [(ConditionExpr, bool); 6] = [
1034            (ConditionExpr::identifier("flag"), true),
1035            (ConditionExpr::identifier("off"), false),
1036            (ConditionExpr::identifier("count"), false),
1037            (ConditionExpr::identifier("name"), true),
1038            (
1039                ConditionExpr::predicate("user.age", ComparisonOperator::GreaterThan, "18"),
1040                true,
1041            ),
1042            (
1043                ConditionExpr::predicate("name", ComparisonOperator::Equal, "'Bob'"),
1044                false,
1045            ),
1046        ];
1047
1048        for (condition, expected) in cases {
1049            assert_eq!(
1050                evaluate(&condition, &state).ok(),
1051                Some(expected),
1052                "single-term condition {:?} must evaluate to {}",
1053                condition,
1054                expected
1055            );
1056        }
1057    }
1058
1059    #[test]
1060    fn test_single_term_predicate_still_reports_missing_value() {
1061        let state = test_json!({ "user": { "name": "John" } });
1062        let condition = ConditionExpr::predicate("user.age", ComparisonOperator::LessThan, "18");
1063
1064        assert!(matches!(
1065            evaluate(&condition, &state),
1066            Err(ExpressionError::MissingValue(_))
1067        ));
1068    }
1069
1070    #[test]
1071    fn test_negated_tree_still_enforces_operator_limit() {
1072        // A `Not` wrapper must not be mistaken for a single term: the operator
1073        // guard has to keep walking the tree underneath it.
1074        let mut inner = ConditionExpr::identifier("a");
1075        for i in 0..6 {
1076            inner = ConditionExpr::compound(
1077                inner,
1078                LogicalOperator::And,
1079                ConditionExpr::identifier(format!("var{}", i)),
1080            );
1081        }
1082        let condition = ConditionExpr::negated(inner);
1083
1084        let state = test_json!({
1085            "a": true, "var0": true, "var1": true,
1086            "var2": true, "var3": true, "var4": true, "var5": true
1087        });
1088
1089        assert!(matches!(
1090            evaluate(&condition, &state),
1091            Err(ExpressionError::TooManyOperators(6))
1092        ));
1093    }
1094
1095    #[test]
1096    fn test_operator_count_is_exact_beyond_inline_stack() {
1097        // A left-deep tree keeps one pending node per open compound, so twelve
1098        // operators spill past the inline stack; the reported count must
1099        // still cover every node.
1100        let mut condition = ConditionExpr::identifier("a");
1101        for i in 0..12 {
1102            condition = ConditionExpr::compound(
1103                condition,
1104                LogicalOperator::Or,
1105                ConditionExpr::identifier(format!("var{}", i)),
1106            );
1107        }
1108
1109        let state = test_json!({ "a": true });
1110
1111        assert!(matches!(
1112            evaluate(&condition, &state),
1113            Err(ExpressionError::TooManyOperators(12))
1114        ));
1115    }
1116
1117    #[test]
1118    fn test_negated_tree_still_rejects_mixed_operators() {
1119        let condition = ConditionExpr::negated(ConditionExpr::compound(
1120            ConditionExpr::compound(
1121                ConditionExpr::identifier("a"),
1122                LogicalOperator::And,
1123                ConditionExpr::identifier("b"),
1124            ),
1125            LogicalOperator::Or,
1126            ConditionExpr::identifier("c"),
1127        ));
1128
1129        let state = test_json!({ "a": true, "b": true, "c": true });
1130
1131        assert!(matches!(
1132            evaluate(&condition, &state),
1133            Err(ExpressionError::MixedOperators)
1134        ));
1135    }
1136
1137    #[test]
1138    fn test_single_term_fast_path_uses_same_resolver_contract() {
1139        let value = Value::String("active".to_string());
1140        let lookups = std::cell::Cell::new(0usize);
1141
1142        let condition = ConditionExpr::predicate("status", ComparisonOperator::Equal, "'active'");
1143        let result = evaluate_with_resolver(&condition, |path| {
1144            lookups.set(lookups.get() + 1);
1145            if path == "status" {
1146                Some(Cow::Borrowed(&value))
1147            } else {
1148                None
1149            }
1150        });
1151
1152        assert!(matches!(result, Ok(true)));
1153        assert_eq!(
1154            lookups.get(),
1155            1,
1156            "a single-term predicate must resolve exactly one path"
1157        );
1158    }
1159}