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`].
66pub fn to_expression_failed(err: LanguageError, meta: &EvalMeta) -> CamelError {
67    let class = err.class().unwrap_or(ExpressionErrorClass::Runtime);
68    let position = err.position();
69    let conversion = match &err {
70        LanguageError::ConversionError {
71            source_type,
72            target,
73        } => {
74            let target = match meta.target.as_deref() {
75                Some(trusted) if GENERIC_TARGETS.contains(&target.as_str()) => trusted.to_string(),
76                _ => target.clone(),
77            };
78            Some(ConversionDetail {
79                source_type: source_type.clone(),
80                target,
81            })
82        }
83        _ => None,
84    };
85    CamelError::ExpressionFailed {
86        language: meta.language.clone(),
87        route_id: meta.route_id.clone(),
88        step_id: meta.step_id.clone(),
89        verb: meta.verb.clone(),
90        class,
91        position,
92        conversion,
93        cause: None,
94    }
95}
96
97/// An [`Expression`] bound to trusted route metadata, evaluating to
98/// `Result<Value, CamelError>`.
99#[derive(Clone)]
100pub struct LanguageExpressionEval {
101    expr: Arc<dyn Expression>,
102    meta: EvalMeta,
103}
104
105impl LanguageExpressionEval {
106    /// Bind an expression to route metadata.
107    pub fn new(expr: Arc<dyn Expression>, meta: EvalMeta) -> Self {
108        Self { expr, meta }
109    }
110
111    /// The trusted route metadata this carrier was built with.
112    pub fn meta(&self) -> &EvalMeta {
113        &self.meta
114    }
115
116    /// Evaluate the bound expression, mapping failures to
117    /// [`CamelError::ExpressionFailed`] via [`to_expression_failed`].
118    pub async fn evaluate(&self, exchange: &Exchange) -> Result<Value, CamelError> {
119        self.expr
120            .evaluate(exchange)
121            .await
122            .map_err(|err| to_expression_failed(err, &self.meta))
123    }
124
125    /// Convert into a clone-based async closure matching the async arm of
126    /// `camel_api::ValueSource`. The closure clones the expression handle, the
127    /// metadata and the exchange per call (the boxed future is `'static`).
128    ///
129    /// The `Exchange` clone is the real per-call cost: a deep copy
130    /// proportional to payload size for `Json`/`Text`/`Xml` bodies and
131    /// header/property maps, while `Bytes` is refcount-cheap and `Stream`
132    /// shares its single-consumption handle, so read-only isolation for
133    /// streams rests on the stream-read guard, not the clone. If profiling
134    /// ever flags this, the upgrade path is a lifetime-carrying
135    /// `BoxValueFuture<'a>` or an owned-Exchange API.
136    pub fn into_value_fn(self) -> Arc<dyn Fn(&Exchange) -> BoxValueFuture + Send + Sync> {
137        let expr = self.expr;
138        let meta = self.meta;
139        Arc::new(move |exchange: &Exchange| {
140            let expr = Arc::clone(&expr);
141            let meta = meta.clone();
142            let exchange = exchange.clone();
143            Box::pin(async move {
144                expr.evaluate(&exchange)
145                    .await
146                    .map_err(|err| to_expression_failed(err, &meta))
147            }) as BoxValueFuture
148        })
149    }
150}
151
152/// A [`Predicate`] bound to trusted route metadata, evaluating to
153/// `Result<bool, CamelError>`.
154#[derive(Clone)]
155pub struct LanguagePredicateEval {
156    pred: Arc<dyn Predicate>,
157    meta: EvalMeta,
158}
159
160impl LanguagePredicateEval {
161    /// Bind a predicate to route metadata.
162    pub fn new(pred: Arc<dyn Predicate>, meta: EvalMeta) -> Self {
163        Self { pred, meta }
164    }
165
166    /// The trusted route metadata this carrier was built with.
167    pub fn meta(&self) -> &EvalMeta {
168        &self.meta
169    }
170
171    /// Evaluate the bound predicate, mapping failures to
172    /// [`CamelError::ExpressionFailed`] via [`to_expression_failed`].
173    pub async fn matches(&self, exchange: &Exchange) -> Result<bool, CamelError> {
174        self.pred
175            .matches(exchange)
176            .await
177            .map_err(|err| to_expression_failed(err, &self.meta))
178    }
179
180    /// Convert into a clone-based async closure matching the async arm of
181    /// `camel_api::PredicateSource`. The closure clones the predicate handle,
182    /// the metadata and the exchange per call (the boxed future is
183    /// `'static`).
184    ///
185    /// The `Exchange` clone is the real per-call cost: a deep copy
186    /// proportional to payload size for `Json`/`Text`/`Xml` bodies and
187    /// header/property maps, while `Bytes` is refcount-cheap and `Stream`
188    /// shares its single-consumption handle, so read-only isolation for
189    /// streams rests on the stream-read guard, not the clone. If profiling
190    /// ever flags this, the upgrade path is a lifetime-carrying
191    /// `BoxBoolFuture<'a>` or an owned-Exchange API.
192    pub fn into_bool_fn(self) -> Arc<dyn Fn(&Exchange) -> BoxBoolFuture + Send + Sync> {
193        let pred = self.pred;
194        let meta = self.meta;
195        Arc::new(move |exchange: &Exchange| {
196            let pred = Arc::clone(&pred);
197            let meta = meta.clone();
198            let exchange = exchange.clone();
199            Box::pin(async move {
200                pred.matches(&exchange)
201                    .await
202                    .map_err(|err| to_expression_failed(err, &meta))
203            }) as BoxBoolFuture
204        })
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::{Exchange, Message};
212    use camel_api::{ErrorPosition, ExpressionErrorClass};
213
214    fn test_meta(target: Option<&str>) -> EvalMeta {
215        EvalMeta {
216            language: "rhai".into(),
217            route_id: "r1".into(),
218            step_id: "set_property#0".into(),
219            verb: "set_property".into(),
220            target: target.map(str::to_string),
221        }
222    }
223
224    #[test]
225    fn to_expression_failed_maps_class_and_position() {
226        let err = LanguageError::EvalFailure {
227            class: ExpressionErrorClass::Arithmetic,
228            position: Some(ErrorPosition { line: 3, column: 8 }),
229            detail: None,
230        };
231        let out = to_expression_failed(err, &test_meta(None));
232        assert!(matches!(
233            out,
234            CamelError::ExpressionFailed {
235                class: ExpressionErrorClass::Arithmetic,
236                position: Some(p),
237                ..
238            } if p.line == 3 && p.column == 8
239        ));
240    }
241
242    #[test]
243    fn to_expression_failed_defaults_eval_error_to_runtime() {
244        let out = to_expression_failed(LanguageError::EvalError("x".into()), &test_meta(None));
245        assert!(matches!(
246            out,
247            CamelError::ExpressionFailed {
248                class: ExpressionErrorClass::Runtime,
249                position: None,
250                ..
251            }
252        ));
253    }
254
255    #[test]
256    fn to_expression_failed_rewrites_generic_conversion_target() {
257        // Language crate did not know the destination: it reported the
258        // generic placeholder `value`. `meta.target` is the trusted
259        // compile-time destination and must win in route diagnostics.
260        let err = LanguageError::ConversionError {
261            source_type: "f64".into(),
262            target: "value".into(),
263        };
264        let out = to_expression_failed(err, &test_meta(Some("property m")));
265        if let CamelError::ExpressionFailed {
266            conversion: Some(detail),
267            ..
268        } = out
269        {
270            assert_eq!(detail.source_type, "f64");
271            assert_eq!(detail.target, "property m");
272        } else {
273            panic!("expected ExpressionFailed with conversion detail, got {out:?}");
274        }
275    }
276
277    #[test]
278    fn to_expression_failed_keeps_specific_conversion_target() {
279        // A specific incoming target is kept as-is even when meta.target
280        // is also known: the emitting crate knew the destination.
281        let err = LanguageError::ConversionError {
282            source_type: "int".into(),
283            target: "f64".into(),
284        };
285        let out = to_expression_failed(err, &test_meta(Some("property m")));
286        if let CamelError::ExpressionFailed {
287            conversion: Some(detail),
288            ..
289        } = out
290        {
291            assert_eq!(detail.target, "f64");
292        } else {
293            panic!("expected ExpressionFailed with conversion detail, got {out:?}");
294        }
295    }
296
297    /// Hand-rolled expression that always fails with a structured failure.
298    struct FailingExpression;
299
300    #[async_trait::async_trait]
301    impl crate::Expression for FailingExpression {
302        async fn evaluate(&self, _exchange: &Exchange) -> Result<crate::Value, LanguageError> {
303            Err(LanguageError::EvalFailure {
304                class: ExpressionErrorClass::Arithmetic,
305                position: Some(ErrorPosition { line: 1, column: 1 }),
306                detail: None,
307            })
308        }
309    }
310
311    #[tokio::test]
312    async fn language_expression_eval_wraps_error() {
313        let expr: Arc<dyn crate::Expression> = Arc::new(FailingExpression);
314        let eval = LanguageExpressionEval::new(expr, test_meta(None));
315        let exchange = Exchange::new(Message::default());
316        let err = eval.evaluate(&exchange).await.unwrap_err();
317        assert!(matches!(
318            err,
319            CamelError::ExpressionFailed { ref verb, .. } if verb == "set_property"
320        ));
321    }
322
323    #[tokio::test]
324    async fn value_fn_maps_errors_with_meta() {
325        let expr: Arc<dyn crate::Expression> = Arc::new(FailingExpression);
326        let value_fn = LanguageExpressionEval::new(expr, test_meta(Some("body"))).into_value_fn();
327        let exchange = Exchange::new(Message::default());
328        let err = value_fn(&exchange).await.unwrap_err();
329        assert!(matches!(
330            err,
331            CamelError::ExpressionFailed {
332                ref language,
333                ref route_id,
334                ref verb,
335                ..
336            } if language == "rhai" && route_id == "r1" && verb == "set_property"
337        ));
338    }
339
340    /// Hand-rolled predicate that always fails with a conversion error whose
341    /// target is the `header entry` placeholder.
342    struct FailingPredicate;
343
344    #[async_trait::async_trait]
345    impl crate::Predicate for FailingPredicate {
346        async fn matches(&self, _exchange: &Exchange) -> Result<bool, LanguageError> {
347            Err(LanguageError::ConversionError {
348                source_type: "null".into(),
349                target: "header entry".into(),
350            })
351        }
352    }
353
354    #[tokio::test]
355    async fn predicate_matches_and_bool_fn_map_errors() {
356        let pred: Arc<dyn crate::Predicate> = Arc::new(FailingPredicate);
357        let eval = LanguagePredicateEval::new(pred, test_meta(Some("header x")));
358        let exchange = Exchange::new(Message::default());
359
360        let err = eval.matches(&exchange).await.unwrap_err();
361        if let CamelError::ExpressionFailed {
362            class: ExpressionErrorClass::Conversion,
363            conversion: Some(detail),
364            ..
365        } = err.clone()
366        {
367            // Entry-level placeholders are NOT rewritten: this failure names
368            // the scope surface, not the step's destination.
369            assert_eq!(detail.target, "header entry");
370        } else {
371            panic!("expected ExpressionFailed conversion, got {err:?}");
372        }
373
374        let bool_fn = eval.into_bool_fn();
375        let err = bool_fn(&exchange).await.unwrap_err();
376        assert!(matches!(err, CamelError::ExpressionFailed { .. }));
377    }
378}