Skip to main content

areev_run_core/
cond.rs

1//! The frozen v1 condition grammar (governed-agents §6.4):
2//!
3//! ```text
4//! cond    := path op literal | path "exists" | ["!"] path
5//! op      := "==" | "!="
6//! path    := segment ("." segment)*      segment := [A-Za-z0-9_-]+
7//! literal := JSON string | number | true | false | null
8//! ```
9//!
10//! Strict JSON equality — no coercion (`"1" != 1`; numbers compare by
11//! serde_json's own representation semantics, so `1` and `1.0` are distinct,
12//! deliberately: coercion rules are exactly the kind of quiet complexity a
13//! frozen grammar exists to refuse). Truthiness: `false`, `null`, missing,
14//! `""`, `0`, `[]`, `{}` are false; everything else is true. Parse errors are
15//! load-time (`RUN-E005`); **evaluation is total** — a missing path is falsey
16//! / `exists`-false, never an error. No expression language beyond this in
17//! v1: that is a spec-shaped surface and a standing exclusion.
18//!
19//! Host evaluators plug in driver-side; this module is the built-in. Purity
20//! of any evaluator is enforced by the journaled decision record + replay
21//! assertion (§5.1), not by trust.
22
23use serde_json::Value;
24
25/// A parsed condition — the only forms the v1 grammar admits.
26#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
27pub enum Cond {
28    /// `path == literal` / `path != literal`.
29    Compare {
30        path: Vec<String>,
31        negated: bool,
32        literal: Value,
33    },
34    /// `path exists`.
35    Exists { path: Vec<String> },
36    /// `path` / `!path`.
37    Truthy { path: Vec<String>, negated: bool },
38}
39
40fn valid_segment(s: &str) -> bool {
41    !s.is_empty()
42        && s.chars()
43            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
44}
45
46fn parse_path(s: &str) -> Result<Vec<String>, String> {
47    let segments: Vec<String> = s.split('.').map(str::to_string).collect();
48    if segments.iter().all(|seg| valid_segment(seg)) {
49        Ok(segments)
50    } else {
51        Err(format!(
52            "path '{s}' is invalid: segments are [A-Za-z0-9_-]+ joined by '.'"
53        ))
54    }
55}
56
57/// Parse a condition string. Errors carry the reason for `RUN-E005`.
58pub fn parse(cond: &str) -> Result<Cond, String> {
59    let s = cond.trim();
60    if s.is_empty() {
61        return Err("empty condition".into());
62    }
63    // Split at whichever operator occurs FIRST BY POSITION — checking `==`
64    // before `!=` over the whole string would mis-split `x != "a == b"` at
65    // the literal's `==` and refuse a grammatically legal condition.
66    let first = [("==", false), ("!=", true)]
67        .into_iter()
68        .filter_map(|(op, neg)| s.find(op).map(|i| (i, op, neg)))
69        .min_by_key(|(i, _, _)| *i);
70    {
71        if let Some((idx, op, negated)) = first {
72            let path = parse_path(s[..idx].trim())?;
73            let lit_text = s[idx + op.len()..].trim();
74            if lit_text.is_empty() {
75                return Err(format!("'{op}' needs a JSON literal on the right"));
76            }
77            let literal: Value = serde_json::from_str(lit_text).map_err(|e| {
78                format!(
79                    "right side of '{op}' must be a JSON literal \
80                     (string/number/true/false/null): {e}"
81                )
82            })?;
83            if literal.is_object() || literal.is_array() {
84                return Err("object/array literals are not in the v1 grammar".into());
85            }
86            return Ok(Cond::Compare { path, negated, literal });
87        }
88    }
89    // `path exists`
90    if let Some(head) = s.strip_suffix(" exists") {
91        return Ok(Cond::Exists { path: parse_path(head.trim())? });
92    }
93    // `!path` / `path`
94    if let Some(rest) = s.strip_prefix('!') {
95        return Ok(Cond::Truthy { path: parse_path(rest.trim())?, negated: true });
96    }
97    Ok(Cond::Truthy { path: parse_path(s)?, negated: false })
98}
99
100fn lookup<'a>(state: &'a Value, path: &[String]) -> Option<&'a Value> {
101    let mut cur = state;
102    for seg in path {
103        cur = cur.as_object()?.get(seg)?;
104    }
105    Some(cur)
106}
107
108fn truthy(v: Option<&Value>) -> bool {
109    match v {
110        None | Some(Value::Null) => false,
111        Some(Value::Bool(b)) => *b,
112        Some(Value::String(s)) => !s.is_empty(),
113        Some(Value::Number(n)) => n.as_f64() != Some(0.0),
114        Some(Value::Array(a)) => !a.is_empty(),
115        Some(Value::Object(o)) => !o.is_empty(),
116    }
117}
118
119/// Evaluate a parsed condition against the accumulated state. Total: never
120/// errors.
121pub fn eval(cond: &Cond, state: &Value) -> bool {
122    match cond {
123        Cond::Compare { path, negated, literal } => {
124            // Strict equality; a missing path equals nothing (and therefore
125            // satisfies `!=` — "is not that value" is true of absent).
126            let eq = lookup(state, path) == Some(literal);
127            eq != *negated
128        }
129        Cond::Exists { path } => lookup(state, path).is_some(),
130        Cond::Truthy { path, negated } => truthy(lookup(state, path)) != *negated,
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use serde_json::json;
138
139    fn ev(cond: &str, state: &Value) -> bool {
140        eval(&parse(cond).unwrap(), state)
141    }
142
143    #[test]
144    fn compare_is_strict_json_equality_no_coercion() {
145        let state = json!({"n": 1, "s": "1", "f": 1.0, "b": true});
146        assert!(ev("n == 1", &state));
147        assert!(!ev("s == 1", &state), "\"1\" != 1 — no coercion");
148        assert!(!ev("n == \"1\"", &state));
149        assert!(!ev("f == 1", &state), "1.0 and 1 are distinct representations");
150        assert!(ev("f == 1.0", &state));
151        assert!(ev("b == true", &state));
152        assert!(ev("s == \"1\"", &state));
153        assert!(ev("n != 2", &state));
154        assert!(ev("missing != 1", &state), "absent is not that value");
155        assert!(!ev("missing == null", &state), "absent is not null either");
156        let with_null = json!({"x": null});
157        assert!(ev("x == null", &with_null));
158    }
159
160    #[test]
161    fn truthiness_table_is_exact() {
162        let state = json!({
163            "t": true, "f": false, "z": 0, "zf": 0.0, "n": 3,
164            "e": "", "s": "x", "ea": [], "a": [1], "eo": {}, "o": {"k": 1},
165            "nul": null
166        });
167        for (path, expect) in [
168            ("t", true), ("f", false), ("z", false), ("zf", false), ("n", true),
169            ("e", false), ("s", true), ("ea", false), ("a", true),
170            ("eo", false), ("o", true), ("nul", false), ("missing", false),
171        ] {
172            assert_eq!(ev(path, &state), expect, "truthy({path})");
173            assert_eq!(ev(&format!("!{path}"), &state), !expect, "!{path}");
174        }
175    }
176
177    #[test]
178    fn exists_distinguishes_null_from_absent() {
179        let state = json!({"present": null, "nested": {"deep": 0}});
180        assert!(ev("present exists", &state));
181        assert!(ev("nested.deep exists", &state));
182        assert!(!ev("missing exists", &state));
183        assert!(!ev("nested.missing exists", &state));
184        // But a present-null is falsey.
185        assert!(!ev("present", &state));
186    }
187
188    #[test]
189    fn dotted_paths_traverse_objects_only() {
190        let state = json!({"a": {"b": {"c": "deep"}}, "arr": [1, 2]});
191        assert!(ev("a.b.c == \"deep\"", &state));
192        assert!(!ev("arr.0 exists", &state), "array indexing is not in the grammar");
193    }
194
195    /// Every malformed shape is a parse ERROR (load-time RUN-E005), never a
196    /// silent false — and evaluation is total, so this is the only place a
197    /// condition can fail.
198    #[test]
199    fn malformed_conditions_are_parse_errors() {
200        for bad in [
201            "",
202            "  ",
203            "a == ",
204            "a == {\"k\": 1}",
205            "a == [1]",
206            "a == 1 extra",
207            "a b == 1",
208            "a..b == 1",
209            ".a == 1",
210            "a. == 1",
211            "path with spaces",
212            "a === 1",
213            "!",
214            "a exists now",
215            "café == 1",
216        ] {
217            assert!(parse(bad).is_err(), "must reject: {bad:?}");
218        }
219    }
220
221    #[test]
222    fn string_literals_containing_operators_survive() {
223        let state = json!({"note": "a != b"});
224        assert!(!ev("note == \"x\"", &state));
225        // The FIRST operator splits: `note == "a != b"` parses as == with the
226        // full string literal.
227        assert!(ev("note == \"a != b\"", &state));
228    }
229
230    /// The parsed form serializes — decision records carry it.
231    #[test]
232    fn parsed_conditions_round_trip_serde() {
233        for c in ["a.b == 3", "x exists", "!flag", "s == \"v\""] {
234            let parsed = parse(c).unwrap();
235            let json = serde_json::to_string(&parsed).unwrap();
236            let back: Cond = serde_json::from_str(&json).unwrap();
237            assert_eq!(parsed, back);
238        }
239    }
240}