Skip to main content

camel_language_api/
eval.rs

1//! Evaluation carriers: bind language expressions/predicates to trusted
2//! route-level metadata and transport evaluation failures as typed
3//! [`camel_api::CamelError::ExpressionFailed`] errors.
4//!
5//! Language crates produce [`Expression`]/[`Predicate`] implementations whose
6//! failures are [`LanguageError`]s. Route execution needs the opposite: a
7//! fallible closure returning `Result<_, CamelError>` enriched with route
8//! metadata (route id, step id, verb) and the trusted compile-time
9//! destination. [`to_expression_failed`] performs that mapping; the carrier
10//! structs package it behind the closure shapes
11//! `camel_api::ValueSource`/`PredicateSource` consume.
12
13use std::sync::Arc;
14
15use camel_api::{
16    BoxBoolFuture, BoxValueFuture, CamelError, ConversionDetail, ExpressionErrorClass, Value,
17};
18
19use crate::error::LanguageError;
20use crate::{Exchange, Expression, Predicate};
21
22/// Generic conversion-target placeholders a language crate may report when it
23/// did not know the compile-time destination at conversion time. When
24/// [`to_expression_failed`] sees one and [`EvalMeta::target`] is known, the
25/// placeholder is rewritten to the trusted destination so route-level
26/// diagnostics name the real target.
27///
28/// Entry-level placeholders (`"header entry"`, `"property entry"`) are
29/// deliberately excluded: they name a scope surface the evaluating step does
30/// not own, so rewriting them to the step's destination would misattribute an
31/// unrelated inbound refusal (for example reading an unrelated property rhai
32/// cannot represent) to the wrong slot.
33const GENERIC_TARGETS: [&str; 2] = ["value", "body"];
34
35/// Trusted route-level metadata describing WHERE and HOW an expression is
36/// evaluated.
37///
38/// All fields are compile-time configuration (operator-supplied route
39/// definitions, ADR-0032): none of them ever carry runtime exchange data.
40#[derive(Clone, Debug)]
41pub struct EvalMeta {
42    /// Language the expression is written in (e.g. `rhai`).
43    pub language: String,
44    /// Id of the route owning the evaluating step.
45    pub route_id: String,
46    /// Id of the evaluating step.
47    pub step_id: String,
48    /// DSL verb that evaluates the expression (e.g. `set_property`).
49    pub verb: String,
50    /// TRUSTED compile-time destination: a property key, a header key, or
51    /// `body`. Nested value keys inside a map are NOT part of the target —
52    /// the target names the exchange slot, never data inside it.
53    pub target: Option<String>,
54}
55
56/// Map a [`LanguageError`] onto [`CamelError::ExpressionFailed`] using the
57/// trusted route metadata.
58///
59/// The class and position come from the error itself (defaulting to class
60/// `Runtime`, position `None` when the variant carries none). `ConversionError`
61/// populates `conversion`; when its target is a generic placeholder (see
62/// `GENERIC_TARGETS`) and `meta.target` is known, the placeholder is
63/// rewritten to the trusted destination. Runtime-derived strings (script map
64/// keys, exchange header keys) are never written into the
65/// [`ConversionDetail`].
66///
67/// The `cause` carries a fixed redacted detail (via
68/// [`LanguageError::redacted_detail`]) when the error variant has a payload
69/// that is contractually free of exchange/provider data; free-form payloads
70/// keep `cause: None`.
71pub fn to_expression_failed(err: LanguageError, meta: &EvalMeta) -> CamelError {
72    let class = err.class().unwrap_or(ExpressionErrorClass::Runtime);
73    let position = err.position();
74    let conversion = match &err {
75        LanguageError::ConversionError {
76            source_type,
77            target,
78        } => {
79            let target = match meta.target.as_deref() {
80                Some(trusted) if GENERIC_TARGETS.contains(&target.as_str()) => trusted.to_string(),
81                _ => target.clone(),
82            };
83            Some(ConversionDetail {
84                source_type: source_type.clone(),
85                target,
86            })
87        }
88        _ => None,
89    };
90    CamelError::ExpressionFailed {
91        language: meta.language.clone(),
92        route_id: meta.route_id.clone(),
93        step_id: meta.step_id.clone(),
94        verb: meta.verb.clone(),
95        class,
96        position,
97        conversion,
98        cause: err.redacted_detail().map(|d| {
99            Box::new(CamelError::ProcessorErrorWithSource(
100                d,
101                Arc::new(ExpressionDetailCause),
102            ))
103        }),
104    }
105}
106
107/// Crate-private provenance marker for the cause that [`to_expression_failed`]
108/// attaches when it carries a contractually redacted detail.
109///
110/// `std::error::Error` requires `Debug`; the marker never carries payload.
111#[derive(Debug)]
112struct ExpressionDetailCause;
113
114impl std::fmt::Display for ExpressionDetailCause {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.write_str("expression evaluation diagnostic")
117    }
118}
119
120impl std::error::Error for ExpressionDetailCause {}
121
122/// Whether `err` is an [`CamelError::ExpressionFailed`] produced by
123/// [`to_expression_failed`] with a contractually redacted detail attached as
124/// its cause.
125///
126/// Detection is exact mapper provenance, never shape matching: the chained
127/// cause must be a [`CamelError::ProcessorErrorWithSource`] whose `source`
128/// carries the crate-private `ExpressionDetailCause` marker. A hand-built
129/// `ProcessorErrorWithSource` cannot carry that private type and is therefore
130/// rejected.
131pub fn is_expression_detail_cause(err: &CamelError) -> bool {
132    let CamelError::ExpressionFailed {
133        cause: Some(cause), ..
134    } = err
135    else {
136        return false;
137    };
138    match &**cause {
139        CamelError::ProcessorErrorWithSource(_, src) => src
140            .as_ref()
141            .downcast_ref::<ExpressionDetailCause>()
142            .is_some(),
143        _ => false,
144    }
145}
146
147/// An [`Expression`] bound to trusted route metadata, evaluating to
148/// `Result<Value, CamelError>`.
149#[derive(Clone)]
150pub struct LanguageExpressionEval {
151    expr: Arc<dyn Expression>,
152    meta: EvalMeta,
153}
154
155impl LanguageExpressionEval {
156    /// Bind an expression to route metadata.
157    pub fn new(expr: Arc<dyn Expression>, meta: EvalMeta) -> Self {
158        Self { expr, meta }
159    }
160
161    /// The trusted route metadata this carrier was built with.
162    pub fn meta(&self) -> &EvalMeta {
163        &self.meta
164    }
165
166    /// Evaluate the bound expression, mapping failures to
167    /// [`CamelError::ExpressionFailed`] via [`to_expression_failed`].
168    pub async fn evaluate(&self, exchange: &Exchange) -> Result<Value, CamelError> {
169        self.expr
170            .evaluate(exchange)
171            .await
172            .map_err(|err| to_expression_failed(err, &self.meta))
173    }
174
175    /// Convert into a clone-based async closure matching the async arm of
176    /// `camel_api::ValueSource`. The closure clones the expression handle, the
177    /// metadata and the exchange per call (the boxed future is `'static`).
178    ///
179    /// The `Exchange` clone is the real per-call cost: a deep copy
180    /// proportional to payload size for `Json`/`Text`/`Xml` bodies and
181    /// header/property maps, while `Bytes` is refcount-cheap and `Stream`
182    /// shares its single-consumption handle, so read-only isolation for
183    /// streams rests on the stream-read guard, not the clone. If profiling
184    /// ever flags this, the upgrade path is a lifetime-carrying
185    /// `BoxValueFuture<'a>` or an owned-Exchange API.
186    pub fn into_value_fn(self) -> Arc<dyn Fn(&Exchange) -> BoxValueFuture + Send + Sync> {
187        let expr = self.expr;
188        let meta = self.meta;
189        Arc::new(move |exchange: &Exchange| {
190            let expr = Arc::clone(&expr);
191            let meta = meta.clone();
192            let exchange = exchange.clone();
193            Box::pin(async move {
194                expr.evaluate(&exchange)
195                    .await
196                    .map_err(|err| to_expression_failed(err, &meta))
197            }) as BoxValueFuture
198        })
199    }
200}
201
202/// A [`Predicate`] bound to trusted route metadata, evaluating to
203/// `Result<bool, CamelError>`.
204#[derive(Clone)]
205pub struct LanguagePredicateEval {
206    pred: Arc<dyn Predicate>,
207    meta: EvalMeta,
208}
209
210impl LanguagePredicateEval {
211    /// Bind a predicate to route metadata.
212    pub fn new(pred: Arc<dyn Predicate>, meta: EvalMeta) -> Self {
213        Self { pred, meta }
214    }
215
216    /// The trusted route metadata this carrier was built with.
217    pub fn meta(&self) -> &EvalMeta {
218        &self.meta
219    }
220
221    /// Evaluate the bound predicate, mapping failures to
222    /// [`CamelError::ExpressionFailed`] via [`to_expression_failed`].
223    pub async fn matches(&self, exchange: &Exchange) -> Result<bool, CamelError> {
224        self.pred
225            .matches(exchange)
226            .await
227            .map_err(|err| to_expression_failed(err, &self.meta))
228    }
229
230    /// Convert into a clone-based async closure matching the async arm of
231    /// `camel_api::PredicateSource`. The closure clones the predicate handle,
232    /// the metadata and the exchange per call (the boxed future is
233    /// `'static`).
234    ///
235    /// The `Exchange` clone is the real per-call cost: a deep copy
236    /// proportional to payload size for `Json`/`Text`/`Xml` bodies and
237    /// header/property maps, while `Bytes` is refcount-cheap and `Stream`
238    /// shares its single-consumption handle, so read-only isolation for
239    /// streams rests on the stream-read guard, not the clone. If profiling
240    /// ever flags this, the upgrade path is a lifetime-carrying
241    /// `BoxBoolFuture<'a>` or an owned-Exchange API.
242    pub fn into_bool_fn(self) -> Arc<dyn Fn(&Exchange) -> BoxBoolFuture + Send + Sync> {
243        let pred = self.pred;
244        let meta = self.meta;
245        Arc::new(move |exchange: &Exchange| {
246            let pred = Arc::clone(&pred);
247            let meta = meta.clone();
248            let exchange = exchange.clone();
249            Box::pin(async move {
250                pred.matches(&exchange)
251                    .await
252                    .map_err(|err| to_expression_failed(err, &meta))
253            }) as BoxBoolFuture
254        })
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::{Exchange, Message};
262    use camel_api::{ErrorPosition, ExpressionErrorClass};
263
264    fn test_meta(target: Option<&str>) -> EvalMeta {
265        EvalMeta {
266            language: "rhai".into(),
267            route_id: "r1".into(),
268            step_id: "set_property#0".into(),
269            verb: "set_property".into(),
270            target: target.map(str::to_string),
271        }
272    }
273
274    #[test]
275    fn to_expression_failed_maps_class_and_position() {
276        let err = LanguageError::EvalFailure {
277            class: ExpressionErrorClass::Arithmetic,
278            position: Some(ErrorPosition { line: 3, column: 8 }),
279            detail: None,
280        };
281        let out = to_expression_failed(err, &test_meta(None));
282        assert!(matches!(
283            out,
284            CamelError::ExpressionFailed {
285                class: ExpressionErrorClass::Arithmetic,
286                position: Some(p),
287                ..
288            } if p.line == 3 && p.column == 8
289        ));
290    }
291
292    #[test]
293    fn to_expression_failed_defaults_eval_error_to_runtime() {
294        let out = to_expression_failed(LanguageError::EvalError("x".into()), &test_meta(None));
295        assert!(matches!(
296            out,
297            CamelError::ExpressionFailed {
298                class: ExpressionErrorClass::Runtime,
299                position: None,
300                cause: None,
301                ..
302            }
303        ));
304    }
305
306    #[test]
307    fn to_expression_failed_carries_redacted_detail_as_cause() {
308        use std::error::Error;
309
310        let err = LanguageError::EvalFailure {
311            class: ExpressionErrorClass::Arithmetic,
312            position: None,
313            detail: Some("overflow".into()),
314        };
315        let out = to_expression_failed(err, &test_meta(None));
316        let CamelError::ExpressionFailed { cause, .. } = out else {
317            panic!("expected ExpressionFailed");
318        };
319        let cause = cause.expect("redacted detail must be attached as cause");
320        assert_eq!(cause.to_string(), "Processor error: overflow");
321        assert!(
322            cause.as_ref().source().is_some(),
323            "cause must expose the marker as its source"
324        );
325    }
326
327    #[test]
328    fn to_expression_failed_keeps_cause_none_for_free_form() {
329        let eval = to_expression_failed(LanguageError::EvalError("x".into()), &test_meta(None));
330        assert!(matches!(
331            eval,
332            CamelError::ExpressionFailed { cause: None, .. }
333        ));
334
335        let parse = to_expression_failed(
336            LanguageError::ParseError {
337                expr: "e".into(),
338                reason: "r".into(),
339            },
340            &test_meta(None),
341        );
342        assert!(matches!(
343            parse,
344            CamelError::ExpressionFailed { cause: None, .. }
345        ));
346    }
347
348    #[test]
349    fn redacted_detail_covers_typed_variants() {
350        let mismatch = LanguageError::TypeMismatch {
351            expected: "bool".into(),
352            actual: "string".into(),
353            position: None,
354        };
355        assert_eq!(
356            mismatch.redacted_detail(),
357            Some("expected bool, actual string".to_string())
358        );
359
360        let conversion = LanguageError::ConversionError {
361            source_type: "null".into(),
362            target: "f64".into(),
363        };
364        assert_eq!(
365            conversion.redacted_detail(),
366            Some("cannot convert null to f64".to_string())
367        );
368
369        assert_eq!(LanguageError::EvalError("x".into()).redacted_detail(), None);
370        assert_eq!(
371            LanguageError::ParseError {
372                expr: "e".into(),
373                reason: "r".into(),
374            }
375            .redacted_detail(),
376            None
377        );
378    }
379
380    #[test]
381    fn is_expression_detail_cause_is_exact() {
382        let mapped = to_expression_failed(
383            LanguageError::EvalFailure {
384                class: ExpressionErrorClass::Arithmetic,
385                position: None,
386                detail: Some("overflow".into()),
387            },
388            &test_meta(None),
389        );
390        assert!(is_expression_detail_cause(&mapped));
391
392        let hand_built = CamelError::ExpressionFailed {
393            language: "rhai".into(),
394            route_id: "r1".into(),
395            step_id: "set_property#0".into(),
396            verb: "set_property".into(),
397            class: ExpressionErrorClass::Runtime,
398            position: None,
399            conversion: None,
400            cause: Some(Box::new(CamelError::ProcessorError(
401                "caller diagnostic".into(),
402            ))),
403        };
404        assert!(!is_expression_detail_cause(&hand_built));
405
406        let no_cause = CamelError::ExpressionFailed {
407            language: "rhai".into(),
408            route_id: "r1".into(),
409            step_id: "set_property#0".into(),
410            verb: "set_property".into(),
411            class: ExpressionErrorClass::Runtime,
412            position: None,
413            conversion: None,
414            cause: None,
415        };
416        assert!(!is_expression_detail_cause(&no_cause));
417    }
418
419    #[test]
420    fn mapped_typed_variants_carry_cause() {
421        let mismatch = to_expression_failed(
422            LanguageError::TypeMismatch {
423                expected: "bool".into(),
424                actual: "string".into(),
425                position: None,
426            },
427            &test_meta(None),
428        );
429        assert!(is_expression_detail_cause(&mismatch));
430        let CamelError::ExpressionFailed { class, cause, .. } = mismatch else {
431            panic!("expected ExpressionFailed");
432        };
433        assert_eq!(class, ExpressionErrorClass::TypeMismatch);
434        assert_eq!(
435            cause.expect("typed mismatch cause").to_string(),
436            "Processor error: expected bool, actual string"
437        );
438
439        let conversion = to_expression_failed(
440            LanguageError::ConversionError {
441                source_type: "null".into(),
442                target: "f64".into(),
443            },
444            &test_meta(None),
445        );
446        assert!(is_expression_detail_cause(&conversion));
447        let CamelError::ExpressionFailed { class, cause, .. } = conversion else {
448            panic!("expected ExpressionFailed");
449        };
450        assert_eq!(class, ExpressionErrorClass::Conversion);
451        assert_eq!(
452            cause.expect("typed conversion cause").to_string(),
453            "Processor error: cannot convert null to f64"
454        );
455    }
456
457    #[test]
458    fn to_expression_failed_rewrites_generic_conversion_target() {
459        // Language crate did not know the destination: it reported the
460        // generic placeholder `value`. `meta.target` is the trusted
461        // compile-time destination and must win in route diagnostics.
462        let err = LanguageError::ConversionError {
463            source_type: "f64".into(),
464            target: "value".into(),
465        };
466        let out = to_expression_failed(err, &test_meta(Some("property m")));
467        if let CamelError::ExpressionFailed {
468            conversion: Some(detail),
469            ..
470        } = out
471        {
472            assert_eq!(detail.source_type, "f64");
473            assert_eq!(detail.target, "property m");
474        } else {
475            panic!("expected ExpressionFailed with conversion detail, got {out:?}");
476        }
477    }
478
479    #[test]
480    fn to_expression_failed_keeps_specific_conversion_target() {
481        // A specific incoming target is kept as-is even when meta.target
482        // is also known: the emitting crate knew the destination.
483        let err = LanguageError::ConversionError {
484            source_type: "int".into(),
485            target: "f64".into(),
486        };
487        let out = to_expression_failed(err, &test_meta(Some("property m")));
488        if let CamelError::ExpressionFailed {
489            conversion: Some(detail),
490            ..
491        } = out
492        {
493            assert_eq!(detail.target, "f64");
494        } else {
495            panic!("expected ExpressionFailed with conversion detail, got {out:?}");
496        }
497    }
498
499    /// Hand-rolled expression that always fails with a structured failure.
500    struct FailingExpression;
501
502    #[async_trait::async_trait]
503    impl crate::Expression for FailingExpression {
504        async fn evaluate(&self, _exchange: &Exchange) -> Result<crate::Value, LanguageError> {
505            Err(LanguageError::EvalFailure {
506                class: ExpressionErrorClass::Arithmetic,
507                position: Some(ErrorPosition { line: 1, column: 1 }),
508                detail: None,
509            })
510        }
511    }
512
513    #[tokio::test]
514    async fn language_expression_eval_wraps_error() {
515        let expr: Arc<dyn crate::Expression> = Arc::new(FailingExpression);
516        let eval = LanguageExpressionEval::new(expr, test_meta(None));
517        let exchange = Exchange::new(Message::default());
518        let err = eval.evaluate(&exchange).await.unwrap_err();
519        assert!(matches!(
520            err,
521            CamelError::ExpressionFailed { ref verb, .. } if verb == "set_property"
522        ));
523    }
524
525    #[tokio::test]
526    async fn value_fn_maps_errors_with_meta() {
527        let expr: Arc<dyn crate::Expression> = Arc::new(FailingExpression);
528        let value_fn = LanguageExpressionEval::new(expr, test_meta(Some("body"))).into_value_fn();
529        let exchange = Exchange::new(Message::default());
530        let err = value_fn(&exchange).await.unwrap_err();
531        assert!(matches!(
532            err,
533            CamelError::ExpressionFailed {
534                ref language,
535                ref route_id,
536                ref verb,
537                ..
538            } if language == "rhai" && route_id == "r1" && verb == "set_property"
539        ));
540    }
541
542    /// Hand-rolled predicate that always fails with a conversion error whose
543    /// target is the `header entry` placeholder.
544    struct FailingPredicate;
545
546    #[async_trait::async_trait]
547    impl crate::Predicate for FailingPredicate {
548        async fn matches(&self, _exchange: &Exchange) -> Result<bool, LanguageError> {
549            Err(LanguageError::ConversionError {
550                source_type: "null".into(),
551                target: "header entry".into(),
552            })
553        }
554    }
555
556    #[tokio::test]
557    async fn predicate_matches_and_bool_fn_map_errors() {
558        let pred: Arc<dyn crate::Predicate> = Arc::new(FailingPredicate);
559        let eval = LanguagePredicateEval::new(pred, test_meta(Some("header x")));
560        let exchange = Exchange::new(Message::default());
561
562        let err = eval.matches(&exchange).await.unwrap_err();
563        if let CamelError::ExpressionFailed {
564            class: ExpressionErrorClass::Conversion,
565            conversion: Some(detail),
566            ..
567        } = err.clone()
568        {
569            // Entry-level placeholders are NOT rewritten: this failure names
570            // the scope surface, not the step's destination.
571            assert_eq!(detail.target, "header entry");
572        } else {
573            panic!("expected ExpressionFailed conversion, got {err:?}");
574        }
575
576        let bool_fn = eval.into_bool_fn();
577        let err = bool_fn(&exchange).await.unwrap_err();
578        assert!(matches!(err, CamelError::ExpressionFailed { .. }));
579    }
580}