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