Skip to main content

camel_language_jsonpath/
lib.rs

1//! JSONPath language for rust-camel — evaluates `jsonpath_rust` queries against exchange bodies.
2//!
3//! Main types: `JsonPathLanguage`, `JsonPathConfig`, `JsonPathExpression`, `JsonPathPredicate`.
4//! Provides expressions and predicates for JSON-based routing and transformation.
5
6use async_trait::async_trait;
7use camel_language_api::{Body, Exchange, ExpressionErrorClass, Value};
8use camel_language_api::{Expression, Language, LanguageError, Predicate};
9use jsonpath_rust::parser::model::JpQuery;
10use jsonpath_rust::parser::parse_json_path;
11use jsonpath_rust::query::js_path_process;
12use serde_json::Value as JsonValue;
13#[cfg(test)]
14thread_local! {
15    static COMPILE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
16}
17
18/// Default maximum nesting depth for JSON values.
19const DEFAULT_MAX_DEPTH: usize = 64;
20
21/// Default maximum input size in bytes for text-to-JSON conversion (M-L2).
22/// Matches the spirit of the XPath language's input cap.
23const DEFAULT_MAX_INPUT_BYTES: usize = 16 * 1024 * 1024; // 16 MiB
24
25/// How a JSONPath predicate converts its query result to a boolean.
26///
27/// Selected through [`JsonPathConfig::predicate_mode`].
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub enum JsonPathPredicateMode {
30    /// Only a JSON boolean result is a valid predicate result. Any other
31    /// value kind is a `type-mismatch` error; no truthiness coercion is
32    /// applied (change `language-value-boundary`, sealed Q3).
33    #[default]
34    Strict,
35    /// Cardinality coercion over the raw match sequence: zero matches
36    /// (including a missing path) are `false`; exactly one boolean match is
37    /// that boolean; every other non-empty match sequence is `true`. No
38    /// value-based truthiness is imported (empty strings and `0` stay
39    /// `true` — this is not XPath `boolean()` value semantics).
40    Matches,
41}
42
43/// Configuration for [`JsonPathLanguage`] resource limits.
44#[derive(Debug, Clone)]
45pub struct JsonPathConfig {
46    /// Maximum allowed input size in bytes for text-to-JSON conversion.
47    /// When `None`, no size limit is enforced.
48    pub max_input_bytes: Option<usize>,
49    /// Maximum allowed nesting depth for JSON values.
50    /// Defaults to `DEFAULT_MAX_DEPTH` (64).
51    pub max_depth: Option<usize>,
52    /// Predicate conversion mode. Defaults to
53    /// [`JsonPathPredicateMode::Strict`].
54    pub predicate_mode: JsonPathPredicateMode,
55}
56
57impl JsonPathConfig {
58    /// Returns the effective max depth, applying the default when not explicitly set.
59    fn effective_max_depth(&self) -> usize {
60        self.max_depth.unwrap_or(DEFAULT_MAX_DEPTH)
61    }
62}
63
64impl Default for JsonPathConfig {
65    fn default() -> Self {
66        Self {
67            max_input_bytes: Some(DEFAULT_MAX_INPUT_BYTES),
68            max_depth: None,
69            predicate_mode: JsonPathPredicateMode::default(),
70        }
71    }
72}
73
74/// JSONPath language implementation for rust-camel.
75pub struct JsonPathLanguage {
76    config: JsonPathConfig,
77}
78
79impl JsonPathLanguage {
80    /// Create a new instance with default configuration.
81    pub fn new() -> Self {
82        Self {
83            config: JsonPathConfig::default(),
84        }
85    }
86
87    /// Create a new instance with the given configuration.
88    pub fn with_config(config: JsonPathConfig) -> Self {
89        Self { config }
90    }
91}
92
93impl Default for JsonPathLanguage {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99struct JsonPathExpression {
100    query: JpQuery,
101    config: JsonPathConfig,
102}
103
104struct JsonPathPredicate {
105    query: JpQuery,
106    config: JsonPathConfig,
107}
108
109/// Check that a [`JsonValue`] does not exceed the given nesting depth.
110fn check_depth(value: &JsonValue, max_depth: usize) -> Result<(), LanguageError> {
111    fn recurse(value: &JsonValue, max_depth: usize, current: usize) -> Result<(), LanguageError> {
112        if current > max_depth {
113            return Err(LanguageError::EvalError(format!(
114                "JSON nesting depth {current} exceeds limit of {max_depth}"
115            )));
116        }
117        match value {
118            JsonValue::Object(map) => {
119                for v in map.values() {
120                    recurse(v, max_depth, current + 1)?;
121                }
122                Ok(())
123            }
124            JsonValue::Array(arr) => {
125                for v in arr {
126                    recurse(v, max_depth, current + 1)?;
127                }
128                Ok(())
129            }
130            _ => Ok(()),
131        }
132    }
133    recurse(value, max_depth, 0)
134}
135
136/// Extract and validate JSON from an exchange body.
137///
138/// Applies resource limits from `config`:
139/// - `max_input_bytes` is checked against text body length before parsing.
140/// - `max_depth` is checked against the resulting JSON value (both pre-parsed and newly-parsed).
141fn extract_json(exchange: &Exchange, config: &JsonPathConfig) -> Result<JsonValue, LanguageError> {
142    let max_depth = config.effective_max_depth();
143
144    match &exchange.input.body {
145        Body::Json(v) => {
146            // Already-parsed JSON: check depth only (no input_bytes limit applies).
147            check_depth(v, max_depth)?;
148            Ok(v.clone())
149        }
150        Body::Text(s) => {
151            // Check input size before parsing.
152            if let Some(limit) = config.max_input_bytes
153                && s.len() > limit
154            {
155                return Err(LanguageError::EvalError(format!(
156                    "input size {} bytes exceeds limit of {limit} bytes",
157                    s.len()
158                )));
159            }
160            // Redacted: serde's Display can quote a body fragment. Class +
161            // static detail only (change `language-value-boundary`).
162            let value: JsonValue =
163                serde_json::from_str(s).map_err(|_| LanguageError::EvalFailure {
164                    class: ExpressionErrorClass::Conversion,
165                    position: None,
166                    detail: Some("body is not valid JSON".to_string()),
167                })?;
168            check_depth(&value, max_depth)?;
169            Ok(value)
170        }
171        other => other
172            .clone()
173            .try_into_json()
174            .map_err(|e| {
175                LanguageError::EvalError(format!("body is not JSON and cannot be coerced: {e}"))
176            })
177            .and_then(|b| match b {
178                Body::Json(v) => {
179                    check_depth(&v, max_depth)?;
180                    Ok(v)
181                }
182                _ => Err(LanguageError::EvalError(
183                    "body coercion did not produce JSON".into(),
184                )),
185            }),
186    }
187}
188
189/// Run a parsed JSONPath query and return every raw match in order.
190fn run_query_all(query: &JpQuery, json: &JsonValue) -> Result<Vec<JsonValue>, LanguageError> {
191    let results = js_path_process(query, json)
192        .map_err(|e| LanguageError::EvalError(format!("jsonpath query '{query}' failed: {e}")))?;
193    Ok(results.into_iter().map(|r| r.val.clone()).collect())
194}
195
196/// Collapse the raw match sequence into the value-source result shape:
197/// zero matches are `null`, a single match keeps its type, and multiple
198/// matches become an array.
199fn run_query(query: &JpQuery, json: &JsonValue) -> Result<JsonValue, LanguageError> {
200    let values = run_query_all(query, json)?;
201    Ok(match values.as_slice() {
202        [] => JsonValue::Null,
203        [one] => one.clone(),
204        _ => JsonValue::Array(values),
205    })
206}
207
208#[async_trait]
209impl Expression for JsonPathExpression {
210    // TODO(JPT-004): The return type is currently raw serde_json::Value. For better
211    // interoperability with Camel routing (e.g. header assignments, simple expressions),
212    // the result should be coerced: single scalars unwrapped to their native types
213    // (string, number, bool), arrays preserved, and Null mapped to a clear sentinel.
214    async fn evaluate(&self, exchange: &Exchange) -> Result<Value, LanguageError> {
215        let json = extract_json(exchange, &self.config)?;
216        run_query(&self.query, &json)
217    }
218}
219
220#[async_trait]
221impl Predicate for JsonPathPredicate {
222    async fn matches(&self, exchange: &Exchange) -> Result<bool, LanguageError> {
223        let json = extract_json(exchange, &self.config)?;
224        match self.config.predicate_mode {
225            JsonPathPredicateMode::Matches => {
226                // Cardinality coercion over the raw match sequence, before
227                // value-source normalization: zero matches (missing path) are
228                // false, a sole boolean keeps its value (a literal `false`
229                // stays false), and every other non-empty sequence is true.
230                let values = run_query_all(&self.query, &json)?;
231                Ok(match values.as_slice() {
232                    [] => false,
233                    [JsonValue::Bool(b)] => *b,
234                    _ => true,
235                })
236            }
237            JsonPathPredicateMode::Strict => {
238                let result = run_query(&self.query, &json)?;
239                // Strict bool: only a JSON boolean is a valid predicate
240                // result. No truthiness coercion — `"false"`, `0`, `[]`,
241                // `{}`, and `null` are all type errors (change
242                // `language-value-boundary`, sealed Q3).
243                match &result {
244                    JsonValue::Bool(b) => Ok(*b),
245                    other => Err(LanguageError::TypeMismatch {
246                        expected: "bool".to_string(),
247                        actual: json_type_name(other).to_string(),
248                        position: None,
249                    }),
250                }
251            }
252        }
253    }
254}
255
256/// Type name of a [`JsonValue`] for `TypeMismatch` diagnostics. Type names
257/// only — never runtime values.
258fn json_type_name(value: &JsonValue) -> &'static str {
259    match value {
260        JsonValue::Null => "null",
261        JsonValue::Bool(_) => "bool",
262        JsonValue::Number(_) => "number",
263        JsonValue::String(_) => "string",
264        JsonValue::Array(_) => "array",
265        JsonValue::Object(_) => "object",
266    }
267}
268
269impl Language for JsonPathLanguage {
270    fn name(&self) -> &'static str {
271        "jsonpath"
272    }
273
274    fn create_expression(&self, script: &str) -> Result<Box<dyn Expression>, LanguageError> {
275        if !script.starts_with('$') {
276            return Err(LanguageError::ParseError {
277                expr: script.to_string(),
278                reason: "JsonPath expression must start with '$'".into(),
279            });
280        }
281        let parsed = parse_json_path(script).map_err(|e| LanguageError::ParseError {
282            expr: script.to_string(),
283            reason: e.to_string(),
284        })?;
285        #[cfg(test)]
286        {
287            COMPILE_COUNT.with(|c| c.set(c.get() + 1));
288        }
289        Ok(Box::new(JsonPathExpression {
290            query: parsed,
291            config: self.config.clone(),
292        }))
293    }
294
295    fn create_predicate(&self, script: &str) -> Result<Box<dyn Predicate>, LanguageError> {
296        if !script.starts_with('$') {
297            return Err(LanguageError::ParseError {
298                expr: script.to_string(),
299                reason: "JsonPath expression must start with '$'".into(),
300            });
301        }
302        let parsed = parse_json_path(script).map_err(|e| LanguageError::ParseError {
303            expr: script.to_string(),
304            reason: e.to_string(),
305        })?;
306        #[cfg(test)]
307        {
308            COMPILE_COUNT.with(|c| c.set(c.get() + 1));
309        }
310        Ok(Box::new(JsonPathPredicate {
311            query: parsed,
312            config: self.config.clone(),
313        }))
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use camel_language_api::Message;
321
322    async fn exchange_with_json(json: &str) -> Exchange {
323        let value: JsonValue = serde_json::from_str(json).unwrap();
324        Exchange::new(Message::new(Body::Json(value)))
325    }
326
327    async fn exchange_with_text_body(text: &str) -> Exchange {
328        Exchange::new(Message::new(Body::Text(text.to_string())))
329    }
330
331    async fn empty_exchange() -> Exchange {
332        Exchange::new(Message::default())
333    }
334
335    async fn default_lang() -> JsonPathLanguage {
336        JsonPathLanguage::new()
337    }
338
339    #[tokio::test]
340    async fn expression_simple_path() {
341        let lang = default_lang().await;
342        let expr = lang.create_expression("$.store.name").unwrap();
343        let ex = exchange_with_json(r#"{"store":{"name":"books"}}"#).await;
344        let result = expr.evaluate(&ex).await.unwrap();
345        assert_eq!(result, JsonValue::String("books".to_string()));
346    }
347
348    #[tokio::test]
349    async fn expression_nested_path() {
350        let lang = default_lang().await;
351        let expr = lang.create_expression("$.a.b.c").unwrap();
352        let ex = exchange_with_json(r#"{"a":{"b":{"c":42}}}"#).await;
353        let result = expr.evaluate(&ex).await.unwrap();
354        assert_eq!(result, JsonValue::Number(42.into()));
355    }
356
357    #[tokio::test]
358    async fn expression_array_index() {
359        let lang = default_lang().await;
360        let expr = lang.create_expression("$.items[0]").unwrap();
361        let ex = exchange_with_json(r#"{"items":["a","b","c"]}"#).await;
362        let result = expr.evaluate(&ex).await.unwrap();
363        assert_eq!(result, JsonValue::String("a".to_string()));
364    }
365
366    #[tokio::test]
367    async fn expression_wildcard() {
368        let lang = default_lang().await;
369        let expr = lang.create_expression("$.items[*].name").unwrap();
370        let ex = exchange_with_json(r#"{"items":[{"name":"a"},{"name":"b"}]}"#).await;
371        let result = expr.evaluate(&ex).await.unwrap();
372        assert_eq!(
373            result,
374            JsonValue::Array(vec![
375                JsonValue::String("a".to_string()),
376                JsonValue::String("b".to_string())
377            ])
378        );
379    }
380
381    #[tokio::test]
382    async fn expression_root_path() {
383        let lang = default_lang().await;
384        let expr = lang.create_expression("$").unwrap();
385        let ex = exchange_with_json(r#"{"x":1}"#).await;
386        let result = expr.evaluate(&ex).await.unwrap();
387        assert_eq!(result["x"], JsonValue::Number(1.into()));
388    }
389
390    #[tokio::test]
391    async fn expression_text_body_with_valid_json() {
392        let lang = default_lang().await;
393        let expr = lang.create_expression("$.name").unwrap();
394        let ex = exchange_with_text_body(r#"{"name":"test"}"#).await;
395        let result = expr.evaluate(&ex).await.unwrap();
396        assert_eq!(result, JsonValue::String("test".to_string()));
397    }
398
399    #[tokio::test]
400    async fn expression_empty_body_is_error() {
401        let lang = default_lang().await;
402        let expr = lang.create_expression("$.x").unwrap();
403        let ex = empty_exchange().await;
404        let result = expr.evaluate(&ex).await;
405        assert!(result.is_err());
406    }
407
408    #[tokio::test]
409    async fn expression_invalid_jsonpath_syntax() {
410        let lang = default_lang().await;
411        let result = lang.create_expression("$[invalid");
412        let err = match result {
413            Err(e) => e,
414            Ok(_) => panic!("expected ParseError"),
415        };
416        match err {
417            LanguageError::ParseError { expr, reason } => {
418                assert!(!expr.is_empty());
419                assert!(!reason.is_empty());
420            }
421            other => panic!("expected ParseError, got {other:?}"),
422        }
423    }
424
425    // --- JPT-005: $ prefix validation ---
426
427    #[tokio::test]
428    async fn expression_without_dollar_prefix_is_rejected() {
429        let lang = default_lang().await;
430        let result = lang.create_expression("store.name");
431        assert!(result.is_err(), "expected error for missing $ prefix");
432        let err = match result {
433            Err(e) => e,
434            Ok(_) => panic!("expected ParseError"),
435        };
436        match err {
437            LanguageError::ParseError { expr, reason } => {
438                assert_eq!(expr, "store.name");
439                assert!(
440                    reason.contains("'$'"),
441                    "reason should mention '$', got: {reason}"
442                );
443            }
444            other => panic!("expected ParseError, got {other:?}"),
445        }
446    }
447
448    #[tokio::test]
449    async fn predicate_without_dollar_prefix_is_rejected() {
450        let lang = default_lang().await;
451        let result = lang.create_predicate("store.name");
452        assert!(result.is_err(), "expected error for missing $ prefix");
453        let err = match result {
454            Err(e) => e,
455            Ok(_) => panic!("expected ParseError"),
456        };
457        match err {
458            LanguageError::ParseError { reason, .. } => {
459                assert!(
460                    reason.contains("'$'"),
461                    "reason should mention '$', got: {reason}"
462                );
463            }
464            other => panic!("expected ParseError, got {other:?}"),
465        }
466    }
467
468    // --- JPT-006: Nested path and array index tests ---
469
470    #[tokio::test]
471    async fn expression_deeply_nested_path() {
472        let lang = default_lang().await;
473        let expr = lang.create_expression("$.a.b.c.d").unwrap();
474        let ex = exchange_with_json(r#"{"a":{"b":{"c":{"d":"deep"}}}}"#).await;
475        let result = expr.evaluate(&ex).await.unwrap();
476        assert_eq!(result, JsonValue::String("deep".to_string()));
477    }
478
479    #[tokio::test]
480    async fn expression_array_index_nested() {
481        let lang = default_lang().await;
482        let expr = lang.create_expression("$.data.items[1].name").unwrap();
483        let ex = exchange_with_json(
484            r#"{"data":{"items":[{"name":"first"},{"name":"second"},{"name":"third"}]}}"#,
485        )
486        .await;
487        let result = expr.evaluate(&ex).await.unwrap();
488        assert_eq!(result, JsonValue::String("second".to_string()));
489    }
490
491    #[tokio::test]
492    async fn jsonpath_predicate_non_bool_is_type_mismatch() {
493        // Sealed Q3: only JSON booleans are valid predicate results. Strings,
494        // numbers, arrays, objects, and null are all type errors — including
495        // the old `[]` false vs `{}` true asymmetry.
496        let lang = default_lang().await;
497        for (json_body, actual) in [
498            (r#"{"val":"false"}"#, "string"),
499            (r#"{"val":"x"}"#, "string"),
500            (r#"{"val":0}"#, "number"),
501            (r#"{"val":1}"#, "number"),
502            (r#"{"val":[]}"#, "array"),
503            (r#"{"val":{}}"#, "object"),
504            (r#"{"other":1}"#, "null"),
505        ] {
506            let pred = lang.create_predicate("$.val").unwrap();
507            let ex = exchange_with_json(json_body).await;
508            match pred.matches(&ex).await {
509                Err(LanguageError::TypeMismatch {
510                    expected,
511                    actual: got,
512                    position: None,
513                }) => {
514                    assert_eq!(expected, "bool");
515                    assert_eq!(got, actual, "body {json_body}");
516                }
517                other => panic!("expected TypeMismatch for {json_body}, got: {other:?}"),
518            }
519        }
520
521        // Real booleans still work.
522        let pred = lang.create_predicate("$.active").unwrap();
523        assert!(
524            pred.matches(&exchange_with_json(r#"{"active":true}"#).await)
525                .await
526                .unwrap()
527        );
528        let pred = lang.create_predicate("$.active").unwrap();
529        assert!(
530            !pred
531                .matches(&exchange_with_json(r#"{"active":false}"#).await)
532                .await
533                .unwrap()
534        );
535    }
536
537    #[tokio::test]
538    async fn jsonpath_invalid_body_error_is_conversion_class_no_snippet() {
539        let lang = default_lang().await;
540        let expr = lang.create_expression("$.key").unwrap();
541        let mut ex = Exchange::new(Message::default());
542        ex.input.body = Body::Text("SECRETVAL not json".to_string());
543        let err = expr.evaluate(&ex).await.expect_err("must fail");
544        match &err {
545            LanguageError::EvalFailure {
546                class: ExpressionErrorClass::Conversion,
547                position: None,
548                detail: Some(detail),
549            } => {
550                assert_eq!(detail, "body is not valid JSON");
551            }
552            other => panic!("expected Conversion EvalFailure, got: {other:?}"),
553        }
554        assert!(!err.to_string().contains("SECRETVAL"), "body leaked: {err}");
555    }
556
557    // --- Resource limit tests (A-26) ---
558
559    #[tokio::test]
560    async fn oversized_input_is_rejected() {
561        let config = JsonPathConfig {
562            max_input_bytes: Some(100),
563            ..Default::default()
564        };
565        let lang = JsonPathLanguage::with_config(config);
566        let expr = lang.create_expression("$.key").unwrap();
567        // Build a valid JSON string that exceeds 100 bytes
568        let big_value = "x".repeat(200);
569        let big_json = format!(r#"{{"key":"{}"}}"#, big_value);
570        assert!(big_json.len() > 100);
571        let ex = exchange_with_text_body(&big_json).await;
572        let result = expr.evaluate(&ex).await;
573        assert!(
574            result.is_err(),
575            "expected error for oversized input, got {result:?}"
576        );
577    }
578
579    #[tokio::test]
580    async fn input_under_limit_is_accepted() {
581        let config = JsonPathConfig {
582            max_input_bytes: Some(1024),
583            ..Default::default()
584        };
585        let lang = JsonPathLanguage::with_config(config);
586        let expr = lang.create_expression("$.key").unwrap();
587        let ex = exchange_with_text_body(r#"{"key":"value"}"#).await;
588        let result = expr.evaluate(&ex).await;
589        assert!(
590            result.is_ok(),
591            "expected success for input under limit, got {result:?}"
592        );
593    }
594
595    #[tokio::test]
596    async fn deeply_nested_input_is_rejected() {
597        let config = JsonPathConfig {
598            max_depth: Some(5),
599            ..Default::default()
600        };
601        let lang = JsonPathLanguage::with_config(config);
602        let expr = lang.create_expression("$.a").unwrap();
603        // Build nesting of depth 10: {"a":{"a":{"a":...}}}
604        let mut json = "1".to_string();
605        for _ in 0..10 {
606            json = format!(r#"{{"a":{json}}}"#);
607        }
608        let ex = exchange_with_text_body(&json).await;
609        let result = expr.evaluate(&ex).await;
610        assert!(
611            result.is_err(),
612            "expected error for deeply nested input, got {result:?}"
613        );
614    }
615
616    #[tokio::test]
617    async fn nesting_within_depth_limit_is_accepted() {
618        let config = JsonPathConfig {
619            max_depth: Some(10),
620            ..Default::default()
621        };
622        let lang = JsonPathLanguage::with_config(config);
623        let expr = lang.create_expression("$.a").unwrap();
624        // Build nesting of depth 5
625        let mut json = "1".to_string();
626        for _ in 0..5 {
627            json = format!(r#"{{"a":{json}}}"#);
628        }
629        let ex = exchange_with_text_body(&json).await;
630        let result = expr.evaluate(&ex).await;
631        assert!(
632            result.is_ok(),
633            "expected success for nesting within limit, got {result:?}"
634        );
635    }
636
637    #[tokio::test]
638    async fn default_config_has_safe_defaults() {
639        // M-L2: max_input_bytes defaults to 16 MiB (was None).
640        let config = JsonPathConfig::default();
641        assert_eq!(config.max_input_bytes, Some(16 * 1024 * 1024));
642        // max_depth still defaults to DEFAULT_MAX_DEPTH via effective_max_depth().
643        assert_eq!(config.max_depth, None);
644    }
645
646    #[tokio::test]
647    async fn default_config_rejects_oversized_text_body() {
648        // Default config must reject a >16 MiB text body without an explicit cap.
649        let lang = JsonPathLanguage::new(); // default config
650        let expr = lang.create_expression("$.key").unwrap();
651        let big_value = "x".repeat(16 * 1024 * 1024 + 10);
652        let big_json = format!(r#"{{"key":"{}"}}"#, big_value);
653        let ex = exchange_with_text_body(&big_json).await;
654        let result = expr.evaluate(&ex).await;
655        assert!(
656            result.is_err(),
657            "default config must reject oversized input, got {result:?}"
658        );
659    }
660
661    #[tokio::test]
662    async fn oversized_input_also_rejected_for_predicate() {
663        let config = JsonPathConfig {
664            max_input_bytes: Some(100),
665            ..Default::default()
666        };
667        let lang = JsonPathLanguage::with_config(config);
668        let pred = lang.create_predicate("$.key").unwrap();
669        let big_value = "x".repeat(200);
670        let big_json = format!(r#"{{"key":"{}"}}"#, big_value);
671        let ex = exchange_with_text_body(&big_json).await;
672        let result = pred.matches(&ex).await;
673        assert!(
674            result.is_err(),
675            "expected error for oversized input in predicate, got {result:?}"
676        );
677    }
678
679    #[tokio::test]
680    async fn deeply_nested_input_rejected_for_predicate() {
681        let config = JsonPathConfig {
682            max_depth: Some(3),
683            ..Default::default()
684        };
685        let lang = JsonPathLanguage::with_config(config);
686        let pred = lang.create_predicate("$.a").unwrap();
687        let mut json = "1".to_string();
688        for _ in 0..5 {
689            json = format!(r#"{{"a":{json}}}"#);
690        }
691        let ex = exchange_with_text_body(&json).await;
692        let result = pred.matches(&ex).await;
693        assert!(
694            result.is_err(),
695            "expected error for deeply nested input in predicate, got {result:?}"
696        );
697    }
698
699    #[tokio::test]
700    async fn body_json_no_input_size_check_but_depth_checked() {
701        // When body is already Body::Json (already parsed), skip input_bytes check
702        // but still enforce depth limit
703        let config = JsonPathConfig {
704            max_input_bytes: Some(10), // very small — but body is already JSON
705            max_depth: Some(3),
706            predicate_mode: JsonPathPredicateMode::Strict,
707        };
708        let lang = JsonPathLanguage::with_config(config);
709        let expr = lang.create_expression("$.a").unwrap();
710        // Build a JSON value with nesting depth 5
711        let mut json_str = "1".to_string();
712        for _ in 0..5 {
713            json_str = format!(r#"{{"a":{json_str}}}"#);
714        }
715        let ex = exchange_with_json(&json_str).await;
716        let result = expr.evaluate(&ex).await;
717        assert!(
718            result.is_err(),
719            "expected depth error for pre-parsed JSON, got {result:?}"
720        );
721    }
722
723    // --- FC-LANG-RECOMPILE: compile-once regression tests ---
724
725    /// Compile-time assertion that the compile-once implementation
726    /// produces `Send + Sync` types, matching the `Expression` /
727    /// `Predicate` trait contract.
728    #[test]
729    fn compile_once_types_are_send_sync() {
730        fn assert_send_sync<T: Send + Sync>() {}
731        assert_send_sync::<JsonPathExpression>();
732        assert_send_sync::<JsonPathPredicate>();
733        assert_send_sync::<JpQuery>();
734    }
735
736    /// Regression test for FC-LANG-RECOMPILE: `create_expression` and
737    /// `create_predicate` must parse the script exactly once at creation
738    /// time. Re-evaluating the resulting `Expression` / `Predicate`
739    /// against new exchanges must NOT re-parse the query. The
740    /// `COMPILE_COUNT` thread-local is incremented inside
741    /// `create_expression` / `create_predicate` under `#[cfg(test)]`, so
742    /// a re-introduction of per-evaluation compilation would show up as
743    /// additional increments on this thread.
744    #[tokio::test]
745    async fn compilation_happens_only_once_for_expression() {
746        let before = COMPILE_COUNT.with(std::cell::Cell::get);
747        let lang = JsonPathLanguage::new();
748        let expr = lang.create_expression("$.foo.bar").unwrap();
749        let after_create = COMPILE_COUNT.with(std::cell::Cell::get);
750        assert_eq!(
751            after_create,
752            before + 1,
753            "create_expression must compile exactly once"
754        );
755
756        // Evaluate many times — compilation count must stay flat.
757        for _ in 0..50 {
758            let ex = exchange_with_json(r#"{"foo":{"bar":"baz"}}"#).await;
759            let result = expr.evaluate(&ex).await.unwrap();
760            assert_eq!(result, JsonValue::String("baz".to_string()));
761        }
762        assert_eq!(
763            COMPILE_COUNT.with(std::cell::Cell::get),
764            after_create,
765            "evaluate must NOT trigger re-compilation"
766        );
767    }
768
769    #[tokio::test]
770    async fn compilation_happens_only_once_for_predicate() {
771        let before = COMPILE_COUNT.with(std::cell::Cell::get);
772        let lang = JsonPathLanguage::new();
773        let pred = lang.create_predicate("$.active").unwrap();
774        let after_create = COMPILE_COUNT.with(std::cell::Cell::get);
775        assert_eq!(
776            after_create,
777            before + 1,
778            "create_predicate must compile exactly once"
779        );
780
781        for _ in 0..50 {
782            let ex = exchange_with_json(r#"{"active":true}"#).await;
783            assert!(pred.matches(&ex).await.unwrap());
784        }
785        assert_eq!(
786            COMPILE_COUNT.with(std::cell::Cell::get),
787            after_create,
788            "matches must NOT trigger re-compilation"
789        );
790    }
791
792    fn matches_mode_lang() -> JsonPathLanguage {
793        JsonPathLanguage::with_config(JsonPathConfig {
794            predicate_mode: JsonPathPredicateMode::Matches,
795            ..JsonPathConfig::default()
796        })
797    }
798
799    #[tokio::test]
800    async fn matches_mode_cardinality_table() {
801        // Ruling (mission 399, oracle 2026-10-24): in `matches` mode the raw
802        // match sequence decides — zero matches are false, a sole boolean
803        // keeps its value, every other non-empty sequence is true. No value
804        // truthiness: empty strings and 0 are true; an existing null value
805        // is true.
806        let lang = matches_mode_lang();
807        let table = [
808            // (body, query, expected)
809            (
810                r#"{"items":[{"price":20},{"price":5}]}"#,
811                "$.items[?@.price>10]",
812                true,
813            ),
814            (r#"{"items":[{"price":20}]}"#, "$.items[?@.price>10]", true),
815            (r#"{"items":[{"price":5}]}"#, "$.items[?@.price>10]", false),
816            (r#"{"items":[1,2]}"#, "$.missing", false),
817            (r#"{"val":{}}"#, "$.val", true),
818            (r#"{"val":[]}"#, "$.val", true),
819            (r#"{"val":""}"#, "$.val", true),
820            (r#"{"val":"false"}"#, "$.val", true),
821            (r#"{"val":0}"#, "$.val", true),
822            (r#"{"val":null}"#, "$.val", true),
823            (r#"{"val":true}"#, "$.val", true),
824            (r#"{"val":false}"#, "$.val", false),
825        ];
826        for (body, query, expected) in table {
827            let pred = lang.create_predicate(query).unwrap();
828            let ex = exchange_with_json(body).await;
829            let got = pred.matches(&ex).await.unwrap_or_else(|e| {
830                panic!("matches mode must not error for {query} on {body}: {e:?}")
831            });
832            assert_eq!(got, expected, "query {query} on {body}");
833        }
834    }
835
836    #[tokio::test]
837    async fn strict_stays_default_and_repro_cases_remain_type_errors() {
838        // The owner-approved strict default is unchanged: the two mission
839        // repro cases stay type errors unless `Matches` is configured.
840        let lang = default_lang().await;
841        let ex = exchange_with_json(r#"{"items":[{"price":20},{"price":5}]}"#).await;
842        let pred = lang.create_predicate("$.items[?@.price>10]").unwrap();
843        match pred.matches(&ex).await {
844            Err(LanguageError::TypeMismatch {
845                actual,
846                position: None,
847                ..
848            }) => {
849                // One item (price 20) matches the filter; the single match
850                // collapses to that object.
851                assert_eq!(actual, "object");
852            }
853            other => panic!("strict default must stay a type error, got: {other:?}"),
854        }
855        let pred = lang.create_predicate("$.missing").unwrap();
856        match pred.matches(&ex).await {
857            Err(LanguageError::TypeMismatch {
858                actual,
859                position: None,
860                ..
861            }) => {
862                assert_eq!(actual, "null");
863            }
864            other => panic!("strict default must stay a type error, got: {other:?}"),
865        }
866    }
867
868    #[tokio::test]
869    async fn matches_mode_evaluation_failure_stays_typed_error() {
870        // Coercion applies to match results only: a body that cannot be
871        // evaluated still fails with a typed error and is NOT coerced to
872        // false.
873        let lang = matches_mode_lang();
874        let pred = lang.create_predicate("$.val").unwrap();
875        let ex = exchange_with_text_body("not json at all").await;
876        assert!(
877            pred.matches(&ex).await.is_err(),
878            "evaluation failure must not coerce to false"
879        );
880    }
881}