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        )
360    }
361
362    pub fn evaluate_with_timing(
363        &self,
364        assertion: &str,
365        response: &Value,
366        headers: Option<&HashMap<String, String>>,
367        trailers: Option<&HashMap<String, String>>,
368        timing: Option<&AssertionTiming>,
369        variables: &HashMap<String, Value>,
370    ) -> Result<AssertionResult> {
371        let trimmed = assertion.trim();
372
373        match operators::evaluate_assertion(
374            &*self.plugin_registry,
375            trimmed,
376            response,
377            headers,
378            trailers,
379            timing,
380            variables,
381        ) {
382            Ok(Some(result)) => Ok(result),
383            Ok(None) => {
384                // AST could not parse it — fall through to JQ.
385                // A lone `=` (not `==`/`!=`/`<=`/`>=`) reaching this point is almost
386                // always a typo for `==`; jq would silently treat it as assignment
387                // (truthy) and the assertion would false-pass. Reject it explicitly.
388                if let Some(pos) = find_lone_equals(trimmed) {
389                    return Ok(AssertionResult::fail(format!(
390                        "Assertion uses `=` at position {} — did you mean `==`? \
391                         (`=` is not a comparison operator): {}",
392                        pos, trimmed
393                    )));
394                }
395                self.evaluate_jaq(trimmed, response)
396            }
397            Err(e) => Err(e),
398        }
399    }
400
401    /// Execute a JQ query and return the result(s)
402    pub fn query(&self, expr: &str, input: &Value) -> Result<Vec<Value>> {
403        let values = self.run_jaq(expr, input)?;
404        Ok(values.iter().map(jaq_to_json).collect())
405    }
406
407    fn evaluate_jaq(&self, expr: &str, response: &Value) -> Result<AssertionResult> {
408        let out = match self.run_jaq(expr, response) {
409            Ok(out) => out,
410            Err(e) => return Ok(AssertionResult::Error(format!("JQ Parse Error: {}", e))),
411        };
412
413        // JQ truthiness: everything except `false` and `null` is truthy
414        // (so e.g. `.tags | length` returning 3 passes).
415        for val in &out {
416            if matches!(val, JaqVal::Bool(false) | JaqVal::Null) {
417                let rendered = serde_json::to_string(&jaq_to_json(val))
418                    .unwrap_or_else(|_| "<unprintable>".to_string());
419                return Ok(AssertionResult::fail(format!(
420                    "JQ assertion evaluated to falsy value {}: {}",
421                    rendered, expr
422                )));
423            }
424        }
425
426        if out.is_empty() {
427            Ok(AssertionResult::fail(format!(
428                "JQ assertion produced no output (falsey): {}",
429                expr
430            )))
431        } else {
432            Ok(AssertionResult::Pass)
433        }
434    }
435
436    fn run_jaq(&self, expr: &str, input: &Value) -> Result<Vec<JaqVal>> {
437        // Rewrite `@plugin(...)` calls so jaq can dispatch them to registered
438        // plugins; the cache is keyed on the rewritten form for consistency.
439        let rewritten = rewrite_plugin_calls(expr)?;
440        let filter = Self::get_or_compile_jaq_filter(&rewritten)?;
441
442        let input = json_to_jaq(input);
443
444        // Expose plugins to the `__plugin` native function for this run only.
445        let _registry_guard = PluginRegistryGuard::set(self.plugin_registry.clone());
446
447        let ctx = Ctx::<data::JustLut<JaqVal>>::new(&filter.lut, Vars::new([]));
448        let out = filter.id.run((ctx, input)).map(unwrap_valr);
449
450        let mut values = Vec::new();
451        for item in out {
452            match item {
453                Ok(v) => values.push(v),
454                Err(e) => return Err(anyhow::anyhow!("JQ Runtime Error: {}", e)),
455            }
456        }
457
458        Ok(values)
459    }
460
461    fn get_or_compile_jaq_filter(expr: &str) -> Result<Arc<JaqFilter>> {
462        use jaq_core::defs as core_defs;
463        use jaq_core::funs as core_funs;
464
465        if let Some(cached) = JAQ_FILTER_CACHE
466            .lock()
467            .unwrap_or_else(|e| e.into_inner())
468            .get(expr)
469            .cloned()
470        {
471            return Ok(cached);
472        }
473
474        let arena = load::Arena::default();
475        let defs = core_defs().chain(jaq_std::defs()).chain(jaq_json::defs());
476        let funs = core_funs()
477            .chain(jaq_std::funs())
478            .chain(jaq_json::funs())
479            .chain(std::iter::once(jaq_plugin_fun()));
480        let loader = load::Loader::new(defs);
481        let program = load::File {
482            code: expr,
483            path: (),
484        };
485
486        let modules = loader
487            .load(&arena, program)
488            .map_err(|errs| anyhow::anyhow!("Failed to parse JQ expression: {:?}", errs))?;
489
490        let filter = Compiler::default()
491            .with_funs(funs)
492            .compile(modules)
493            .map_err(|errs| anyhow::anyhow!("Failed to compile JQ expression: {:?}", errs))?;
494
495        let filter = Arc::new(filter);
496        JAQ_FILTER_CACHE
497            .lock()
498            .unwrap_or_else(|e| e.into_inner())
499            .insert(expr.to_string(), Arc::clone(&filter));
500
501        Ok(filter)
502    }
503
504    /// Evaluate a JQ expression against `input`, returning the first output value.
505    /// Uses `JAQ_FILTER_CACHE` to avoid recompilation on repeated calls.
506    pub(super) fn eval_jaq_one(expr: &str, input: &Value) -> anyhow::Result<Value> {
507        let filter = Self::get_or_compile_jaq_filter(expr)?;
508        let jaq_input = json_to_jaq(input);
509        let ctx = Ctx::<data::JustLut<JaqVal>>::new(&filter.lut, Vars::new([]));
510        let mut out = filter.id.run((ctx, jaq_input)).map(unwrap_valr);
511        if let Some(Ok(val)) = out.next() {
512            Ok(jaq_to_json(&val))
513        } else {
514            Err(anyhow::anyhow!("JQ produced no output for: {}", expr))
515        }
516    }
517
518    // Check if any assertion failed (re-exported wrapper)
519    #[must_use]
520    pub fn has_failures(&self, results: &[AssertionResult]) -> bool {
521        results
522            .iter()
523            .any(|r| matches!(r, AssertionResult::Fail { .. } | AssertionResult::Error(_)))
524    }
525
526    // Get failed assertions (re-exported wrapper)
527    pub fn get_failures<'a>(&self, results: &'a [AssertionResult]) -> Vec<&'a AssertionResult> {
528        results
529            .iter()
530            .filter(|r| matches!(r, AssertionResult::Fail { .. } | AssertionResult::Error(_)))
531            .collect()
532    }
533
534    // Evaluate multiple assertions (re-exported wrapper)
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        )
550    }
551
552    pub fn evaluate_all_with_timing(
553        &self,
554        assertions: &[String],
555        response: &serde_json::Value,
556        headers: Option<&HashMap<String, String>>,
557        trailers: Option<&HashMap<String, String>>,
558        timing: Option<&AssertionTiming>,
559        variables: &HashMap<String, Value>,
560    ) -> Vec<AssertionResult> {
561        assertions
562            .iter()
563            .map(|assertion| {
564                self.evaluate_with_timing(assertion, response, headers, trailers, timing, variables)
565                    .unwrap_or_else(|e| AssertionResult::Error(format!("Internal error: {}", e)))
566            })
567            .collect()
568    }
569}
570
571fn json_to_jaq(value: &Value) -> JaqVal {
572    match value {
573        Value::Null => JaqVal::Null,
574        Value::Bool(v) => JaqVal::Bool(*v),
575        Value::Number(n) => {
576            if let Some(i) = n.as_i64() {
577                JaqVal::Num(JaqNum::from_integral(i))
578            } else if let Some(u) = n.as_u64() {
579                JaqVal::Num(JaqNum::from_integral(u))
580            } else if let Some(f) = n.as_f64() {
581                JaqVal::Num(JaqNum::Float(f))
582            } else {
583                JaqVal::Null
584            }
585        }
586        Value::String(s) => JaqVal::utf8_str(s.clone()),
587        Value::Array(items) => JaqVal::Arr(JaqRc::new(items.iter().map(json_to_jaq).collect())),
588        Value::Object(obj) => {
589            let map: JaqMap = obj
590                .iter()
591                .map(|(k, v)| (JaqVal::utf8_str(k.clone()), json_to_jaq(v)))
592                .collect();
593            JaqVal::Obj(JaqRc::new(map))
594        }
595    }
596}
597
598fn jaq_to_json(value: &JaqVal) -> Value {
599    match value {
600        JaqVal::Null => Value::Null,
601        JaqVal::Bool(v) => Value::Bool(*v),
602        JaqVal::Num(n) => match n {
603            JaqNum::Int(v) => Value::Number(serde_json::Number::from(*v)),
604            JaqNum::Float(v) => serde_json::Number::from_f64(*v)
605                .map(Value::Number)
606                .unwrap_or(Value::Null),
607            JaqNum::BigInt(bi) => {
608                // Try to fit in isize first (public API), then fall back to string parse
609                if let Some(i) = n.as_isize() {
610                    Value::Number(serde_json::Number::from(i))
611                } else {
612                    // BigInt too large for isize — avoid JSON parser on hot path
613                    let s = bi.to_string();
614                    if let Ok(i) = s.parse::<i64>() {
615                        Value::Number(serde_json::Number::from(i))
616                    } else if let Ok(u) = s.parse::<u64>() {
617                        Value::Number(serde_json::Number::from(u))
618                    } else {
619                        Value::Null
620                    }
621                }
622            }
623            JaqNum::Dec(s) => {
624                // Dec is a string like "3.14" — parse as f64 directly, no JSON parser
625                s.parse::<f64>()
626                    .ok()
627                    .and_then(serde_json::Number::from_f64)
628                    .map(Value::Number)
629                    .unwrap_or(Value::Null)
630            }
631        },
632        JaqVal::TStr(s) | JaqVal::BStr(s) => {
633            match std::str::from_utf8(s.as_ref()) {
634                Ok(v) => Value::String(v.to_string()),
635                Err(_) => Value::Null, // non-UTF8 bytes can't be represented in JSON
636            }
637        }
638        JaqVal::Arr(items) => Value::Array(items.iter().map(jaq_to_json).collect()),
639        JaqVal::Obj(obj) => {
640            let map: serde_json::Map<String, Value> = obj
641                .iter()
642                .filter_map(|(k, v)| {
643                    let key = match k {
644                        JaqVal::TStr(s) | JaqVal::BStr(s) => {
645                            std::str::from_utf8(s.as_ref()).ok().map(str::to_owned)
646                        }
647                        _ => None,
648                    }?;
649                    Some((key, jaq_to_json(v)))
650                })
651                .collect();
652            Value::Object(map)
653        }
654    }
655}
656
657impl Default for AssertionEngine {
658    fn default() -> Self {
659        Self::new()
660    }
661}
662
663/// Find a top-level lone `=` (not part of `==`, `!=`, `<=`, `>=`) outside of
664/// string literals. Returns the byte position of the offending `=`, if any.
665/// Used to catch `.x = 5` typos before they reach jq (where `=` is assignment).
666fn find_lone_equals(expr: &str) -> Option<usize> {
667    let bytes = expr.as_bytes();
668    let mut in_string: Option<u8> = None; // Some(quote_char) while inside a string
669    let mut i = 0;
670    while i < bytes.len() {
671        let b = bytes[i];
672        match in_string {
673            Some(q) => {
674                if b == b'\\' {
675                    i += 2; // skip escaped char
676                    continue;
677                }
678                if b == q {
679                    in_string = None;
680                }
681            }
682            None => match b {
683                b'"' | b'\'' => in_string = Some(b),
684                b'=' => {
685                    let prev = if i > 0 { bytes[i - 1] } else { 0 };
686                    let next = if i + 1 < bytes.len() { bytes[i + 1] } else { 0 };
687                    // Skip `==`, and the second `=` of `!=`/`<=`/`>=`/`==`.
688                    let is_double = next == b'=' || prev == b'=';
689                    let is_compound = matches!(prev, b'!' | b'<' | b'>');
690                    if !is_double && !is_compound {
691                        return Some(i);
692                    }
693                }
694                _ => {}
695            },
696        }
697        i += 1;
698    }
699    None
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use serde_json::json;
706
707    fn create_test_response() -> Value {
708        json!({
709            "id": 123,
710            "name": "test",
711            "email": "test@example.com",
712            "active": true,
713            "tags": ["a", "b", "c"],
714            "nested": {
715                "value": 42
716            }
717        })
718    }
719
720    #[test]
721    fn test_find_lone_equals_detects_typo() {
722        assert_eq!(find_lone_equals(".x = 5"), Some(3));
723        assert_eq!(find_lone_equals(".name = \"a\""), Some(6));
724    }
725
726    #[test]
727    fn test_find_lone_equals_ignores_comparisons() {
728        assert_eq!(find_lone_equals(".x == 5"), None);
729        assert_eq!(find_lone_equals(".x != 5"), None);
730        assert_eq!(find_lone_equals(".x <= 5"), None);
731        assert_eq!(find_lone_equals(".x >= 5"), None);
732    }
733
734    #[test]
735    fn test_find_lone_equals_ignores_string_contents() {
736        // `=` inside a string literal is not a typo'd operator
737        assert_eq!(find_lone_equals(".x == \"a=b\""), None);
738        assert_eq!(find_lone_equals(".x == \"a\\\"=b\""), None);
739    }
740
741    #[test]
742    fn test_lone_equals_assertion_fails_not_passes() {
743        let engine = AssertionEngine::new();
744        let response = create_test_response();
745        // `.id = 123` is a typo for `==`; must be a diagnosed failure, not a
746        // silent jq-assignment pass.
747        let result = engine.evaluate(".id = 123", &response, None, None).unwrap();
748        assert!(
749            matches!(result, AssertionResult::Fail { .. }),
750            "lone `=` must fail, got {:?}",
751            result
752        );
753    }
754
755    #[test]
756    fn test_assertion_result_fail() {
757        let result = AssertionResult::fail("test message");
758        if let AssertionResult::Fail { message, .. } = result {
759            assert_eq!(message, "test message");
760        } else {
761            panic!("Expected Fail result");
762        }
763    }
764
765    #[test]
766    fn test_assertion_result_fail_with_diff() {
767        let result = AssertionResult::fail_with_diff("mismatch", "expected", "actual");
768        if let AssertionResult::Fail {
769            message,
770            expected,
771            actual,
772        } = result
773        {
774            assert_eq!(message, "mismatch");
775            assert_eq!(expected, Some("expected".to_string()));
776            assert_eq!(actual, Some("actual".to_string()));
777        } else {
778            panic!("Expected Fail result");
779        }
780    }
781
782    #[test]
783    fn test_assertion_result_debug() {
784        let result = AssertionResult::Pass;
785        let debug_str = format!("{:?}", result);
786        assert!(debug_str.contains("Pass"));
787    }
788
789    #[test]
790    fn test_evaluate_equality_operator() {
791        let engine = AssertionEngine::new();
792        let response = create_test_response();
793
794        let result = engine
795            .evaluate(".id == 123", &response, None, None)
796            .unwrap();
797        if let AssertionResult::Pass = result {
798            // Pass
799        } else {
800            panic!("Expected Pass for equality check");
801        }
802    }
803
804    #[test]
805    fn test_evaluate_bracket_index_assertion() {
806        let engine = AssertionEngine::new();
807        let response = serde_json::json!({
808            "ipsToDecorations": {
809                "10.0.0.1": {
810                    "decoration": "web-frontend",
811                    "environment": "production"
812                }
813            }
814        });
815
816        // Correct value - should PASS
817        let result1 = engine
818            .evaluate(
819                ".ipsToDecorations[\"10.0.0.1\"].environment == \"production\"",
820                &response,
821                None,
822                None,
823            )
824            .unwrap();
825        assert!(
826            matches!(result1, AssertionResult::Pass),
827            "Expected Pass for correct value, got: {:?}",
828            result1
829        );
830
831        // Wrong value - should FAIL
832        let result2 = engine
833            .evaluate(
834                ".ipsToDecorations[\"10.0.0.1\"].environment == \"production1\"",
835                &response,
836                None,
837                None,
838            )
839            .unwrap();
840        assert!(
841            matches!(result2, AssertionResult::Fail { .. }),
842            "Expected Fail for wrong value, got: {:?}",
843            result2
844        );
845    }
846
847    #[test]
848    fn test_evaluate_equality_operator_fail() {
849        let engine = AssertionEngine::new();
850        let response = create_test_response();
851
852        let result = engine
853            .evaluate(".id == 456", &response, None, None)
854            .unwrap();
855        if let AssertionResult::Fail { .. } = result {
856            // Pass
857        } else {
858            panic!("Expected Fail for equality check");
859        }
860    }
861
862    #[test]
863    fn test_evaluate_inequality_operator() {
864        let engine = AssertionEngine::new();
865        let response = create_test_response();
866
867        let result = engine
868            .evaluate(".id != 456", &response, None, None)
869            .unwrap();
870        if let AssertionResult::Pass = result {
871            // Pass
872        } else {
873            panic!("Expected Pass for inequality check");
874        }
875    }
876
877    #[test]
878    fn test_evaluate_contains_operator() {
879        let engine = AssertionEngine::new();
880        let response = create_test_response();
881
882        let result = engine
883            .evaluate(".name contains \"test\"", &response, None, None)
884            .unwrap();
885        if let AssertionResult::Pass = result {
886            // Pass
887        } else {
888            panic!("Expected Pass for contains check");
889        }
890    }
891
892    #[test]
893    fn test_evaluate_contains_operator_array() {
894        let engine = AssertionEngine::new();
895        let response = create_test_response();
896
897        let result = engine
898            .evaluate(".tags contains \"a\"", &response, None, None)
899            .unwrap();
900        if let AssertionResult::Pass = result {
901            // Pass
902        } else {
903            panic!("Expected Pass for array contains check");
904        }
905    }
906
907    #[test]
908    fn test_evaluate_starts_with_operator() {
909        let engine = AssertionEngine::new();
910        let response = create_test_response();
911
912        let result = engine
913            .evaluate(".name startsWith \"te\"", &response, None, None)
914            .unwrap();
915        if let AssertionResult::Pass = result {
916            // Pass
917        } else {
918            panic!("Expected Pass for startsWith check");
919        }
920    }
921
922    #[test]
923    fn test_evaluate_ends_with_operator() {
924        let engine = AssertionEngine::new();
925        let response = create_test_response();
926
927        let result = engine
928            .evaluate(".name endsWith \"st\"", &response, None, None)
929            .unwrap();
930        if let AssertionResult::Pass = result {
931            // Pass
932        } else {
933            panic!("Expected Pass for endsWith check");
934        }
935    }
936
937    #[test]
938    fn test_evaluate_numeric_greater_than() {
939        let engine = AssertionEngine::new();
940        let response = create_test_response();
941
942        let result = engine.evaluate(".id > 100", &response, None, None).unwrap();
943        if let AssertionResult::Pass = result {
944            // Pass
945        } else {
946            panic!("Expected Pass for greater than check");
947        }
948    }
949
950    #[test]
951    fn test_evaluate_numeric_less_than() {
952        let engine = AssertionEngine::new();
953        let response = create_test_response();
954
955        let result = engine.evaluate(".id < 200", &response, None, None).unwrap();
956        if let AssertionResult::Pass = result {
957            // Pass
958        } else {
959            panic!("Expected Pass for less than check");
960        }
961    }
962
963    #[test]
964    fn test_evaluate_numeric_gte() {
965        let engine = AssertionEngine::new();
966        let response = create_test_response();
967
968        let result = engine
969            .evaluate(".id >= 123", &response, None, None)
970            .unwrap();
971        if let AssertionResult::Pass = result {
972            // Pass
973        } else {
974            panic!("Expected Pass for gte check");
975        }
976    }
977
978    #[test]
979    fn test_evaluate_numeric_lte() {
980        let engine = AssertionEngine::new();
981        let response = create_test_response();
982
983        let result = engine
984            .evaluate(".id <= 123", &response, None, None)
985            .unwrap();
986        if let AssertionResult::Pass = result {
987            // Pass
988        } else {
989            panic!("Expected Pass for lte check");
990        }
991    }
992
993    #[test]
994    fn test_evaluate_matches_regex() {
995        let engine = AssertionEngine::new();
996        let response = create_test_response();
997
998        let result = engine
999            .evaluate(".name matches \"^te.*t$\"", &response, None, None)
1000            .unwrap();
1001        if let AssertionResult::Pass = result {
1002            // Pass
1003        } else {
1004            panic!("Expected Pass for regex match");
1005        }
1006    }
1007
1008    #[test]
1009    fn test_evaluate_matches_regex_fail() {
1010        let engine = AssertionEngine::new();
1011        let response = create_test_response();
1012
1013        let result = engine
1014            .evaluate(".name matches \"^xyz\"", &response, None, None)
1015            .unwrap();
1016        if let AssertionResult::Fail { .. } = result {
1017            // Pass
1018        } else {
1019            panic!("Expected Fail for regex match");
1020        }
1021    }
1022
1023    #[test]
1024    fn test_evaluate_nested_path() {
1025        let engine = AssertionEngine::new();
1026        let response = create_test_response();
1027
1028        let result = engine
1029            .evaluate(".nested.value == 42", &response, None, None)
1030            .unwrap();
1031        if let AssertionResult::Pass = result {
1032            // Pass
1033        } else {
1034            panic!("Expected Pass for nested path check");
1035        }
1036    }
1037
1038    #[test]
1039    fn test_evaluate_boolean_path() {
1040        let engine = AssertionEngine::new();
1041        let response = create_test_response();
1042
1043        let result = engine
1044            .evaluate(".active == true", &response, None, None)
1045            .unwrap();
1046        if let AssertionResult::Pass = result {
1047            // Pass
1048        } else {
1049            panic!("Expected Pass for boolean check");
1050        }
1051    }
1052
1053    #[test]
1054    fn test_evaluate_array_index() {
1055        let engine = AssertionEngine::new();
1056        let response = create_test_response();
1057
1058        let result = engine
1059            .evaluate(".tags[0] == \"a\"", &response, None, None)
1060            .unwrap();
1061        if let AssertionResult::Pass = result {
1062            // Pass
1063        } else {
1064            panic!("Expected Pass for array index check");
1065        }
1066    }
1067
1068    #[test]
1069    fn test_evaluate_unsupported_syntax() {
1070        let engine = AssertionEngine::new();
1071        let response = create_test_response();
1072
1073        // This should fall through to JQ evaluation
1074        let result = engine.evaluate("some_unknown_function()", &response, None, None);
1075        // Should not panic, should return Error or handle gracefully
1076        assert!(result.is_ok());
1077    }
1078
1079    #[test]
1080    fn test_evaluate_all() {
1081        let engine = AssertionEngine::new();
1082        let response = create_test_response();
1083
1084        let assertions = vec![".id == 123".to_string(), ".name == \"test\"".to_string()];
1085
1086        let results = engine.evaluate_all(&assertions, &response, None, None);
1087        assert_eq!(results.len(), 2);
1088        assert!(results.iter().all(|r| matches!(r, AssertionResult::Pass)));
1089    }
1090
1091    #[test]
1092    fn test_evaluate_all_with_failure() {
1093        let engine = AssertionEngine::new();
1094        let response = create_test_response();
1095
1096        let assertions = vec![".id == 123".to_string(), ".id == 999".to_string()];
1097
1098        let results = engine.evaluate_all(&assertions, &response, None, None);
1099        assert_eq!(results.len(), 2);
1100        assert!(matches!(&results[0], AssertionResult::Pass));
1101        assert!(matches!(&results[1], AssertionResult::Fail { .. }));
1102    }
1103
1104    #[test]
1105    fn test_evaluate_type_cast_number() {
1106        let engine = AssertionEngine::new();
1107        let response = json!({
1108            "price": 42
1109        });
1110
1111        let result = engine.evaluate(".price:number >= 0", &response, None, None);
1112        assert!(
1113            matches!(result, Ok(AssertionResult::Pass)),
1114            "Expected Pass, got: {:?}",
1115            result
1116        );
1117
1118        let result = engine.evaluate(".price:number < 0", &response, None, None);
1119        assert!(
1120            matches!(result, Ok(AssertionResult::Fail { .. })),
1121            "Expected Fail, got: {:?}",
1122            result
1123        );
1124    }
1125
1126    #[test]
1127    fn test_evaluate_type_cast_string() {
1128        let engine = AssertionEngine::new();
1129        let response = json!({
1130            "name": "hello world"
1131        });
1132
1133        let result = engine.evaluate(".name:string contains \"hello\"", &response, None, None);
1134        assert!(
1135            matches!(result, Ok(AssertionResult::Pass)),
1136            "Expected Pass, got: {:?}",
1137            result
1138        );
1139
1140        let result = engine.evaluate(".name:string startsWith \"he\"", &response, None, None);
1141        assert!(
1142            matches!(result, Ok(AssertionResult::Pass)),
1143            "Expected Pass, got: {:?}",
1144            result
1145        );
1146    }
1147
1148    #[test]
1149    fn test_evaluate_type_cast_is_noop() {
1150        let engine = AssertionEngine::new();
1151        let response = json!({
1152            "value": 123
1153        });
1154
1155        // Type cast should not affect evaluation result
1156        let without_cast = engine.evaluate(".value == 123", &response, None, None);
1157        let with_cast = engine.evaluate(".value:number == 123", &response, None, None);
1158        assert_eq!(
1159            matches!(without_cast, Ok(AssertionResult::Pass)),
1160            matches!(with_cast, Ok(AssertionResult::Pass)),
1161            "Type cast should not change evaluation result"
1162        );
1163    }
1164
1165    #[test]
1166    fn test_jq_fallback_truthy_non_bool_output() {
1167        // Regression: jq truthiness — any output except false/null passes,
1168        // so `.tags | length` returning 3 must be a Pass.
1169        let engine = AssertionEngine::new();
1170        let response = create_test_response();
1171
1172        let result = engine
1173            .evaluate(".tags | length", &response, None, None)
1174            .unwrap();
1175        assert!(
1176            matches!(result, AssertionResult::Pass),
1177            "Expected Pass, got: {:?}",
1178            result
1179        );
1180    }
1181
1182    #[test]
1183    fn test_jq_fallback_false_output_shows_value() {
1184        let engine = AssertionEngine::new();
1185        let response = create_test_response();
1186
1187        // `.tags | length > 10` is 3 > 10 == false — must fail and show the value
1188        let result = engine
1189            .evaluate(".tags | length > 10", &response, None, None)
1190            .unwrap();
1191        if let AssertionResult::Fail { message, .. } = result {
1192            assert!(message.contains("false"), "message: {}", message);
1193        } else {
1194            panic!("Expected Fail, got: {:?}", result);
1195        }
1196    }
1197
1198    #[test]
1199    fn test_jq_fallback_null_output_fails() {
1200        let engine = AssertionEngine::new();
1201        let response = create_test_response();
1202
1203        // Missing key piped through identity yields null — falsy in jq
1204        let result = engine
1205            .evaluate(".missing_key | .", &response, None, None)
1206            .unwrap();
1207        assert!(
1208            matches!(result, AssertionResult::Fail { .. }),
1209            "Expected Fail, got: {:?}",
1210            result
1211        );
1212    }
1213
1214    #[test]
1215    fn test_query_jq_simple() {
1216        let engine = AssertionEngine::new();
1217        let response = create_test_response();
1218
1219        let results = engine.query(".id", &response).unwrap();
1220        assert_eq!(results.len(), 1);
1221        assert_eq!(results[0], json!(123));
1222    }
1223
1224    #[test]
1225    fn test_query_jq_nested() {
1226        let engine = AssertionEngine::new();
1227        let response = create_test_response();
1228
1229        let results = engine.query(".nested.value", &response).unwrap();
1230        assert_eq!(results.len(), 1);
1231        assert_eq!(results[0], json!(42));
1232    }
1233
1234    #[test]
1235    fn test_query_jq_array() {
1236        let engine = AssertionEngine::new();
1237        let response = create_test_response();
1238
1239        let results = engine.query(".tags[]", &response).unwrap();
1240        assert_eq!(results.len(), 3);
1241        assert_eq!(results[0], json!("a"));
1242        assert_eq!(results[1], json!("b"));
1243        assert_eq!(results[2], json!("c"));
1244    }
1245
1246    #[test]
1247    fn test_query_jq_filter() {
1248        let engine = AssertionEngine::new();
1249        let response = json!([1, 2, 3, 4, 5]);
1250
1251        let results = engine.query(".[] | select(. > 3)", &response).unwrap();
1252        assert_eq!(results.len(), 2);
1253        assert_eq!(results[0], json!(4));
1254        assert_eq!(results[1], json!(5));
1255    }
1256
1257    #[test]
1258    fn test_query_jq_length() {
1259        let engine = AssertionEngine::new();
1260        let response = create_test_response();
1261
1262        let results = engine.query(".tags | length", &response).unwrap();
1263        assert_eq!(results.len(), 1);
1264        assert_eq!(results[0], json!(3));
1265    }
1266
1267    #[test]
1268    fn test_query_invalid_expression() {
1269        let engine = AssertionEngine::new();
1270        let response = create_test_response();
1271
1272        let results = engine.query("invalid[[[", &response);
1273        assert!(results.is_err());
1274    }
1275
1276    #[test]
1277    fn test_jaq_to_json_dec_number() {
1278        let dec = JaqVal::Num(JaqNum::Dec(JaqRc::new("2.5".to_string())));
1279        assert_eq!(jaq_to_json(&dec), json!(2.5));
1280    }
1281
1282    #[test]
1283    fn test_jaq_to_json_invalid_dec_number() {
1284        let dec = JaqVal::Num(JaqNum::Dec(JaqRc::new("not-a-number".to_string())));
1285        assert_eq!(jaq_to_json(&dec), Value::Null);
1286    }
1287
1288    #[test]
1289    fn test_json_to_jaq_null() {
1290        let result = json_to_jaq(&json!(null));
1291        assert!(matches!(result, JaqVal::Null));
1292    }
1293
1294    #[test]
1295    fn test_json_to_jaq_bool() {
1296        let result = json_to_jaq(&json!(true));
1297        assert!(matches!(result, JaqVal::Bool(true)));
1298    }
1299
1300    #[test]
1301    fn test_json_to_jaq_number_int() {
1302        let result = json_to_jaq(&json!(42));
1303        assert!(matches!(result, JaqVal::Num(JaqNum::Int(42))));
1304    }
1305
1306    #[test]
1307    fn test_json_to_jaq_number_float() {
1308        let result = json_to_jaq(&json!(4.14));
1309        assert!(matches!(result, JaqVal::Num(JaqNum::Float(f)) if (f - 4.14).abs() < 0.001));
1310    }
1311
1312    #[test]
1313    fn test_json_to_jaq_string() {
1314        let result = json_to_jaq(&json!("hello"));
1315        assert!(matches!(result, JaqVal::TStr(_)));
1316    }
1317
1318    #[test]
1319    fn test_json_to_jaq_array() {
1320        let result = json_to_jaq(&json!([1, 2, 3]));
1321        assert!(matches!(result, JaqVal::Arr(_)));
1322    }
1323
1324    #[test]
1325    fn test_json_to_jaq_object() {
1326        let result = json_to_jaq(&json!({"key": "value"}));
1327        assert!(matches!(result, JaqVal::Obj(_)));
1328    }
1329
1330    #[test]
1331    fn test_jaq_filter_cache_returns_same_arc() {
1332        let expr = ".__cache_test_sentinel__";
1333        let first = AssertionEngine::get_or_compile_jaq_filter(expr).unwrap();
1334        let second = AssertionEngine::get_or_compile_jaq_filter(expr).unwrap();
1335        assert!(Arc::ptr_eq(&first, &second));
1336    }
1337    #[test]
1338    fn test_assertion_result_negate() {
1339        let pass = AssertionResult::Pass;
1340        assert!(matches!(pass.negate(), AssertionResult::Fail { .. }));
1341
1342        let fail = AssertionResult::fail("msg");
1343        assert!(matches!(fail.negate(), AssertionResult::Pass));
1344
1345        let error = AssertionResult::Error("err".into());
1346        assert!(matches!(error.negate(), AssertionResult::Error(_)));
1347    }
1348
1349    #[test]
1350    fn test_assertion_engine_get_failures() {
1351        let engine = AssertionEngine::new();
1352        let response = create_test_response();
1353        let assertions = vec![".id == 123".to_string(), ".id == 999".to_string()];
1354        let results = engine.evaluate_all(&assertions, &response, None, None);
1355        let failures = engine.get_failures(&results);
1356        assert_eq!(failures.len(), 1);
1357    }
1358
1359    #[test]
1360    fn test_assertion_engine_has_failures() {
1361        let engine = AssertionEngine::new();
1362        let response = create_test_response();
1363        let result = engine.evaluate_all(&[".id == 999".to_string()], &response, None, None);
1364        assert!(engine.has_failures(&result));
1365    }
1366
1367    #[test]
1368    fn test_assertion_engine_no_failures() {
1369        let engine = AssertionEngine::new();
1370        let response = create_test_response();
1371        let result = engine.evaluate_all(&[".id == 123".to_string()], &response, None, None);
1372        assert!(!engine.has_failures(&result));
1373    }
1374
1375    #[test]
1376    fn test_assertion_engine_default() {
1377        let engine = AssertionEngine::default();
1378        let response = create_test_response();
1379        let result = engine
1380            .evaluate(".id == 123", &response, None, None)
1381            .unwrap();
1382        assert!(matches!(result, AssertionResult::Pass));
1383    }
1384
1385    #[test]
1386    fn test_assertion_result_fail_with_diff_fields() {
1387        let result = AssertionResult::fail_with_diff("mismatch", "{\"a\":1}", "{\"a\":2}");
1388        match result {
1389            AssertionResult::Fail {
1390                message,
1391                expected,
1392                actual,
1393            } => {
1394                assert_eq!(message, "mismatch");
1395                assert_eq!(expected.unwrap(), "{\"a\":1}");
1396                assert_eq!(actual.unwrap(), "{\"a\":2}");
1397            }
1398            _ => panic!("Expected Fail"),
1399        }
1400    }
1401
1402    #[test]
1403    fn test_evaluate_url_scheme_parse_only() {
1404        use apif_ast::assertion_ast::{AssertionExpr, assertion_to_string, parse_assertion};
1405        let expr = parse_assertion("@url.scheme(\"https://example.com\") == \"https\"");
1406        assert!(
1407            !matches!(&expr, AssertionExpr::Raw(_)),
1408            "Expression should be parsed, not Raw: {:?}",
1409            expr
1410        );
1411        let s = assertion_to_string(&expr);
1412        assert_eq!(
1413            s, "@url.scheme(\"https://example.com\") == \"https\"",
1414            "Roundtrip failed"
1415        );
1416    }
1417
1418    #[test]
1419    fn test_rewrite_plugin_calls_basic() {
1420        assert_eq!(
1421            rewrite_plugin_calls("@len(.items) == .n").unwrap(),
1422            "__plugin(\"len\"; [.items]) == .n"
1423        );
1424    }
1425
1426    #[test]
1427    fn test_rewrite_plugin_calls_multiple_args() {
1428        assert_eq!(
1429            rewrite_plugin_calls("@regex(.name, \"^A\")").unwrap(),
1430            "__plugin(\"regex\"; [.name, \"^A\"])"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_rewrite_plugin_calls_nested() {
1436        assert_eq!(
1437            rewrite_plugin_calls(".x | map(@is_email(.)) | all").unwrap(),
1438            ".x | map(__plugin(\"is_email\"; [.])) | all"
1439        );
1440    }
1441
1442    #[test]
1443    fn test_rewrite_plugin_calls_leaves_format_strings() {
1444        // `@base64` is a jq format string (not followed by `(`) — must be untouched.
1445        assert_eq!(
1446            rewrite_plugin_calls(".x | @base64").unwrap(),
1447            ".x | @base64"
1448        );
1449    }
1450
1451    #[test]
1452    fn test_rewrite_plugin_calls_ignores_at_in_string() {
1453        // A `@name(` sequence inside a string literal is not a plugin call.
1454        assert_eq!(
1455            rewrite_plugin_calls(".x == \"@len(a)\"").unwrap(),
1456            ".x == \"@len(a)\""
1457        );
1458    }
1459
1460    #[test]
1461    fn test_rewrite_plugin_calls_rejects_context_plugin() {
1462        let err = rewrite_plugin_calls("@header(\"x\") | length").unwrap_err();
1463        assert!(
1464            err.to_string().contains("not available in jq expressions"),
1465            "unexpected error: {}",
1466            err
1467        );
1468    }
1469
1470    #[test]
1471    fn test_jaq_context_plugin_reports_clear_error() {
1472        // A context-dependent plugin used inside a jq pipe (so the AST engine can't
1473        // handle it and it falls to jaq) must yield a clear message, not a parse error.
1474        let engine = AssertionEngine::new();
1475        let response = json!({"x": 1});
1476        let result = engine
1477            .evaluate(".list | map(@header(\"y\")) | all", &response, None, None)
1478            .unwrap();
1479        let msg = match result {
1480            AssertionResult::Error(m) => m,
1481            AssertionResult::Fail { message, .. } => message,
1482            other => panic!("expected error/fail, got {:?}", other),
1483        };
1484        assert!(
1485            msg.contains("not available in jq expressions"),
1486            "unexpected message: {}",
1487            msg
1488        );
1489    }
1490}