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
19thread_local! {
20    static REGEX_CACHE: RefCell<HashMap<String, std::result::Result<Rc<Regex>, String>>> =
21        RefCell::new(HashMap::new());
22}
23
24fn cached_regex(pattern: &str) -> std::result::Result<Rc<Regex>, String> {
25    if let Some(cached) = REGEX_CACHE.with(|cache| cache.borrow().get(pattern).cloned()) {
26        return cached;
27    }
28
29    let compiled = Regex::new(pattern)
30        .map(Rc::new)
31        .map_err(|err| err.to_string());
32
33    REGEX_CACHE.with(|cache| {
34        cache
35            .borrow_mut()
36            .insert(pattern.to_string(), compiled.clone());
37    });
38
39    compiled
40}
41
42/// Evaluate an assertion expression.
43/// Returns `Ok(Some(result))` when the AST engine handled the expression,
44/// `Ok(None)` when the expression should fall through to the JQ evaluator.
45pub fn evaluate_assertion(
46    registry: &dyn PluginRegistry,
47    assertion: &str,
48    response: &Value,
49    headers: Option<&HashMap<String, String>>,
50    trailers: Option<&HashMap<String, String>>,
51    timing: Option<&AssertionTiming>,
52) -> Result<Option<AssertionResult>> {
53    let trimmed = assertion.trim();
54    if trimmed.is_empty() {
55        return Ok(None);
56    }
57
58    let ast = parse_assertion(trimmed);
59    match &ast {
60        AssertionExpr::Raw(_) => Ok(None),
61        _ => evaluate_ast(registry, &ast, response, headers, trailers, timing).map(Some),
62    }
63}
64
65fn evaluate_ast(
66    pm: &dyn PluginRegistry,
67    expr: &AssertionExpr,
68    response: &Value,
69    headers: Option<&HashMap<String, String>>,
70    trailers: Option<&HashMap<String, String>>,
71    timing: Option<&AssertionTiming>,
72) -> Result<AssertionResult> {
73    match expr {
74        AssertionExpr::Not(inner) => {
75            let r = evaluate_ast(pm, inner, response, headers, trailers, timing)?;
76            Ok(negate(r))
77        }
78        AssertionExpr::NotNot(inner) => {
79            evaluate_ast(pm, inner, response, headers, trailers, timing)
80        }
81        AssertionExpr::And { left, right } => {
82            let lr = evaluate_ast(pm, left, response, headers, trailers, timing)?;
83            if !is_pass(&lr) {
84                return Ok(AssertionResult::fail(format!(
85                    "Left of 'and' failed: {}",
86                    fmt_result_short(&lr)
87                )));
88            }
89            let rr = evaluate_ast(pm, right, response, headers, trailers, timing)?;
90            if !is_pass(&rr) {
91                return Ok(AssertionResult::fail(format!(
92                    "Right of 'and' failed: {}",
93                    fmt_result_short(&rr)
94                )));
95            }
96            Ok(AssertionResult::Pass)
97        }
98        AssertionExpr::Or { left, right } => {
99            let lr = evaluate_ast(pm, left, response, headers, trailers, timing)?;
100            if is_pass(&lr) {
101                return Ok(AssertionResult::Pass);
102            }
103            let rr = evaluate_ast(pm, right, response, headers, trailers, timing)?;
104            if is_pass(&rr) {
105                return Ok(AssertionResult::Pass);
106            }
107            Ok(AssertionResult::fail(format!(
108                "Both sides of 'or' failed: left={}, right={}",
109                fmt_result_short(&lr),
110                fmt_result_short(&rr)
111            )))
112        }
113        AssertionExpr::Xor { left, right } => {
114            let lr = evaluate_ast(pm, left, response, headers, trailers, timing)?;
115            let rr = evaluate_ast(pm, right, response, headers, trailers, timing)?;
116            let lp = is_pass(&lr);
117            let rp = is_pass(&rr);
118            if lp != rp {
119                Ok(AssertionResult::Pass)
120            } else {
121                Ok(AssertionResult::fail(format!(
122                    "Xor expects exactly one true, got left={} right={}",
123                    lp, rp
124                )))
125            }
126        }
127        AssertionExpr::Binary { op, left, right } => {
128            let lhs = eval_value(pm, left, response, headers, trailers, timing);
129            let rhs = eval_value(pm, right, response, headers, trailers, timing);
130            compare(lhs, op, rhs, left, right)
131        }
132        AssertionExpr::Paren(inner) => evaluate_ast(pm, inner, response, headers, trailers, timing),
133        AssertionExpr::IfThenElse {
134            condition,
135            then_branch,
136            else_branch,
137        } => {
138            let cond = evaluate_ast(pm, condition, response, headers, trailers, timing)?;
139            if is_pass(&cond) {
140                evaluate_ast(pm, then_branch, response, headers, trailers, timing)
141            } else {
142                evaluate_ast(pm, else_branch, response, headers, trailers, timing)
143            }
144        }
145        AssertionExpr::Atom(_) => {
146            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = expr {
147                eval_plugin_as_assertion(pm, name, args, response, headers, trailers, timing)
148            } else {
149                let val = eval_value(pm, expr, response, headers, trailers, timing);
150                if is_truthy(&val) {
151                    Ok(AssertionResult::Pass)
152                } else {
153                    Ok(AssertionResult::fail(format!(
154                        "Expression evaluated to falsy: {:?}",
155                        val
156                    )))
157                }
158            }
159        }
160        AssertionExpr::Raw(_) => Ok(AssertionResult::Error("Unparsed expression".into())),
161    }
162}
163
164/// Evaluate an AST node as a JSON value (for use inside Binary, plugin args, etc.)
165/// Validate a value against a type annotation.
166/// Returns `Value::Null` if the value doesn't match the expected type,
167/// otherwise returns the value unchanged.
168fn validate_type_cast(val: &Value, type_name: &str) -> Value {
169    let valid = match type_name {
170        "bool" => val.is_boolean(),
171        "uint" => val.as_u64().is_some(),
172        "number" => val.is_number(),
173        "string" | "uuid" | "email" | "url" | "ip" => val.is_string(),
174        "time" | "timestamp" | "duration" => val.is_string() || val.is_number(),
175        "json" => val.is_object() || val.is_array(),
176        "yaml" => val.is_string(),
177        _ => true,
178    };
179    if valid { val.clone() } else { Value::Null }
180}
181
182fn eval_plugin_as_assertion(
183    pm: &dyn PluginRegistry,
184    name: &str,
185    args: &[AssertionExpr],
186    response: &Value,
187    headers: Option<&HashMap<String, String>>,
188    trailers: Option<&HashMap<String, String>>,
189    timing: Option<&AssertionTiming>,
190) -> Result<AssertionResult> {
191    let func_name = format!("@{}", name);
192    let resolved_name = normalize_plugin_name(&func_name);
193    if let Some(plugin) = pm.get_plugin(resolved_name) {
194        let ctx = PluginContext::new(response)
195            .with_headers(headers)
196            .with_trailers(trailers)
197            .with_timing(timing);
198        let arg_values: Vec<Value> = args
199            .iter()
200            .map(|a| eval_value(pm, a, response, headers, trailers, timing))
201            .collect();
202        match plugin.execute(&arg_values, &ctx) {
203            Ok(PluginResult::Assertion(res)) => Ok(res),
204            Ok(PluginResult::Value(val)) => {
205                if is_truthy(&val) {
206                    Ok(AssertionResult::Pass)
207                } else {
208                    Ok(AssertionResult::fail(format!(
209                        "Plugin {} returned falsy value: {:?}",
210                        resolved_name, val
211                    )))
212                }
213            }
214            Err(e) => Ok(AssertionResult::Error(format!("Plugin error: {}", e))),
215        }
216    } else {
217        Ok(AssertionResult::Error(format!("Unknown plugin: {}", name)))
218    }
219}
220
221fn eval_value(
222    pm: &dyn PluginRegistry,
223    expr: &AssertionExpr,
224    response: &Value,
225    headers: Option<&HashMap<String, String>>,
226    trailers: Option<&HashMap<String, String>>,
227    timing: Option<&AssertionTiming>,
228) -> Value {
229    match expr {
230        AssertionExpr::Atom(atom) => eval_atom(pm, atom, response, headers, trailers, timing),
231        AssertionExpr::Paren(inner) => eval_value(pm, inner, response, headers, trailers, timing),
232        AssertionExpr::Not(inner) => {
233            let v = eval_value(pm, inner, response, headers, trailers, timing);
234            Value::Bool(!is_truthy(&v))
235        }
236        AssertionExpr::NotNot(inner) => eval_value(pm, inner, response, headers, trailers, timing),
237        AssertionExpr::And { left, right } => {
238            let lv = eval_value(pm, left, response, headers, trailers, timing);
239            if !is_truthy(&lv) {
240                return Value::Bool(false);
241            }
242            let rv = eval_value(pm, right, response, headers, trailers, timing);
243            Value::Bool(is_truthy(&rv))
244        }
245        AssertionExpr::Or { left, right } => {
246            let lv = eval_value(pm, left, response, headers, trailers, timing);
247            if is_truthy(&lv) {
248                return Value::Bool(true);
249            }
250            let rv = eval_value(pm, right, response, headers, trailers, timing);
251            Value::Bool(is_truthy(&rv))
252        }
253        AssertionExpr::Xor { left, right } => {
254            let lv = eval_value(pm, left, response, headers, trailers, timing);
255            let rv = eval_value(pm, right, response, headers, trailers, timing);
256            Value::Bool(is_truthy(&lv) != is_truthy(&rv))
257        }
258        AssertionExpr::Binary { op, left, right } => {
259            let lhs = eval_value(pm, left, response, headers, trailers, timing);
260            let rhs = eval_value(pm, right, response, headers, trailers, timing);
261            eval_binary_value(lhs, op, rhs)
262        }
263        AssertionExpr::IfThenElse {
264            condition,
265            then_branch,
266            else_branch,
267        } => {
268            let cv = eval_value(pm, condition, response, headers, trailers, timing);
269            if is_truthy(&cv) {
270                eval_value(pm, then_branch, response, headers, trailers, timing)
271            } else {
272                eval_value(pm, else_branch, response, headers, trailers, timing)
273            }
274        }
275        AssertionExpr::Raw(s) => resolve_path(s, response),
276    }
277}
278
279fn eval_atom(
280    pm: &dyn PluginRegistry,
281    atom: &Expr,
282    response: &Value,
283    headers: Option<&HashMap<String, String>>,
284    trailers: Option<&HashMap<String, String>>,
285    timing: Option<&AssertionTiming>,
286) -> Value {
287    match atom {
288        Expr::JqPath(p) => resolve_path(p, response),
289        Expr::PluginCall { name, args } => {
290            let func_name = format!("@{}", name);
291            let resolved_name = normalize_plugin_name(&func_name);
292            if let Some(plugin) = pm.get_plugin(resolved_name) {
293                let ctx = PluginContext::new(response)
294                    .with_headers(headers)
295                    .with_trailers(trailers)
296                    .with_timing(timing);
297                let arg_values: Vec<Value> = args
298                    .iter()
299                    .map(|a| eval_value(pm, a, response, headers, trailers, timing))
300                    .collect();
301                match plugin.execute(&arg_values, &ctx) {
302                    Ok(PluginResult::Value(v)) => v,
303                    Ok(PluginResult::Assertion(AssertionResult::Pass)) => Value::Bool(true),
304                    Ok(PluginResult::Assertion(AssertionResult::Fail { .. })) => Value::Bool(false),
305                    Ok(PluginResult::Assertion(AssertionResult::Error(e))) => {
306                        Value::String(format!("error: {}", e))
307                    }
308                    Err(_) => Value::Null,
309                }
310            } else {
311                Value::Null
312            }
313        }
314        Expr::Literal(lit) => match lit {
315            Literal::Bool(b) => Value::Bool(*b),
316            Literal::Number(n) => n
317                .parse::<i64>()
318                .map(|i| Value::Number(serde_json::Number::from(i)))
319                .unwrap_or_else(|_| {
320                    n.parse::<f64>()
321                        .ok()
322                        .and_then(serde_json::Number::from_f64)
323                        .map(Value::Number)
324                        .unwrap_or(Value::Null)
325                }),
326            Literal::Str(s) => Value::String(s.clone()),
327            Literal::Null => Value::Null,
328        },
329        Expr::Variable(name) => Value::String(format!("${}", name)),
330        Expr::RegExp { pattern, flags: _ } => Value::String(pattern.clone()),
331        Expr::Json(s) | Expr::Yaml(s) => serde_json::from_str(s).unwrap_or(Value::Null),
332        Expr::As(inner, type_name) => {
333            let val = eval_atom(pm, inner, response, headers, trailers, timing);
334            validate_type_cast(&val, type_name)
335        }
336    }
337}
338
339fn eval_binary_value(lhs: Value, op: &BinaryOp, rhs: Value) -> Value {
340    let pass = match op {
341        BinaryOp::Eq => lhs == rhs,
342        BinaryOp::Ne => lhs != rhs,
343        BinaryOp::Gt => compare_numeric(&lhs, &rhs, ">").unwrap_or(false),
344        BinaryOp::Lt => compare_numeric(&lhs, &rhs, "<").unwrap_or(false),
345        BinaryOp::Ge => compare_numeric(&lhs, &rhs, ">=").unwrap_or(false),
346        BinaryOp::Le => compare_numeric(&lhs, &rhs, "<=").unwrap_or(false),
347        BinaryOp::Contains => match (&lhs, &rhs) {
348            (Value::String(l), Value::String(r)) => l.contains(r),
349            (Value::Array(l), r) => l.contains(r),
350            (Value::Object(l), Value::String(r)) => l.contains_key(r),
351            _ => false,
352        },
353        BinaryOp::StartsWith => match (&lhs, &rhs) {
354            (Value::String(l), Value::String(r)) => l.starts_with(r),
355            _ => false,
356        },
357        BinaryOp::EndsWith => match (&lhs, &rhs) {
358            (Value::String(l), Value::String(r)) => l.ends_with(r),
359            _ => false,
360        },
361        BinaryOp::Matches => match (&lhs, &rhs) {
362            (Value::String(l), Value::String(r)) => cached_regex(r).is_ok_and(|re| re.is_match(l)),
363            _ => false,
364        },
365    };
366    Value::Bool(pass)
367}
368
369fn compare(
370    lhs: Value,
371    op: &BinaryOp,
372    rhs: Value,
373    left_expr: &AssertionExpr,
374    right_expr: &AssertionExpr,
375) -> Result<AssertionResult> {
376    if let BinaryOp::Matches = op
377        && let (Value::String(_l), Value::String(r)) = (&lhs, &rhs)
378        && cached_regex(r).is_err()
379    {
380        return Ok(AssertionResult::Error(format!("Invalid regex: {}", r)));
381    }
382    let pass = eval_binary_value(lhs.clone(), op, rhs.clone());
383    if pass == Value::Bool(true) {
384        Ok(AssertionResult::Pass)
385    } else {
386        Ok(AssertionResult::Fail {
387            message: format!(
388                "Assertion failed: {} {} {} (Values: {:?} vs {:?})",
389                left_expr,
390                op.as_str(),
391                right_expr,
392                lhs,
393                rhs
394            ),
395            expected: Some(format!("{} {:?}", op.as_str(), rhs)),
396            actual: Some(format!("{:?}", lhs)),
397        })
398    }
399}
400
401fn compare_numeric(lhs: &Value, rhs: &Value, op: &str) -> Option<bool> {
402    let lhs_num = lhs.as_number()?;
403    let rhs_num = rhs.as_number()?;
404
405    let lhs_i = lhs_num
406        .as_i64()
407        .map(i128::from)
408        .or_else(|| lhs_num.as_u64().map(i128::from));
409    let rhs_i = rhs_num
410        .as_i64()
411        .map(i128::from)
412        .or_else(|| rhs_num.as_u64().map(i128::from));
413
414    if let (Some(l), Some(r)) = (lhs_i, rhs_i) {
415        return Some(match op {
416            ">" => l > r,
417            "<" => l < r,
418            ">=" => l >= r,
419            "<=" => l <= r,
420            _ => return None,
421        });
422    }
423
424    let (l, r) = (lhs_num.as_f64()?, rhs_num.as_f64()?);
425    Some(match op {
426        ">" => l > r,
427        "<" => l < r,
428        ">=" => l >= r,
429        "<=" => l <= r,
430        _ => return None,
431    })
432}
433
434fn resolve_path(path: &str, root: &Value) -> Value {
435    if path == "." {
436        return root.clone();
437    }
438    if path.is_empty() {
439        return Value::Null;
440    }
441    if !path.starts_with('.') && !path.starts_with('$') {
442        return Value::String(path.to_string());
443    }
444    eval_jaq_one(path, root).unwrap_or(Value::Null)
445}
446
447fn eval_jaq_one(expr: &str, input: &Value) -> anyhow::Result<Value> {
448    super::engine::AssertionEngine::eval_jaq_one(expr, input)
449}
450
451fn is_truthy(val: &Value) -> bool {
452    !val.is_null() && val != &Value::Bool(false)
453}
454
455fn is_pass(r: &AssertionResult) -> bool {
456    matches!(r, AssertionResult::Pass)
457}
458
459fn negate(r: AssertionResult) -> AssertionResult {
460    r.negate()
461}
462
463fn fmt_result_short(r: &AssertionResult) -> String {
464    match r {
465        AssertionResult::Pass => "pass".into(),
466        AssertionResult::Fail { message, .. } => message.clone(),
467        AssertionResult::Error(e) => format!("error: {}", e),
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use serde_json::json;
475
476    fn pm() -> crate::registry::NoopPluginRegistry {
477        crate::registry::NoopPluginRegistry
478    }
479
480    fn eval(pm: &dyn PluginRegistry, expr: &str, response: &Value) -> AssertionResult {
481        evaluate_assertion(pm, expr, response, None, None, None)
482            .unwrap()
483            .unwrap_or(AssertionResult::Error("AST returned None".into()))
484    }
485
486    #[test]
487    fn test_equality_pass() {
488        let r = eval(
489            &pm(),
490            ".status == \"success\"",
491            &json!({"status": "success"}),
492        );
493        assert!(matches!(r, AssertionResult::Pass));
494    }
495
496    #[test]
497    fn test_equality_fail() {
498        let r = eval(&pm(), ".status == \"error\"", &json!({"status": "success"}));
499        assert!(matches!(r, AssertionResult::Fail { .. }));
500    }
501
502    #[test]
503    fn test_contains() {
504        let r = eval(&pm(), ".name contains \"te\"", &json!({"name": "test"}));
505        assert!(matches!(r, AssertionResult::Pass));
506    }
507
508    #[test]
509    fn test_xor_both_true() {
510        let r = eval(&pm(), ".x == 1 xor .y == 2", &json!({"x": 1, "y": 2}));
511        assert!(matches!(r, AssertionResult::Fail { .. }), "got: {:?}", r);
512    }
513
514    #[test]
515    fn test_xor_both_false() {
516        let r = eval(&pm(), ".x == 9 xor .y == 9", &json!({"x": 1, "y": 2}));
517        assert!(matches!(r, AssertionResult::Fail { .. }), "got: {:?}", r);
518    }
519
520    #[test]
521    fn test_numeric_greater() {
522        let r = eval(&pm(), ".id > 100", &json!({"id": 123}));
523        assert!(matches!(r, AssertionResult::Pass));
524    }
525
526    #[test]
527    fn test_numeric_less() {
528        let r = eval(&pm(), ".id < 200", &json!({"id": 123}));
529        assert!(matches!(r, AssertionResult::Pass));
530    }
531
532    #[test]
533    fn test_matches_regex() {
534        let r = eval(&pm(), ".name matches \"^te.*t$\"", &json!({"name": "test"}));
535        assert!(matches!(r, AssertionResult::Pass));
536    }
537
538    #[test]
539    fn test_matches_regex_fail() {
540        let r = eval(&pm(), ".name matches \"^xyz\"", &json!({"name": "test"}));
541        assert!(matches!(r, AssertionResult::Fail { .. }));
542    }
543
544    #[test]
545    fn test_jq_fallback_via_raw() {
546        let p = pm();
547        let r = evaluate_assertion(
548            &p,
549            ".tags | length",
550            &json!({"tags": [1, 2, 3]}),
551            None,
552            None,
553            None,
554        )
555        .unwrap();
556        assert!(
557            r.is_none(),
558            "JQ pipe should return None to trigger JQ fallback"
559        );
560    }
561
562    #[test]
563    fn test_resolve_path_simple() {
564        let r = resolve_path(".key", &json!({"key": "value"}));
565        assert_eq!(r, json!("value"));
566    }
567
568    #[test]
569    fn test_resolve_path_nested() {
570        let r = resolve_path(".outer.inner", &json!({"outer": {"inner": "value"}}));
571        assert_eq!(r, json!("value"));
572    }
573
574    #[test]
575    fn test_resolve_path_array_index() {
576        let r = resolve_path(".items[0]", &json!({"items": ["first", "second"]}));
577        assert_eq!(r, json!("first"));
578    }
579
580    #[test]
581    fn test_resolve_path_missing_key() {
582        let r = resolve_path(".missing", &json!({"a": 1}));
583        assert!(r.is_null());
584    }
585
586    #[test]
587    fn test_compare_numeric_greater() {
588        assert_eq!(compare_numeric(&json!(5), &json!(3), ">"), Some(true));
589    }
590
591    #[test]
592    fn test_compare_numeric_less() {
593        assert_eq!(compare_numeric(&json!(3), &json!(5), "<"), Some(true));
594    }
595
596    #[test]
597    fn test_compare_numeric_equality() {
598        assert_eq!(compare_numeric(&json!(5), &json!(5), ">="), Some(true));
599        assert_eq!(compare_numeric(&json!(5), &json!(5), "<="), Some(true));
600    }
601
602    #[test]
603    fn test_compare_numeric_mixed_types() {
604        assert_eq!(compare_numeric(&json!(5), &json!("5"), ">"), None);
605    }
606
607    #[test]
608    fn test_cached_regex_valid() {
609        assert!(cached_regex(r"\d+").is_ok());
610    }
611
612    #[test]
613    fn test_cached_regex_invalid() {
614        assert!(cached_regex(r"[").is_err());
615    }
616
617    #[test]
618    fn test_validate_type_cast() {
619        use serde_json::json;
620        assert_eq!(validate_type_cast(&json!(42), "number"), json!(42));
621        assert_eq!(validate_type_cast(&json!("hello"), "number"), Value::Null);
622        assert_eq!(
623            validate_type_cast(&json!("hello"), "string"),
624            json!("hello")
625        );
626        assert_eq!(validate_type_cast(&json!(42), "string"), Value::Null);
627        assert_eq!(validate_type_cast(&json!(true), "bool"), json!(true));
628        assert_eq!(validate_type_cast(&json!("hello"), "bool"), Value::Null);
629        assert_eq!(validate_type_cast(&json!(42u64), "uint"), json!(42u64));
630        assert_eq!(validate_type_cast(&json!(-1), "uint"), Value::Null);
631        assert_eq!(
632            validate_type_cast(&json!("uuid-str"), "uuid"),
633            json!("uuid-str")
634        );
635        assert_eq!(
636            validate_type_cast(&json!("email@x.com"), "email"),
637            json!("email@x.com")
638        );
639        assert_eq!(validate_type_cast(&json!("url"), "url"), json!("url"));
640        assert_eq!(
641            validate_type_cast(&json!("1.2.3.4"), "ip"),
642            json!("1.2.3.4")
643        );
644        assert_eq!(
645            validate_type_cast(&json!("2024-01-01"), "time"),
646            json!("2024-01-01")
647        );
648        assert_eq!(validate_type_cast(&json!(12345), "timestamp"), json!(12345));
649        assert_eq!(
650            validate_type_cast(&json!("100ms"), "duration"),
651            json!("100ms")
652        );
653        assert_eq!(
654            validate_type_cast(&json!({"k": "v"}), "json"),
655            json!({"k": "v"})
656        );
657        assert_eq!(validate_type_cast(&json!([1, 2]), "json"), json!([1, 2]));
658        assert_eq!(validate_type_cast(&json!("hello"), "json"), Value::Null);
659        assert_eq!(
660            validate_type_cast(&json!("yaml:val"), "yaml"),
661            json!("yaml:val")
662        );
663        assert_eq!(
664            validate_type_cast(&json!("any_val"), "unknown_type"),
665            json!("any_val")
666        );
667    }
668
669    #[test]
670    fn test_normalize_plugin_name_assert() {
671        assert_eq!(normalize_plugin_name("@uuid"), "uuid");
672        assert_eq!(normalize_plugin_name("uuid"), "uuid");
673        assert_eq!(normalize_plugin_name(" @uuid "), "uuid");
674    }
675
676    #[test]
677    fn test_is_truthy() {
678        assert!(!is_truthy(&Value::Null));
679        assert!(!is_truthy(&Value::Bool(false)));
680        assert!(is_truthy(&Value::Bool(true)));
681        assert!(is_truthy(&Value::Number(0.into())));
682        assert!(is_truthy(&Value::String("".into())));
683    }
684
685    #[test]
686    fn test_negate() {
687        let pass = AssertionResult::Pass;
688        assert!(matches!(negate(pass), AssertionResult::Fail { .. }));
689
690        let fail = AssertionResult::fail("msg");
691        assert!(matches!(negate(fail), AssertionResult::Pass));
692
693        let err = AssertionResult::Error("err".into());
694        assert!(matches!(negate(err), AssertionResult::Error(_)));
695    }
696
697    #[test]
698    fn test_fmt_result_short() {
699        assert_eq!(fmt_result_short(&AssertionResult::Pass), "pass");
700        assert_eq!(fmt_result_short(&AssertionResult::fail("msg")), "msg");
701        assert_eq!(
702            fmt_result_short(&AssertionResult::Error("err".into())),
703            "error: err"
704        );
705    }
706
707    #[test]
708    fn test_eval_atom_literal() {
709        let pm = crate::registry::NoopPluginRegistry;
710        let ctx = &json!({});
711        use apif_ast::assertion_ast::{Expr, Literal};
712        let result = eval_atom(
713            &pm,
714            &Expr::Literal(Literal::Number("42".into())),
715            ctx,
716            None,
717            None,
718            None,
719        );
720        assert_eq!(result, json!(42));
721    }
722
723    #[test]
724    fn test_eval_binary_value_num() {
725        use apif_ast::assertion_ast::BinaryOp;
726        assert_eq!(
727            eval_binary_value(json!(5), &BinaryOp::Gt, json!(3)),
728            json!(true)
729        );
730        assert_eq!(
731            eval_binary_value(json!(3), &BinaryOp::Gt, json!(5)),
732            json!(false)
733        );
734    }
735}