Skip to main content

jamjet_core/
condition.rs

1//! Pure, total evaluator for `Condition`-node branch expressions.
2//!
3//! # Expression grammar
4//!
5//! Only three forms are authored in practice (grounding §1c):
6//!
7//! ```text
8//! expr    := path                        # truthiness
9//!          | path "==" literal           # equality
10//!          | path "!=" literal           # inequality
11//! path    := "state" ("." ident)+        # always rooted at `state`, dotted segments
12//! literal := '"' … '"'                   # double-quoted JSON string
13//!          | "true" | "false" | "null"   # JSON keywords (bare, lowercase)
14//!          | number                       # JSON number
15//! ```
16//!
17//! # Truthiness rule
18//!
19//! For the bare-path form a value is **falsy** iff it is:
20//! - absent (path does not resolve),
21//! - JSON `null`,
22//! - `false`,
23//! - the number `0` (or `0.0`),
24//! - the empty string `""`,
25//! - an empty array `[]`, or
26//! - an empty object `{}`.
27//!
28//! Everything else is truthy. This mirrors JS/Python truthiness and matches the
29//! budget code that sets `__cost_exceeded__` as a bool.
30//!
31//! # Total / never-panics contract
32//!
33//! A malformed or unsupported expression always returns `false` — no panic.
34//! Unrecognised forms emit a `tracing::warn!` to aid debugging.
35//!
36//! # Path resolution
37//!
38//! The path after `state.` is split on `.` and each segment descends into the
39//! JSON object (or an array by zero-based numeric index when the segment is all
40//! digits). A missing or non-traversable segment resolves to absent (treated as
41//! `null` for comparisons and as falsy for truthiness).
42
43use serde_json::Value;
44
45/// Evaluate a Condition-node branch expression against committed workflow state.
46///
47/// Pure + total: never panics; a malformed or unsupported expression returns `false`.
48///
49/// # Truthiness rule (bare-path form)
50///
51/// Falsy: absent path, `null`, `false`, `0`/`0.0`, `""`, `[]`, `{}`.
52/// Everything else is truthy.
53///
54/// # Literal parsing (comparison form)
55///
56/// The RHS must be a valid JSON literal: a double-quoted string (`"x"`),
57/// `true`, `false`, `null`, or a JSON number. Bare words (e.g. `done`) are NOT
58/// valid literals; an unparseable RHS causes the whole comparison to return
59/// `false` (fail-closed). Comparison uses typed `serde_json::Value` equality:
60/// `"5" != 5`.
61pub fn eval_condition(expr: &str, state: &Value) -> bool {
62    let expr = expr.trim();
63    if expr.is_empty() {
64        return false;
65    }
66
67    match find_first_op(expr) {
68        Some((op_pos, op)) => eval_comparison(expr, state, op_pos, op),
69        None => eval_truthiness(expr, state),
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Internal helpers
75// ---------------------------------------------------------------------------
76
77/// Scan left-to-right for the first `!=` or `==` operator.
78///
79/// Returns `(byte_position_of_operator, operator_str)`.
80fn find_first_op(expr: &str) -> Option<(usize, &'static str)> {
81    let b = expr.as_bytes();
82    let len = b.len();
83    if len < 2 {
84        return None;
85    }
86    for i in 0..len - 1 {
87        match (b[i], b[i + 1]) {
88            (b'!', b'=') => return Some((i, "!=")),
89            (b'=', b'=') => return Some((i, "==")),
90            _ => {}
91        }
92    }
93    None
94}
95
96/// Evaluate a comparison expression: `lhs OP rhs`.
97fn eval_comparison(expr: &str, state: &Value, op_pos: usize, op: &str) -> bool {
98    let lhs = expr[..op_pos].trim();
99    // op_pos + 2 is safe: find_first_op only returns positions where i+1 < len,
100    // so i+2 <= len is guaranteed.
101    let rhs = expr[op_pos + 2..].trim();
102
103    let path = match lhs.strip_prefix("state.") {
104        Some(p) if !p.is_empty() => p,
105        _ => {
106            tracing::warn!(
107                "eval_condition: lhs `{}` does not start with `state.<path>`; returning false",
108                lhs
109            );
110            return false;
111        }
112    };
113
114    // Fail closed: an unparseable RHS literal (e.g. bare word `done`) is an
115    // invalid expression — return false rather than silently coercing to a string.
116    let rhs_val = match parse_literal(rhs) {
117        Some(v) => v,
118        None => {
119            tracing::warn!(
120                "eval_condition: RHS `{}` is not a valid literal \
121                 (must be a quoted string, true, false, null, or a number); returning false",
122                rhs
123            );
124            return false;
125        }
126    };
127
128    let resolved = resolve_path(state, path);
129    let equal = values_equal(resolved, &rhs_val);
130
131    match op {
132        "==" => equal,
133        "!=" => !equal,
134        _ => false, // unreachable guard; keeps the function total
135    }
136}
137
138/// Evaluate a truthiness expression: the whole expr is a `state.<path>`.
139fn eval_truthiness(expr: &str, state: &Value) -> bool {
140    let path = match expr.strip_prefix("state.") {
141        Some(p) if !p.is_empty() => p,
142        _ => {
143            tracing::warn!(
144                "eval_condition: expr `{}` does not start with `state.<path>`; returning false",
145                expr
146            );
147            return false;
148        }
149    };
150    is_truthy(resolve_path(state, path))
151}
152
153/// Walk a dotted path (e.g. `"a.b.c"`) through a JSON value.
154///
155/// Returns `None` for any missing or non-traversable segment.
156/// Array indexing by zero-based numeric segment is supported: `"arr.0"` reads index 0.
157fn resolve_path<'a>(root: &'a Value, dotted_path: &str) -> Option<&'a Value> {
158    let mut current = root;
159    for segment in dotted_path.split('.') {
160        if segment.is_empty() {
161            return None;
162        }
163        match current {
164            Value::Object(map) => {
165                current = map.get(segment)?;
166            }
167            Value::Array(arr) => {
168                let idx: usize = segment.parse().ok()?;
169                current = arr.get(idx)?;
170            }
171            _ => return None,
172        }
173    }
174    Some(current)
175}
176
177/// JSON truthiness: falsy = absent | null | false | 0 | "" | [] | {}.
178/// Everything else is truthy.
179fn is_truthy(v: Option<&Value>) -> bool {
180    match v {
181        None => false,
182        Some(Value::Null) => false,
183        Some(Value::Bool(b)) => *b,
184        Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0),
185        Some(Value::String(s)) => !s.is_empty(),
186        Some(Value::Array(a)) => !a.is_empty(),
187        Some(Value::Object(o)) => !o.is_empty(),
188    }
189}
190
191/// Parse an RHS literal string into a `serde_json::Value`.
192///
193/// Valid forms: double-quoted JSON string (`"x"`), `true`, `false`, `null`,
194/// or a JSON number. Returns `None` for any other input (e.g. bare words),
195/// which the caller must treat as an invalid expression (fail-closed: return
196/// `false`).
197fn parse_literal(s: &str) -> Option<Value> {
198    serde_json::from_str::<Value>(s).ok()
199}
200
201/// Compare a resolved path value to an RHS literal.
202///
203/// A missing path (`None`) is treated as `null`: it equals `Value::Null` and
204/// nothing else.
205fn values_equal(resolved: Option<&Value>, rhs: &Value) -> bool {
206    match resolved {
207        None => rhs == &Value::Null,
208        Some(v) => v == rhs,
209    }
210}
211
212// ---------------------------------------------------------------------------
213// Tests
214// ---------------------------------------------------------------------------
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serde_json::json;
220
221    // ---- string equality ----
222
223    #[test]
224    fn string_eq_match() {
225        assert!(eval_condition(
226            r#"state.last_model_finish_reason == "tool_calls""#,
227            &json!({"last_model_finish_reason": "tool_calls"}),
228        ));
229    }
230
231    #[test]
232    fn string_eq_mismatch() {
233        assert!(!eval_condition(
234            r#"state.last_model_finish_reason == "tool_calls""#,
235            &json!({"last_model_finish_reason": "stop"}),
236        ));
237    }
238
239    #[test]
240    fn string_eq_missing_key() {
241        assert!(!eval_condition(
242            r#"state.last_model_finish_reason == "tool_calls""#,
243            &json!({}),
244        ));
245    }
246
247    // ---- string inequality ----
248
249    #[test]
250    fn string_neq_true() {
251        assert!(eval_condition(r#"state.x != "a""#, &json!({"x": "b"}),));
252    }
253
254    #[test]
255    fn string_neq_false() {
256        assert!(!eval_condition(r#"state.x != "a""#, &json!({"x": "a"}),));
257    }
258
259    // ---- truthiness ----
260
261    #[test]
262    fn truthiness_bool_true() {
263        assert!(eval_condition(
264            "state.__cost_exceeded__",
265            &json!({"__cost_exceeded__": true}),
266        ));
267    }
268
269    #[test]
270    fn truthiness_bool_false() {
271        assert!(!eval_condition(
272            "state.__cost_exceeded__",
273            &json!({"__cost_exceeded__": false}),
274        ));
275    }
276
277    #[test]
278    fn truthiness_absent_key() {
279        assert!(!eval_condition("state.__cost_exceeded__", &json!({})));
280    }
281
282    #[test]
283    fn truthiness_non_empty_string() {
284        assert!(eval_condition(
285            "state.__cost_exceeded__",
286            &json!({"__cost_exceeded__": "yes"}),
287        ));
288    }
289
290    #[test]
291    fn truthiness_zero_is_falsy() {
292        assert!(!eval_condition("state.count", &json!({"count": 0})));
293    }
294
295    #[test]
296    fn truthiness_empty_string_is_falsy() {
297        assert!(!eval_condition("state.s", &json!({"s": ""})));
298    }
299
300    #[test]
301    fn truthiness_empty_array_is_falsy() {
302        assert!(!eval_condition("state.arr", &json!({"arr": []})));
303    }
304
305    #[test]
306    fn truthiness_empty_object_is_falsy() {
307        assert!(!eval_condition("state.obj", &json!({"obj": {}})));
308    }
309
310    // ---- nested paths ----
311
312    #[test]
313    fn nested_bool_eq_true() {
314        assert!(eval_condition(
315            "state.__critic_0_verdict__.passed == true",
316            &json!({"__critic_0_verdict__": {"passed": true}}),
317        ));
318    }
319
320    #[test]
321    fn nested_bool_eq_false_value() {
322        assert!(!eval_condition(
323            "state.__critic_0_verdict__.passed == true",
324            &json!({"__critic_0_verdict__": {"passed": false}}),
325        ));
326    }
327
328    #[test]
329    fn nested_missing_parent() {
330        assert!(!eval_condition(
331            "state.__critic_0_verdict__.passed == true",
332            &json!({}),
333        ));
334    }
335
336    // ---- null literal ----
337
338    #[test]
339    fn null_eq_missing_path() {
340        // A missing path is treated as null.
341        assert!(eval_condition("state.missing_key == null", &json!({})));
342    }
343
344    #[test]
345    fn null_eq_explicit_null() {
346        assert!(eval_condition("state.x == null", &json!({"x": null}),));
347    }
348
349    #[test]
350    fn null_eq_non_null_is_false() {
351        assert!(!eval_condition(
352            "state.x == null",
353            &json!({"x": "something"}),
354        ));
355    }
356
357    // ---- number literal ----
358
359    #[test]
360    fn number_eq_true() {
361        assert!(eval_condition("state.count == 5", &json!({"count": 5})));
362    }
363
364    #[test]
365    fn number_eq_string_is_false() {
366        // Typed equality: "5" (string) != 5 (number).
367        assert!(!eval_condition("state.count == 5", &json!({"count": "5"}),));
368    }
369
370    // ---- bool literal ----
371
372    #[test]
373    fn bool_false_literal_match() {
374        assert!(eval_condition(
375            "state.flag == false",
376            &json!({"flag": false}),
377        ));
378    }
379
380    // ---- malformed inputs — must return false, never panic ----
381
382    #[test]
383    fn malformed_garbage() {
384        assert!(!eval_condition("garbage", &json!({})));
385    }
386
387    #[test]
388    fn malformed_lhs_not_state() {
389        assert!(!eval_condition(r#"x == "y""#, &json!({"x": "y"})));
390    }
391
392    #[test]
393    fn malformed_empty_expr() {
394        assert!(!eval_condition("", &json!({})));
395    }
396
397    #[test]
398    fn malformed_just_state_dot() {
399        assert!(!eval_condition("state.", &json!({})));
400    }
401
402    #[test]
403    fn malformed_whitespace_only() {
404        assert!(!eval_condition("   ", &json!({})));
405    }
406
407    // ---- fail-closed: invalid (unquoted) RHS literal ----
408
409    #[test]
410    fn unquoted_rhs_bareword_fail_closed() {
411        // `done` is a bare word, not a valid JSON literal.
412        // Even though state.status == "done" would be true, the malformed
413        // expression must return false (fail-closed contract).
414        assert!(!eval_condition(
415            "state.status == done",
416            &json!({"status": "done"}),
417        ));
418    }
419
420    #[test]
421    fn quoted_rhs_string_still_works() {
422        // The quoted form must continue to work correctly.
423        assert!(eval_condition(
424            r#"state.status == "done""#,
425            &json!({"status": "done"}),
426        ));
427    }
428}