use serde_json::Value;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Cond {
Compare {
path: Vec<String>,
negated: bool,
literal: Value,
},
Exists { path: Vec<String> },
Truthy { path: Vec<String>, negated: bool },
}
fn valid_segment(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn parse_path(s: &str) -> Result<Vec<String>, String> {
let segments: Vec<String> = s.split('.').map(str::to_string).collect();
if segments.iter().all(|seg| valid_segment(seg)) {
Ok(segments)
} else {
Err(format!(
"path '{s}' is invalid: segments are [A-Za-z0-9_-]+ joined by '.'"
))
}
}
pub fn parse(cond: &str) -> Result<Cond, String> {
let s = cond.trim();
if s.is_empty() {
return Err("empty condition".into());
}
let first = [("==", false), ("!=", true)]
.into_iter()
.filter_map(|(op, neg)| s.find(op).map(|i| (i, op, neg)))
.min_by_key(|(i, _, _)| *i);
{
if let Some((idx, op, negated)) = first {
let path = parse_path(s[..idx].trim())?;
let lit_text = s[idx + op.len()..].trim();
if lit_text.is_empty() {
return Err(format!("'{op}' needs a JSON literal on the right"));
}
let literal: Value = serde_json::from_str(lit_text).map_err(|e| {
format!(
"right side of '{op}' must be a JSON literal \
(string/number/true/false/null): {e}"
)
})?;
if literal.is_object() || literal.is_array() {
return Err("object/array literals are not in the v1 grammar".into());
}
return Ok(Cond::Compare { path, negated, literal });
}
}
if let Some(head) = s.strip_suffix(" exists") {
return Ok(Cond::Exists { path: parse_path(head.trim())? });
}
if let Some(rest) = s.strip_prefix('!') {
return Ok(Cond::Truthy { path: parse_path(rest.trim())?, negated: true });
}
Ok(Cond::Truthy { path: parse_path(s)?, negated: false })
}
fn lookup<'a>(state: &'a Value, path: &[String]) -> Option<&'a Value> {
let mut cur = state;
for seg in path {
cur = cur.as_object()?.get(seg)?;
}
Some(cur)
}
fn truthy(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::String(s)) => !s.is_empty(),
Some(Value::Number(n)) => n.as_f64() != Some(0.0),
Some(Value::Array(a)) => !a.is_empty(),
Some(Value::Object(o)) => !o.is_empty(),
}
}
pub fn eval(cond: &Cond, state: &Value) -> bool {
match cond {
Cond::Compare { path, negated, literal } => {
let eq = lookup(state, path) == Some(literal);
eq != *negated
}
Cond::Exists { path } => lookup(state, path).is_some(),
Cond::Truthy { path, negated } => truthy(lookup(state, path)) != *negated,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ev(cond: &str, state: &Value) -> bool {
eval(&parse(cond).unwrap(), state)
}
#[test]
fn compare_is_strict_json_equality_no_coercion() {
let state = json!({"n": 1, "s": "1", "f": 1.0, "b": true});
assert!(ev("n == 1", &state));
assert!(!ev("s == 1", &state), "\"1\" != 1 — no coercion");
assert!(!ev("n == \"1\"", &state));
assert!(!ev("f == 1", &state), "1.0 and 1 are distinct representations");
assert!(ev("f == 1.0", &state));
assert!(ev("b == true", &state));
assert!(ev("s == \"1\"", &state));
assert!(ev("n != 2", &state));
assert!(ev("missing != 1", &state), "absent is not that value");
assert!(!ev("missing == null", &state), "absent is not null either");
let with_null = json!({"x": null});
assert!(ev("x == null", &with_null));
}
#[test]
fn truthiness_table_is_exact() {
let state = json!({
"t": true, "f": false, "z": 0, "zf": 0.0, "n": 3,
"e": "", "s": "x", "ea": [], "a": [1], "eo": {}, "o": {"k": 1},
"nul": null
});
for (path, expect) in [
("t", true), ("f", false), ("z", false), ("zf", false), ("n", true),
("e", false), ("s", true), ("ea", false), ("a", true),
("eo", false), ("o", true), ("nul", false), ("missing", false),
] {
assert_eq!(ev(path, &state), expect, "truthy({path})");
assert_eq!(ev(&format!("!{path}"), &state), !expect, "!{path}");
}
}
#[test]
fn exists_distinguishes_null_from_absent() {
let state = json!({"present": null, "nested": {"deep": 0}});
assert!(ev("present exists", &state));
assert!(ev("nested.deep exists", &state));
assert!(!ev("missing exists", &state));
assert!(!ev("nested.missing exists", &state));
assert!(!ev("present", &state));
}
#[test]
fn dotted_paths_traverse_objects_only() {
let state = json!({"a": {"b": {"c": "deep"}}, "arr": [1, 2]});
assert!(ev("a.b.c == \"deep\"", &state));
assert!(!ev("arr.0 exists", &state), "array indexing is not in the grammar");
}
#[test]
fn malformed_conditions_are_parse_errors() {
for bad in [
"",
" ",
"a == ",
"a == {\"k\": 1}",
"a == [1]",
"a == 1 extra",
"a b == 1",
"a..b == 1",
".a == 1",
"a. == 1",
"path with spaces",
"a === 1",
"!",
"a exists now",
"café == 1",
] {
assert!(parse(bad).is_err(), "must reject: {bad:?}");
}
}
#[test]
fn string_literals_containing_operators_survive() {
let state = json!({"note": "a != b"});
assert!(!ev("note == \"x\"", &state));
assert!(ev("note == \"a != b\"", &state));
}
#[test]
fn parsed_conditions_round_trip_serde() {
for c in ["a.b == 3", "x exists", "!flag", "s == \"v\""] {
let parsed = parse(c).unwrap();
let json = serde_json::to_string(&parsed).unwrap();
let back: Cond = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, back);
}
}
}