1use serde_json::Value;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Operator {
14 Equals,
16 NotEquals,
18 Contains,
20 StartsWith,
22 EndsWith,
24 Gt,
26 Lt,
28 Exists,
30 Truthy,
33 Matches,
38}
39
40impl Operator {
41 pub fn from_wire(s: &str) -> Option<Self> {
43 match s {
44 "equals" => Some(Operator::Equals),
45 "not_equals" => Some(Operator::NotEquals),
46 "contains" => Some(Operator::Contains),
47 "starts_with" => Some(Operator::StartsWith),
48 "ends_with" => Some(Operator::EndsWith),
49 "gt" => Some(Operator::Gt),
50 "lt" => Some(Operator::Lt),
51 "exists" => Some(Operator::Exists),
52 "truthy" => Some(Operator::Truthy),
53 "matches" => Some(Operator::Matches),
54 _ => None,
55 }
56 }
57
58 pub fn as_str(self) -> &'static str {
60 match self {
61 Operator::Equals => "equals",
62 Operator::NotEquals => "not_equals",
63 Operator::Contains => "contains",
64 Operator::StartsWith => "starts_with",
65 Operator::EndsWith => "ends_with",
66 Operator::Gt => "gt",
67 Operator::Lt => "lt",
68 Operator::Exists => "exists",
69 Operator::Truthy => "truthy",
70 Operator::Matches => "matches",
71 }
72 }
73}
74
75fn as_number(v: &Value) -> Option<f64> {
77 match v {
78 Value::Number(n) => n.as_f64(),
79 Value::String(s) => s.trim().parse::<f64>().ok(),
80 _ => None,
81 }
82}
83
84fn as_text(v: &Value) -> String {
87 match v {
88 Value::String(s) => s.clone(),
89 Value::Null => String::new(),
90 other => other.to_string(),
91 }
92}
93
94fn is_truthy(v: &Value) -> bool {
96 match v {
97 Value::Null => false,
98 Value::Bool(b) => *b,
99 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
100 Value::String(s) => !s.is_empty(),
101 Value::Array(a) => !a.is_empty(),
102 Value::Object(o) => !o.is_empty(),
103 }
104}
105
106pub fn evaluate(op: Operator, actual: Option<&Value>, expected: Option<&Value>) -> bool {
113 let null = Value::Null;
114 let a = actual.unwrap_or(&null);
115
116 match op {
117 Operator::Exists => actual.is_some() && !a.is_null(),
118 Operator::Truthy => is_truthy(a),
119 Operator::Equals | Operator::NotEquals => {
120 let eq = match (as_number(a), expected.and_then(as_number)) {
121 (Some(x), Some(y)) => x == y,
122 _ => match expected {
123 Some(e) => as_text(a) == as_text(e),
124 None => a.is_null(),
125 },
126 };
127 if op == Operator::Equals { eq } else { !eq }
128 }
129 Operator::Gt | Operator::Lt => {
130 match (as_number(a), expected.and_then(as_number)) {
131 (Some(x), Some(y)) => {
132 if op == Operator::Gt { x > y } else { x < y }
133 }
134 _ => false,
135 }
136 }
137 Operator::Contains => match expected {
138 Some(e) => as_text(a).contains(&as_text(e)),
139 None => false,
140 },
141 Operator::StartsWith => match expected {
142 Some(e) => as_text(a).starts_with(&as_text(e)),
143 None => false,
144 },
145 Operator::EndsWith => match expected {
146 Some(e) => as_text(a).ends_with(&as_text(e)),
147 None => false,
148 },
149 Operator::Matches => match expected {
150 Some(e) => matches_regex(&as_text(a), &as_text(e)),
151 None => false,
152 },
153 }
154}
155
156#[cfg(feature = "regex")]
157fn matches_regex(text: &str, pattern: &str) -> bool {
158 regex::Regex::new(pattern).map(|re| re.is_match(text)).unwrap_or(false)
159}
160
161#[cfg(not(feature = "regex"))]
162fn matches_regex(_text: &str, _pattern: &str) -> bool {
163 false
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use serde_json::json;
170
171 #[test]
172 fn numeric_gt_lt_not_lexicographic() {
173 assert!(!evaluate(Operator::Gt, Some(&json!(18)), Some(&json!(50))));
175 assert!(evaluate(Operator::Lt, Some(&json!(18)), Some(&json!(50))));
176 assert!(evaluate(Operator::Gt, Some(&json!(75)), Some(&json!(50))));
177 assert!(evaluate(Operator::Gt, Some(&json!("75")), Some(&json!("50"))));
179 assert!(!evaluate(Operator::Gt, Some(&json!("18")), Some(&json!("50"))));
180 }
181
182 #[test]
183 fn gt_on_non_numbers_is_false() {
184 assert!(!evaluate(Operator::Gt, Some(&json!("hot")), Some(&json!("cold"))));
185 assert!(!evaluate(Operator::Gt, None, Some(&json!(1))));
186 }
187
188 #[test]
189 fn equals_type_aware() {
190 assert!(evaluate(Operator::Equals, Some(&json!(5)), Some(&json!("5"))));
191 assert!(evaluate(Operator::Equals, Some(&json!("P0")), Some(&json!("P0"))));
192 assert!(evaluate(Operator::NotEquals, Some(&json!("P0")), Some(&json!("P1"))));
193 }
194
195 #[test]
196 fn string_ops() {
197 assert!(evaluate(Operator::Contains, Some(&json!("hello world")), Some(&json!("wor"))));
198 assert!(evaluate(Operator::StartsWith, Some(&json!("hello")), Some(&json!("he"))));
199 assert!(evaluate(Operator::EndsWith, Some(&json!("hello")), Some(&json!("lo"))));
200 }
201
202 #[test]
203 fn exists_and_truthy() {
204 assert!(evaluate(Operator::Exists, Some(&json!("x")), None));
205 assert!(!evaluate(Operator::Exists, Some(&json!(null)), None));
206 assert!(!evaluate(Operator::Exists, None, None));
207 assert!(evaluate(Operator::Truthy, Some(&json!(1)), None));
208 assert!(!evaluate(Operator::Truthy, Some(&json!(0)), None));
209 assert!(!evaluate(Operator::Truthy, Some(&json!("")), None));
210 }
211
212 #[cfg(feature = "regex")]
213 #[test]
214 fn regex_matches() {
215 assert!(evaluate(Operator::Matches, Some(&json!("abc123")), Some(&json!(r"\d+"))));
216 assert!(!evaluate(Operator::Matches, Some(&json!("abc")), Some(&json!(r"\d+"))));
217 }
218
219 #[test]
220 fn operator_round_trips() {
221 for op in [
222 Operator::Equals, Operator::NotEquals, Operator::Contains,
223 Operator::StartsWith, Operator::EndsWith, Operator::Gt, Operator::Lt,
224 Operator::Exists, Operator::Truthy, Operator::Matches,
225 ] {
226 assert_eq!(Operator::from_wire(op.as_str()), Some(op));
227 }
228 }
229}