1use std::cmp::Ordering;
11use std::collections::{HashMap, HashSet};
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum CondOp {
22 Eq,
24 Ne,
26 Gt,
28 Lt,
30 Gte,
32 Lte,
34 Contains,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum LogicalOp {
42 #[default]
44 And,
45 Or,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Condition {
60 pub from: String,
62 pub path: String,
65 pub op: CondOp,
67 pub value: Value,
69}
70
71impl Condition {
72 pub fn evaluate(&self, outputs: &HashMap<String, Value>, skipped: &HashSet<String>) -> bool {
77 if skipped.contains(&self.from) {
78 return false;
79 }
80 let from_output = match outputs.get(&self.from) {
81 Some(v) => v,
82 None => return false,
83 };
84 self.evaluate_on_value(from_output)
85 }
86
87 pub fn evaluate_on_value(&self, from_output: &Value) -> bool {
91 if from_output.is_null() {
92 return false;
93 }
94 let actual = match get_path(from_output, &self.path) {
95 Some(v) => v,
96 None => return false,
97 };
98 compare(actual, &self.op, &self.value)
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Case {
122 pub id: String,
124 #[serde(default)]
126 pub logical_operator: LogicalOp,
127 pub conditions: Vec<Condition>,
129}
130
131impl Case {
132 pub fn evaluate(&self, inputs: &HashMap<String, Value>) -> bool {
136 if self.conditions.is_empty() {
137 return false;
138 }
139 let results = self.conditions.iter().map(|cond| {
140 inputs
141 .get(&cond.from)
142 .map(|v| cond.evaluate_on_value(v))
143 .unwrap_or(false)
144 });
145 match self.logical_operator {
146 LogicalOp::And => results.into_iter().all(|b| b),
147 LogicalOp::Or => results.into_iter().any(|b| b),
148 }
149 }
150}
151
152pub(crate) fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
158 if path.is_empty() {
159 return Some(value);
160 }
161 let mut current = value;
162 for key in path.split('.') {
163 current = current.get(key)?;
164 }
165 Some(current)
166}
167
168fn compare(actual: &Value, op: &CondOp, expected: &Value) -> bool {
169 match op {
170 CondOp::Eq => actual == expected,
171 CondOp::Ne => actual != expected,
172 CondOp::Gt => numeric_cmp(actual, expected) == Some(Ordering::Greater),
173 CondOp::Lt => numeric_cmp(actual, expected) == Some(Ordering::Less),
174 CondOp::Gte => matches!(
175 numeric_cmp(actual, expected),
176 Some(Ordering::Greater | Ordering::Equal)
177 ),
178 CondOp::Lte => matches!(
179 numeric_cmp(actual, expected),
180 Some(Ordering::Less | Ordering::Equal)
181 ),
182 CondOp::Contains => match (actual, expected) {
183 (Value::String(s), Value::String(sub)) => s.contains(sub.as_str()),
184 (Value::Array(arr), v) => arr.contains(v),
185 _ => false,
186 },
187 }
188}
189
190fn numeric_cmp(a: &Value, b: &Value) -> Option<Ordering> {
191 a.as_f64()?.partial_cmp(&b.as_f64()?)
192}
193
194#[cfg(test)]
197mod tests {
198 use super::*;
199 use serde_json::json;
200
201 fn outputs(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
202 pairs
203 .iter()
204 .map(|(k, v)| (k.to_string(), v.clone()))
205 .collect()
206 }
207
208 fn inputs(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
209 outputs(pairs)
210 }
211
212 #[test]
213 fn condition_eq_passes() {
214 let cond = Condition {
215 from: "a".into(),
216 path: "status".into(),
217 op: CondOp::Eq,
218 value: json!(200),
219 };
220 assert!(cond.evaluate(&outputs(&[("a", json!({"status": 200}))]), &HashSet::new()));
221 }
222
223 #[test]
224 fn condition_eq_fails() {
225 let cond = Condition {
226 from: "a".into(),
227 path: "status".into(),
228 op: CondOp::Eq,
229 value: json!(200),
230 };
231 assert!(!cond.evaluate(&outputs(&[("a", json!({"status": 404}))]), &HashSet::new()));
232 }
233
234 #[test]
235 fn skipped_upstream_propagates() {
236 let cond = Condition {
237 from: "a".into(),
238 path: "".into(),
239 op: CondOp::Eq,
240 value: json!(true),
241 };
242 let mut skipped = HashSet::new();
243 skipped.insert("a".to_string());
244 assert!(!cond.evaluate(&outputs(&[("a", json!(null))]), &skipped));
245 }
246
247 #[test]
248 fn null_upstream_returns_false() {
249 let cond = Condition {
250 from: "a".into(),
251 path: "x".into(),
252 op: CondOp::Eq,
253 value: json!(1),
254 };
255 assert!(!cond.evaluate_on_value(&json!(null)));
256 }
257
258 #[test]
259 fn gt_numeric() {
260 let cond = Condition {
261 from: "a".into(),
262 path: "count".into(),
263 op: CondOp::Gt,
264 value: json!(5),
265 };
266 assert!(cond.evaluate(&outputs(&[("a", json!({"count": 10}))]), &HashSet::new()));
267 }
268
269 #[test]
270 fn contains_string() {
271 let cond = Condition {
272 from: "a".into(),
273 path: "msg".into(),
274 op: CondOp::Contains,
275 value: json!("hello"),
276 };
277 assert!(cond.evaluate(
278 &outputs(&[("a", json!({"msg": "say hello world"}))]),
279 &HashSet::new()
280 ));
281 }
282
283 #[test]
284 fn case_and_all_pass() {
285 let case = Case {
286 id: "ok".into(),
287 logical_operator: LogicalOp::And,
288 conditions: vec![
289 Condition {
290 from: "a".into(),
291 path: "x".into(),
292 op: CondOp::Eq,
293 value: json!(1),
294 },
295 Condition {
296 from: "a".into(),
297 path: "y".into(),
298 op: CondOp::Eq,
299 value: json!(2),
300 },
301 ],
302 };
303 assert!(case.evaluate(&inputs(&[("a", json!({"x": 1, "y": 2}))])));
304 }
305
306 #[test]
307 fn case_and_one_fails() {
308 let case = Case {
309 id: "ok".into(),
310 logical_operator: LogicalOp::And,
311 conditions: vec![
312 Condition {
313 from: "a".into(),
314 path: "x".into(),
315 op: CondOp::Eq,
316 value: json!(1),
317 },
318 Condition {
319 from: "a".into(),
320 path: "y".into(),
321 op: CondOp::Eq,
322 value: json!(99),
323 },
324 ],
325 };
326 assert!(!case.evaluate(&inputs(&[("a", json!({"x": 1, "y": 2}))])));
327 }
328
329 #[test]
330 fn case_or_one_passes() {
331 let case = Case {
332 id: "ok".into(),
333 logical_operator: LogicalOp::Or,
334 conditions: vec![
335 Condition {
336 from: "a".into(),
337 path: "x".into(),
338 op: CondOp::Eq,
339 value: json!(99),
340 },
341 Condition {
342 from: "a".into(),
343 path: "y".into(),
344 op: CondOp::Eq,
345 value: json!(2),
346 },
347 ],
348 };
349 assert!(case.evaluate(&inputs(&[("a", json!({"x": 1, "y": 2}))])));
350 }
351
352 #[test]
353 fn case_empty_conditions_returns_false() {
354 let case = Case {
355 id: "ok".into(),
356 logical_operator: LogicalOp::And,
357 conditions: vec![],
358 };
359 assert!(!case.evaluate(&inputs(&[])));
360 }
361}