1use serde_json::Value;
24
25#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
27pub enum Cond {
28 Compare {
30 path: Vec<String>,
31 negated: bool,
32 literal: Value,
33 },
34 Exists { path: Vec<String> },
36 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
57pub 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 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 if let Some(head) = s.strip_suffix(" exists") {
91 return Ok(Cond::Exists { path: parse_path(head.trim())? });
92 }
93 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
119pub fn eval(cond: &Cond, state: &Value) -> bool {
122 match cond {
123 Cond::Compare { path, negated, literal } => {
124 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 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 #[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 assert!(ev("note == \"a != b\"", &state));
228 }
229
230 #[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}