use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Operator {
Equals,
NotEquals,
Contains,
StartsWith,
EndsWith,
Gt,
Lt,
Exists,
Truthy,
Matches,
}
impl Operator {
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"equals" => Some(Operator::Equals),
"not_equals" => Some(Operator::NotEquals),
"contains" => Some(Operator::Contains),
"starts_with" => Some(Operator::StartsWith),
"ends_with" => Some(Operator::EndsWith),
"gt" => Some(Operator::Gt),
"lt" => Some(Operator::Lt),
"exists" => Some(Operator::Exists),
"truthy" => Some(Operator::Truthy),
"matches" => Some(Operator::Matches),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Operator::Equals => "equals",
Operator::NotEquals => "not_equals",
Operator::Contains => "contains",
Operator::StartsWith => "starts_with",
Operator::EndsWith => "ends_with",
Operator::Gt => "gt",
Operator::Lt => "lt",
Operator::Exists => "exists",
Operator::Truthy => "truthy",
Operator::Matches => "matches",
}
}
}
fn as_number(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse::<f64>().ok(),
_ => None,
}
}
fn as_text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn is_truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
pub fn evaluate(op: Operator, actual: Option<&Value>, expected: Option<&Value>) -> bool {
let null = Value::Null;
let a = actual.unwrap_or(&null);
match op {
Operator::Exists => actual.is_some() && !a.is_null(),
Operator::Truthy => is_truthy(a),
Operator::Equals | Operator::NotEquals => {
let eq = match (as_number(a), expected.and_then(as_number)) {
(Some(x), Some(y)) => x == y,
_ => match expected {
Some(e) => as_text(a) == as_text(e),
None => a.is_null(),
},
};
if op == Operator::Equals { eq } else { !eq }
}
Operator::Gt | Operator::Lt => {
match (as_number(a), expected.and_then(as_number)) {
(Some(x), Some(y)) => {
if op == Operator::Gt { x > y } else { x < y }
}
_ => false,
}
}
Operator::Contains => match expected {
Some(e) => as_text(a).contains(&as_text(e)),
None => false,
},
Operator::StartsWith => match expected {
Some(e) => as_text(a).starts_with(&as_text(e)),
None => false,
},
Operator::EndsWith => match expected {
Some(e) => as_text(a).ends_with(&as_text(e)),
None => false,
},
Operator::Matches => match expected {
Some(e) => matches_regex(&as_text(a), &as_text(e)),
None => false,
},
}
}
#[cfg(feature = "regex")]
fn matches_regex(text: &str, pattern: &str) -> bool {
regex::Regex::new(pattern).map(|re| re.is_match(text)).unwrap_or(false)
}
#[cfg(not(feature = "regex"))]
fn matches_regex(_text: &str, _pattern: &str) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn numeric_gt_lt_not_lexicographic() {
assert!(!evaluate(Operator::Gt, Some(&json!(18)), Some(&json!(50))));
assert!(evaluate(Operator::Lt, Some(&json!(18)), Some(&json!(50))));
assert!(evaluate(Operator::Gt, Some(&json!(75)), Some(&json!(50))));
assert!(evaluate(Operator::Gt, Some(&json!("75")), Some(&json!("50"))));
assert!(!evaluate(Operator::Gt, Some(&json!("18")), Some(&json!("50"))));
}
#[test]
fn gt_on_non_numbers_is_false() {
assert!(!evaluate(Operator::Gt, Some(&json!("hot")), Some(&json!("cold"))));
assert!(!evaluate(Operator::Gt, None, Some(&json!(1))));
}
#[test]
fn equals_type_aware() {
assert!(evaluate(Operator::Equals, Some(&json!(5)), Some(&json!("5"))));
assert!(evaluate(Operator::Equals, Some(&json!("P0")), Some(&json!("P0"))));
assert!(evaluate(Operator::NotEquals, Some(&json!("P0")), Some(&json!("P1"))));
}
#[test]
fn string_ops() {
assert!(evaluate(Operator::Contains, Some(&json!("hello world")), Some(&json!("wor"))));
assert!(evaluate(Operator::StartsWith, Some(&json!("hello")), Some(&json!("he"))));
assert!(evaluate(Operator::EndsWith, Some(&json!("hello")), Some(&json!("lo"))));
}
#[test]
fn exists_and_truthy() {
assert!(evaluate(Operator::Exists, Some(&json!("x")), None));
assert!(!evaluate(Operator::Exists, Some(&json!(null)), None));
assert!(!evaluate(Operator::Exists, None, None));
assert!(evaluate(Operator::Truthy, Some(&json!(1)), None));
assert!(!evaluate(Operator::Truthy, Some(&json!(0)), None));
assert!(!evaluate(Operator::Truthy, Some(&json!("")), None));
}
#[cfg(feature = "regex")]
#[test]
fn regex_matches() {
assert!(evaluate(Operator::Matches, Some(&json!("abc123")), Some(&json!(r"\d+"))));
assert!(!evaluate(Operator::Matches, Some(&json!("abc")), Some(&json!(r"\d+"))));
}
#[test]
fn operator_round_trips() {
for op in [
Operator::Equals, Operator::NotEquals, Operator::Contains,
Operator::StartsWith, Operator::EndsWith, Operator::Gt, Operator::Lt,
Operator::Exists, Operator::Truthy, Operator::Matches,
] {
assert_eq!(Operator::from_wire(op.as_str()), Some(op));
}
}
}