Skip to main content

qa_spec/
expr.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5/// Lightweight expression AST used for `visible_if`, computed fields, and validations.
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
7#[serde(tag = "op", rename_all = "snake_case")]
8pub enum Expr {
9    Literal { value: Value },
10    Var { path: String },
11    Answer { path: String },
12    IsSet { path: String },
13    And { expressions: Vec<Expr> },
14    Or { expressions: Vec<Expr> },
15    Not { expression: Box<Expr> },
16    Eq { left: Box<Expr>, right: Box<Expr> },
17    Ne { left: Box<Expr>, right: Box<Expr> },
18    Lt { left: Box<Expr>, right: Box<Expr> },
19    Lte { left: Box<Expr>, right: Box<Expr> },
20    Gt { left: Box<Expr>, right: Box<Expr> },
21    Gte { left: Box<Expr>, right: Box<Expr> },
22}
23
24impl Expr {
25    /// Evaluates the expression and returns a JSON value when possible.
26    pub fn evaluate_value(&self, ctx: &Value) -> Option<Value> {
27        match self {
28            Expr::Literal { value } => Some(value.clone()),
29            Expr::Var { path } => Self::lookup(ctx, path).cloned(),
30            Expr::Answer { path } => Self::lookup_answer(ctx, path).cloned(),
31            Expr::IsSet { path } => {
32                let present = Self::lookup_answer(ctx, path).is_some();
33                Some(Value::Bool(present))
34            }
35            Expr::And { expressions } => Self::evaluate_and(expressions, ctx),
36            Expr::Or { expressions } => Self::evaluate_or(expressions, ctx),
37            Expr::Not { expression } => expression
38                .evaluate_bool(ctx)
39                .map(|value| Value::Bool(!value)),
40            Expr::Eq { left, right } => {
41                let left_value = left.evaluate_value(ctx)?;
42                let right_value = right.evaluate_value(ctx)?;
43                Some(Value::Bool(left_value == right_value))
44            }
45            Expr::Ne { left, right } => {
46                let left_value = left.evaluate_value(ctx)?;
47                let right_value = right.evaluate_value(ctx)?;
48                Some(Value::Bool(left_value != right_value))
49            }
50            Expr::Lt { left, right } => {
51                Self::evaluate_compare(left, right, ctx, |o| matches!(o, std::cmp::Ordering::Less))
52            }
53            Expr::Lte { left, right } => Self::evaluate_compare(left, right, ctx, |o| {
54                matches!(o, std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
55            }),
56            Expr::Gt { left, right } => Self::evaluate_compare(left, right, ctx, |o| {
57                matches!(o, std::cmp::Ordering::Greater)
58            }),
59            Expr::Gte { left, right } => Self::evaluate_compare(left, right, ctx, |o| {
60                matches!(o, std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
61            }),
62        }
63    }
64
65    /// Evaluates the expression and coerces the result into a boolean when possible.
66    pub fn evaluate_bool(&self, ctx: &Value) -> Option<bool> {
67        let value = self.evaluate_value(ctx)?;
68        match value {
69            Value::Bool(value) => Some(value),
70            Value::Number(number) => number.as_f64().map(|value| value != 0.0),
71            Value::String(text) => match text.to_lowercase().as_str() {
72                "true" | "t" | "yes" | "y" | "1" => Some(true),
73                "false" | "f" | "no" | "n" | "0" => Some(false),
74                _ => None,
75            },
76            Value::Null => Some(false),
77            _ => None,
78        }
79    }
80
81    fn evaluate_and(expressions: &[Expr], ctx: &Value) -> Option<Value> {
82        let mut seen_none = false;
83        for expression in expressions {
84            match expression.evaluate_bool(ctx) {
85                Some(false) => return Some(Value::Bool(false)),
86                Some(true) => continue,
87                None => seen_none = true,
88            }
89        }
90        if seen_none {
91            None
92        } else {
93            Some(Value::Bool(true))
94        }
95    }
96
97    fn evaluate_or(expressions: &[Expr], ctx: &Value) -> Option<Value> {
98        let mut seen_none = false;
99        for expression in expressions {
100            match expression.evaluate_bool(ctx) {
101                Some(true) => return Some(Value::Bool(true)),
102                Some(false) => continue,
103                None => seen_none = true,
104            }
105        }
106        if seen_none {
107            None
108        } else {
109            Some(Value::Bool(false))
110        }
111    }
112
113    fn evaluate_compare<F>(left: &Expr, right: &Expr, ctx: &Value, predicate: F) -> Option<Value>
114    where
115        F: Fn(std::cmp::Ordering) -> bool,
116    {
117        let left_value = left.evaluate_value(ctx)?;
118        let right_value = right.evaluate_value(ctx)?;
119        let ordering = Self::compare_values(&left_value, &right_value)?;
120        if predicate(ordering) {
121            Some(Value::Bool(true))
122        } else {
123            Some(Value::Bool(false))
124        }
125    }
126
127    fn compare_values(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
128        match (left, right) {
129            (Value::Number(left), Value::Number(right)) => {
130                let left_num = left.as_f64()?;
131                let right_num = right.as_f64()?;
132                left_num.partial_cmp(&right_num)
133            }
134            (Value::String(left_text), Value::String(right_text)) => {
135                Some(left_text.cmp(right_text))
136            }
137            _ => {
138                if left == right {
139                    Some(std::cmp::Ordering::Equal)
140                } else {
141                    None
142                }
143            }
144        }
145    }
146
147    fn lookup<'a>(ctx: &'a Value, path: &str) -> Option<&'a Value> {
148        let pointer = Self::normalize_pointer(path);
149        ctx.pointer(&pointer)
150    }
151
152    fn lookup_answer<'a>(ctx: &'a Value, path: &str) -> Option<&'a Value> {
153        if let Some(value) = ctx.get("answers") {
154            Self::fetch_nested(value, path)
155        } else {
156            Self::fetch_nested(ctx, path)
157        }
158    }
159
160    fn fetch_nested<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
161        if path.starts_with('/') {
162            return value.pointer(path);
163        }
164        let mut current = value;
165        for segment in path.split('.') {
166            if segment.is_empty() {
167                continue;
168            }
169            current = if let Ok(index) = segment.parse::<usize>() {
170                current.get(index)?
171            } else {
172                current.get(segment)?
173            };
174        }
175        Some(current)
176    }
177
178    fn normalize_pointer(path: &str) -> String {
179        let trimmed = path.trim();
180        if trimmed.is_empty() {
181            return "/".to_string();
182        }
183        if trimmed.starts_with('/') {
184            return trimmed.to_string();
185        }
186        let cleaned = trimmed
187            .trim_start_matches('/')
188            .split('.')
189            .filter(|segment| !segment.is_empty())
190            .collect::<Vec<_>>();
191        format!("/{}", cleaned.join("/"))
192    }
193}