Skip to main content

apif_assert/
operators.rs

1// AST-based assertion engine
2// All evaluation goes through the AssertionExpr AST — no string-based parsing.
3
4use anyhow::Result;
5use regex::Regex;
6use serde_json::Value;
7use std::cell::RefCell;
8use std::collections::HashMap;
9use std::rc::Rc;
10
11use crate::engine::AssertionResult;
12use crate::registry::{AssertionTiming, PluginContext, PluginRegistry, PluginResult};
13use apif_ast::assertion_ast::{AssertionExpr, BinaryOp, Expr, Literal, parse_assertion};
14fn normalize_plugin_name(name: &str) -> &str {
15    let trimmed = name.trim();
16    trimmed.strip_prefix('@').unwrap_or(trimmed)
17}
18
19/// Result of evaluating an expression as a value.
20/// `Err` carries a plugin error message that must surface as
21/// `AssertionResult::Error` instead of participating in comparisons.
22type ValueResult = std::result::Result<Value, String>;
23
24/// Prefix a regex pattern with the inline flags supported by the `regex` crate
25/// (`i`, `m`, `s`, `x`, `u`, `U`), so `/foo/i` matches case-insensitively.
26fn regex_with_flags(pattern: &str, flags: &str) -> String {
27    let supported: String = flags
28        .chars()
29        .filter(|c| matches!(c, 'i' | 'm' | 's' | 'x' | 'u' | 'U'))
30        .collect();
31    if supported.is_empty() {
32        pattern.to_string()
33    } else {
34        format!("(?{}){}", supported, pattern)
35    }
36}
37
38thread_local! {
39    static REGEX_CACHE: RefCell<HashMap<String, std::result::Result<Rc<Regex>, String>>> =
40        RefCell::new(HashMap::new());
41}
42
43fn cached_regex(pattern: &str) -> std::result::Result<Rc<Regex>, String> {
44    if let Some(cached) = REGEX_CACHE.with(|cache| cache.borrow().get(pattern).cloned()) {
45        return cached;
46    }
47
48    let compiled = Regex::new(pattern)
49        .map(Rc::new)
50        .map_err(|err| err.to_string());
51
52    REGEX_CACHE.with(|cache| {
53        cache
54            .borrow_mut()
55            .insert(pattern.to_string(), compiled.clone());
56    });
57
58    compiled
59}
60
61/// Evaluate an assertion expression.
62/// Returns `Ok(Some(result))` when the AST engine handled the expression,
63/// `Ok(None)` when the expression should fall through to the JQ evaluator.
64pub fn evaluate_assertion(
65    registry: &dyn PluginRegistry,
66    assertion: &str,
67    response: &Value,
68    headers: Option<&HashMap<String, String>>,
69    trailers: Option<&HashMap<String, String>>,
70    timing: Option<&AssertionTiming>,
71    variables: &HashMap<String, Value>,
72) -> Result<Option<AssertionResult>> {
73    let trimmed = assertion.trim();
74    if trimmed.is_empty() {
75        return Ok(None);
76    }
77
78    let ast = parse_assertion(trimmed);
79    match &ast {
80        AssertionExpr::Raw(_) => Ok(None),
81        _ => evaluate_ast(
82            registry, &ast, response, headers, trailers, timing, variables,
83        )
84        .map(Some),
85    }
86}
87
88fn evaluate_ast(
89    pm: &dyn PluginRegistry,
90    expr: &AssertionExpr,
91    response: &Value,
92    headers: Option<&HashMap<String, String>>,
93    trailers: Option<&HashMap<String, String>>,
94    timing: Option<&AssertionTiming>,
95    variables: &HashMap<String, Value>,
96) -> Result<AssertionResult> {
97    match expr {
98        AssertionExpr::Not(inner) => {
99            let r = evaluate_ast(pm, inner, response, headers, trailers, timing, variables)?;
100            Ok(negate(r))
101        }
102        AssertionExpr::NotNot(inner) => {
103            evaluate_ast(pm, inner, response, headers, trailers, timing, variables)
104        }
105        AssertionExpr::And { left, right } => {
106            let lr = evaluate_ast(pm, left, response, headers, trailers, timing, variables)?;
107            if !is_pass(&lr) {
108                return Ok(AssertionResult::fail(format!(
109                    "Left of 'and' failed: {}",
110                    fmt_result_short(&lr)
111                )));
112            }
113            let rr = evaluate_ast(pm, right, response, headers, trailers, timing, variables)?;
114            if !is_pass(&rr) {
115                return Ok(AssertionResult::fail(format!(
116                    "Right of 'and' failed: {}",
117                    fmt_result_short(&rr)
118                )));
119            }
120            Ok(AssertionResult::Pass)
121        }
122        AssertionExpr::Or { left, right } => {
123            let lr = evaluate_ast(pm, left, response, headers, trailers, timing, variables)?;
124            if is_pass(&lr) {
125                return Ok(AssertionResult::Pass);
126            }
127            let rr = evaluate_ast(pm, right, response, headers, trailers, timing, variables)?;
128            if is_pass(&rr) {
129                return Ok(AssertionResult::Pass);
130            }
131            Ok(AssertionResult::fail(format!(
132                "Both sides of 'or' failed: left={}, right={}",
133                fmt_result_short(&lr),
134                fmt_result_short(&rr)
135            )))
136        }
137        AssertionExpr::Xor { left, right } => {
138            let lr = evaluate_ast(pm, left, response, headers, trailers, timing, variables)?;
139            let rr = evaluate_ast(pm, right, response, headers, trailers, timing, variables)?;
140            let lp = is_pass(&lr);
141            let rp = is_pass(&rr);
142            if lp != rp {
143                Ok(AssertionResult::Pass)
144            } else {
145                Ok(AssertionResult::fail(format!(
146                    "Xor expects exactly one true, got left={} right={}",
147                    lp, rp
148                )))
149            }
150        }
151        AssertionExpr::Binary { op, left, right } => {
152            let lhs = match eval_value(pm, left, response, headers, trailers, timing, variables) {
153                Ok(v) => v,
154                Err(e) => return Ok(AssertionResult::Error(e)),
155            };
156            let rhs = match eval_value(pm, right, response, headers, trailers, timing, variables) {
157                Ok(v) => v,
158                Err(e) => return Ok(AssertionResult::Error(e)),
159            };
160            compare(lhs, op, rhs, left, right)
161        }
162        AssertionExpr::Paren(inner) => {
163            evaluate_ast(pm, inner, response, headers, trailers, timing, variables)
164        }
165        AssertionExpr::IfThenElse {
166            condition,
167            then_branch,
168            else_branch,
169        } => {
170            let cond = evaluate_ast(
171                pm, condition, response, headers, trailers, timing, variables,
172            )?;
173            if is_pass(&cond) {
174                evaluate_ast(
175                    pm,
176                    then_branch,
177                    response,
178                    headers,
179                    trailers,
180                    timing,
181                    variables,
182                )
183            } else {
184                evaluate_ast(
185                    pm,
186                    else_branch,
187                    response,
188                    headers,
189                    trailers,
190                    timing,
191                    variables,
192                )
193            }
194        }
195        AssertionExpr::Atom(_) => {
196            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = expr {
197                eval_plugin_as_assertion(
198                    pm, name, args, response, headers, trailers, timing, variables,
199                )
200            } else {
201                let val = match eval_value(pm, expr, response, headers, trailers, timing, variables)
202                {
203                    Ok(v) => v,
204                    Err(e) => return Ok(AssertionResult::Error(e)),
205                };
206                if is_truthy(&val) {
207                    Ok(AssertionResult::Pass)
208                } else {
209                    Ok(AssertionResult::fail(format!(
210                        "Expression evaluated to falsy: {:?}",
211                        val
212                    )))
213                }
214            }
215        }
216        AssertionExpr::Raw(_) => Ok(AssertionResult::Error("Unparsed expression".into())),
217    }
218}
219
220/// Validate a value against a type annotation.
221/// Returns `Value::Null` if the value doesn't match the expected type,
222/// otherwise returns the value unchanged.
223fn validate_type_cast(val: &Value, type_name: &str) -> Value {
224    let valid = match type_name {
225        "bool" => val.is_boolean(),
226        "uint" => val.as_u64().is_some(),
227        "number" => val.is_number(),
228        "string" | "uuid" | "email" | "url" | "ip" => val.is_string(),
229        "time" | "timestamp" | "duration" => val.is_string() || val.is_number(),
230        "json" => val.is_object() || val.is_array(),
231        "yaml" => val.is_string(),
232        _ => true,
233    };
234    if valid { val.clone() } else { Value::Null }
235}
236
237#[expect(clippy::too_many_arguments)]
238fn eval_plugin_as_assertion(
239    pm: &dyn PluginRegistry,
240    name: &str,
241    args: &[AssertionExpr],
242    response: &Value,
243    headers: Option<&HashMap<String, String>>,
244    trailers: Option<&HashMap<String, String>>,
245    timing: Option<&AssertionTiming>,
246    variables: &HashMap<String, Value>,
247) -> Result<AssertionResult> {
248    let func_name = format!("@{}", name);
249    let resolved_name = normalize_plugin_name(&func_name);
250    if let Some(plugin) = pm.get_plugin(resolved_name) {
251        let ctx = PluginContext::new(response)
252            .with_headers(headers)
253            .with_trailers(trailers)
254            .with_timing(timing);
255        let arg_values: Vec<Value> = match args
256            .iter()
257            .map(|a| eval_value(pm, a, response, headers, trailers, timing, variables))
258            .collect::<std::result::Result<_, _>>()
259        {
260            Ok(values) => values,
261            Err(e) => return Ok(AssertionResult::Error(e)),
262        };
263        match plugin.execute(&arg_values, &ctx) {
264            Ok(PluginResult::Assertion(res)) => Ok(res),
265            Ok(PluginResult::Value(val)) => {
266                if is_truthy(&val) {
267                    Ok(AssertionResult::Pass)
268                } else {
269                    Ok(AssertionResult::fail(format!(
270                        "Plugin {} returned falsy value: {:?}",
271                        resolved_name, val
272                    )))
273                }
274            }
275            Err(e) => Ok(AssertionResult::Error(format!("Plugin error: {}", e))),
276        }
277    } else {
278        Ok(AssertionResult::Error(format!("Unknown plugin: {}", name)))
279    }
280}
281
282fn eval_value(
283    pm: &dyn PluginRegistry,
284    expr: &AssertionExpr,
285    response: &Value,
286    headers: Option<&HashMap<String, String>>,
287    trailers: Option<&HashMap<String, String>>,
288    timing: Option<&AssertionTiming>,
289    variables: &HashMap<String, Value>,
290) -> ValueResult {
291    match expr {
292        AssertionExpr::Atom(atom) => {
293            eval_atom(pm, atom, response, headers, trailers, timing, variables)
294        }
295        AssertionExpr::Paren(inner) => {
296            eval_value(pm, inner, response, headers, trailers, timing, variables)
297        }
298        AssertionExpr::Not(inner) => {
299            let v = eval_value(pm, inner, response, headers, trailers, timing, variables)?;
300            Ok(Value::Bool(!is_truthy(&v)))
301        }
302        AssertionExpr::NotNot(inner) => {
303            eval_value(pm, inner, response, headers, trailers, timing, variables)
304        }
305        AssertionExpr::And { left, right } => {
306            let lv = eval_value(pm, left, response, headers, trailers, timing, variables)?;
307            if !is_truthy(&lv) {
308                return Ok(Value::Bool(false));
309            }
310            let rv = eval_value(pm, right, response, headers, trailers, timing, variables)?;
311            Ok(Value::Bool(is_truthy(&rv)))
312        }
313        AssertionExpr::Or { left, right } => {
314            let lv = eval_value(pm, left, response, headers, trailers, timing, variables)?;
315            if is_truthy(&lv) {
316                return Ok(Value::Bool(true));
317            }
318            let rv = eval_value(pm, right, response, headers, trailers, timing, variables)?;
319            Ok(Value::Bool(is_truthy(&rv)))
320        }
321        AssertionExpr::Xor { left, right } => {
322            let lv = eval_value(pm, left, response, headers, trailers, timing, variables)?;
323            let rv = eval_value(pm, right, response, headers, trailers, timing, variables)?;
324            Ok(Value::Bool(is_truthy(&lv) != is_truthy(&rv)))
325        }
326        AssertionExpr::Binary { op, left, right } => {
327            let lhs = eval_value(pm, left, response, headers, trailers, timing, variables)?;
328            let rhs = eval_value(pm, right, response, headers, trailers, timing, variables)?;
329            Ok(eval_binary_value(lhs, op, rhs))
330        }
331        AssertionExpr::IfThenElse {
332            condition,
333            then_branch,
334            else_branch,
335        } => {
336            let cv = eval_value(
337                pm, condition, response, headers, trailers, timing, variables,
338            )?;
339            if is_truthy(&cv) {
340                eval_value(
341                    pm,
342                    then_branch,
343                    response,
344                    headers,
345                    trailers,
346                    timing,
347                    variables,
348                )
349            } else {
350                eval_value(
351                    pm,
352                    else_branch,
353                    response,
354                    headers,
355                    trailers,
356                    timing,
357                    variables,
358                )
359            }
360        }
361        AssertionExpr::Raw(s) => Ok(resolve_path(s, response)),
362    }
363}
364
365fn eval_atom(
366    pm: &dyn PluginRegistry,
367    atom: &Expr,
368    response: &Value,
369    headers: Option<&HashMap<String, String>>,
370    trailers: Option<&HashMap<String, String>>,
371    timing: Option<&AssertionTiming>,
372    variables: &HashMap<String, Value>,
373) -> ValueResult {
374    match atom {
375        Expr::JqPath(p) => Ok(resolve_path(p, response)),
376        Expr::PluginCall { name, args } => {
377            let func_name = format!("@{}", name);
378            let resolved_name = normalize_plugin_name(&func_name);
379            if let Some(plugin) = pm.get_plugin(resolved_name) {
380                let ctx = PluginContext::new(response)
381                    .with_headers(headers)
382                    .with_trailers(trailers)
383                    .with_timing(timing);
384                let arg_values: Vec<Value> = args
385                    .iter()
386                    .map(|a| eval_value(pm, a, response, headers, trailers, timing, variables))
387                    .collect::<std::result::Result<_, _>>()?;
388                match plugin.execute(&arg_values, &ctx) {
389                    Ok(PluginResult::Value(v)) => Ok(v),
390                    Ok(PluginResult::Assertion(AssertionResult::Pass)) => Ok(Value::Bool(true)),
391                    Ok(PluginResult::Assertion(AssertionResult::Fail { .. })) => {
392                        Ok(Value::Bool(false))
393                    }
394                    // Plugin errors must never become comparable values —
395                    // propagate so the assertion surfaces as Error.
396                    Ok(PluginResult::Assertion(AssertionResult::Error(e))) => {
397                        Err(format!("Plugin {} error: {}", resolved_name, e))
398                    }
399                    Err(e) => Err(format!("Plugin {} error: {}", resolved_name, e)),
400                }
401            } else {
402                Ok(Value::Null)
403            }
404        }
405        Expr::Literal(lit) => Ok(match lit {
406            Literal::Bool(b) => Value::Bool(*b),
407            Literal::Number(n) => n
408                .parse::<i64>()
409                .map(|i| Value::Number(serde_json::Number::from(i)))
410                .unwrap_or_else(|_| {
411                    n.parse::<f64>()
412                        .ok()
413                        .and_then(serde_json::Number::from_f64)
414                        .map(Value::Number)
415                        .unwrap_or(Value::Null)
416                }),
417            Literal::Str(s) => Value::String(s.clone()),
418            Literal::Null => Value::Null,
419        }),
420        Expr::Variable(name) => match variables.get(name.as_str()) {
421            // `$name` resolves to the JSON value bound by a prior EXTRACT.
422            Some(v) => Ok(v.clone()),
423            None => Err(format!("Undefined variable: ${}", name)),
424        },
425        Expr::RegExp { pattern, flags } => Ok(Value::String(regex_with_flags(pattern, flags))),
426        Expr::Json(s) | Expr::Yaml(s) => Ok(serde_json::from_str(s).unwrap_or(Value::Null)),
427        Expr::As(inner, type_name) => {
428            let val = eval_atom(pm, inner, response, headers, trailers, timing, variables)?;
429            Ok(validate_type_cast(&val, type_name))
430        }
431    }
432}
433
434/// Equality that compares integers exactly (no lossy `as_f64`), so large
435/// `i64`/`u64` values like `9223372036854775807` don't collapse onto a
436/// neighbouring float. Mixed int/float (`3 == 3.0`) still compares by value.
437fn values_numerically_equal(lhs: &Value, rhs: &Value) -> bool {
438    if let (Value::Number(l), Value::Number(r)) = (lhs, rhs) {
439        if let (Some(li), Some(ri)) = (l.as_i64(), r.as_i64()) {
440            return li == ri;
441        }
442        if let (Some(lu), Some(ru)) = (l.as_u64(), r.as_u64()) {
443            return lu == ru;
444        }
445        // A float is involved (or one u64 sits outside i64 range): fall back to
446        // f64 so `3 == 3.0` holds.
447        if let (Some(lf), Some(rf)) = (l.as_f64(), r.as_f64()) {
448            return lf == rf;
449        }
450        return l == r;
451    }
452    lhs == rhs
453}
454
455fn eval_binary_value(lhs: Value, op: &BinaryOp, rhs: Value) -> Value {
456    let pass = match op {
457        BinaryOp::Eq => values_numerically_equal(&lhs, &rhs),
458        BinaryOp::Ne => !values_numerically_equal(&lhs, &rhs),
459        BinaryOp::Gt => compare_numeric(&lhs, &rhs, ">").unwrap_or(false),
460        BinaryOp::Lt => compare_numeric(&lhs, &rhs, "<").unwrap_or(false),
461        BinaryOp::Ge => compare_numeric(&lhs, &rhs, ">=").unwrap_or(false),
462        BinaryOp::Le => compare_numeric(&lhs, &rhs, "<=").unwrap_or(false),
463        BinaryOp::Contains => match (&lhs, &rhs) {
464            (Value::String(l), Value::String(r)) => l.contains(r),
465            (Value::Array(l), r) => l.contains(r),
466            (Value::Object(l), Value::String(r)) => l.contains_key(r),
467            _ => false,
468        },
469        BinaryOp::StartsWith => match (&lhs, &rhs) {
470            (Value::String(l), Value::String(r)) => l.starts_with(r),
471            _ => false,
472        },
473        BinaryOp::EndsWith => match (&lhs, &rhs) {
474            (Value::String(l), Value::String(r)) => l.ends_with(r),
475            _ => false,
476        },
477        BinaryOp::Matches => match (&lhs, &rhs) {
478            (Value::String(l), Value::String(r)) => cached_regex(r).is_ok_and(|re| re.is_match(l)),
479            _ => false,
480        },
481    };
482    Value::Bool(pass)
483}
484
485fn compare(
486    lhs: Value,
487    op: &BinaryOp,
488    rhs: Value,
489    left_expr: &AssertionExpr,
490    right_expr: &AssertionExpr,
491) -> Result<AssertionResult> {
492    if let BinaryOp::Matches = op
493        && let (Value::String(_l), Value::String(r)) = (&lhs, &rhs)
494        && cached_regex(r).is_err()
495    {
496        return Ok(AssertionResult::Error(format!("Invalid regex: {}", r)));
497    }
498    let pass = eval_binary_value(lhs.clone(), op, rhs.clone());
499    if pass == Value::Bool(true) {
500        Ok(AssertionResult::Pass)
501    } else {
502        Ok(AssertionResult::Fail {
503            message: format!(
504                "Assertion failed: {} {} {} (Values: {:?} vs {:?})",
505                left_expr,
506                op.as_str(),
507                right_expr,
508                lhs,
509                rhs
510            ),
511            expected: Some(format!("{} {:?}", op.as_str(), rhs)),
512            actual: Some(format!("{:?}", lhs)),
513        })
514    }
515}
516
517fn compare_numeric(lhs: &Value, rhs: &Value, op: &str) -> Option<bool> {
518    let lhs_num = lhs.as_number()?;
519    let rhs_num = rhs.as_number()?;
520
521    let lhs_i = lhs_num
522        .as_i64()
523        .map(i128::from)
524        .or_else(|| lhs_num.as_u64().map(i128::from));
525    let rhs_i = rhs_num
526        .as_i64()
527        .map(i128::from)
528        .or_else(|| rhs_num.as_u64().map(i128::from));
529
530    if let (Some(l), Some(r)) = (lhs_i, rhs_i) {
531        return Some(match op {
532            ">" => l > r,
533            "<" => l < r,
534            ">=" => l >= r,
535            "<=" => l <= r,
536            _ => return None,
537        });
538    }
539
540    let (l, r) = (lhs_num.as_f64()?, rhs_num.as_f64()?);
541    Some(match op {
542        ">" => l > r,
543        "<" => l < r,
544        ">=" => l >= r,
545        "<=" => l <= r,
546        _ => return None,
547    })
548}
549
550fn resolve_path(path: &str, root: &Value) -> Value {
551    if path == "." {
552        return root.clone();
553    }
554    if path.is_empty() {
555        return Value::Null;
556    }
557    if !path.starts_with('.') && !path.starts_with('$') {
558        return Value::String(path.to_string());
559    }
560    eval_jaq_one(path, root).unwrap_or(Value::Null)
561}
562
563fn eval_jaq_one(expr: &str, input: &Value) -> anyhow::Result<Value> {
564    super::engine::AssertionEngine::eval_jaq_one(expr, input)
565}
566
567fn is_truthy(val: &Value) -> bool {
568    !val.is_null() && val != &Value::Bool(false)
569}
570
571fn is_pass(r: &AssertionResult) -> bool {
572    matches!(r, AssertionResult::Pass)
573}
574
575fn negate(r: AssertionResult) -> AssertionResult {
576    r.negate()
577}
578
579fn fmt_result_short(r: &AssertionResult) -> String {
580    match r {
581        AssertionResult::Pass => "pass".into(),
582        AssertionResult::Fail { message, .. } => message.clone(),
583        AssertionResult::Error(e) => format!("error: {}", e),
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use serde_json::json;
591
592    fn pm() -> crate::registry::NoopPluginRegistry {
593        crate::registry::NoopPluginRegistry
594    }
595
596    fn eval(pm: &dyn PluginRegistry, expr: &str, response: &Value) -> AssertionResult {
597        evaluate_assertion(pm, expr, response, None, None, None, &HashMap::new())
598            .unwrap()
599            .unwrap_or(AssertionResult::Error("AST returned None".into()))
600    }
601
602    fn eval_with_vars(
603        pm: &dyn PluginRegistry,
604        expr: &str,
605        response: &Value,
606        variables: &HashMap<String, Value>,
607    ) -> AssertionResult {
608        evaluate_assertion(pm, expr, response, None, None, None, variables)
609            .unwrap()
610            .unwrap_or(AssertionResult::Error("AST returned None".into()))
611    }
612
613    #[test]
614    fn test_equality_pass() {
615        let r = eval(
616            &pm(),
617            ".status == \"success\"",
618            &json!({"status": "success"}),
619        );
620        assert!(matches!(r, AssertionResult::Pass));
621    }
622
623    #[test]
624    fn test_equality_fail() {
625        let r = eval(&pm(), ".status == \"error\"", &json!({"status": "success"}));
626        assert!(matches!(r, AssertionResult::Fail { .. }));
627    }
628
629    #[test]
630    fn test_contains() {
631        let r = eval(&pm(), ".name contains \"te\"", &json!({"name": "test"}));
632        assert!(matches!(r, AssertionResult::Pass));
633    }
634
635    #[test]
636    fn test_xor_both_true() {
637        let r = eval(&pm(), ".x == 1 xor .y == 2", &json!({"x": 1, "y": 2}));
638        assert!(matches!(r, AssertionResult::Fail { .. }), "got: {:?}", r);
639    }
640
641    #[test]
642    fn test_xor_both_false() {
643        let r = eval(&pm(), ".x == 9 xor .y == 9", &json!({"x": 1, "y": 2}));
644        assert!(matches!(r, AssertionResult::Fail { .. }), "got: {:?}", r);
645    }
646
647    #[test]
648    fn test_numeric_greater() {
649        let r = eval(&pm(), ".id > 100", &json!({"id": 123}));
650        assert!(matches!(r, AssertionResult::Pass));
651    }
652
653    #[test]
654    fn test_numeric_less() {
655        let r = eval(&pm(), ".id < 200", &json!({"id": 123}));
656        assert!(matches!(r, AssertionResult::Pass));
657    }
658
659    #[test]
660    fn test_matches_regex() {
661        let r = eval(&pm(), ".name matches \"^te.*t$\"", &json!({"name": "test"}));
662        assert!(matches!(r, AssertionResult::Pass));
663    }
664
665    #[test]
666    fn test_matches_regex_fail() {
667        let r = eval(&pm(), ".name matches \"^xyz\"", &json!({"name": "test"}));
668        assert!(matches!(r, AssertionResult::Fail { .. }));
669    }
670
671    #[test]
672    fn test_jq_fallback_via_raw() {
673        let p = pm();
674        let r = evaluate_assertion(
675            &p,
676            ".tags | length",
677            &json!({"tags": [1, 2, 3]}),
678            None,
679            None,
680            None,
681            &HashMap::new(),
682        )
683        .unwrap();
684        assert!(
685            r.is_none(),
686            "JQ pipe should return None to trigger JQ fallback"
687        );
688    }
689
690    #[test]
691    fn test_resolve_path_simple() {
692        let r = resolve_path(".key", &json!({"key": "value"}));
693        assert_eq!(r, json!("value"));
694    }
695
696    #[test]
697    fn test_resolve_path_nested() {
698        let r = resolve_path(".outer.inner", &json!({"outer": {"inner": "value"}}));
699        assert_eq!(r, json!("value"));
700    }
701
702    #[test]
703    fn test_resolve_path_array_index() {
704        let r = resolve_path(".items[0]", &json!({"items": ["first", "second"]}));
705        assert_eq!(r, json!("first"));
706    }
707
708    #[test]
709    fn test_resolve_path_missing_key() {
710        let r = resolve_path(".missing", &json!({"a": 1}));
711        assert!(r.is_null());
712    }
713
714    #[test]
715    fn test_compare_numeric_greater() {
716        assert_eq!(compare_numeric(&json!(5), &json!(3), ">"), Some(true));
717    }
718
719    #[test]
720    fn test_compare_numeric_less() {
721        assert_eq!(compare_numeric(&json!(3), &json!(5), "<"), Some(true));
722    }
723
724    #[test]
725    fn test_compare_numeric_equality() {
726        assert_eq!(compare_numeric(&json!(5), &json!(5), ">="), Some(true));
727        assert_eq!(compare_numeric(&json!(5), &json!(5), "<="), Some(true));
728    }
729
730    #[test]
731    fn test_compare_numeric_mixed_types() {
732        assert_eq!(compare_numeric(&json!(5), &json!("5"), ">"), None);
733    }
734
735    #[test]
736    fn test_cached_regex_valid() {
737        assert!(cached_regex(r"\d+").is_ok());
738    }
739
740    #[test]
741    fn test_cached_regex_invalid() {
742        assert!(cached_regex(r"[").is_err());
743    }
744
745    #[test]
746    fn test_validate_type_cast() {
747        use serde_json::json;
748        assert_eq!(validate_type_cast(&json!(42), "number"), json!(42));
749        assert_eq!(validate_type_cast(&json!("hello"), "number"), Value::Null);
750        assert_eq!(
751            validate_type_cast(&json!("hello"), "string"),
752            json!("hello")
753        );
754        assert_eq!(validate_type_cast(&json!(42), "string"), Value::Null);
755        assert_eq!(validate_type_cast(&json!(true), "bool"), json!(true));
756        assert_eq!(validate_type_cast(&json!("hello"), "bool"), Value::Null);
757        assert_eq!(validate_type_cast(&json!(42u64), "uint"), json!(42u64));
758        assert_eq!(validate_type_cast(&json!(-1), "uint"), Value::Null);
759        assert_eq!(
760            validate_type_cast(&json!("uuid-str"), "uuid"),
761            json!("uuid-str")
762        );
763        assert_eq!(
764            validate_type_cast(&json!("email@x.com"), "email"),
765            json!("email@x.com")
766        );
767        assert_eq!(validate_type_cast(&json!("url"), "url"), json!("url"));
768        assert_eq!(
769            validate_type_cast(&json!("1.2.3.4"), "ip"),
770            json!("1.2.3.4")
771        );
772        assert_eq!(
773            validate_type_cast(&json!("2024-01-01"), "time"),
774            json!("2024-01-01")
775        );
776        assert_eq!(validate_type_cast(&json!(12345), "timestamp"), json!(12345));
777        assert_eq!(
778            validate_type_cast(&json!("100ms"), "duration"),
779            json!("100ms")
780        );
781        assert_eq!(
782            validate_type_cast(&json!({"k": "v"}), "json"),
783            json!({"k": "v"})
784        );
785        assert_eq!(validate_type_cast(&json!([1, 2]), "json"), json!([1, 2]));
786        assert_eq!(validate_type_cast(&json!("hello"), "json"), Value::Null);
787        assert_eq!(
788            validate_type_cast(&json!("yaml:val"), "yaml"),
789            json!("yaml:val")
790        );
791        assert_eq!(
792            validate_type_cast(&json!("any_val"), "unknown_type"),
793            json!("any_val")
794        );
795    }
796
797    #[test]
798    fn test_normalize_plugin_name_assert() {
799        assert_eq!(normalize_plugin_name("@uuid"), "uuid");
800        assert_eq!(normalize_plugin_name("uuid"), "uuid");
801        assert_eq!(normalize_plugin_name(" @uuid "), "uuid");
802    }
803
804    #[test]
805    fn test_is_truthy() {
806        assert!(!is_truthy(&Value::Null));
807        assert!(!is_truthy(&Value::Bool(false)));
808        assert!(is_truthy(&Value::Bool(true)));
809        assert!(is_truthy(&Value::Number(0.into())));
810        assert!(is_truthy(&Value::String("".into())));
811    }
812
813    #[test]
814    fn test_negate() {
815        let pass = AssertionResult::Pass;
816        assert!(matches!(negate(pass), AssertionResult::Fail { .. }));
817
818        let fail = AssertionResult::fail("msg");
819        assert!(matches!(negate(fail), AssertionResult::Pass));
820
821        let err = AssertionResult::Error("err".into());
822        assert!(matches!(negate(err), AssertionResult::Error(_)));
823    }
824
825    #[test]
826    fn test_fmt_result_short() {
827        assert_eq!(fmt_result_short(&AssertionResult::Pass), "pass");
828        assert_eq!(fmt_result_short(&AssertionResult::fail("msg")), "msg");
829        assert_eq!(
830            fmt_result_short(&AssertionResult::Error("err".into())),
831            "error: err"
832        );
833    }
834
835    #[test]
836    fn test_eval_atom_literal() {
837        let pm = crate::registry::NoopPluginRegistry;
838        let ctx = &json!({});
839        use apif_ast::assertion_ast::{Expr, Literal};
840        let result = eval_atom(
841            &pm,
842            &Expr::Literal(Literal::Number("42".into())),
843            ctx,
844            None,
845            None,
846            None,
847            &HashMap::new(),
848        )
849        .unwrap();
850        assert_eq!(result, json!(42));
851    }
852
853    #[test]
854    fn test_regex_with_flags() {
855        assert_eq!(regex_with_flags("^te.*t$", ""), "^te.*t$");
856        assert_eq!(regex_with_flags("^te.*t$", "i"), "(?i)^te.*t$");
857        assert_eq!(regex_with_flags("^te.*t$", "im"), "(?im)^te.*t$");
858        // Unsupported flags (e.g. JS "g") are dropped instead of breaking the pattern
859        assert_eq!(regex_with_flags("^te.*t$", "gi"), "(?i)^te.*t$");
860        assert_eq!(regex_with_flags("^te.*t$", "g"), "^te.*t$");
861    }
862
863    #[test]
864    fn test_matches_regex_honors_case_insensitive_flag() {
865        use apif_ast::assertion_ast::{BinaryOp, Expr};
866        let expr = AssertionExpr::Binary {
867            op: BinaryOp::Matches,
868            left: Box::new(AssertionExpr::Atom(Expr::JqPath(".name".into()))),
869            right: Box::new(AssertionExpr::Atom(Expr::RegExp {
870                pattern: "^TE.*T$".into(),
871                flags: "i".into(),
872            })),
873        };
874        let response = json!({"name": "test"});
875        let r = evaluate_ast(&pm(), &expr, &response, None, None, None, &HashMap::new()).unwrap();
876        assert!(matches!(r, AssertionResult::Pass), "got: {:?}", r);
877
878        // Without the flag the same pattern must fail
879        let expr = AssertionExpr::Binary {
880            op: BinaryOp::Matches,
881            left: Box::new(AssertionExpr::Atom(Expr::JqPath(".name".into()))),
882            right: Box::new(AssertionExpr::Atom(Expr::RegExp {
883                pattern: "^TE.*T$".into(),
884                flags: String::new(),
885            })),
886        };
887        let r = evaluate_ast(&pm(), &expr, &response, None, None, None, &HashMap::new()).unwrap();
888        assert!(matches!(r, AssertionResult::Fail { .. }), "got: {:?}", r);
889    }
890
891    struct ErrorPlugin;
892
893    impl crate::registry::PluginApi for ErrorPlugin {
894        fn execute(
895            &self,
896            _args: &[Value],
897            _context: &PluginContext,
898        ) -> anyhow::Result<crate::registry::PluginResult> {
899            Ok(crate::registry::PluginResult::Assertion(
900                AssertionResult::Error("boom".into()),
901            ))
902        }
903    }
904
905    struct ErrorPluginRegistry;
906
907    impl PluginRegistry for ErrorPluginRegistry {
908        fn get_plugin(&self, name: &str) -> Option<std::sync::Arc<dyn crate::registry::PluginApi>> {
909            (name == "err").then(|| {
910                std::sync::Arc::new(ErrorPlugin) as std::sync::Arc<dyn crate::registry::PluginApi>
911            })
912        }
913    }
914
915    #[test]
916    fn test_plugin_error_in_value_position_propagates() {
917        // Regression: a plugin Error used as a value became the truthy string
918        // "error: ..." and could flip a comparison to PASS.
919        let r = eval(
920            &ErrorPluginRegistry,
921            "@err(.x) == \"error: boom\"",
922            &json!({"x": 1}),
923        );
924        assert!(matches!(r, AssertionResult::Error(_)), "got: {:?}", r);
925
926        // A plugin error must not be silently comparable to anything either
927        let r = eval(&ErrorPluginRegistry, "@err(.x) != 1", &json!({"x": 1}));
928        assert!(matches!(r, AssertionResult::Error(_)), "got: {:?}", r);
929    }
930
931    #[test]
932    fn test_eval_binary_value_num() {
933        use apif_ast::assertion_ast::BinaryOp;
934        assert_eq!(
935            eval_binary_value(json!(5), &BinaryOp::Gt, json!(3)),
936            json!(true)
937        );
938        assert_eq!(
939            eval_binary_value(json!(3), &BinaryOp::Gt, json!(5)),
940            json!(false)
941        );
942    }
943
944    #[test]
945    fn test_eq_is_exact_on_large_integers() {
946        // Regression: `as_f64` collapsed neighbouring i64 values onto the same
947        // float, so `...807 == ...806` false-passed.
948        let r = eval(
949            &pm(),
950            ".id == 9223372036854775807",
951            &json!({"id": 9223372036854775806i64}),
952        );
953        assert!(matches!(r, AssertionResult::Fail { .. }), "got: {:?}", r);
954
955        // Exact match still passes.
956        let r = eval(
957            &pm(),
958            ".id == 9223372036854775807",
959            &json!({"id": 9223372036854775807i64}),
960        );
961        assert!(matches!(r, AssertionResult::Pass), "got: {:?}", r);
962
963        // `!=` mirrors the exact comparison.
964        let r = eval(
965            &pm(),
966            ".id != 9223372036854775807",
967            &json!({"id": 9223372036854775806i64}),
968        );
969        assert!(matches!(r, AssertionResult::Pass), "got: {:?}", r);
970    }
971
972    #[test]
973    fn test_extract_variable_resolves_in_assertion() {
974        // Regression: `$price >= 0` must resolve `$price` to the EXTRACT-bound
975        // value, not compare the literal string "$price" to 0.
976        let mut vars = HashMap::new();
977        vars.insert("price".to_string(), json!(42));
978        let r = eval_with_vars(&pm(), "$price >= 0", &json!({}), &vars);
979        assert!(matches!(r, AssertionResult::Pass), "got: {:?}", r);
980
981        vars.insert("price".to_string(), json!(-5));
982        let r = eval_with_vars(&pm(), "$price >= 0", &json!({}), &vars);
983        assert!(matches!(r, AssertionResult::Fail { .. }), "got: {:?}", r);
984    }
985
986    #[test]
987    fn test_extract_variable_string_contains() {
988        let mut vars = HashMap::new();
989        vars.insert("name".to_string(), json!("hello world"));
990        let r = eval_with_vars(&pm(), "$name contains \"hello\"", &json!({}), &vars);
991        assert!(matches!(r, AssertionResult::Pass), "got: {:?}", r);
992    }
993
994    #[test]
995    fn test_unbound_variable_errors() {
996        let vars = HashMap::new();
997        let r = eval_with_vars(&pm(), "$missing >= 0", &json!({}), &vars);
998        match r {
999            AssertionResult::Error(msg) => assert!(msg.contains("missing"), "msg: {}", msg),
1000            other => panic!("expected Error for unbound variable, got: {:?}", other),
1001        }
1002    }
1003
1004    #[test]
1005    fn test_eq_int_vs_float_still_equal_by_value() {
1006        let r = eval(&pm(), ".x == 3.0", &json!({"x": 3}));
1007        assert!(matches!(r, AssertionResult::Pass), "got: {:?}", r);
1008        let r = eval(&pm(), ".x == 3", &json!({"x": 3.0}));
1009        assert!(matches!(r, AssertionResult::Pass), "got: {:?}", r);
1010    }
1011}