Skip to main content

apif_assert/
engine.rs

1// Assertion engine using embedded jaq and operators fallback
2
3use anyhow::Result;
4use serde_json::Value;
5use std::cell::RefCell;
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::sync::{LazyLock, Mutex};
9
10use crate::registry::AssertionTiming;
11
12use jaq_core::{
13    Bind, Compiler, Ctx, Cv, Error as JaqError, Vars, data, load, native::bome, unwrap_valr,
14};
15use jaq_json::{Map as JaqMap, Num as JaqNum, Rc as JaqRc, Val as JaqVal};
16
17use super::operators;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum AssertionResult {
21    Pass,
22    Fail {
23        message: String,
24        expected: Option<String>,
25        actual: Option<String>,
26    },
27    Error(String),
28}
29
30impl AssertionResult {
31    pub fn fail(message: impl Into<String>) -> Self {
32        Self::Fail {
33            message: message.into(),
34            expected: None,
35            actual: None,
36        }
37    }
38
39    pub fn fail_with_diff(
40        message: impl Into<String>,
41        expected: impl Into<String>,
42        actual: impl Into<String>,
43    ) -> Self {
44        Self::Fail {
45            message: message.into(),
46            expected: Some(expected.into()),
47            actual: Some(actual.into()),
48        }
49    }
50
51    pub fn negate(self) -> Self {
52        match self {
53            Self::Pass => Self::fail("Negated assertion passed (expected false)"),
54            Self::Fail { .. } => Self::Pass,
55            Self::Error(e) => Self::Error(e),
56        }
57    }
58}
59
60pub struct AssertionEngine {
61    plugin_registry: Arc<dyn crate::registry::PluginRegistry>,
62}
63
64type JaqFilter = jaq_core::Filter<data::JustLut<JaqVal>>;
65
66/// Thread-safe cache for compiled JQ filters.
67/// Uses `Mutex` instead of `thread_local!` + `RefCell` to be safe with
68/// tokio's work-stealing runtime where futures can migrate across threads.
69static JAQ_FILTER_CACHE: LazyLock<Mutex<HashMap<String, Arc<JaqFilter>>>> =
70    LazyLock::new(|| Mutex::new(HashMap::new()));
71
72/// Plugins that depend on external context (headers, trailers, timing, env) and
73/// therefore cannot be a pure function of a single JSON value. They stay
74/// AST-engine-only and are rejected with a clear message if used inside a jq
75/// expression (the jaq-fallback path).
76const JAQ_CONTEXT_ONLY_PLUGINS: &[&str] = &[
77    "header",
78    "has_header",
79    "trailer",
80    "has_trailer",
81    "elapsed_ms",
82    "total_elapsed_ms",
83    "env",
84    "scope.message_count",
85    "scope.index",
86    "scope_message_count",
87    "scope_index",
88];
89
90thread_local! {
91    /// Registry made available to the `__plugin` native jaq function for the
92    /// duration of a single `run_jaq` call. jaq native filters are bare `fn`
93    /// pointers and cannot capture state, so we hand the registry over via a
94    /// thread-local that is set (and restored) by [`PluginRegistryGuard`].
95    static JAQ_PLUGIN_REGISTRY: RefCell<Option<Arc<dyn crate::registry::PluginRegistry>>> =
96        const { RefCell::new(None) };
97}
98
99/// RAII guard that installs the plugin registry into the thread-local for the
100/// current jaq run and restores the previous value on drop (reentrancy-safe).
101struct PluginRegistryGuard(Option<Arc<dyn crate::registry::PluginRegistry>>);
102
103impl PluginRegistryGuard {
104    fn set(registry: Arc<dyn crate::registry::PluginRegistry>) -> Self {
105        let prev = JAQ_PLUGIN_REGISTRY.with(|cell| cell.borrow_mut().replace(registry));
106        Self(prev)
107    }
108}
109
110impl Drop for PluginRegistryGuard {
111    fn drop(&mut self) {
112        let prev = self.0.take();
113        JAQ_PLUGIN_REGISTRY.with(|cell| *cell.borrow_mut() = prev);
114    }
115}
116
117/// Look the plugin up in the thread-local registry, execute it against `args`,
118/// and map its result into a jaq value: a `PluginResult::Value(v)` becomes `v`,
119/// and a passing/failing assertion becomes `true`/`false`, so plugins compose
120/// with jq operators (`map`, `select`, `all`, arithmetic).
121fn dispatch_jaq_plugin(name: &str, args: &[Value]) -> std::result::Result<JaqVal, String> {
122    let registry = JAQ_PLUGIN_REGISTRY.with(|cell| cell.borrow().clone());
123    let registry =
124        registry.ok_or_else(|| format!("plugin '@{}' is not available in this context", name))?;
125    let plugin = registry
126        .get_plugin(name)
127        .ok_or_else(|| format!("unknown plugin '@{}' in jq expression", name))?;
128
129    let null = Value::Null;
130    let ctx = crate::registry::PluginContext::new(&null);
131    match plugin
132        .execute(args, &ctx)
133        .map_err(|e| format!("plugin '@{}' error: {}", name, e))?
134    {
135        crate::registry::PluginResult::Value(v) => Ok(json_to_jaq(&v)),
136        crate::registry::PluginResult::Assertion(AssertionResult::Pass) => Ok(JaqVal::Bool(true)),
137        crate::registry::PluginResult::Assertion(AssertionResult::Fail { .. }) => {
138            Ok(JaqVal::Bool(false))
139        }
140        crate::registry::PluginResult::Assertion(AssertionResult::Error(e)) => {
141            Err(format!("plugin '@{}' error: {}", name, e))
142        }
143    }
144}
145
146/// The `__plugin` native function registered into every compiled jaq filter.
147///
148/// Invoked as `__plugin("name"; [arg, ...])` — the form produced by
149/// [`rewrite_plugin_calls`] from `@name(arg, ...)`. It evaluates the name and
150/// argument filters against the current input, then dispatches to the plugin.
151///
152/// Written as a closure (not a named fn) so it coerces cleanly to jaq's
153/// higher-ranked `RunPtr<D>`, mirroring how `jaq_json` defines native filters.
154fn jaq_plugin_fun<D>() -> jaq_core::native::Fun<D>
155where
156    D: for<'a> jaq_core::DataT<V<'a> = JaqVal>,
157{
158    jaq_core::native::run((
159        "__plugin",
160        Box::new([Bind::Fun(()), Bind::Fun(())]),
161        |mut cv: Cv<D>| {
162            let input = cv.1.clone();
163            // Arguments are popped last-to-first: `__plugin(name; args)`.
164            let (args_id, args_ctx) = cv.0.pop_fun();
165            let (name_id, name_ctx) = cv.0.pop_fun();
166
167            let name = match name_id
168                .run((name_ctx, input.clone()))
169                .map(unwrap_valr)
170                .next()
171            {
172                Some(Ok(v)) => v,
173                Some(Err(e)) => return bome(Err(e)),
174                None => return bome(Err(JaqError::str("plugin call produced no name"))),
175            };
176            let name = match jaq_to_json(&name) {
177                Value::String(s) => s,
178                other => {
179                    return bome(Err(JaqError::str(format!(
180                        "plugin name must be a string, got {}",
181                        other
182                    ))));
183                }
184            };
185
186            let args_val = match args_id.run((args_ctx, input)).map(unwrap_valr).next() {
187                Some(Ok(v)) => v,
188                Some(Err(e)) => return bome(Err(e)),
189                None => {
190                    return bome(Err(JaqError::str(format!(
191                        "plugin '@{}' produced no arguments",
192                        name
193                    ))));
194                }
195            };
196            let args_json = match jaq_to_json(&args_val) {
197                Value::Array(items) => items,
198                other => vec![other],
199            };
200
201            match dispatch_jaq_plugin(&name, &args_json) {
202                Ok(v) => bome(Ok(v)),
203                Err(e) => bome(Err(JaqError::str(e))),
204            }
205        },
206    ))
207}
208
209/// Rewrite `@name(args)` plugin calls into `__plugin("name"; [args])` so jaq can
210/// dispatch them to registered plugins. Nested plugin calls and string literals
211/// are handled; jq format strings like `@base64` (not followed by `(`) are left
212/// untouched. Context-dependent plugins are rejected with a clear message.
213fn rewrite_plugin_calls(expr: &str) -> Result<String> {
214    let bytes = expr.as_bytes();
215    let mut out = String::with_capacity(expr.len() + 16);
216    let mut i = 0;
217    while i < bytes.len() {
218        let b = bytes[i];
219        match b {
220            b'"' | b'\'' => {
221                // Copy the whole string literal verbatim.
222                let start = i;
223                i += 1;
224                while i < bytes.len() {
225                    if bytes[i] == b'\\' {
226                        i += 2;
227                        continue;
228                    }
229                    let end = bytes[i] == b;
230                    i += 1;
231                    if end {
232                        break;
233                    }
234                }
235                out.push_str(&expr[start..i.min(bytes.len())]);
236            }
237            b'@' => {
238                let name_start = i + 1;
239                let mut j = name_start;
240                while j < bytes.len()
241                    && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] == b'.')
242                {
243                    j += 1;
244                }
245                if j > name_start && j < bytes.len() && bytes[j] == b'(' {
246                    let name = &expr[name_start..j];
247                    if JAQ_CONTEXT_ONLY_PLUGINS.contains(&name) {
248                        return Err(anyhow::anyhow!(
249                            "@{} is not available in jq expressions: it needs response \
250                             header/trailer/timing/env context; use it as a standalone assertion",
251                            name
252                        ));
253                    }
254                    let close = find_matching_paren(bytes, j).ok_or_else(|| {
255                        anyhow::anyhow!("unbalanced parentheses in plugin call @{}", name)
256                    })?;
257                    let inner = rewrite_plugin_calls(&expr[j + 1..close])?;
258                    out.push_str("__plugin(\"");
259                    out.push_str(name);
260                    out.push_str("\"; [");
261                    out.push_str(&inner);
262                    out.push_str("])");
263                    i = close + 1;
264                } else {
265                    out.push('@');
266                    i += 1;
267                }
268            }
269            _ => {
270                let len = utf8_char_len(b);
271                out.push_str(&expr[i..(i + len).min(bytes.len())]);
272                i += len;
273            }
274        }
275    }
276    Ok(out)
277}
278
279/// Length in bytes of a UTF-8 sequence starting with the leading byte `b`.
280fn utf8_char_len(b: u8) -> usize {
281    if b < 0x80 {
282        1
283    } else if b >> 5 == 0b110 {
284        2
285    } else if b >> 4 == 0b1110 {
286        3
287    } else if b >> 3 == 0b11110 {
288        4
289    } else {
290        1
291    }
292}
293
294/// Given the index of an opening `(`, return the index of its matching `)`,
295/// tracking nested brackets and skipping string literals.
296fn find_matching_paren(bytes: &[u8], open: usize) -> Option<usize> {
297    let mut depth = 0usize;
298    let mut i = open;
299    let mut in_string: Option<u8> = None;
300    while i < bytes.len() {
301        let b = bytes[i];
302        match in_string {
303            Some(q) => {
304                if b == b'\\' {
305                    i += 2;
306                    continue;
307                }
308                if b == q {
309                    in_string = None;
310                }
311            }
312            None => match b {
313                b'"' | b'\'' => in_string = Some(b),
314                b'(' | b'[' | b'{' => depth += 1,
315                b')' | b']' | b'}' => {
316                    depth -= 1;
317                    if depth == 0 {
318                        return Some(i);
319                    }
320                }
321                _ => {}
322            },
323        }
324        i += 1;
325    }
326    None
327}
328
329impl AssertionEngine {
330    /// Create a new assertion engine with default plugins
331    pub fn new() -> Self {
332        Self {
333            plugin_registry: Arc::new(crate::registry::NoopPluginRegistry),
334        }
335    }
336
337    /// Create a new assertion engine with a custom plugin registry
338    pub fn with_registry(registry: Arc<dyn crate::registry::PluginRegistry>) -> Self {
339        Self {
340            plugin_registry: registry,
341        }
342    }
343
344    /// Evaluate a single assertion
345    pub fn evaluate(
346        &self,
347        assertion: &str,
348        response: &Value,
349        headers: Option<&HashMap<String, String>>,
350        trailers: Option<&HashMap<String, String>>,
351    ) -> Result<AssertionResult> {
352        self.evaluate_with_timing(
353            assertion,
354            response,
355            headers,
356            trailers,
357            None,
358            &HashMap::new(),
359            None,
360        )
361    }
362
363    #[allow(clippy::too_many_arguments)]
364    pub fn evaluate_with_timing(
365        &self,
366        assertion: &str,
367        response: &Value,
368        headers: Option<&HashMap<String, String>>,
369        trailers: Option<&HashMap<String, String>>,
370        timing: Option<&AssertionTiming>,
371        variables: &HashMap<String, Value>,
372        protocol: Option<&str>,
373    ) -> Result<AssertionResult> {
374        let trimmed = assertion.trim();
375
376        let ctx = operators::EvalCtx::new(response, variables)
377            .with_headers(headers)
378            .with_trailers(trailers)
379            .with_timing(timing)
380            .with_protocol(protocol);
381
382        match operators::evaluate_assertion(&*self.plugin_registry, trimmed, &ctx) {
383            Ok(Some(result)) => Ok(result),
384            Ok(None) => {
385                // AST could not parse it — fall through to JQ.
386                // A lone `=` (not `==`/`!=`/`<=`/`>=`) reaching this point is almost
387                // always a typo for `==`; jq would silently treat it as assignment
388                // (truthy) and the assertion would false-pass. Reject it explicitly.
389                if let Some(pos) = find_lone_equals(trimmed) {
390                    return Ok(AssertionResult::fail(format!(
391                        "Assertion uses `=` at position {} — did you mean `==`? \
392                         (`=` is not a comparison operator): {}",
393                        pos, trimmed
394                    )));
395                }
396                self.evaluate_jaq(trimmed, response)
397            }
398            Err(e) => Err(e),
399        }
400    }
401
402    /// Execute a JQ query and return the result(s)
403    pub fn query(&self, expr: &str, input: &Value) -> Result<Vec<Value>> {
404        let values = self.run_jaq(expr, input)?;
405        Ok(values.iter().map(jaq_to_json).collect())
406    }
407
408    fn evaluate_jaq(&self, expr: &str, response: &Value) -> Result<AssertionResult> {
409        let out = match self.run_jaq(expr, response) {
410            Ok(out) => out,
411            Err(e) => return Ok(AssertionResult::Error(format!("JQ Parse Error: {}", e))),
412        };
413
414        // JQ truthiness: everything except `false` and `null` is truthy
415        // (so e.g. `.tags | length` returning 3 passes).
416        for val in &out {
417            if matches!(val, JaqVal::Bool(false) | JaqVal::Null) {
418                let rendered = serde_json::to_string(&jaq_to_json(val))
419                    .unwrap_or_else(|_| "<unprintable>".to_string());
420                return Ok(AssertionResult::fail(format!(
421                    "JQ assertion evaluated to falsy value {}: {}",
422                    rendered, expr
423                )));
424            }
425        }
426
427        if out.is_empty() {
428            Ok(AssertionResult::fail(format!(
429                "JQ assertion produced no output (falsey): {}",
430                expr
431            )))
432        } else {
433            Ok(AssertionResult::Pass)
434        }
435    }
436
437    fn run_jaq(&self, expr: &str, input: &Value) -> Result<Vec<JaqVal>> {
438        // Rewrite `@plugin(...)` calls so jaq can dispatch them to registered
439        // plugins; the cache is keyed on the rewritten form for consistency.
440        let rewritten = rewrite_plugin_calls(expr)?;
441        let filter = Self::get_or_compile_jaq_filter(&rewritten)?;
442
443        let input = json_to_jaq(input);
444
445        // Expose plugins to the `__plugin` native function for this run only.
446        let _registry_guard = PluginRegistryGuard::set(self.plugin_registry.clone());
447
448        let ctx = Ctx::<data::JustLut<JaqVal>>::new(&filter.lut, Vars::new([]));
449        let out = filter.id.run((ctx, input)).map(unwrap_valr);
450
451        let mut values = Vec::new();
452        for item in out {
453            match item {
454                Ok(v) => values.push(v),
455                Err(e) => return Err(anyhow::anyhow!("JQ Runtime Error: {}", e)),
456            }
457        }
458
459        Ok(values)
460    }
461
462    fn get_or_compile_jaq_filter(expr: &str) -> Result<Arc<JaqFilter>> {
463        use jaq_core::defs as core_defs;
464        use jaq_core::funs as core_funs;
465
466        if let Some(cached) = JAQ_FILTER_CACHE
467            .lock()
468            .unwrap_or_else(|e| e.into_inner())
469            .get(expr)
470            .cloned()
471        {
472            return Ok(cached);
473        }
474
475        let cleaned = strip_numeric_underscores(expr);
476
477        let arena = load::Arena::default();
478        let defs = core_defs().chain(jaq_std::defs()).chain(jaq_json::defs());
479        let funs = core_funs()
480            .chain(jaq_std::funs())
481            .chain(jaq_json::funs())
482            .chain(std::iter::once(jaq_plugin_fun()));
483        let loader = load::Loader::new(defs);
484        let program = load::File {
485            code: cleaned.as_str(),
486            path: (),
487        };
488
489        let modules = loader
490            .load(&arena, program)
491            .map_err(|errs| anyhow::anyhow!("Failed to parse JQ expression: {:?}", errs))?;
492
493        let filter = Compiler::default()
494            .with_funs(funs)
495            .compile(modules)
496            .map_err(|errs| anyhow::anyhow!("Failed to compile JQ expression: {:?}", errs))?;
497
498        let filter = Arc::new(filter);
499        JAQ_FILTER_CACHE
500            .lock()
501            .unwrap_or_else(|e| e.into_inner())
502            .insert(expr.to_string(), Arc::clone(&filter));
503
504        Ok(filter)
505    }
506
507    /// Evaluate a JQ expression against `input`, returning the first output value.
508    /// Uses `JAQ_FILTER_CACHE` to avoid recompilation on repeated calls.
509    pub(super) fn eval_jaq_one(expr: &str, input: &Value) -> anyhow::Result<Value> {
510        let filter = Self::get_or_compile_jaq_filter(expr)?;
511        let jaq_input = json_to_jaq(input);
512        let ctx = Ctx::<data::JustLut<JaqVal>>::new(&filter.lut, Vars::new([]));
513        let mut out = filter.id.run((ctx, jaq_input)).map(unwrap_valr);
514        if let Some(Ok(val)) = out.next() {
515            Ok(jaq_to_json(&val))
516        } else {
517            Err(anyhow::anyhow!("JQ produced no output for: {}", expr))
518        }
519    }
520
521    #[must_use]
522    pub fn has_failures(&self, results: &[AssertionResult]) -> bool {
523        results
524            .iter()
525            .any(|r| matches!(r, AssertionResult::Fail { .. } | AssertionResult::Error(_)))
526    }
527
528    pub fn get_failures<'a>(&self, results: &'a [AssertionResult]) -> Vec<&'a AssertionResult> {
529        results
530            .iter()
531            .filter(|r| matches!(r, AssertionResult::Fail { .. } | AssertionResult::Error(_)))
532            .collect()
533    }
534
535    pub fn evaluate_all(
536        &self,
537        assertions: &[String],
538        response: &serde_json::Value,
539        headers: Option<&HashMap<String, String>>,
540        trailers: Option<&HashMap<String, String>>,
541    ) -> Vec<AssertionResult> {
542        self.evaluate_all_with_timing(
543            assertions,
544            response,
545            headers,
546            trailers,
547            None,
548            &HashMap::new(),
549            None,
550        )
551    }
552
553    #[allow(clippy::too_many_arguments)]
554    pub fn evaluate_all_with_timing(
555        &self,
556        assertions: &[String],
557        response: &serde_json::Value,
558        headers: Option<&HashMap<String, String>>,
559        trailers: Option<&HashMap<String, String>>,
560        timing: Option<&AssertionTiming>,
561        variables: &HashMap<String, Value>,
562        protocol: Option<&str>,
563    ) -> Vec<AssertionResult> {
564        self.evaluate_all_with_records(
565            assertions, response, headers, trailers, timing, variables, protocol,
566        )
567        .into_iter()
568        .map(|(result, _elapsed_ms)| result)
569        .collect()
570    }
571
572    /// Same as [`Self::evaluate_all_with_timing`], but also returns the wall-clock
573    /// time each individual assertion took to evaluate — used to surface
574    /// per-assertion timing in reports/`explain` without re-running the batch.
575    #[allow(clippy::too_many_arguments)]
576    pub fn evaluate_all_with_records(
577        &self,
578        assertions: &[String],
579        response: &serde_json::Value,
580        headers: Option<&HashMap<String, String>>,
581        trailers: Option<&HashMap<String, String>>,
582        timing: Option<&AssertionTiming>,
583        variables: &HashMap<String, Value>,
584        protocol: Option<&str>,
585    ) -> Vec<(AssertionResult, u64)> {
586        assertions
587            .iter()
588            .map(|assertion| {
589                let start = std::time::Instant::now();
590                let result = self
591                    .evaluate_with_timing(
592                        assertion, response, headers, trailers, timing, variables, protocol,
593                    )
594                    .unwrap_or_else(|e| AssertionResult::Error(format!("Internal error: {}", e)));
595                tracing::trace!("assertion: {assertion} -> {result:?}");
596                (result, start.elapsed().as_millis() as u64)
597            })
598            .collect()
599    }
600}
601
602/// Merge digit-separators (`1_000_000`) outside string literals — jaq's own
603/// number lexer doesn't support them.
604fn strip_numeric_underscores(expr: &str) -> String {
605    let mut out = String::with_capacity(expr.len());
606    let mut chars = expr.chars().peekable();
607
608    while let Some(c) = chars.next() {
609        if c == '"' {
610            out.push(c);
611            while let Some(next) = chars.next() {
612                out.push(next);
613                if next == '\\' {
614                    if let Some(escaped) = chars.next() {
615                        out.push(escaped);
616                    }
617                } else if next == '"' {
618                    break;
619                }
620            }
621        } else {
622            let is_digit_separator = c == '_'
623                && out.chars().next_back().is_some_and(|p| p.is_ascii_digit())
624                && chars.peek().is_some_and(|n| n.is_ascii_digit());
625            if !is_digit_separator {
626                out.push(c);
627            }
628        }
629    }
630
631    out
632}
633
634fn json_to_jaq(value: &Value) -> JaqVal {
635    match value {
636        Value::Null => JaqVal::Null,
637        Value::Bool(v) => JaqVal::Bool(*v),
638        Value::Number(n) => {
639            if let Some(i) = n.as_i64() {
640                JaqVal::Num(JaqNum::from_integral(i))
641            } else if let Some(u) = n.as_u64() {
642                JaqVal::Num(JaqNum::from_integral(u))
643            } else if let Some(f) = n.as_f64() {
644                JaqVal::Num(JaqNum::Float(f))
645            } else {
646                JaqVal::Null
647            }
648        }
649        Value::String(s) => JaqVal::utf8_str(s.clone()),
650        Value::Array(items) => JaqVal::Arr(JaqRc::new(items.iter().map(json_to_jaq).collect())),
651        Value::Object(obj) => {
652            let map: JaqMap = obj
653                .iter()
654                .map(|(k, v)| (JaqVal::utf8_str(k.clone()), json_to_jaq(v)))
655                .collect();
656            JaqVal::Obj(JaqRc::new(map))
657        }
658    }
659}
660
661fn jaq_to_json(value: &JaqVal) -> Value {
662    match value {
663        JaqVal::Null => Value::Null,
664        JaqVal::Bool(v) => Value::Bool(*v),
665        JaqVal::Num(n) => match n {
666            JaqNum::Int(v) => Value::Number(serde_json::Number::from(*v)),
667            JaqNum::Float(v) => serde_json::Number::from_f64(*v)
668                .map(Value::Number)
669                .unwrap_or(Value::Null),
670            JaqNum::BigInt(bi) => {
671                // Try to fit in isize first (public API), then fall back to string parse
672                if let Some(i) = n.as_isize() {
673                    Value::Number(serde_json::Number::from(i))
674                } else {
675                    // BigInt too large for isize — avoid JSON parser on hot path
676                    let s = bi.to_string();
677                    if let Ok(i) = s.parse::<i64>() {
678                        Value::Number(serde_json::Number::from(i))
679                    } else if let Ok(u) = s.parse::<u64>() {
680                        Value::Number(serde_json::Number::from(u))
681                    } else {
682                        Value::Null
683                    }
684                }
685            }
686            JaqNum::Dec(s) => {
687                // Dec is a string like "3.14" — parse as f64 directly, no JSON parser
688                s.parse::<f64>()
689                    .ok()
690                    .and_then(serde_json::Number::from_f64)
691                    .map(Value::Number)
692                    .unwrap_or(Value::Null)
693            }
694        },
695        JaqVal::TStr(s) | JaqVal::BStr(s) => {
696            match std::str::from_utf8(s.as_ref()) {
697                Ok(v) => Value::String(v.to_string()),
698                Err(_) => Value::Null, // non-UTF8 bytes can't be represented in JSON
699            }
700        }
701        JaqVal::Arr(items) => Value::Array(items.iter().map(jaq_to_json).collect()),
702        JaqVal::Obj(obj) => {
703            let map: serde_json::Map<String, Value> = obj
704                .iter()
705                .filter_map(|(k, v)| {
706                    let key = match k {
707                        JaqVal::TStr(s) | JaqVal::BStr(s) => {
708                            std::str::from_utf8(s.as_ref()).ok().map(str::to_owned)
709                        }
710                        _ => None,
711                    }?;
712                    Some((key, jaq_to_json(v)))
713                })
714                .collect();
715            Value::Object(map)
716        }
717    }
718}
719
720impl Default for AssertionEngine {
721    fn default() -> Self {
722        Self::new()
723    }
724}
725
726/// Find a top-level lone `=` (not part of `==`, `!=`, `<=`, `>=`) outside of
727/// string literals. Returns the byte position of the offending `=`, if any.
728/// Used to catch `.x = 5` typos before they reach jq (where `=` is assignment).
729fn find_lone_equals(expr: &str) -> Option<usize> {
730    let bytes = expr.as_bytes();
731    let mut in_string: Option<u8> = None; // Some(quote_char) while inside a string
732    let mut i = 0;
733    while i < bytes.len() {
734        let b = bytes[i];
735        match in_string {
736            Some(q) => {
737                if b == b'\\' {
738                    i += 2; // skip escaped char
739                    continue;
740                }
741                if b == q {
742                    in_string = None;
743                }
744            }
745            None => match b {
746                b'"' | b'\'' => in_string = Some(b),
747                b'=' => {
748                    let prev = if i > 0 { bytes[i - 1] } else { 0 };
749                    let next = if i + 1 < bytes.len() { bytes[i + 1] } else { 0 };
750                    // Skip `==`, and the second `=` of `!=`/`<=`/`>=`/`==`.
751                    let is_double = next == b'=' || prev == b'=';
752                    let is_compound = matches!(prev, b'!' | b'<' | b'>');
753                    if !is_double && !is_compound {
754                        return Some(i);
755                    }
756                }
757                _ => {}
758            },
759        }
760        i += 1;
761    }
762    None
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use serde_json::json;
769
770    fn create_test_response() -> Value {
771        json!({
772            "id": 123,
773            "name": "test",
774            "email": "test@example.com",
775            "active": true,
776            "tags": ["a", "b", "c"],
777            "nested": {
778                "value": 42
779            }
780        })
781    }
782
783    #[test]
784    fn strip_numeric_underscores_merges_digit_separators_outside_strings() {
785        assert_eq!(
786            strip_numeric_underscores(".amount == 1_000_000"),
787            ".amount == 1000000"
788        );
789        assert_eq!(
790            strip_numeric_underscores(".price == 1_234.567_89"),
791            ".price == 1234.56789"
792        );
793        // Underscores in field names/identifiers are untouched (no digit on
794        // both sides).
795        assert_eq!(strip_numeric_underscores(".foo_bar == 1"), ".foo_bar == 1");
796        // Underscores inside string literals are untouched even between digits.
797        assert_eq!(
798            strip_numeric_underscores(r#".id == "a_1_2_3""#),
799            r#".id == "a_1_2_3""#
800        );
801    }
802
803    #[test]
804    fn assertion_with_numeric_digit_separators_matches_the_plain_number() {
805        let engine = AssertionEngine::new();
806        let response = json!({"amount": 1_000_000});
807        let result = engine
808            .evaluate(".amount == 1_000_000", &response, None, None)
809            .unwrap();
810        assert_eq!(result, AssertionResult::Pass);
811    }
812
813    #[test]
814    fn test_find_lone_equals_detects_typo() {
815        assert_eq!(find_lone_equals(".x = 5"), Some(3));
816        assert_eq!(find_lone_equals(".name = \"a\""), Some(6));
817    }
818
819    #[test]
820    fn test_find_lone_equals_ignores_comparisons() {
821        assert_eq!(find_lone_equals(".x == 5"), None);
822        assert_eq!(find_lone_equals(".x != 5"), None);
823        assert_eq!(find_lone_equals(".x <= 5"), None);
824        assert_eq!(find_lone_equals(".x >= 5"), None);
825    }
826
827    #[test]
828    fn test_find_lone_equals_ignores_string_contents() {
829        // `=` inside a string literal is not a typo'd operator
830        assert_eq!(find_lone_equals(".x == \"a=b\""), None);
831        assert_eq!(find_lone_equals(".x == \"a\\\"=b\""), None);
832    }
833
834    #[test]
835    fn test_lone_equals_assertion_fails_not_passes() {
836        let engine = AssertionEngine::new();
837        let response = create_test_response();
838        // `.id = 123` is a typo for `==`; must be a diagnosed failure, not a
839        // silent jq-assignment pass.
840        let result = engine.evaluate(".id = 123", &response, None, None).unwrap();
841        assert!(
842            matches!(result, AssertionResult::Fail { .. }),
843            "lone `=` must fail, got {:?}",
844            result
845        );
846    }
847
848    #[test]
849    fn test_assertion_result_fail() {
850        let result = AssertionResult::fail("test message");
851        if let AssertionResult::Fail { message, .. } = result {
852            assert_eq!(message, "test message");
853        } else {
854            panic!("Expected Fail result");
855        }
856    }
857
858    #[test]
859    fn test_assertion_result_fail_with_diff() {
860        let result = AssertionResult::fail_with_diff("mismatch", "expected", "actual");
861        if let AssertionResult::Fail {
862            message,
863            expected,
864            actual,
865        } = result
866        {
867            assert_eq!(message, "mismatch");
868            assert_eq!(expected, Some("expected".to_string()));
869            assert_eq!(actual, Some("actual".to_string()));
870        } else {
871            panic!("Expected Fail result");
872        }
873    }
874
875    #[test]
876    fn test_assertion_result_debug() {
877        let result = AssertionResult::Pass;
878        let debug_str = format!("{:?}", result);
879        assert!(debug_str.contains("Pass"));
880    }
881
882    #[test]
883    fn test_evaluate_equality_operator() {
884        let engine = AssertionEngine::new();
885        let response = create_test_response();
886
887        let result = engine
888            .evaluate(".id == 123", &response, None, None)
889            .unwrap();
890        if let AssertionResult::Pass = result {
891            // Pass
892        } else {
893            panic!("Expected Pass for equality check");
894        }
895    }
896
897    #[test]
898    fn test_evaluate_bracket_index_assertion() {
899        let engine = AssertionEngine::new();
900        let response = serde_json::json!({
901            "ipsToDecorations": {
902                "10.0.0.1": {
903                    "decoration": "web-frontend",
904                    "environment": "production"
905                }
906            }
907        });
908
909        // Correct value - should PASS
910        let result1 = engine
911            .evaluate(
912                ".ipsToDecorations[\"10.0.0.1\"].environment == \"production\"",
913                &response,
914                None,
915                None,
916            )
917            .unwrap();
918        assert!(
919            matches!(result1, AssertionResult::Pass),
920            "Expected Pass for correct value, got: {:?}",
921            result1
922        );
923
924        // Wrong value - should FAIL
925        let result2 = engine
926            .evaluate(
927                ".ipsToDecorations[\"10.0.0.1\"].environment == \"production1\"",
928                &response,
929                None,
930                None,
931            )
932            .unwrap();
933        assert!(
934            matches!(result2, AssertionResult::Fail { .. }),
935            "Expected Fail for wrong value, got: {:?}",
936            result2
937        );
938    }
939
940    #[test]
941    fn test_evaluate_equality_operator_fail() {
942        let engine = AssertionEngine::new();
943        let response = create_test_response();
944
945        let result = engine
946            .evaluate(".id == 456", &response, None, None)
947            .unwrap();
948        if let AssertionResult::Fail { .. } = result {
949            // Pass
950        } else {
951            panic!("Expected Fail for equality check");
952        }
953    }
954
955    #[test]
956    fn test_evaluate_inequality_operator() {
957        let engine = AssertionEngine::new();
958        let response = create_test_response();
959
960        let result = engine
961            .evaluate(".id != 456", &response, None, None)
962            .unwrap();
963        if let AssertionResult::Pass = result {
964            // Pass
965        } else {
966            panic!("Expected Pass for inequality check");
967        }
968    }
969
970    #[test]
971    fn test_evaluate_contains_operator() {
972        let engine = AssertionEngine::new();
973        let response = create_test_response();
974
975        let result = engine
976            .evaluate(".name contains \"test\"", &response, None, None)
977            .unwrap();
978        if let AssertionResult::Pass = result {
979            // Pass
980        } else {
981            panic!("Expected Pass for contains check");
982        }
983    }
984
985    #[test]
986    fn test_evaluate_contains_operator_array() {
987        let engine = AssertionEngine::new();
988        let response = create_test_response();
989
990        let result = engine
991            .evaluate(".tags contains \"a\"", &response, None, None)
992            .unwrap();
993        if let AssertionResult::Pass = result {
994            // Pass
995        } else {
996            panic!("Expected Pass for array contains check");
997        }
998    }
999
1000    #[test]
1001    fn test_evaluate_starts_with_operator() {
1002        let engine = AssertionEngine::new();
1003        let response = create_test_response();
1004
1005        let result = engine
1006            .evaluate(".name startsWith \"te\"", &response, None, None)
1007            .unwrap();
1008        if let AssertionResult::Pass = result {
1009            // Pass
1010        } else {
1011            panic!("Expected Pass for startsWith check");
1012        }
1013    }
1014
1015    #[test]
1016    fn test_evaluate_ends_with_operator() {
1017        let engine = AssertionEngine::new();
1018        let response = create_test_response();
1019
1020        let result = engine
1021            .evaluate(".name endsWith \"st\"", &response, None, None)
1022            .unwrap();
1023        if let AssertionResult::Pass = result {
1024            // Pass
1025        } else {
1026            panic!("Expected Pass for endsWith check");
1027        }
1028    }
1029
1030    #[test]
1031    fn test_evaluate_numeric_greater_than() {
1032        let engine = AssertionEngine::new();
1033        let response = create_test_response();
1034
1035        let result = engine.evaluate(".id > 100", &response, None, None).unwrap();
1036        if let AssertionResult::Pass = result {
1037            // Pass
1038        } else {
1039            panic!("Expected Pass for greater than check");
1040        }
1041    }
1042
1043    #[test]
1044    fn test_evaluate_numeric_less_than() {
1045        let engine = AssertionEngine::new();
1046        let response = create_test_response();
1047
1048        let result = engine.evaluate(".id < 200", &response, None, None).unwrap();
1049        if let AssertionResult::Pass = result {
1050            // Pass
1051        } else {
1052            panic!("Expected Pass for less than check");
1053        }
1054    }
1055
1056    #[test]
1057    fn test_evaluate_numeric_gte() {
1058        let engine = AssertionEngine::new();
1059        let response = create_test_response();
1060
1061        let result = engine
1062            .evaluate(".id >= 123", &response, None, None)
1063            .unwrap();
1064        if let AssertionResult::Pass = result {
1065            // Pass
1066        } else {
1067            panic!("Expected Pass for gte check");
1068        }
1069    }
1070
1071    #[test]
1072    fn test_evaluate_numeric_lte() {
1073        let engine = AssertionEngine::new();
1074        let response = create_test_response();
1075
1076        let result = engine
1077            .evaluate(".id <= 123", &response, None, None)
1078            .unwrap();
1079        if let AssertionResult::Pass = result {
1080            // Pass
1081        } else {
1082            panic!("Expected Pass for lte check");
1083        }
1084    }
1085
1086    #[test]
1087    fn test_evaluate_matches_regex() {
1088        let engine = AssertionEngine::new();
1089        let response = create_test_response();
1090
1091        let result = engine
1092            .evaluate(".name matches \"^te.*t$\"", &response, None, None)
1093            .unwrap();
1094        if let AssertionResult::Pass = result {
1095            // Pass
1096        } else {
1097            panic!("Expected Pass for regex match");
1098        }
1099    }
1100
1101    #[test]
1102    fn test_evaluate_matches_regex_fail() {
1103        let engine = AssertionEngine::new();
1104        let response = create_test_response();
1105
1106        let result = engine
1107            .evaluate(".name matches \"^xyz\"", &response, None, None)
1108            .unwrap();
1109        if let AssertionResult::Fail { .. } = result {
1110            // Pass
1111        } else {
1112            panic!("Expected Fail for regex match");
1113        }
1114    }
1115
1116    #[test]
1117    fn test_evaluate_nested_path() {
1118        let engine = AssertionEngine::new();
1119        let response = create_test_response();
1120
1121        let result = engine
1122            .evaluate(".nested.value == 42", &response, None, None)
1123            .unwrap();
1124        if let AssertionResult::Pass = result {
1125            // Pass
1126        } else {
1127            panic!("Expected Pass for nested path check");
1128        }
1129    }
1130
1131    #[test]
1132    fn test_evaluate_boolean_path() {
1133        let engine = AssertionEngine::new();
1134        let response = create_test_response();
1135
1136        let result = engine
1137            .evaluate(".active == true", &response, None, None)
1138            .unwrap();
1139        if let AssertionResult::Pass = result {
1140            // Pass
1141        } else {
1142            panic!("Expected Pass for boolean check");
1143        }
1144    }
1145
1146    #[test]
1147    fn test_evaluate_array_index() {
1148        let engine = AssertionEngine::new();
1149        let response = create_test_response();
1150
1151        let result = engine
1152            .evaluate(".tags[0] == \"a\"", &response, None, None)
1153            .unwrap();
1154        if let AssertionResult::Pass = result {
1155            // Pass
1156        } else {
1157            panic!("Expected Pass for array index check");
1158        }
1159    }
1160
1161    #[test]
1162    fn test_evaluate_unsupported_syntax() {
1163        let engine = AssertionEngine::new();
1164        let response = create_test_response();
1165
1166        // This should fall through to JQ evaluation
1167        let result = engine.evaluate("some_unknown_function()", &response, None, None);
1168        // Should not panic, should return Error or handle gracefully
1169        assert!(result.is_ok());
1170    }
1171
1172    #[test]
1173    fn test_evaluate_all() {
1174        let engine = AssertionEngine::new();
1175        let response = create_test_response();
1176
1177        let assertions = vec![".id == 123".to_string(), ".name == \"test\"".to_string()];
1178
1179        let results = engine.evaluate_all(&assertions, &response, None, None);
1180        assert_eq!(results.len(), 2);
1181        assert!(results.iter().all(|r| matches!(r, AssertionResult::Pass)));
1182    }
1183
1184    #[test]
1185    fn test_evaluate_all_with_failure() {
1186        let engine = AssertionEngine::new();
1187        let response = create_test_response();
1188
1189        let assertions = vec![".id == 123".to_string(), ".id == 999".to_string()];
1190
1191        let results = engine.evaluate_all(&assertions, &response, None, None);
1192        assert_eq!(results.len(), 2);
1193        assert!(matches!(&results[0], AssertionResult::Pass));
1194        assert!(matches!(&results[1], AssertionResult::Fail { .. }));
1195    }
1196
1197    #[test]
1198    fn test_evaluate_type_cast_number() {
1199        let engine = AssertionEngine::new();
1200        let response = json!({
1201            "price": 42
1202        });
1203
1204        let result = engine.evaluate(".price:number >= 0", &response, None, None);
1205        assert!(
1206            matches!(result, Ok(AssertionResult::Pass)),
1207            "Expected Pass, got: {:?}",
1208            result
1209        );
1210
1211        let result = engine.evaluate(".price:number < 0", &response, None, None);
1212        assert!(
1213            matches!(result, Ok(AssertionResult::Fail { .. })),
1214            "Expected Fail, got: {:?}",
1215            result
1216        );
1217    }
1218
1219    #[test]
1220    fn test_evaluate_type_cast_string() {
1221        let engine = AssertionEngine::new();
1222        let response = json!({
1223            "name": "hello world"
1224        });
1225
1226        let result = engine.evaluate(".name:string contains \"hello\"", &response, None, None);
1227        assert!(
1228            matches!(result, Ok(AssertionResult::Pass)),
1229            "Expected Pass, got: {:?}",
1230            result
1231        );
1232
1233        let result = engine.evaluate(".name:string startsWith \"he\"", &response, None, None);
1234        assert!(
1235            matches!(result, Ok(AssertionResult::Pass)),
1236            "Expected Pass, got: {:?}",
1237            result
1238        );
1239    }
1240
1241    #[test]
1242    fn test_evaluate_type_cast_is_noop() {
1243        let engine = AssertionEngine::new();
1244        let response = json!({
1245            "value": 123
1246        });
1247
1248        // Type cast should not affect evaluation result
1249        let without_cast = engine.evaluate(".value == 123", &response, None, None);
1250        let with_cast = engine.evaluate(".value:number == 123", &response, None, None);
1251        assert_eq!(
1252            matches!(without_cast, Ok(AssertionResult::Pass)),
1253            matches!(with_cast, Ok(AssertionResult::Pass)),
1254            "Type cast should not change evaluation result"
1255        );
1256    }
1257
1258    #[test]
1259    fn test_jq_fallback_truthy_non_bool_output() {
1260        // Regression: jq truthiness — any output except false/null passes,
1261        // so `.tags | length` returning 3 must be a Pass.
1262        let engine = AssertionEngine::new();
1263        let response = create_test_response();
1264
1265        let result = engine
1266            .evaluate(".tags | length", &response, None, None)
1267            .unwrap();
1268        assert!(
1269            matches!(result, AssertionResult::Pass),
1270            "Expected Pass, got: {:?}",
1271            result
1272        );
1273    }
1274
1275    #[test]
1276    fn test_jq_fallback_false_output_shows_value() {
1277        let engine = AssertionEngine::new();
1278        let response = create_test_response();
1279
1280        // `.tags | length > 10` is 3 > 10 == false — must fail and show the value
1281        let result = engine
1282            .evaluate(".tags | length > 10", &response, None, None)
1283            .unwrap();
1284        if let AssertionResult::Fail { message, .. } = result {
1285            assert!(message.contains("false"), "message: {}", message);
1286        } else {
1287            panic!("Expected Fail, got: {:?}", result);
1288        }
1289    }
1290
1291    #[test]
1292    fn test_jq_fallback_null_output_fails() {
1293        let engine = AssertionEngine::new();
1294        let response = create_test_response();
1295
1296        // Missing key piped through identity yields null — falsy in jq
1297        let result = engine
1298            .evaluate(".missing_key | .", &response, None, None)
1299            .unwrap();
1300        assert!(
1301            matches!(result, AssertionResult::Fail { .. }),
1302            "Expected Fail, got: {:?}",
1303            result
1304        );
1305    }
1306
1307    #[test]
1308    fn test_query_jq_simple() {
1309        let engine = AssertionEngine::new();
1310        let response = create_test_response();
1311
1312        let results = engine.query(".id", &response).unwrap();
1313        assert_eq!(results.len(), 1);
1314        assert_eq!(results[0], json!(123));
1315    }
1316
1317    #[test]
1318    fn test_query_jq_nested() {
1319        let engine = AssertionEngine::new();
1320        let response = create_test_response();
1321
1322        let results = engine.query(".nested.value", &response).unwrap();
1323        assert_eq!(results.len(), 1);
1324        assert_eq!(results[0], json!(42));
1325    }
1326
1327    #[test]
1328    fn test_query_jq_array() {
1329        let engine = AssertionEngine::new();
1330        let response = create_test_response();
1331
1332        let results = engine.query(".tags[]", &response).unwrap();
1333        assert_eq!(results.len(), 3);
1334        assert_eq!(results[0], json!("a"));
1335        assert_eq!(results[1], json!("b"));
1336        assert_eq!(results[2], json!("c"));
1337    }
1338
1339    #[test]
1340    fn test_query_jq_filter() {
1341        let engine = AssertionEngine::new();
1342        let response = json!([1, 2, 3, 4, 5]);
1343
1344        let results = engine.query(".[] | select(. > 3)", &response).unwrap();
1345        assert_eq!(results.len(), 2);
1346        assert_eq!(results[0], json!(4));
1347        assert_eq!(results[1], json!(5));
1348    }
1349
1350    #[test]
1351    fn test_query_jq_length() {
1352        let engine = AssertionEngine::new();
1353        let response = create_test_response();
1354
1355        let results = engine.query(".tags | length", &response).unwrap();
1356        assert_eq!(results.len(), 1);
1357        assert_eq!(results[0], json!(3));
1358    }
1359
1360    #[test]
1361    fn test_query_invalid_expression() {
1362        let engine = AssertionEngine::new();
1363        let response = create_test_response();
1364
1365        let results = engine.query("invalid[[[", &response);
1366        assert!(results.is_err());
1367    }
1368
1369    #[test]
1370    fn test_jaq_to_json_dec_number() {
1371        let dec = JaqVal::Num(JaqNum::Dec(JaqRc::new("2.5".to_string())));
1372        assert_eq!(jaq_to_json(&dec), json!(2.5));
1373    }
1374
1375    #[test]
1376    fn test_jaq_to_json_invalid_dec_number() {
1377        let dec = JaqVal::Num(JaqNum::Dec(JaqRc::new("not-a-number".to_string())));
1378        assert_eq!(jaq_to_json(&dec), Value::Null);
1379    }
1380
1381    #[test]
1382    fn test_json_to_jaq_null() {
1383        let result = json_to_jaq(&json!(null));
1384        assert!(matches!(result, JaqVal::Null));
1385    }
1386
1387    #[test]
1388    fn test_json_to_jaq_bool() {
1389        let result = json_to_jaq(&json!(true));
1390        assert!(matches!(result, JaqVal::Bool(true)));
1391    }
1392
1393    #[test]
1394    fn test_json_to_jaq_number_int() {
1395        let result = json_to_jaq(&json!(42));
1396        assert!(matches!(result, JaqVal::Num(JaqNum::Int(42))));
1397    }
1398
1399    #[test]
1400    fn test_json_to_jaq_number_float() {
1401        let result = json_to_jaq(&json!(4.14));
1402        assert!(matches!(result, JaqVal::Num(JaqNum::Float(f)) if (f - 4.14).abs() < 0.001));
1403    }
1404
1405    #[test]
1406    fn test_json_to_jaq_string() {
1407        let result = json_to_jaq(&json!("hello"));
1408        assert!(matches!(result, JaqVal::TStr(_)));
1409    }
1410
1411    #[test]
1412    fn test_json_to_jaq_array() {
1413        let result = json_to_jaq(&json!([1, 2, 3]));
1414        assert!(matches!(result, JaqVal::Arr(_)));
1415    }
1416
1417    #[test]
1418    fn test_json_to_jaq_object() {
1419        let result = json_to_jaq(&json!({"key": "value"}));
1420        assert!(matches!(result, JaqVal::Obj(_)));
1421    }
1422
1423    #[test]
1424    fn test_jaq_filter_cache_returns_same_arc() {
1425        let expr = ".__cache_test_sentinel__";
1426        let first = AssertionEngine::get_or_compile_jaq_filter(expr).unwrap();
1427        let second = AssertionEngine::get_or_compile_jaq_filter(expr).unwrap();
1428        assert!(Arc::ptr_eq(&first, &second));
1429    }
1430    #[test]
1431    fn test_assertion_result_negate() {
1432        let pass = AssertionResult::Pass;
1433        assert!(matches!(pass.negate(), AssertionResult::Fail { .. }));
1434
1435        let fail = AssertionResult::fail("msg");
1436        assert!(matches!(fail.negate(), AssertionResult::Pass));
1437
1438        let error = AssertionResult::Error("err".into());
1439        assert!(matches!(error.negate(), AssertionResult::Error(_)));
1440    }
1441
1442    #[test]
1443    fn test_assertion_engine_get_failures() {
1444        let engine = AssertionEngine::new();
1445        let response = create_test_response();
1446        let assertions = vec![".id == 123".to_string(), ".id == 999".to_string()];
1447        let results = engine.evaluate_all(&assertions, &response, None, None);
1448        let failures = engine.get_failures(&results);
1449        assert_eq!(failures.len(), 1);
1450    }
1451
1452    #[test]
1453    fn test_assertion_engine_has_failures() {
1454        let engine = AssertionEngine::new();
1455        let response = create_test_response();
1456        let result = engine.evaluate_all(&[".id == 999".to_string()], &response, None, None);
1457        assert!(engine.has_failures(&result));
1458    }
1459
1460    #[test]
1461    fn test_assertion_engine_no_failures() {
1462        let engine = AssertionEngine::new();
1463        let response = create_test_response();
1464        let result = engine.evaluate_all(&[".id == 123".to_string()], &response, None, None);
1465        assert!(!engine.has_failures(&result));
1466    }
1467
1468    #[test]
1469    fn test_assertion_engine_default() {
1470        let engine = AssertionEngine::default();
1471        let response = create_test_response();
1472        let result = engine
1473            .evaluate(".id == 123", &response, None, None)
1474            .unwrap();
1475        assert!(matches!(result, AssertionResult::Pass));
1476    }
1477
1478    #[test]
1479    fn test_assertion_result_fail_with_diff_fields() {
1480        let result = AssertionResult::fail_with_diff("mismatch", "{\"a\":1}", "{\"a\":2}");
1481        match result {
1482            AssertionResult::Fail {
1483                message,
1484                expected,
1485                actual,
1486            } => {
1487                assert_eq!(message, "mismatch");
1488                assert_eq!(expected.unwrap(), "{\"a\":1}");
1489                assert_eq!(actual.unwrap(), "{\"a\":2}");
1490            }
1491            _ => panic!("Expected Fail"),
1492        }
1493    }
1494
1495    #[test]
1496    fn test_evaluate_url_scheme_parse_only() {
1497        use apif_ast::assertion_ast::{AssertionExpr, assertion_to_string, parse_assertion};
1498        let expr = parse_assertion("@url.scheme(\"https://example.com\") == \"https\"");
1499        assert!(
1500            !matches!(&expr, AssertionExpr::Raw(_)),
1501            "Expression should be parsed, not Raw: {:?}",
1502            expr
1503        );
1504        let s = assertion_to_string(&expr);
1505        assert_eq!(
1506            s, "@url.scheme(\"https://example.com\") == \"https\"",
1507            "Roundtrip failed"
1508        );
1509    }
1510
1511    #[test]
1512    fn test_rewrite_plugin_calls_basic() {
1513        assert_eq!(
1514            rewrite_plugin_calls("@len(.items) == .n").unwrap(),
1515            "__plugin(\"len\"; [.items]) == .n"
1516        );
1517    }
1518
1519    #[test]
1520    fn test_rewrite_plugin_calls_multiple_args() {
1521        assert_eq!(
1522            rewrite_plugin_calls("@regex(.name, \"^A\")").unwrap(),
1523            "__plugin(\"regex\"; [.name, \"^A\"])"
1524        );
1525    }
1526
1527    #[test]
1528    fn test_rewrite_plugin_calls_nested() {
1529        assert_eq!(
1530            rewrite_plugin_calls(".x | map(@is_email(.)) | all").unwrap(),
1531            ".x | map(__plugin(\"is_email\"; [.])) | all"
1532        );
1533    }
1534
1535    #[test]
1536    fn test_rewrite_plugin_calls_leaves_format_strings() {
1537        // `@base64` is a jq format string (not followed by `(`) — must be untouched.
1538        assert_eq!(
1539            rewrite_plugin_calls(".x | @base64").unwrap(),
1540            ".x | @base64"
1541        );
1542    }
1543
1544    #[test]
1545    fn test_rewrite_plugin_calls_ignores_at_in_string() {
1546        // A `@name(` sequence inside a string literal is not a plugin call.
1547        assert_eq!(
1548            rewrite_plugin_calls(".x == \"@len(a)\"").unwrap(),
1549            ".x == \"@len(a)\""
1550        );
1551    }
1552
1553    #[test]
1554    fn test_rewrite_plugin_calls_rejects_context_plugin() {
1555        let err = rewrite_plugin_calls("@header(\"x\") | length").unwrap_err();
1556        assert!(
1557            err.to_string().contains("not available in jq expressions"),
1558            "unexpected error: {}",
1559            err
1560        );
1561    }
1562
1563    #[test]
1564    fn test_jaq_context_plugin_reports_clear_error() {
1565        // A context-dependent plugin used inside a jq pipe (so the AST engine can't
1566        // handle it and it falls to jaq) must yield a clear message, not a parse error.
1567        let engine = AssertionEngine::new();
1568        let response = json!({"x": 1});
1569        let result = engine
1570            .evaluate(".list | map(@header(\"y\")) | all", &response, None, None)
1571            .unwrap();
1572        let msg = match result {
1573            AssertionResult::Error(m) => m,
1574            AssertionResult::Fail { message, .. } => message,
1575            other => panic!("expected error/fail, got {:?}", other),
1576        };
1577        assert!(
1578            msg.contains("not available in jq expressions"),
1579            "unexpected message: {}",
1580            msg
1581        );
1582    }
1583}