1use std::collections::BTreeSet;
42
43use serde::{Deserialize, Serialize};
44use serde_json::{Value, json};
45
46use crate::state::State;
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
50#[serde(rename_all = "snake_case")]
51pub enum Expr {
52 Const(Value),
54 Key(String),
56 Add(Vec<Expr>),
58 Mul(Vec<Expr>),
60 Sub(Box<Expr>, Box<Expr>),
62 Div(Box<Expr>, Box<Expr>),
64 Min(Vec<Expr>),
66 Max(Vec<Expr>),
68 Eq(Box<Expr>, Box<Expr>),
70 Gt(Box<Expr>, Box<Expr>),
72 Gte(Box<Expr>, Box<Expr>),
74 Lt(Box<Expr>, Box<Expr>),
76 Lte(Box<Expr>, Box<Expr>),
78 All(Vec<Expr>),
80 Any(Vec<Expr>),
82 Not(Box<Expr>),
84 If {
86 when: Box<Expr>,
88 then: Box<Expr>,
90 #[serde(rename = "else")]
92 otherwise: Box<Expr>,
93 },
94 Coalesce(Vec<Expr>),
96 Concat(Vec<Expr>),
99 CountTrue(Vec<String>),
101}
102
103impl Expr {
104 pub fn eval(&self, state: &State) -> Option<Value> {
107 match self {
108 Expr::Const(v) => Some(v.clone()),
109 Expr::Key(k) => state.get::<Value>(k),
110 Expr::Add(items) => nary(items, state, |acc, n| acc + n, 0.0),
111 Expr::Mul(items) => nary(items, state, |acc, n| acc * n, 1.0),
112 Expr::Sub(a, b) => Some(number(num(a, state)? - num(b, state)?)),
113 Expr::Div(a, b) => {
114 let d = num(b, state)?;
115 if d == 0.0 {
116 None
117 } else {
118 Some(number(num(a, state)? / d))
119 }
120 }
121 Expr::Min(items) => fold_nums(items, state, f64::min),
122 Expr::Max(items) => fold_nums(items, state, f64::max),
123 Expr::Eq(a, b) => Some(Value::Bool(a.eval(state)? == b.eval(state)?)),
124 Expr::Gt(a, b) => Some(Value::Bool(num(a, state)? > num(b, state)?)),
125 Expr::Gte(a, b) => Some(Value::Bool(num(a, state)? >= num(b, state)?)),
126 Expr::Lt(a, b) => Some(Value::Bool(num(a, state)? < num(b, state)?)),
127 Expr::Lte(a, b) => Some(Value::Bool(num(a, state)? <= num(b, state)?)),
128 Expr::All(items) => Some(Value::Bool(items.iter().all(|e| truthy(e, state)))),
129 Expr::Any(items) => Some(Value::Bool(items.iter().any(|e| truthy(e, state)))),
130 Expr::Not(e) => Some(Value::Bool(!truthy(e, state))),
131 Expr::If {
132 when,
133 then,
134 otherwise,
135 } => {
136 if truthy(when, state) {
137 then.eval(state)
138 } else {
139 otherwise.eval(state)
140 }
141 }
142 Expr::Coalesce(items) => items.iter().find_map(|e| e.eval(state)),
143 Expr::Concat(items) => {
144 let mut out = String::new();
145 for item in items {
146 match item.eval(state) {
147 Some(Value::String(s)) => out.push_str(&s),
148 Some(Value::Null) | None => {}
149 Some(v) => out.push_str(&v.to_string()),
150 }
151 }
152 Some(Value::String(out))
153 }
154 Expr::CountTrue(keys) => Some(json!(
155 keys.iter()
156 .filter(|k| state.get::<bool>(k).unwrap_or(false))
157 .count()
158 )),
159 }
160 }
161
162 pub fn keys_read(&self) -> BTreeSet<String> {
166 let mut keys = BTreeSet::new();
167 self.collect_keys(&mut keys);
168 keys
169 }
170
171 fn collect_keys(&self, keys: &mut BTreeSet<String>) {
172 match self {
173 Expr::Const(_) => {}
174 Expr::Key(k) => {
175 keys.insert(k.clone());
176 }
177 Expr::Add(items)
178 | Expr::Mul(items)
179 | Expr::Min(items)
180 | Expr::Max(items)
181 | Expr::All(items)
182 | Expr::Any(items)
183 | Expr::Coalesce(items)
184 | Expr::Concat(items) => {
185 for item in items {
186 item.collect_keys(keys);
187 }
188 }
189 Expr::Sub(a, b)
190 | Expr::Div(a, b)
191 | Expr::Eq(a, b)
192 | Expr::Gt(a, b)
193 | Expr::Gte(a, b)
194 | Expr::Lt(a, b)
195 | Expr::Lte(a, b) => {
196 a.collect_keys(keys);
197 b.collect_keys(keys);
198 }
199 Expr::Not(e) => e.collect_keys(keys),
200 Expr::If {
201 when,
202 then,
203 otherwise,
204 } => {
205 when.collect_keys(keys);
206 then.collect_keys(keys);
207 otherwise.collect_keys(keys);
208 }
209 Expr::CountTrue(ks) => keys.extend(ks.iter().cloned()),
210 }
211 }
212
213 pub fn describe(&self) -> String {
215 match self {
216 Expr::Const(v) => v.to_string(),
217 Expr::Key(k) => k.clone(),
218 Expr::Add(items) => infix(items, " + "),
219 Expr::Mul(items) => infix(items, " * "),
220 Expr::Sub(a, b) => format!("({} - {})", a.describe(), b.describe()),
221 Expr::Div(a, b) => format!("({} / {})", a.describe(), b.describe()),
222 Expr::Min(items) => format!("min({})", infix_bare(items)),
223 Expr::Max(items) => format!("max({})", infix_bare(items)),
224 Expr::Eq(a, b) => format!("({} == {})", a.describe(), b.describe()),
225 Expr::Gt(a, b) => format!("({} > {})", a.describe(), b.describe()),
226 Expr::Gte(a, b) => format!("({} >= {})", a.describe(), b.describe()),
227 Expr::Lt(a, b) => format!("({} < {})", a.describe(), b.describe()),
228 Expr::Lte(a, b) => format!("({} <= {})", a.describe(), b.describe()),
229 Expr::All(items) => format!("all({})", infix_bare(items)),
230 Expr::Any(items) => format!("any({})", infix_bare(items)),
231 Expr::Not(e) => format!("!{}", e.describe()),
232 Expr::If {
233 when,
234 then,
235 otherwise,
236 } => format!(
237 "if {} then {} else {}",
238 when.describe(),
239 then.describe(),
240 otherwise.describe()
241 ),
242 Expr::Coalesce(items) => format!("coalesce({})", infix_bare(items)),
243 Expr::Concat(items) => format!("concat({})", infix_bare(items)),
244 Expr::CountTrue(keys) => format!("count_true({})", keys.join(", ")),
245 }
246 }
247}
248
249fn num(e: &Expr, state: &State) -> Option<f64> {
250 match e.eval(state)? {
251 Value::Number(n) => n.as_f64(),
252 _ => None,
253 }
254}
255
256fn truthy(e: &Expr, state: &State) -> bool {
257 matches!(e.eval(state), Some(Value::Bool(true)))
258}
259
260fn number(n: f64) -> Value {
262 if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) {
263 json!(n as i64)
264 } else {
265 json!(n)
266 }
267}
268
269fn nary(items: &[Expr], state: &State, op: impl Fn(f64, f64) -> f64, init: f64) -> Option<Value> {
270 let mut acc = init;
271 for item in items {
272 acc = op(acc, num(item, state)?);
273 }
274 Some(number(acc))
275}
276
277fn fold_nums(items: &[Expr], state: &State, op: impl Fn(f64, f64) -> f64) -> Option<Value> {
278 let mut iter = items.iter();
279 let mut acc = num(iter.next()?, state)?;
280 for item in iter {
281 acc = op(acc, num(item, state)?);
282 }
283 Some(number(acc))
284}
285
286fn infix(items: &[Expr], sep: &str) -> String {
287 format!("({})", infix_sep(items, sep))
288}
289
290fn infix_bare(items: &[Expr]) -> String {
291 infix_sep(items, ", ")
292}
293
294fn infix_sep(items: &[Expr], sep: &str) -> String {
295 items
296 .iter()
297 .map(Expr::describe)
298 .collect::<Vec<_>>()
299 .join(sep)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn state_with(pairs: &[(&str, Value)]) -> State {
307 let state = State::new();
308 for (k, v) in pairs {
309 let _ = state.set(*k, v.clone());
310 }
311 state
312 }
313
314 #[test]
315 fn arithmetic_is_strict_on_missing_keys() {
316 let expr: Expr = serde_json::from_value(json!({
317 "add": [{"key": "a"}, {"const": 1}]
318 }))
319 .unwrap();
320 assert_eq!(expr.eval(&State::new()), None);
321 assert_eq!(
322 expr.eval(&state_with(&[("a", json!(2))])),
323 Some(json!(3)),
324 "integers stay integers"
325 );
326 }
327
328 #[test]
329 fn logic_is_total_on_missing_keys() {
330 let expr: Expr = serde_json::from_value(json!({
331 "any": [{"key": "missing"}, {"key": "set"}]
332 }))
333 .unwrap();
334 assert_eq!(
335 expr.eval(&state_with(&[("set", json!(true))])),
336 Some(json!(true))
337 );
338 assert_eq!(expr.eval(&State::new()), Some(json!(false)));
339
340 let not: Expr = serde_json::from_value(json!({"not": {"key": "missing"}})).unwrap();
341 assert_eq!(not.eval(&State::new()), Some(json!(true)));
342 }
343
344 #[test]
345 fn if_coalesce_concat_count() {
346 let state = state_with(&[
347 ("severity", json!("severe")),
348 ("a", json!(true)),
349 ("b", json!(false)),
350 ]);
351 let level: Expr = serde_json::from_value(json!({
352 "if": {
353 "when": {"eq": [{"key": "severity"}, {"const": "severe"}]},
354 "then": {"const": "high"},
355 "else": {"const": "normal"}
356 }
357 }))
358 .unwrap();
359 assert_eq!(level.eval(&state), Some(json!("high")));
360
361 let fallback: Expr = serde_json::from_value(json!({
362 "coalesce": [{"key": "nickname"}, {"key": "severity"}]
363 }))
364 .unwrap();
365 assert_eq!(fallback.eval(&state), Some(json!("severe")));
366
367 let line: Expr = serde_json::from_value(json!({
368 "concat": [{"const": "severity="}, {"key": "severity"}]
369 }))
370 .unwrap();
371 assert_eq!(line.eval(&state), Some(json!("severity=severe")));
372
373 let count: Expr = serde_json::from_value(json!({"count_true": ["a", "b", "c"]})).unwrap();
374 assert_eq!(count.eval(&state), Some(json!(1)));
375 }
376
377 #[test]
378 fn division_by_zero_is_none() {
379 let expr: Expr =
380 serde_json::from_value(json!({"div": [{"const": 1}, {"const": 0}]})).unwrap();
381 assert_eq!(expr.eval(&State::new()), None);
382 }
383
384 #[test]
385 fn comparisons_and_bounds() {
386 let state = state_with(&[("score", json!(0.8))]);
387 let gt: Expr =
388 serde_json::from_value(json!({"gt": [{"key": "score"}, {"const": 0.5}]})).unwrap();
389 assert_eq!(gt.eval(&state), Some(json!(true)));
390 let clamp: Expr = serde_json::from_value(json!({
391 "min": [{"key": "score"}, {"const": 0.6}]
392 }))
393 .unwrap();
394 assert_eq!(clamp.eval(&state), Some(json!(0.6)));
395 }
396
397 #[test]
398 fn keys_read_is_the_full_dependency_set() {
399 let expr: Expr = serde_json::from_value(json!({
400 "if": {
401 "when": {"any": [{"key": "a"}, {"not": {"key": "b"}}]},
402 "then": {"add": [{"key": "c"}, {"const": 1}]},
403 "else": {"count_true": ["d", "e"]}
404 }
405 }))
406 .unwrap();
407 let keys: Vec<String> = expr.keys_read().into_iter().collect();
408 assert_eq!(keys, ["a", "b", "c", "d", "e"]);
409 }
410
411 #[test]
412 fn reads_fall_back_to_derived_keys() {
413 let state = state_with(&[("derived:risk", json!(0.9))]);
414 let expr: Expr = serde_json::from_value(json!({"key": "risk"})).unwrap();
415 assert_eq!(expr.eval(&state), Some(json!(0.9)));
416 }
417
418 #[test]
419 fn serde_round_trips_and_describes() {
420 let doc = json!({
421 "add": [
422 {"mul": [{"const": 0.6}, {"key": "overdue"}]},
423 {"key": "penalty"}
424 ]
425 });
426 let expr: Expr = serde_json::from_value(doc.clone()).unwrap();
427 assert_eq!(serde_json::to_value(&expr).unwrap(), doc);
428 assert_eq!(expr.describe(), "((0.6 * overdue) + penalty)");
429 }
430}