Skip to main content

camel_processor/
do_try_segment.rs

1//! ## Stop semantics (ADR-0025)
2//!
3//! This segment implements `OutcomePipeline` and propagates `PipelineOutcome::Stopped(ex)`
4//! with the exchange state intact. See ADR-0025 §3.
5
6use crate::do_try::CatchMatcher;
7use crate::do_try::chain_predicate_error;
8use crate::error_handler::record_span_error;
9use camel_api::error_handler::ExceptionDisposition;
10use camel_api::exchange::PROPERTY_EXCEPTION_HANDLED;
11use camel_api::outcome_pipeline::OutcomePipeline;
12use camel_api::pipeline_outcome::PipelineOutcome;
13use camel_api::{CamelError, Exchange, PredicateSource};
14use std::future::Future;
15use std::pin::Pin;
16
17/// - `Handled`: the exchange continues through finally and downstream.
18/// - `Propagate`: the catch body runs for side effects, finally runs with the
19///   catch body's exchange, and the original try-error re-throws as `Failed`.
20#[derive(Clone)]
21pub struct CatchClauseSegment {
22    pub matcher: CatchMatcher,
23    pub on_when: Option<PredicateSource>,
24    pub body: camel_api::OutcomeSegment,
25    pub disposition: ExceptionDisposition,
26}
27
28/// Compilable segment for a `doFinally` clause within a `DoTrySegment`.
29#[derive(Clone)]
30pub struct FinallyClauseSegment {
31    pub on_when: Option<PredicateSource>,
32    pub body: camel_api::OutcomeSegment,
33}
34
35/// Outcome-aware structural EIP segment for the doTry/doCatch/doFinally pattern.
36///
37/// Operates at the `PipelineOutcome` layer so that `Stopped(ex)` from a
38/// sub-step (e.g. Stop EIP) is preserved with the exchange including all
39/// mutations. See ADR-0025 §5.1 for full semantics.
40pub struct DoTrySegment {
41    pub try_body: camel_api::OutcomeSegment,
42    pub catches: Vec<CatchClauseSegment>,
43    pub finally: Option<FinallyClauseSegment>,
44}
45
46impl Clone for DoTrySegment {
47    fn clone(&self) -> Self {
48        Self {
49            try_body: self.try_body.clone(),
50            catches: self.catches.clone(),
51            finally: self.finally.clone(),
52        }
53    }
54}
55
56/// Narrow outcome type for `run_finally_body` so the compiler enforces
57/// exhaustiveness at the two call sites instead of `unreachable!()`.
58/// This is a private, transient type; the `Stopped` exchange is boxed to keep
59/// the `Err` variant of `run_finally_body` small (clippy::result_large_err).
60enum FinallyOutcome {
61    Stopped(Box<Exchange>),
62    Failed(CamelError),
63    /// The finally `on_when` predicate itself failed; the typed error fails
64    /// the segment.
65    PredicateFailed(CamelError),
66}
67
68/// Run the finally body if present and on_when permits.
69/// Free function (not a method) to avoid borrow conflict with
70/// `self.catches.iter_mut()` inside the catch loop.
71/// The `FinallyOutcome` variants move the `Exchange` (the catch loop needs
72/// its state); boxing would rewrite the segment call chain for a lint
73/// threshold.
74#[allow(clippy::result_large_err)]
75async fn run_finally_body(
76    finally: &mut Option<FinallyClauseSegment>,
77    ex: Exchange,
78) -> Result<Exchange, FinallyOutcome> {
79    let Some(f) = finally.as_mut() else {
80        return Ok(ex);
81    };
82    if let Some(on_when) = &f.on_when {
83        match on_when.matches(&ex).await {
84            Ok(true) => {}
85            Ok(false) => return Ok(ex),
86            Err(err) => return Err(FinallyOutcome::PredicateFailed(err)),
87        }
88    }
89    match f.body.run(ex).await {
90        PipelineOutcome::Completed(e) => Ok(e),
91        PipelineOutcome::Stopped(e) => Err(FinallyOutcome::Stopped(Box::new(e))),
92        PipelineOutcome::Failed(e) => Err(FinallyOutcome::Failed(e)),
93    }
94}
95
96/// Surface `failure` after running finally.
97///
98/// Used for a failed catch predicate (the predicate error surfaced as the
99/// main failure), the no-catch-matched path, and the Propagate / fail-closed
100/// path (the original try error).
101/// `failure` becomes the main failure (Camel parity: a throwing finally body
102/// restores it; Stop in finally still stops the outer route; a failed finally
103/// `on_when` predicate supersedes it).
104async fn fail_after_finally(
105    finally: &mut Option<FinallyClauseSegment>,
106    ex: Exchange,
107    failure: CamelError,
108) -> PipelineOutcome {
109    match run_finally_body(finally, ex).await {
110        Ok(_) => PipelineOutcome::Failed(failure),
111        Err(FinallyOutcome::Stopped(e)) => PipelineOutcome::Stopped(*e),
112        Err(FinallyOutcome::Failed(_finally_err)) => {
113            tracing::warn!(
114                error = %failure,
115                "doFinally threw; restoring previous error (Camel parity)"
116            );
117            PipelineOutcome::Failed(failure)
118        }
119        Err(FinallyOutcome::PredicateFailed(pred_err)) => PipelineOutcome::Failed(pred_err),
120    }
121}
122
123impl OutcomePipeline for DoTrySegment {
124    fn clone_box(&self) -> Box<dyn OutcomePipeline> {
125        Box::new(self.clone())
126    }
127
128    fn run<'a>(
129        &'a mut self,
130        mut exchange: Exchange,
131    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
132        Box::pin(async move {
133            // Stale handled-marker protocol inherited from the deleted
134            // `DoTryService` (clear-on-entry; `Exchange::clear_error` does not
135            // touch HANDLED).
136            exchange.properties.remove(PROPERTY_EXCEPTION_HANDLED);
137            // Pre-clone for the "no catch matched" path; `exchange` itself is
138            // consumed by `try_body.run(...)` below.
139            let exchange_for_unmatched = exchange.branch_clone();
140
141            // 1. Run try_body. If Stopped, return immediately (skip catch AND finally).
142            let try_outcome = self.try_body.run(exchange).await;
143            let returned_ex = match try_outcome {
144                PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
145                PipelineOutcome::Completed(ex) => ex,
146                PipelineOutcome::Failed(err) => {
147                    // 2. try failed — try each catch in order. Walk the catch
148                    // chain starting from the failed try-exchange. On the first
149                    // matching catch whose `on_when` is true (or unset), run it.
150                    // A failed catch `when`/`on_when` predicate replaces the
151                    // original error (preserved as `cause`) and fails the scope.
152                    let mut current_ex = exchange_for_unmatched;
153                    current_ex.set_error(err.clone());
154                    // Distinguishes "a catch handled the error" (loop breaks
155                    // with `current_ex` = catch output) from "the loop
156                    // exhausted with no match" (must propagate the original).
157                    let mut catch_matched = false;
158                    for catch in self.catches.iter_mut() {
159                        let matched = match catch.matcher.matches(&err, &current_ex).await {
160                            Ok(m) => m,
161                            Err(pred_err) => {
162                                let chained = chain_predicate_error(pred_err, err);
163                                return fail_after_finally(&mut self.finally, current_ex, chained)
164                                    .await;
165                            }
166                        };
167                        if !matched {
168                            continue;
169                        }
170                        if let Some(on_when) = &catch.on_when {
171                            match on_when.matches(&current_ex).await {
172                                Ok(true) => {}
173                                Ok(false) => continue,
174                                Err(pred_err) => {
175                                    let chained = chain_predicate_error(pred_err, err);
176                                    return fail_after_finally(
177                                        &mut self.finally,
178                                        current_ex,
179                                        chained,
180                                    )
181                                    .await;
182                                }
183                            }
184                        }
185                        match catch.body.run(current_ex).await {
186                            // Stop in catch: skip finally AND outer route halts.
187                            PipelineOutcome::Stopped(stopped_ex) => {
188                                return PipelineOutcome::Stopped(stopped_ex);
189                            }
190                            // Catch handled — exit catch chain with this exchange.
191                            PipelineOutcome::Completed(next) => {
192                                match catch.disposition {
193                                    ExceptionDisposition::Handled => {
194                                        current_ex = next;
195                                        catch_matched = true;
196                                        break;
197                                    }
198                                    ExceptionDisposition::Continued => {
199                                        current_ex = next;
200                                        catch_matched = true;
201                                        break;
202                                    }
203                                    // Propagate and any future variant run finally then
204                                    // surface the original error (fail-closed).
205                                    _ => {
206                                        return fail_after_finally(&mut self.finally, next, err)
207                                            .await;
208                                    }
209                                }
210                            }
211                            // Catch-body Failed: surface THAT error to outer
212                            // route. Per ADR-0025 invariant #4 ("doTry is a
213                            // local error-handler island"), a failing catch
214                            // body propagates as Failed — it does NOT re-enter
215                            // the catch chain (no recursive catch-of-catch in
216                            // Camel). Skip remaining catches and finally.
217                            PipelineOutcome::Failed(catch_err) => {
218                                // Sealed failure envelope: the catch error
219                                // stays the main error (returned below);
220                                // the original try error is surfaced
221                                // through the unconditional warn record
222                                // and, when a span is active, the span
223                                // error record. No new span is created.
224                                tracing::warn!(
225                                    original_error = %err,
226                                    catch_error = %catch_err,
227                                    "do_try catch block failed; catch error supersedes original"
228                                );
229                                record_span_error(&catch_err);
230                                return PipelineOutcome::Failed(catch_err);
231                            }
232                        }
233                    }
234                    if catch_matched {
235                        // A catch handled the error: continue to the shared
236                        // finally path and return its exchange as Completed.
237                        current_ex
238                    } else {
239                        // No catch matched: run finally, then propagate the
240                        // original try error as `Failed`. Falling through to the
241                        // shared finally path would return `Completed` and
242                        // swallow the error, letting the route continue past
243                        // the scope (the segment is the only doTry runtime).
244                        return fail_after_finally(&mut self.finally, current_ex, err).await;
245                    }
246                }
247            };
248            // 3. Run finally if present (skip if try/catch returned Stopped —
249            // already returned above; skip if catch-body Failed — also returned).
250            match run_finally_body(&mut self.finally, returned_ex).await {
251                Ok(ex) => PipelineOutcome::Completed(ex),
252                Err(FinallyOutcome::Stopped(e)) => PipelineOutcome::Stopped(*e),
253                Err(FinallyOutcome::Failed(finally_err)) => {
254                    tracing::warn!(
255                        error = %finally_err,
256                        "doFinally threw during/after catch; surfacing finally error"
257                    );
258                    PipelineOutcome::Failed(finally_err)
259                }
260                // Failed finally `on_when` predicate: the typed error fails
261                // the segment.
262                Err(FinallyOutcome::PredicateFailed(pred_err)) => PipelineOutcome::Failed(pred_err),
263            }
264        })
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::do_try::CatchMatcher;
272    use crate::test_log_capture::{capture_debugs_with_span_records, captured_field, record_field};
273    use camel_api::pipeline_outcome::PipelineOutcome;
274    use std::sync::Arc;
275    use std::sync::atomic::{AtomicU32, Ordering};
276
277    // ── Helpers for DoTrySegment tests ──
278
279    struct CompleteSegment;
280
281    impl OutcomePipeline for CompleteSegment {
282        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
283            Box::new(CompleteSegment)
284        }
285        fn run<'a>(
286            &'a mut self,
287            exchange: Exchange,
288        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
289            Box::pin(async move { PipelineOutcome::Completed(exchange) })
290        }
291    }
292
293    fn seg_complete() -> camel_api::OutcomeSegment {
294        camel_api::OutcomeSegment::new(Box::new(CompleteSegment))
295    }
296
297    struct FailSegment(CamelError);
298
299    impl OutcomePipeline for FailSegment {
300        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
301            Box::new(FailSegment(self.0.clone()))
302        }
303        fn run<'a>(
304            &'a mut self,
305            _exchange: Exchange,
306        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
307            let e = self.0.clone();
308            Box::pin(async move { PipelineOutcome::Failed(e) })
309        }
310    }
311
312    fn seg_fail(err: CamelError) -> camel_api::OutcomeSegment {
313        camel_api::OutcomeSegment::new(Box::new(FailSegment(err)))
314    }
315
316    struct MutateThenStop {
317        mutator: Arc<dyn Fn(&mut Exchange) + Send + Sync>,
318    }
319
320    impl OutcomePipeline for MutateThenStop {
321        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
322            Box::new(MutateThenStop {
323                mutator: Arc::clone(&self.mutator),
324            })
325        }
326        fn run<'a>(
327            &'a mut self,
328            mut exchange: Exchange,
329        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
330            let m = Arc::clone(&self.mutator);
331            Box::pin(async move {
332                m(&mut exchange);
333                PipelineOutcome::Stopped(exchange)
334            })
335        }
336    }
337
338    fn seg_stop_with(
339        mutator: impl Fn(&mut Exchange) + Send + Sync + 'static,
340    ) -> camel_api::OutcomeSegment {
341        camel_api::OutcomeSegment::new(Box::new(MutateThenStop {
342            mutator: Arc::new(mutator),
343        }))
344    }
345
346    struct RecordCall {
347        counter: Arc<AtomicU32>,
348    }
349
350    impl OutcomePipeline for RecordCall {
351        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
352            Box::new(RecordCall {
353                counter: Arc::clone(&self.counter),
354            })
355        }
356        fn run<'a>(
357            &'a mut self,
358            exchange: Exchange,
359        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
360            let c = Arc::clone(&self.counter);
361            Box::pin(async move {
362                c.fetch_add(1, Ordering::SeqCst);
363                PipelineOutcome::Completed(exchange)
364            })
365        }
366    }
367
368    fn seg_record(counter: Arc<AtomicU32>) -> camel_api::OutcomeSegment {
369        camel_api::OutcomeSegment::new(Box::new(RecordCall { counter }))
370    }
371
372    // ── 5 New tests for DoTrySegment (ADR-0025) ──
373
374    #[tokio::test]
375    async fn stop_inside_try_skips_catch_and_finally() {
376        let catch_call = Arc::new(AtomicU32::new(0));
377        let finally_call = Arc::new(AtomicU32::new(0));
378
379        let mut seg = DoTrySegment {
380            try_body: seg_stop_with(|ex| {
381                ex.set_property("mutated", camel_api::Value::Bool(true));
382            }),
383            catches: vec![CatchClauseSegment {
384                matcher: CatchMatcher::ByVariant(vec!["*".into()]),
385                on_when: None,
386                body: seg_record(catch_call.clone()),
387                disposition: ExceptionDisposition::Handled,
388            }],
389            finally: Some(FinallyClauseSegment {
390                on_when: None,
391                body: seg_record(finally_call.clone()),
392            }),
393        };
394
395        let result = seg.run(Exchange::default()).await;
396        match result {
397            PipelineOutcome::Stopped(ex) => {
398                assert_eq!(
399                    ex.properties.get("mutated"),
400                    Some(&camel_api::Value::Bool(true)),
401                    "try body mutation must be preserved in Stopped exchange"
402                );
403            }
404            other => panic!("expected Stopped, got {:?}", other),
405        }
406        assert_eq!(
407            catch_call.load(Ordering::SeqCst),
408            0,
409            "catch must NOT run when try stops"
410        );
411        assert_eq!(
412            finally_call.load(Ordering::SeqCst),
413            0,
414            "finally must NOT run when try stops"
415        );
416    }
417
418    #[tokio::test]
419    async fn stop_inside_catch_skips_finally() {
420        let finally_call = Arc::new(AtomicU32::new(0));
421
422        let mut seg = DoTrySegment {
423            try_body: seg_fail(CamelError::ProcessorError("boom".into())),
424            catches: vec![CatchClauseSegment {
425                matcher: CatchMatcher::ByVariant(vec!["ProcessorError".into()]),
426                on_when: None,
427                body: seg_stop_with(|ex| {
428                    ex.set_property("catch_mutated", camel_api::Value::Bool(true));
429                }),
430                disposition: ExceptionDisposition::Handled,
431            }],
432            finally: Some(FinallyClauseSegment {
433                on_when: None,
434                body: seg_record(finally_call.clone()),
435            }),
436        };
437
438        let result = seg.run(Exchange::default()).await;
439        match result {
440            PipelineOutcome::Stopped(ex) => {
441                assert_eq!(
442                    ex.properties.get("catch_mutated"),
443                    Some(&camel_api::Value::Bool(true)),
444                    "catch body mutation must be preserved in Stopped exchange"
445                );
446            }
447            other => panic!("expected Stopped, got {:?}", other),
448        }
449        assert_eq!(
450            finally_call.load(Ordering::SeqCst),
451            0,
452            "finally must NOT run when catch stops"
453        );
454    }
455
456    #[tokio::test]
457    async fn stop_inside_finally_stops_outer_route() {
458        let mut seg = DoTrySegment {
459            try_body: seg_complete(),
460            catches: vec![],
461            finally: Some(FinallyClauseSegment {
462                on_when: None,
463                body: seg_stop_with(|ex| {
464                    ex.set_property("finally_mutated", camel_api::Value::Bool(true));
465                }),
466            }),
467        };
468
469        let result = seg.run(Exchange::default()).await;
470        match result {
471            PipelineOutcome::Stopped(ex) => {
472                assert_eq!(
473                    ex.properties.get("finally_mutated"),
474                    Some(&camel_api::Value::Bool(true)),
475                    "finally body mutation must be preserved in Stopped exchange"
476                );
477            }
478            other => panic!("expected Stopped, got {:?}", other),
479        }
480    }
481
482    #[tokio::test]
483    async fn catch_on_when_false_falls_through_to_next_catch() {
484        let first_call = Arc::new(AtomicU32::new(0));
485        let second_call = Arc::new(AtomicU32::new(0));
486
487        let mut seg = DoTrySegment {
488            try_body: seg_fail(CamelError::Io("disk err".into())),
489            catches: vec![
490                CatchClauseSegment {
491                    matcher: CatchMatcher::ByVariant(vec!["Io".into()]),
492                    on_when: Some(PredicateSource::Sync(FilterPredicate::new(|_ex| false))),
493                    body: seg_record(first_call.clone()),
494                    disposition: ExceptionDisposition::Handled,
495                },
496                CatchClauseSegment {
497                    matcher: CatchMatcher::ByVariant(vec!["*".into()]),
498                    on_when: None,
499                    body: seg_record(second_call.clone()),
500                    disposition: ExceptionDisposition::Handled,
501                },
502            ],
503            finally: None,
504        };
505
506        let result = seg.run(Exchange::default()).await;
507        assert!(
508            matches!(result, PipelineOutcome::Completed(_)),
509            "expected Completed after second catch"
510        );
511        assert_eq!(
512            first_call.load(Ordering::SeqCst),
513            0,
514            "first catch must NOT fire (on_when=false)"
515        );
516        assert_eq!(
517            second_call.load(Ordering::SeqCst),
518            1,
519            "second catch must fire"
520        );
521    }
522
523    #[tokio::test]
524    async fn finally_on_when_false_skips_finally_entirely() {
525        let finally_call = Arc::new(AtomicU32::new(0));
526        let mut ex = Exchange::default();
527        ex.set_property("try_set", camel_api::Value::Bool(true));
528
529        let mut seg = DoTrySegment {
530            try_body: seg_complete(),
531            catches: vec![],
532            finally: Some(FinallyClauseSegment {
533                on_when: Some(PredicateSource::Sync(FilterPredicate::new(|_ex| false))),
534                body: seg_record(finally_call.clone()),
535            }),
536        };
537
538        let result = seg.run(ex).await;
539        match result {
540            PipelineOutcome::Completed(ex) => {
541                assert_eq!(
542                    ex.properties.get("try_set"),
543                    Some(&camel_api::Value::Bool(true)),
544                    "exchange state from try must be preserved"
545                );
546            }
547            other => panic!("expected Completed, got {:?}", other),
548        }
549        assert_eq!(
550            finally_call.load(Ordering::SeqCst),
551            0,
552            "finally must NOT run when on_when=false"
553        );
554    }
555
556    // ── Catch-body failure envelope (compiled-segment path) ──
557
558    fn catch_fails_segment() -> DoTrySegment {
559        DoTrySegment {
560            try_body: seg_fail(CamelError::ProcessorError("orig".into())),
561            catches: vec![CatchClauseSegment {
562                matcher: CatchMatcher::ByVariant(vec!["ProcessorError".into()]),
563                on_when: None,
564                body: seg_fail(CamelError::Io("catch-fail".into())),
565                disposition: ExceptionDisposition::Handled,
566            }],
567            finally: None,
568        }
569    }
570
571    #[test]
572    fn catch_body_failure_returns_catch_err_and_marks_span() {
573        let mut seg = catch_fails_segment();
574        let (result, captured, span_records) = capture_debugs_with_span_records(|| {
575            // Declared `error` field so record_span_error's record lands.
576            let span = tracing::info_span!("dotry_seg_test", error = tracing::field::Empty);
577            let _guard = span.enter();
578            tokio::runtime::Builder::new_current_thread()
579                .enable_all()
580                .build()
581                .expect("current-thread runtime")
582                .block_on(async { seg.run(Exchange::default()).await })
583        });
584
585        assert!(
586            matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
587            "catch failure must surface the catch error as Failed(Io), got: {result:?}"
588        );
589        // The span recorded the `error` field with the CATCH error.
590        // Structured field lookup: substring `error=` would also match
591        // `original_error=` suffixes.
592        assert!(
593            span_records.iter().any(|line| {
594                captured_field(line, "error").is_some_and(|v| v.contains("catch-fail"))
595            }),
596            "expected span error record carrying the catch error, span records: {span_records:?}"
597        );
598        let original = record_field(&captured, "do_try catch block failed", "original_error")
599            .unwrap_or_else(|| {
600                panic!(
601                    "envelope record missing original_error field under active span, captured: {captured:?}"
602                )
603            });
604        assert!(
605            original.contains("orig"),
606            "original_error must carry the original error under an active span, got: {original}"
607        );
608    }
609
610    #[test]
611    fn catch_body_failure_emits_envelope_log() {
612        let mut seg = catch_fails_segment();
613        let (result, captured, _span_records) = capture_debugs_with_span_records(|| {
614            tokio::runtime::Builder::new_current_thread()
615                .enable_all()
616                .build()
617                .expect("current-thread runtime")
618                .block_on(async { seg.run(Exchange::default()).await })
619        });
620
621        assert!(
622            matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
623            "catch failure must surface the catch error as Failed(Io), got: {result:?}"
624        );
625        // Structured field lookup on the envelope record, not message
626        // formatting.
627        let original = record_field(&captured, "do_try catch block failed", "original_error")
628            .unwrap_or_else(|| {
629                panic!("envelope record missing original_error field, captured: {captured:?}")
630            });
631        let catch = record_field(&captured, "do_try catch block failed", "catch_error")
632            .unwrap_or_else(|| {
633                panic!("envelope record missing catch_error field, captured: {captured:?}")
634            });
635        assert!(
636            original.contains("orig"),
637            "original_error must carry the original error, got: {original}"
638        );
639        assert!(
640            catch.contains("catch-fail"),
641            "catch_error must carry the catch error, got: {catch}"
642        );
643    }
644
645    // ── Fallible predicate path (language-value-boundary task 1.4) ──
646
647    use camel_api::{ExpressionErrorClass, FilterPredicate, PredicateSource};
648
649    fn expression_failed() -> CamelError {
650        CamelError::ExpressionFailed {
651            language: "rhai".to_string(),
652            route_id: "r1".to_string(),
653            step_id: "step#0".to_string(),
654            verb: "on_when".to_string(),
655            class: ExpressionErrorClass::Runtime,
656            position: None,
657            conversion: None,
658            cause: None,
659        }
660    }
661
662    fn async_err_predicate(err: CamelError) -> PredicateSource {
663        PredicateSource::Async(Arc::new(move |_: &Exchange| {
664            let err = err.clone();
665            Box::pin(async move { Err(err) }) as camel_api::BoxBoolFuture
666        }))
667    }
668
669    #[tokio::test]
670    async fn finally_on_when_predicate_error_fails_segment() {
671        let finally_call = Arc::new(AtomicU32::new(0));
672        let mut seg = DoTrySegment {
673            try_body: seg_complete(),
674            catches: vec![],
675            finally: Some(FinallyClauseSegment {
676                on_when: Some(async_err_predicate(expression_failed())),
677                body: seg_record(finally_call.clone()),
678            }),
679        };
680
681        let result = seg.run(Exchange::default()).await;
682        match result {
683            PipelineOutcome::Failed(CamelError::ExpressionFailed { .. }) => {}
684            other => panic!("expected Failed(ExpressionFailed), got {other:?}"),
685        }
686        assert_eq!(
687            finally_call.load(Ordering::SeqCst),
688            0,
689            "finally body must not run when its on_when predicate errors"
690        );
691    }
692
693    // ── No-catch-matched path (mission 353) ──
694
695    /// doTry with no matching catch must run finally and propagate the
696    /// original try error as `Failed` (the segment is the only doTry runtime).
697    /// Guards against regressing back to `Completed(bugged_swallow)`.
698    #[tokio::test]
699    async fn no_clause_matches_runs_finally_and_propagates_original() {
700        let catch_call = Arc::new(AtomicU32::new(0));
701        let finally_call = Arc::new(AtomicU32::new(0));
702
703        let mut seg = DoTrySegment {
704            try_body: seg_fail(CamelError::EndpointCreationFailed(
705                "direct endpoint 'direct:does-not-exist' not registered".into(),
706            )),
707            catches: vec![CatchClauseSegment {
708                matcher: CatchMatcher::ByVariant(vec!["Io".into()]),
709                on_when: None,
710                body: seg_record(catch_call.clone()),
711                disposition: ExceptionDisposition::Handled,
712            }],
713            finally: Some(FinallyClauseSegment {
714                on_when: None,
715                body: seg_record(finally_call.clone()),
716            }),
717        };
718
719        let result = seg.run(Exchange::default()).await;
720        match result {
721            PipelineOutcome::Failed(CamelError::EndpointCreationFailed(msg)) => {
722                assert!(
723                    msg.contains("does-not-exist"),
724                    "propagated error must be the original try error, got: {msg}"
725                );
726            }
727            other => {
728                panic!("no catch matched: expected Failed(EndpointCreationFailed), got {other:?}")
729            }
730        }
731        assert_eq!(
732            finally_call.load(Ordering::SeqCst),
733            1,
734            "finally must run exactly once when no catch matches"
735        );
736        assert_eq!(
737            catch_call.load(Ordering::SeqCst),
738            0,
739            "the unmatched catch body must not run"
740        );
741    }
742    // ── Ported from the deleted Tower doTry service (no coverage loss) ──
743
744    use camel_language_api::{EvalMeta, LanguageError, to_expression_failed};
745
746    fn catch_variant(names: &[&str], body: camel_api::OutcomeSegment) -> CatchClauseSegment {
747        catch_variant_with(names, body, ExceptionDisposition::Handled)
748    }
749
750    fn catch_variant_with(
751        names: &[&str],
752        body: camel_api::OutcomeSegment,
753        disposition: ExceptionDisposition,
754    ) -> CatchClauseSegment {
755        CatchClauseSegment {
756            matcher: CatchMatcher::ByVariant(names.iter().map(|n| (*n).to_string()).collect()),
757            on_when: None,
758            body,
759            disposition,
760        }
761    }
762
763    fn finally_seg(body: camel_api::OutcomeSegment) -> FinallyClauseSegment {
764        FinallyClauseSegment {
765            on_when: None,
766            body,
767        }
768    }
769
770    #[tokio::test]
771    async fn happy_path_try_succeeds_finally_runs() {
772        let finally_flag = Arc::new(AtomicU32::new(0));
773        let mut seg = DoTrySegment {
774            try_body: seg_complete(),
775            catches: vec![],
776            finally: Some(finally_seg(seg_record(finally_flag.clone()))),
777        };
778
779        let result = seg.run(Exchange::default()).await;
780        assert!(
781            matches!(result, PipelineOutcome::Completed(_)),
782            "try success must complete, got {result:?}"
783        );
784        assert_eq!(finally_flag.load(Ordering::SeqCst), 1);
785    }
786
787    #[tokio::test]
788    async fn catch_by_variant_handled_returns_ok() {
789        let mut seg = DoTrySegment {
790            try_body: seg_fail(CamelError::ProcessorError("boom".into())),
791            catches: vec![catch_variant(&["ProcessorError"], seg_complete())],
792            finally: None,
793        };
794
795        let result = seg.run(Exchange::default()).await;
796        assert!(
797            matches!(result, PipelineOutcome::Completed(_)),
798            "Handled must return Completed, got {result:?}"
799        );
800    }
801
802    #[tokio::test]
803    async fn catch_by_variant_propagate_runs_side_effects_and_rethrows() {
804        let side_effect = Arc::new(AtomicU32::new(0));
805        let mut seg = DoTrySegment {
806            try_body: seg_fail(CamelError::ProcessorError("boom".into())),
807            catches: vec![catch_variant_with(
808                &["ProcessorError"],
809                seg_record(side_effect.clone()),
810                ExceptionDisposition::Propagate,
811            )],
812            finally: None,
813        };
814
815        let result = seg.run(Exchange::default()).await;
816        assert!(
817            matches!(
818                result,
819                PipelineOutcome::Failed(CamelError::ProcessorError(_))
820            ),
821            "Propagate must rethrow the original error, got {result:?}"
822        );
823        assert_eq!(
824            side_effect.load(Ordering::SeqCst),
825            1,
826            "catch branch must have run for side-effects"
827        );
828    }
829
830    #[tokio::test]
831    async fn catch_by_predicate_matches_via_exception_kind() {
832        let predicate = FilterPredicate::new(|ex: &Exchange| {
833            ex.properties
834                .get(camel_api::exchange::PROPERTY_EXCEPTION_KIND)
835                .map(|v| matches!(v, camel_api::Value::String(s) if s == "io"))
836                .unwrap_or(false)
837        });
838        let mut seg = DoTrySegment {
839            try_body: seg_fail(CamelError::Io("disk full".into())),
840            catches: vec![CatchClauseSegment {
841                matcher: CatchMatcher::Predicate(PredicateSource::Sync(predicate)),
842                on_when: None,
843                body: seg_complete(),
844                disposition: ExceptionDisposition::Handled,
845            }],
846            finally: None,
847        };
848
849        let result = seg.run(Exchange::default()).await;
850        assert!(
851            matches!(result, PipelineOutcome::Completed(_)),
852            "Predicate matcher must catch the error and Handled must return Completed, got {result:?}"
853        );
854    }
855
856    #[tokio::test]
857    async fn first_match_wins_subsequent_clauses_not_evaluated() {
858        let first_call = Arc::new(AtomicU32::new(0));
859        let second_call = Arc::new(AtomicU32::new(0));
860        let mut seg = DoTrySegment {
861            try_body: seg_fail(CamelError::Io("err".into())),
862            catches: vec![
863                catch_variant(&["Io"], seg_record(first_call.clone())),
864                catch_variant(&["*"], seg_record(second_call.clone())),
865            ],
866            finally: None,
867        };
868
869        let _ = seg.run(Exchange::default()).await;
870        assert_eq!(first_call.load(Ordering::SeqCst), 1);
871        assert_eq!(second_call.load(Ordering::SeqCst), 0);
872    }
873
874    #[tokio::test]
875    async fn catch_branch_throws_new_error_wins() {
876        let mut seg = DoTrySegment {
877            try_body: seg_fail(CamelError::ProcessorError("orig".into())),
878            catches: vec![catch_variant(
879                &["ProcessorError"],
880                seg_fail(CamelError::Io("catch-fail".into())),
881            )],
882            finally: None,
883        };
884
885        let result = seg.run(Exchange::default()).await;
886        assert!(
887            matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
888            "catch error must win over the try error, got {result:?}"
889        );
890    }
891
892    #[tokio::test]
893    async fn finally_throws_with_no_previous_error_propagates_finally_error() {
894        let mut seg = DoTrySegment {
895            try_body: seg_complete(),
896            catches: vec![],
897            finally: Some(finally_seg(seg_fail(CamelError::Config("fin".into())))),
898        };
899
900        let result = seg.run(Exchange::default()).await;
901        assert!(
902            matches!(result, PipelineOutcome::Failed(CamelError::Config(_))),
903            "finally error must propagate when no previous error exists, got {result:?}"
904        );
905    }
906
907    #[tokio::test]
908    async fn finally_throws_with_previous_error_restores_previous() {
909        let mut seg = DoTrySegment {
910            try_body: seg_fail(CamelError::ProcessorError("orig".into())),
911            catches: vec![],
912            finally: Some(finally_seg(seg_fail(CamelError::Config("fin".into())))),
913        };
914
915        let result = seg.run(Exchange::default()).await;
916        assert!(
917            matches!(
918                result,
919                PipelineOutcome::Failed(CamelError::ProcessorError(_))
920            ),
921            "previous error must be restored when finally throws (Camel parity), got {result:?}"
922        );
923    }
924
925    #[tokio::test]
926    async fn stale_handled_marker_cleared_on_entry() {
927        let mut ex = Exchange::default();
928        ex.set_property(
929            camel_api::exchange::PROPERTY_EXCEPTION_HANDLED,
930            camel_api::Value::Bool(true),
931        );
932        ex.set_error(CamelError::ProcessorError("stale".into()));
933        let mut seg = DoTrySegment {
934            try_body: seg_complete(),
935            catches: vec![],
936            finally: None,
937        };
938
939        let result = seg.run(ex).await;
940        match result {
941            PipelineOutcome::Completed(ex) => assert!(
942                !ex.properties
943                    .contains_key(camel_api::exchange::PROPERTY_EXCEPTION_HANDLED),
944                "stale CamelExceptionHandled must be cleared on entry"
945            ),
946            other => panic!("expected Completed, got {other:?}"),
947        }
948    }
949
950    #[tokio::test]
951    async fn nested_do_try_inner_catch_does_not_leak_to_outer() {
952        let inner = DoTrySegment {
953            try_body: seg_fail(CamelError::Io("inner".into())),
954            catches: vec![catch_variant(&["Io"], seg_complete())],
955            finally: None,
956        };
957        let mut outer = DoTrySegment {
958            try_body: camel_api::OutcomeSegment::new(Box::new(inner)),
959            catches: vec![catch_variant(&["Io"], seg_complete())],
960            finally: None,
961        };
962
963        let result = outer.run(Exchange::default()).await;
964        assert!(
965            matches!(result, PipelineOutcome::Completed(_)),
966            "outer must see Completed because inner handled its own error, got {result:?}"
967        );
968    }
969
970    #[tokio::test]
971    async fn catch_all_only_fires_when_no_specific_clause_matches() {
972        let processor_call = Arc::new(AtomicU32::new(0));
973        let catch_all_call = Arc::new(AtomicU32::new(0));
974        let mut seg = DoTrySegment {
975            try_body: seg_fail(CamelError::Io("err".into())),
976            catches: vec![
977                catch_variant(&["ProcessorError"], seg_record(processor_call.clone())),
978                catch_variant(&["*"], seg_record(catch_all_call.clone())),
979            ],
980            finally: None,
981        };
982
983        let _ = seg.run(Exchange::default()).await;
984        assert_eq!(
985            processor_call.load(Ordering::SeqCst),
986            0,
987            "specific ProcessorError clause must not fire on Io error"
988        );
989        assert_eq!(
990            catch_all_call.load(Ordering::SeqCst),
991            1,
992            "catch-all clause must fire when no specific clause matches"
993        );
994    }
995
996    #[tokio::test]
997    async fn catch_throws_skips_finally_and_propagates_catch_err() {
998        let finally_flag = Arc::new(AtomicU32::new(0));
999        let mut seg = DoTrySegment {
1000            try_body: seg_fail(CamelError::ProcessorError("orig".into())),
1001            catches: vec![catch_variant(
1002                &["ProcessorError"],
1003                seg_fail(CamelError::Io("catch-fail".into())),
1004            )],
1005            finally: Some(finally_seg(seg_record(finally_flag.clone()))),
1006        };
1007
1008        let result = seg.run(Exchange::default()).await;
1009        assert!(
1010            matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
1011            "catch_err must propagate (not original ProcessorError), got {result:?}"
1012        );
1013        assert_eq!(
1014            finally_flag.load(Ordering::SeqCst),
1015            0,
1016            "compiled segment skips finally after a failed catch body (ADR-0025 invariant #4)"
1017        );
1018    }
1019
1020    #[tokio::test]
1021    async fn catch_throws_and_throwing_finally_still_returns_catch_err() {
1022        let mut seg = DoTrySegment {
1023            try_body: seg_fail(CamelError::ProcessorError("orig".into())),
1024            catches: vec![catch_variant(
1025                &["ProcessorError"],
1026                seg_fail(CamelError::Io("catch-fail".into())),
1027            )],
1028            finally: Some(finally_seg(seg_fail(CamelError::Config("fin-fail".into())))),
1029        };
1030
1031        let result = seg.run(Exchange::default()).await;
1032        assert!(
1033            matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
1034            "catch_err (Io) must be returned over finally_err (Config), got {result:?}"
1035        );
1036    }
1037
1038    #[tokio::test]
1039    async fn finally_on_when_false_with_previous_error_still_propagates_original() {
1040        let finally_flag = Arc::new(AtomicU32::new(0));
1041        let mut seg = DoTrySegment {
1042            try_body: seg_fail(CamelError::ProcessorError("orig".into())),
1043            catches: vec![],
1044            finally: Some(FinallyClauseSegment {
1045                on_when: Some(PredicateSource::Sync(FilterPredicate::new(|_ex| false))),
1046                body: seg_record(finally_flag.clone()),
1047            }),
1048        };
1049
1050        let result = seg.run(Exchange::default()).await;
1051        assert!(
1052            matches!(
1053                result,
1054                PipelineOutcome::Failed(CamelError::ProcessorError(_))
1055            ),
1056            "original error must propagate even when finally_on_when skips finally, got {result:?}"
1057        );
1058        assert_eq!(
1059            finally_flag.load(Ordering::SeqCst),
1060            0,
1061            "doFinally must NOT run when on_when returns false"
1062        );
1063    }
1064
1065    #[tokio::test]
1066    async fn catch_throws_under_propagate_disposition_returns_catch_err() {
1067        let mut seg = DoTrySegment {
1068            try_body: seg_fail(CamelError::ProcessorError("orig".into())),
1069            catches: vec![catch_variant_with(
1070                &["ProcessorError"],
1071                seg_fail(CamelError::Io("catch-fail".into())),
1072                ExceptionDisposition::Propagate,
1073            )],
1074            finally: None,
1075        };
1076
1077        let result = seg.run(Exchange::default()).await;
1078        assert!(
1079            matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
1080            "Propagate-disposition catch failure must surface the catch error, got {result:?}"
1081        );
1082    }
1083
1084    #[tokio::test]
1085    async fn catch_when_predicate_error_chains_original() {
1086        let mut seg = DoTrySegment {
1087            try_body: seg_fail(CamelError::ProcessorError("boom".into())),
1088            catches: vec![CatchClauseSegment {
1089                matcher: CatchMatcher::Predicate(async_err_predicate(expression_failed())),
1090                on_when: None,
1091                body: seg_complete(),
1092                disposition: ExceptionDisposition::Handled,
1093            }],
1094            finally: None,
1095        };
1096
1097        let result = seg.run(Exchange::default()).await;
1098        match result {
1099            PipelineOutcome::Failed(CamelError::ExpressionFailed {
1100                cause: Some(cause), ..
1101            }) => {
1102                assert!(
1103                    cause.to_string().contains("boom"),
1104                    "cause must carry the original error, got: {cause}"
1105                );
1106            }
1107            other => panic!("expected Failed(ExpressionFailed) with chained cause, got {other:?}"),
1108        }
1109    }
1110
1111    #[tokio::test]
1112    async fn catch_on_when_predicate_error_chains_original() {
1113        let mut seg = DoTrySegment {
1114            try_body: seg_fail(CamelError::ProcessorError("boom".into())),
1115            catches: vec![CatchClauseSegment {
1116                matcher: CatchMatcher::ByVariant(vec!["*".into()]),
1117                on_when: Some(async_err_predicate(expression_failed())),
1118                body: seg_complete(),
1119                disposition: ExceptionDisposition::Handled,
1120            }],
1121            finally: None,
1122        };
1123
1124        let result = seg.run(Exchange::default()).await;
1125        match result {
1126            PipelineOutcome::Failed(CamelError::ExpressionFailed {
1127                cause: Some(cause), ..
1128            }) => {
1129                assert!(
1130                    cause.to_string().contains("boom"),
1131                    "cause must carry the original error, got: {cause}"
1132                );
1133            }
1134            other => panic!("expected Failed(ExpressionFailed) with chained cause, got {other:?}"),
1135        }
1136    }
1137
1138    // ── Mapper-generated cause replacement (ported from do_try.rs) ──
1139
1140    fn caller_diagnostic_failed(cause: CamelError) -> CamelError {
1141        CamelError::ExpressionFailed {
1142            language: "rhai".to_string(),
1143            route_id: "r1".to_string(),
1144            step_id: "s1".to_string(),
1145            verb: "when".to_string(),
1146            class: ExpressionErrorClass::Runtime,
1147            position: None,
1148            conversion: None,
1149            cause: Some(Box::new(cause)),
1150        }
1151    }
1152
1153    #[test]
1154    fn chain_replaces_mapper_generated_cause() {
1155        let meta = EvalMeta {
1156            language: "xpath".into(),
1157            route_id: "r1".into(),
1158            step_id: "s1".into(),
1159            verb: "when".into(),
1160            target: None,
1161        };
1162        let mapper_err = to_expression_failed(
1163            LanguageError::EvalFailure {
1164                class: ExpressionErrorClass::Parse,
1165                position: None,
1166                detail: Some("body is not valid XML".into()),
1167            },
1168            &meta,
1169        );
1170
1171        let result = chain_predicate_error(mapper_err, CamelError::ProcessorError("boom".into()));
1172
1173        match result {
1174            CamelError::ExpressionFailed {
1175                cause: Some(cause), ..
1176            } => {
1177                assert!(
1178                    cause.to_string().contains("boom"),
1179                    "mapper diagnostic cause must be replaced by the original, got: {cause}"
1180                );
1181            }
1182            other => panic!("expected ExpressionFailed with chained cause, got {other:?}"),
1183        }
1184    }
1185
1186    #[test]
1187    fn chain_preserves_caller_cause() {
1188        let predicate_err =
1189            caller_diagnostic_failed(CamelError::ProcessorError("caller diagnostic".into()));
1190
1191        let result =
1192            chain_predicate_error(predicate_err, CamelError::ProcessorError("boom".into()));
1193
1194        match result {
1195            CamelError::ExpressionFailed {
1196                cause: Some(cause), ..
1197            } => {
1198                let rendered = cause.to_string();
1199                assert!(
1200                    rendered.contains("caller diagnostic"),
1201                    "caller cause must pass through untouched, got: {rendered}"
1202                );
1203                assert!(
1204                    !rendered.contains("boom"),
1205                    "caller cause must not be replaced, got: {rendered}"
1206                );
1207            }
1208            other => panic!("expected ExpressionFailed with caller cause, got {other:?}"),
1209        }
1210    }
1211
1212    #[test]
1213    fn chain_preserves_caller_source_bearing_cause() {
1214        let predicate_err = caller_diagnostic_failed(CamelError::ProcessorErrorWithSource(
1215            "caller diagnostic".into(),
1216            Arc::new(std::io::Error::other("caller source")),
1217        ));
1218
1219        let result =
1220            chain_predicate_error(predicate_err, CamelError::ProcessorError("boom".into()));
1221
1222        match result {
1223            CamelError::ExpressionFailed {
1224                cause: Some(cause), ..
1225            } => {
1226                let rendered = cause.to_string();
1227                assert!(
1228                    rendered.contains("caller diagnostic"),
1229                    "caller cause must pass through untouched, got: {rendered}"
1230                );
1231                let source = std::error::Error::source(cause.as_ref())
1232                    .unwrap_or_else(|| panic!("caller source must survive, cause: {cause:?}"));
1233                assert!(
1234                    source.to_string().contains("caller source"),
1235                    "caller source must survive untouched, got: {source}"
1236                );
1237            }
1238            other => panic!("expected ExpressionFailed with caller cause, got {other:?}"),
1239        }
1240    }
1241}