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 {
10        value: Value,
11    },
12    Var {
13        path: String,
14    },
15    Answer {
16        path: String,
17    },
18    IsSet {
19        path: String,
20    },
21    /// String concatenation of its parts. Each part is evaluated and
22    /// coerced to a string; the whole expression is `None` if any part is
23    /// unset or a structured (array/object) value. Lets a computed default
24    /// build a value from other answers, e.g. `"/" + bundle_id`.
25    Concat {
26        parts: Vec<Expr>,
27    },
28    And {
29        expressions: Vec<Expr>,
30    },
31    Or {
32        expressions: Vec<Expr>,
33    },
34    Not {
35        expression: Box<Expr>,
36    },
37    Eq {
38        left: Box<Expr>,
39        right: Box<Expr>,
40    },
41    Ne {
42        left: Box<Expr>,
43        right: Box<Expr>,
44    },
45    Lt {
46        left: Box<Expr>,
47        right: Box<Expr>,
48    },
49    Lte {
50        left: Box<Expr>,
51        right: Box<Expr>,
52    },
53    Gt {
54        left: Box<Expr>,
55        right: Box<Expr>,
56    },
57    Gte {
58        left: Box<Expr>,
59        right: Box<Expr>,
60    },
61}
62
63impl Expr {
64    /// Evaluates the expression and returns a JSON value when possible.
65    pub fn evaluate_value(&self, ctx: &Value) -> Option<Value> {
66        match self {
67            Expr::Literal { value } => Some(value.clone()),
68            Expr::Var { path } => Self::lookup(ctx, path).cloned(),
69            Expr::Answer { path } => Self::lookup_answer(ctx, path).cloned(),
70            Expr::IsSet { path } => {
71                let present = Self::lookup_answer(ctx, path).is_some();
72                Some(Value::Bool(present))
73            }
74            Expr::Concat { parts } => {
75                let mut out = String::new();
76                for part in parts {
77                    match part.evaluate_value(ctx)? {
78                        Value::String(text) => out.push_str(&text),
79                        Value::Null | Value::Array(_) | Value::Object(_) => return None,
80                        scalar => out.push_str(&scalar.to_string()),
81                    }
82                }
83                Some(Value::String(out))
84            }
85            Expr::And { expressions } => Self::evaluate_and(expressions, ctx),
86            Expr::Or { expressions } => Self::evaluate_or(expressions, ctx),
87            Expr::Not { expression } => expression
88                .evaluate_bool(ctx)
89                .map(|value| Value::Bool(!value)),
90            Expr::Eq { left, right } => {
91                let left_value = left.evaluate_value(ctx)?;
92                let right_value = right.evaluate_value(ctx)?;
93                Some(Value::Bool(left_value == right_value))
94            }
95            Expr::Ne { left, right } => {
96                let left_value = left.evaluate_value(ctx)?;
97                let right_value = right.evaluate_value(ctx)?;
98                Some(Value::Bool(left_value != right_value))
99            }
100            Expr::Lt { left, right } => {
101                Self::evaluate_compare(left, right, ctx, |o| matches!(o, std::cmp::Ordering::Less))
102            }
103            Expr::Lte { left, right } => Self::evaluate_compare(left, right, ctx, |o| {
104                matches!(o, std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
105            }),
106            Expr::Gt { left, right } => Self::evaluate_compare(left, right, ctx, |o| {
107                matches!(o, std::cmp::Ordering::Greater)
108            }),
109            Expr::Gte { left, right } => Self::evaluate_compare(left, right, ctx, |o| {
110                matches!(o, std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
111            }),
112        }
113    }
114
115    /// Evaluates the expression and coerces the result into a boolean when possible.
116    pub fn evaluate_bool(&self, ctx: &Value) -> Option<bool> {
117        let value = self.evaluate_value(ctx)?;
118        match value {
119            Value::Bool(value) => Some(value),
120            Value::Number(number) => number.as_f64().map(|value| value != 0.0),
121            Value::String(text) => match text.to_lowercase().as_str() {
122                "true" | "t" | "yes" | "y" | "1" => Some(true),
123                "false" | "f" | "no" | "n" | "0" => Some(false),
124                _ => None,
125            },
126            Value::Null => Some(false),
127            _ => None,
128        }
129    }
130
131    fn evaluate_and(expressions: &[Expr], ctx: &Value) -> Option<Value> {
132        let mut seen_none = false;
133        for expression in expressions {
134            match expression.evaluate_bool(ctx) {
135                Some(false) => return Some(Value::Bool(false)),
136                Some(true) => continue,
137                None => seen_none = true,
138            }
139        }
140        if seen_none {
141            None
142        } else {
143            Some(Value::Bool(true))
144        }
145    }
146
147    fn evaluate_or(expressions: &[Expr], ctx: &Value) -> Option<Value> {
148        let mut seen_none = false;
149        for expression in expressions {
150            match expression.evaluate_bool(ctx) {
151                Some(true) => return Some(Value::Bool(true)),
152                Some(false) => continue,
153                None => seen_none = true,
154            }
155        }
156        if seen_none {
157            None
158        } else {
159            Some(Value::Bool(false))
160        }
161    }
162
163    fn evaluate_compare<F>(left: &Expr, right: &Expr, ctx: &Value, predicate: F) -> Option<Value>
164    where
165        F: Fn(std::cmp::Ordering) -> bool,
166    {
167        let left_value = left.evaluate_value(ctx)?;
168        let right_value = right.evaluate_value(ctx)?;
169        let ordering = Self::compare_values(&left_value, &right_value)?;
170        if predicate(ordering) {
171            Some(Value::Bool(true))
172        } else {
173            Some(Value::Bool(false))
174        }
175    }
176
177    fn compare_values(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
178        match (left, right) {
179            (Value::Number(left), Value::Number(right)) => {
180                let left_num = left.as_f64()?;
181                let right_num = right.as_f64()?;
182                left_num.partial_cmp(&right_num)
183            }
184            (Value::String(left_text), Value::String(right_text)) => {
185                Some(left_text.cmp(right_text))
186            }
187            _ => {
188                if left == right {
189                    Some(std::cmp::Ordering::Equal)
190                } else {
191                    None
192                }
193            }
194        }
195    }
196
197    fn lookup<'a>(ctx: &'a Value, path: &str) -> Option<&'a Value> {
198        let pointer = Self::normalize_pointer(path);
199        ctx.pointer(&pointer)
200    }
201
202    fn lookup_answer<'a>(ctx: &'a Value, path: &str) -> Option<&'a Value> {
203        if let Some(value) = ctx.get("answers") {
204            Self::fetch_nested(value, path)
205        } else {
206            Self::fetch_nested(ctx, path)
207        }
208    }
209
210    fn fetch_nested<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
211        if path.starts_with('/') {
212            return value.pointer(path);
213        }
214        let mut current = value;
215        for segment in path.split('.') {
216            if segment.is_empty() {
217                continue;
218            }
219            current = if let Ok(index) = segment.parse::<usize>() {
220                current.get(index)?
221            } else {
222                current.get(segment)?
223            };
224        }
225        Some(current)
226    }
227
228    fn normalize_pointer(path: &str) -> String {
229        let trimmed = path.trim();
230        if trimmed.is_empty() {
231            return "/".to_string();
232        }
233        if trimmed.starts_with('/') {
234            return trimmed.to_string();
235        }
236        let cleaned = trimmed
237            .trim_start_matches('/')
238            .split('.')
239            .filter(|segment| !segment.is_empty())
240            .collect::<Vec<_>>();
241        format!("/{}", cleaned.join("/"))
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use serde_json::json;
249
250    fn concat(parts: Vec<Expr>) -> Expr {
251        Expr::Concat { parts }
252    }
253
254    #[test]
255    fn concat_joins_literal_and_var() {
256        // The motivating case: a route prefix derived from a sibling answer,
257        // e.g. `"/" + bundle_id`.
258        let ctx = json!({ "bundle_id": "legal" });
259        let expr = concat(vec![
260            Expr::Literal { value: json!("/") },
261            Expr::Var {
262                path: "bundle_id".into(),
263            },
264        ]);
265        assert_eq!(expr.evaluate_value(&ctx), Some(json!("/legal")));
266    }
267
268    #[test]
269    fn concat_coerces_scalars_but_rejects_missing_and_structured() {
270        let ctx = json!({ "n": 7, "flag": true, "list": [1], "nested": {"a": 1} });
271        // Numbers / booleans coerce to their textual form.
272        assert_eq!(
273            concat(vec![
274                Expr::Var { path: "n".into() },
275                Expr::Literal { value: json!("-") },
276                Expr::Var {
277                    path: "flag".into()
278                },
279            ])
280            .evaluate_value(&ctx),
281            Some(json!("7-true"))
282        );
283        // A missing part makes the whole concat `None` (no derived value).
284        assert_eq!(
285            concat(vec![Expr::Var {
286                path: "absent".into()
287            }])
288            .evaluate_value(&ctx),
289            None
290        );
291        // Structured parts cannot be concatenated.
292        assert_eq!(
293            concat(vec![Expr::Var {
294                path: "list".into()
295            }])
296            .evaluate_value(&ctx),
297            None
298        );
299    }
300}