Skip to main content

camel_builder/
lib.rs

1//! Fluent builder API for constructing Camel routes programmatically with EIP patterns.
2//!
3//! Main types: `RouteBuilder`, `StepAccumulator`, `SplitBuilder`, `ChoiceBuilder`, `MulticastBuilder`,
4//! `ThrottleBuilder`, `LoopBuilder`, `LoadBalancerBuilder`, `OnExceptionBuilder`.
5
6use std::collections::BTreeMap;
7use std::sync::Arc;
8
9use camel_api::DelayConfig;
10use camel_api::aggregator::{
11    AggregationStrategy, AggregatorConfig, CompletionCondition, CompletionMode, CorrelationStrategy,
12};
13use camel_api::body::Body;
14use camel_api::body_converter::BodyType;
15use camel_api::circuit_breaker::CircuitBreakerConfig;
16use camel_api::dynamic_router::DynamicRouterConfig;
17use camel_api::error_handler::{ErrorHandlerConfig, RedeliveryPolicy};
18use camel_api::load_balancer::LoadBalancerConfig;
19use camel_api::loop_eip::{LoopConfig, LoopMode};
20use camel_api::multicast::{MulticastConfig, MulticastStrategy};
21use camel_api::recipient_list::RecipientListConfig;
22use camel_api::routing_slip::RoutingSlipConfig;
23use camel_api::splitter::SplitterConfig;
24use camel_api::throttler::{ThrottleStrategy, ThrottlerConfig};
25use camel_api::{
26    BoxProcessor, CamelError, CanonicalRouteSpec, EndpointUri, Exchange, FilterPredicate,
27    IdentityProcessor, LanguageExpressionDef, OpaqueProcessor, ProcessorFn, Value,
28    runtime::{
29        CanonicalAggregateSpec, CanonicalAggregateStrategySpec, CanonicalCircuitBreakerSpec,
30        CanonicalSplitAggregationSpec, CanonicalSplitExpressionSpec, CanonicalStepSpec,
31        CanonicalWhenSpec,
32    },
33};
34use camel_component_api::ConcurrencyModel;
35use camel_core::route::{BuilderStep, DeclarativeWhenStep, RouteDefinition, WhenStep};
36use camel_processor::{
37    ConvertBodyTo, DynamicSetHeader, LogLevel, MapBody, MarshalService, SetHeader,
38    StreamCacheService, UnmarshalService, builtin_data_format,
39};
40
41// ── Module declarations ─────────────────────────────────────────────────────
42pub mod do_try;
43pub use do_try::{DoCatchBuilder, DoFinallyBuilder, DoTryBuilder};
44
45/// Shared step-accumulation methods for all builder types.
46///
47/// Implementors provide `steps_mut()` and get step-adding methods for free.
48/// `filter()` and other branching methods are NOT included — they return
49/// different types per builder and stay as per-builder methods.
50pub trait StepAccumulator: Sized {
51    fn steps_mut(&mut self) -> &mut Vec<BuilderStep>;
52
53    fn to(mut self, endpoint: impl Into<String>) -> Self {
54        self.steps_mut().push(BuilderStep::To(endpoint.into()));
55        self
56    }
57
58    fn process<F, Fut>(mut self, f: F) -> Self
59    where
60        F: Fn(Exchange) -> Fut + Send + Sync + 'static,
61        Fut: std::future::Future<Output = Result<Exchange, CamelError>> + Send + 'static,
62    {
63        let svc = ProcessorFn::new(f);
64        self.steps_mut()
65            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
66                svc,
67            ))));
68        self
69    }
70
71    fn process_fn(mut self, processor: BoxProcessor) -> Self {
72        self.steps_mut()
73            .push(BuilderStep::Processor(OpaqueProcessor(processor)));
74        self
75    }
76
77    fn set_header(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
78        let svc = SetHeader::new(IdentityProcessor, key, value);
79        self.steps_mut()
80            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
81                svc,
82            ))));
83        self
84    }
85
86    fn map_body<F>(mut self, mapper: F) -> Self
87    where
88        F: Fn(Body) -> Body + Clone + Send + Sync + 'static,
89    {
90        let svc = MapBody::new(IdentityProcessor, mapper);
91        self.steps_mut()
92            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
93                svc,
94            ))));
95        self
96    }
97
98    fn set_body<B>(mut self, body: B) -> Self
99    where
100        B: Into<Body> + Clone + Send + Sync + 'static,
101    {
102        let body: Body = body.into();
103        // Static Body (may be Bytes/Stream/Xml): assign directly. The
104        // `SetBody` service is reserved for `Value`-shaped sources —
105        // arbitrary Body variants do not round-trip through `Value`.
106        let svc = ProcessorFn::new(move |mut ex: Exchange| {
107            let body = body.clone();
108            async move {
109                ex.input.body = body;
110                Ok(ex)
111            }
112        });
113        self.steps_mut()
114            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
115                svc,
116            ))));
117        self
118    }
119
120    /// Apache Camel-compatible alias for [`set_body`](Self::set_body).
121    ///
122    /// Transforms the message body using the given value. Semantically identical
123    /// to `set_body` — provided for familiarity with Apache Camel route DSLs.
124    fn transform<B>(self, body: B) -> Self
125    where
126        B: Into<Body> + Clone + Send + Sync + 'static,
127    {
128        self.set_body(body)
129    }
130
131    fn set_body_fn<F>(mut self, expr: F) -> Self
132    where
133        F: Fn(&Exchange) -> Body + Clone + Send + Sync + 'static,
134    {
135        // Body-producing closure: assign directly (see `set_body` for why
136        // this no longer routes through the Value-shaped `SetBody`).
137        let svc = ProcessorFn::new(move |mut ex: Exchange| {
138            let body = expr(&ex);
139            async move {
140                ex.input.body = body;
141                Ok(ex)
142            }
143        });
144        self.steps_mut()
145            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
146                svc,
147            ))));
148        self
149    }
150
151    fn set_header_fn<F>(mut self, key: impl Into<String>, expr: F) -> Self
152    where
153        F: Fn(&Exchange) -> Value + Clone + Send + Sync + 'static,
154    {
155        let svc = DynamicSetHeader::new(
156            IdentityProcessor,
157            key,
158            camel_api::ValueSource::Sync(Arc::new(expr)),
159        );
160        self.steps_mut()
161            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
162                svc,
163            ))));
164        self
165    }
166
167    fn aggregate(mut self, config: AggregatorConfig) -> Self {
168        self.steps_mut().push(BuilderStep::Aggregate { config });
169        self
170    }
171
172    /// Stop processing this exchange immediately. No further steps in the
173    /// current pipeline will run.
174    ///
175    /// Can be used at any point in the route: directly on RouteBuilder,
176    /// inside `.filter()`, inside `.split()`, etc.
177    fn stop(mut self) -> Self {
178        self.steps_mut().push(BuilderStep::Stop);
179        self
180    }
181
182    fn delay(mut self, duration: std::time::Duration) -> Self {
183        self.steps_mut().push(BuilderStep::Delay {
184            config: DelayConfig::from_duration(duration),
185        });
186        self
187    }
188
189    fn delay_with_header(
190        mut self,
191        duration: std::time::Duration,
192        header: impl Into<String>,
193    ) -> Self {
194        self.steps_mut().push(BuilderStep::Delay {
195            config: DelayConfig::from_duration_with_header(duration, header),
196        });
197        self
198    }
199
200    /// Log a message at the specified level.
201    ///
202    /// The message will be logged when an exchange passes through this step.
203    fn log(mut self, message: impl Into<String>, level: LogLevel) -> Self {
204        self.steps_mut().push(BuilderStep::Log {
205            level,
206            message: message.into(),
207        });
208        self
209    }
210
211    /// Convert the message body to the target type.
212    ///
213    /// Supported: Text ↔ Json ↔ Bytes. `Body::Stream` always fails.
214    /// Returns `TypeConversionFailed` if conversion is not possible.
215    ///
216    /// # Example
217    /// ```ignore
218    /// route.set_body(Value::String(r#"{"x":1}"#.into()))
219    ///      .convert_body_to(BodyType::Json)
220    ///      .to("direct:next")
221    /// ```
222    fn convert_body_to(mut self, target: BodyType) -> Self {
223        let svc = ConvertBodyTo::new(IdentityProcessor, target);
224        self.steps_mut()
225            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
226                svc,
227            ))));
228        self
229    }
230
231    fn stream_cache(mut self, threshold: usize) -> Self {
232        let config = camel_api::stream_cache::StreamCacheConfig::new(threshold);
233        let svc = StreamCacheService::new(IdentityProcessor, config);
234        self.steps_mut()
235            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
236                svc,
237            ))));
238        self
239    }
240
241    /// Materialize `Body::Stream` into `Body::Bytes` using the default threshold (128 KB).
242    ///
243    /// Equivalent to `.stream_cache(camel_api::stream_cache::DEFAULT_STREAM_CACHE_THRESHOLD)`.
244    fn stream_cache_default(self) -> Self {
245        self.stream_cache(camel_api::stream_cache::DEFAULT_STREAM_CACHE_THRESHOLD)
246    }
247
248    /// Marshal the message body using the specified data format.
249    ///
250    /// Supported formats: `"json"`, `"xml"`, `"csv"`, `"zip"`, `"tar"`, `"gzip"`, `"tar.gz"`.
251    /// Returns `Err(CamelError::Config)` if the format name is unknown.
252    /// Converts a structured body (e.g., `Body::Json`) to a wire-format body (e.g., `Body::Text`).
253    ///
254    /// # Example
255    /// ```ignore
256    /// route.marshal("json")?.to("direct:next")
257    /// ```
258    fn marshal(mut self, format: impl Into<String>) -> Result<Self, CamelError> {
259        let name = format.into();
260        let df = builtin_data_format(&name)
261            .ok_or_else(|| CamelError::Config(format!("unknown data format: '{name}'")))?;
262        let svc = MarshalService::new(IdentityProcessor, df);
263        self.steps_mut()
264            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
265                svc,
266            ))));
267        Ok(self)
268    }
269
270    /// Unmarshal the message body using the specified data format.
271    ///
272    /// Supported formats: `"json"`, `"xml"`, `"csv"`, `"zip"`, `"tar"`, `"gzip"`, `"tar.gz"`.
273    /// Returns `Err(CamelError::Config)` if the format name is unknown.
274    /// Converts a wire-format body (e.g., `Body::Text`) to a structured body (e.g., `Body::Json`).
275    ///
276    /// # Example
277    /// ```ignore
278    /// route.unmarshal("json")?.to("direct:next")
279    /// ```
280    fn unmarshal(mut self, format: impl Into<String>) -> Result<Self, CamelError> {
281        let name = format.into();
282        let df = builtin_data_format(&name)
283            .ok_or_else(|| CamelError::Config(format!("unknown data format: '{name}'")))?;
284        let svc = UnmarshalService::new(IdentityProcessor, df);
285        self.steps_mut()
286            .push(BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
287                svc,
288            ))));
289        Ok(self)
290    }
291
292    /// Validate the exchange using a predicate expression.
293    ///
294    /// If the expression evaluates to `true`, the exchange continues.
295    /// If `false`, a `CamelError::ValidationError` is returned into the route error handler.
296    ///
297    /// # Example
298    /// ```ignore
299    /// route.validate("${body.size()} > 0").to("direct:out")
300    /// ```
301    fn validate(mut self, expression: impl Into<String>) -> Self {
302        let source = expression.into();
303        let expression = LanguageExpressionDef {
304            language: "simple".into(),
305            source,
306        };
307        self.steps_mut().push(BuilderStep::Validate {
308            predicate: expression,
309        });
310        self
311    }
312
313    /// Execute a script that can modify the exchange (headers, properties, body).
314    ///
315    /// The script has access to `headers`, `properties`, and `body` variables
316    /// and can modify them with assignment syntax: `headers["k"] = v`.
317    ///
318    /// # Example
319    /// ```ignore
320    /// // ignore: requires full CamelContext setup with registered language
321    /// route.script("rhai", r#"headers["tenant"] = "acme"; body = body + "_processed""#)
322    /// ```
323    fn script(mut self, language: impl Into<String>, script: impl Into<String>) -> Self {
324        self.steps_mut().push(BuilderStep::Script {
325            language: language.into(),
326            script: script.into(),
327        });
328        self
329    }
330
331    /// EIP-7 enrich: synchronous content enrichment via a resolved producer.
332    ///
333    /// Calls the given endpoint URI as a producer, then merges the response
334    /// back into the original exchange body using the default `UseEnrichedBody`
335    /// strategy (original headers/properties are preserved).
336    fn enrich(mut self, uri: impl Into<String>) -> Self {
337        self.steps_mut().push(BuilderStep::Enrich {
338            uri: uri.into(),
339            strategy: None,
340            timeout_ms: None,
341        });
342        self
343    }
344
345    /// EIP-7 pollEnrich: blocking poll of a PollingConsumer with timeout.
346    ///
347    /// Reads from a polling endpoint (e.g., file) and merges the result
348    /// into the exchange body using the default `UseEnrichedBody` strategy.
349    /// `timeout_ms` controls how long to wait for data.
350    fn poll_enrich(mut self, uri: impl Into<String>, timeout_ms: u64) -> Self {
351        self.steps_mut().push(BuilderStep::PollEnrich {
352            uri: uri.into(),
353            strategy: None,
354            timeout_ms: Some(timeout_ms),
355        });
356        self
357    }
358
359    fn bean(mut self, name: impl Into<String>, method: impl Into<String>) -> Self {
360        self.steps_mut().push(BuilderStep::Bean {
361            name: name.into(),
362            method: method.into(),
363        });
364        self
365    }
366}
367
368/// Identifies the endpoint slot a `.parameters()` call attaches to.
369#[derive(Debug, Clone, PartialEq, Eq)]
370enum EndpointSlot {
371    /// The route's `from` endpoint.
372    From,
373    /// A step in `RouteBuilder::steps`, by index.
374    Step(usize),
375}
376
377/// A fluent builder for constructing routes.
378///
379/// # Example
380///
381/// ```ignore
382/// let definition = RouteBuilder::from("timer:tick?period=1000")
383///     .set_header("source", Value::String("timer".into()))
384///     .filter(|ex| ex.input.body.as_text().is_some())
385///     .to("log:info?showHeaders=true")
386///     .build()?;
387/// ```
388/// `RouteBuilder` is `Clone`: a partially-built route can be cloned and reused as a
389/// template for multiple routes (mirrors Apache Camel's cloneable `RouteBuilder`).
390/// Clone is a deep copy of the step list; step closures live behind `Arc`/`BoxProcessor`
391/// so cloning shares the closure and duplicates only the light wrapper (rc-8m5o).
392#[derive(Clone)]
393pub struct RouteBuilder {
394    from_uri: String,
395    steps: Vec<BuilderStep>,
396    /// Pending `.parameters()` maps, each attached to a specific endpoint slot.
397    parameter_assignments: Vec<(EndpointSlot, BTreeMap<String, String>)>,
398    /// Misuse recorded at `.parameters()` call time and surfaced at `build()`.
399    parameter_misuse: Option<String>,
400    error_handler: Option<ErrorHandlerConfig>,
401    error_handler_mode: ErrorHandlerMode,
402    circuit_breaker_config: Option<CircuitBreakerConfig>,
403    security_policy_config: Option<camel_api::security_policy::SecurityPolicyConfig>,
404    security_authenticator: Option<std::sync::Arc<dyn camel_auth::TokenAuthenticator>>,
405    provider_registry: Option<std::sync::Arc<camel_auth::ProviderRegistry>>,
406    concurrency: Option<ConcurrencyModel>,
407    route_id: Option<String>,
408    auto_startup: Option<bool>,
409    startup_order: Option<i32>,
410}
411
412#[derive(Default, Clone)]
413enum ErrorHandlerMode {
414    #[default]
415    None,
416    ExplicitConfig,
417    Shorthand {
418        dlc_uri: Option<String>,
419        specs: Vec<OnExceptionSpec>,
420    },
421    Mixed,
422}
423
424#[derive(Clone)]
425struct OnExceptionSpec {
426    matches: std::sync::Arc<dyn Fn(&CamelError) -> bool + Send + Sync>,
427    retry: Option<RedeliveryPolicy>,
428    handled_by: Option<String>,
429}
430
431impl RouteBuilder {
432    /// Start building a route from the given source endpoint URI.
433    pub fn from(endpoint: &str) -> Self {
434        Self {
435            from_uri: endpoint.to_string(),
436            steps: Vec::new(),
437            parameter_assignments: Vec::new(),
438            parameter_misuse: None,
439            error_handler: None,
440            error_handler_mode: ErrorHandlerMode::None,
441            circuit_breaker_config: None,
442            security_policy_config: None,
443            security_authenticator: None,
444            provider_registry: None,
445            concurrency: None,
446            route_id: None,
447            auto_startup: None,
448            startup_order: None,
449        }
450    }
451
452    /// Open a filter scope. Only exchanges matching `predicate` will be processed
453    /// by the steps inside the scope. Non-matching exchanges skip the scope entirely
454    /// and continue to steps after `.end_filter()`.
455    pub fn filter<F>(self, predicate: F) -> FilterBuilder
456    where
457        F: Fn(&Exchange) -> bool + Send + Sync + 'static,
458    {
459        FilterBuilder {
460            parent: self,
461            predicate: camel_api::FilterPredicate::new(predicate),
462            steps: vec![],
463        }
464    }
465
466    /// Open a choice scope for content-based routing.
467    ///
468    /// Within the choice, you can define multiple `.when()` clauses and an
469    /// optional `.otherwise()` clause. The first matching `when` predicate
470    /// determines which sub-pipeline executes.
471    pub fn choice(self) -> ChoiceBuilder {
472        ChoiceBuilder {
473            parent: self,
474            whens: vec![],
475            _otherwise: None,
476        }
477    }
478
479    /// Add a WireTap step that sends a clone of the exchange to the given
480    /// endpoint URI (fire-and-forget). The original exchange continues
481    /// downstream unchanged.
482    pub fn wire_tap(mut self, endpoint: &str) -> Self {
483        self.steps.push(BuilderStep::WireTap {
484            uri: endpoint.to_string(),
485        });
486        self
487    }
488
489    /// Attach a `parameters:` map to the most recent endpoint slot — the `from`
490    /// endpoint when called before any step, otherwise the last added step.
491    ///
492    /// Parameters on different endpoints each persist independently; none
493    /// overwrites another. Misuse (a second `.parameters()` on the same slot,
494    /// or a call with no pending endpoint step) is deferred and surfaced as a
495    /// `CamelError::RouteError` at `build()` / `build_canonical()`, never a panic.
496    pub fn parameters(mut self, params: BTreeMap<String, String>) -> Self {
497        let slot = if self.steps.is_empty() {
498            EndpointSlot::From
499        } else {
500            EndpointSlot::Step(self.steps.len() - 1)
501        };
502
503        if self
504            .parameter_assignments
505            .iter()
506            .any(|(existing, _)| *existing == slot)
507        {
508            self.parameter_misuse =
509                Some("multiple .parameters() calls attached to the same endpoint".to_string());
510        } else if !endpoint_slot_bears_uri(&slot, &self.steps) {
511            self.parameter_misuse =
512                Some(".parameters() called with no pending endpoint step".to_string());
513        }
514
515        self.parameter_assignments.push((slot, params));
516        self
517    }
518
519    /// Set a per-route error handler. Overrides the global error handler on `CamelContext`.
520    pub fn error_handler(mut self, config: ErrorHandlerConfig) -> Self {
521        self.error_handler_mode = match self.error_handler_mode {
522            ErrorHandlerMode::None | ErrorHandlerMode::ExplicitConfig => {
523                ErrorHandlerMode::ExplicitConfig
524            }
525            ErrorHandlerMode::Shorthand { .. } | ErrorHandlerMode::Mixed => ErrorHandlerMode::Mixed,
526        };
527        self.error_handler = Some(config);
528        self
529    }
530
531    /// Set a dead letter channel URI for shorthand error handler mode.
532    pub fn dead_letter_channel(mut self, uri: impl Into<String>) -> Self {
533        let uri = uri.into();
534        self.error_handler_mode = match self.error_handler_mode {
535            ErrorHandlerMode::None => ErrorHandlerMode::Shorthand {
536                dlc_uri: Some(uri),
537                specs: Vec::new(),
538            },
539            ErrorHandlerMode::Shorthand { specs, .. } => ErrorHandlerMode::Shorthand {
540                dlc_uri: Some(uri),
541                specs,
542            },
543            ErrorHandlerMode::ExplicitConfig | ErrorHandlerMode::Mixed => ErrorHandlerMode::Mixed,
544        };
545        self
546    }
547
548    /// Add a shorthand exception policy scope. Call `.end_on_exception()` to return to route builder.
549    pub fn on_exception<F>(mut self, matches: F) -> OnExceptionBuilder
550    where
551        F: Fn(&CamelError) -> bool + Send + Sync + 'static,
552    {
553        self.error_handler_mode = match self.error_handler_mode {
554            ErrorHandlerMode::None => ErrorHandlerMode::Shorthand {
555                dlc_uri: None,
556                specs: Vec::new(),
557            },
558            ErrorHandlerMode::ExplicitConfig | ErrorHandlerMode::Mixed => ErrorHandlerMode::Mixed,
559            shorthand @ ErrorHandlerMode::Shorthand { .. } => shorthand,
560        };
561
562        OnExceptionBuilder {
563            parent: self,
564            policy: OnExceptionSpec {
565                matches: std::sync::Arc::new(matches),
566                retry: None,
567                handled_by: None,
568            },
569        }
570    }
571
572    /// Set a circuit breaker for this route.
573    pub fn circuit_breaker(mut self, config: CircuitBreakerConfig) -> Self {
574        self.circuit_breaker_config = Some(config);
575        self
576    }
577
578    pub fn security_policy(
579        mut self,
580        config: camel_api::security_policy::SecurityPolicyConfig,
581    ) -> Self {
582        self.security_policy_config = Some(config);
583        self
584    }
585
586    pub fn security_authenticator(
587        mut self,
588        auth: std::sync::Arc<dyn camel_auth::TokenAuthenticator>,
589    ) -> Self {
590        self.security_authenticator = Some(auth);
591        self
592    }
593
594    /// Named provider registry for security plan compilation (ADR-0061):
595    /// routes declaring security resolve their provider here; staging
596    /// aborts when the registry cannot satisfy the declaration.
597    pub fn provider_registry(
598        mut self,
599        registry: std::sync::Arc<camel_auth::ProviderRegistry>,
600    ) -> Self {
601        self.provider_registry = Some(registry);
602        self
603    }
604
605    /// Override the consumer's default concurrency model.
606    ///
607    /// When set, the pipeline spawns a task per exchange, processing them
608    /// concurrently. `max` limits the number of simultaneously active
609    /// pipeline executions (0 = unbounded, channel buffer is backpressure).
610    ///
611    /// # Example
612    /// ```ignore
613    /// RouteBuilder::from("http://0.0.0.0:8080/api")
614    ///     .concurrent(16)  // max 16 in-flight pipeline executions
615    ///     .process(handle_request)
616    ///     .build()
617    /// ```
618    pub fn concurrent(mut self, max: usize) -> Self {
619        let max = if max == 0 { None } else { Some(max) };
620        self.concurrency = Some(ConcurrencyModel::Concurrent { max });
621        self
622    }
623
624    /// Force sequential processing, overriding a concurrent-capable consumer.
625    ///
626    /// Useful for HTTP routes that mutate shared state and need ordering
627    /// guarantees.
628    pub fn sequential(mut self) -> Self {
629        self.concurrency = Some(ConcurrencyModel::Sequential);
630        self
631    }
632
633    /// Set the route ID for this route.
634    ///
635    /// If not set, the route will be assigned an auto-generated ID.
636    pub fn route_id(mut self, id: impl Into<String>) -> Self {
637        self.route_id = Some(id.into());
638        self
639    }
640
641    /// Set whether this route should automatically start when the context starts.
642    ///
643    /// Default is `true`.
644    pub fn auto_startup(mut self, auto: bool) -> Self {
645        self.auto_startup = Some(auto);
646        self
647    }
648
649    /// Set the startup order for this route.
650    ///
651    /// Routes with lower values start first. Default is 1000.
652    pub fn startup_order(mut self, order: i32) -> Self {
653        self.startup_order = Some(order);
654        self
655    }
656
657    /// Begin a Splitter sub-pipeline. Steps added after this call (until
658    /// `.end_split()`) will be executed per-fragment.
659    ///
660    /// Returns a `SplitBuilder` — you cannot call `.build()` until
661    /// `.end_split()` closes the split scope (enforced by the type system).
662    pub fn split(self, config: SplitterConfig) -> SplitBuilder {
663        SplitBuilder {
664            parent: self,
665            config,
666            steps: Vec::new(),
667        }
668    }
669
670    /// Begin a Multicast sub-pipeline. Steps added after this call (until
671    /// `.end_multicast()`) will each receive a copy of the exchange.
672    ///
673    /// Returns a `MulticastBuilder` — you cannot call `.build()` until
674    /// `.end_multicast()` closes the multicast scope (enforced by the type system).
675    pub fn multicast(self) -> MulticastBuilder {
676        MulticastBuilder {
677            parent: self,
678            steps: Vec::new(),
679            config: MulticastConfig::new(),
680        }
681    }
682
683    /// Begin a Throttle sub-pipeline. Rate limits message processing to at most
684    /// `max_requests` per `period`. Steps inside the throttle scope are only
685    /// executed when the rate limit allows.
686    ///
687    /// Returns a `ThrottleBuilder` — you cannot call `.build()` until
688    /// `.end_throttle()` closes the throttle scope (enforced by the type system).
689    pub fn throttle(self, max_requests: usize, period: std::time::Duration) -> ThrottleBuilder {
690        ThrottleBuilder {
691            parent: self,
692            config: ThrottlerConfig::new(max_requests, period),
693            steps: Vec::new(),
694        }
695    }
696
697    /// Begin a Loop sub-pipeline that iterates a fixed number of times.
698    pub fn loop_count(self, count: usize) -> LoopBuilder {
699        LoopBuilder {
700            parent: self,
701            config: LoopConfig::new(LoopMode::Count(count)),
702            steps: vec![],
703        }
704    }
705
706    /// Begin a Loop sub-pipeline that iterates while a predicate is true.
707    pub fn loop_while<F>(self, predicate: F) -> LoopBuilder
708    where
709        F: Fn(&Exchange) -> bool + Send + Sync + 'static,
710    {
711        LoopBuilder {
712            parent: self,
713            config: LoopConfig::new(LoopMode::While(camel_api::PredicateSource::Sync(
714                camel_api::FilterPredicate::new(predicate),
715            ))),
716            steps: vec![],
717        }
718    }
719
720    /// Begin a LoadBalance sub-pipeline. Distributes exchanges across multiple
721    /// endpoints using a configurable strategy (round-robin, random, weighted, failover).
722    ///
723    /// Returns a `LoadBalancerBuilder` — you cannot call `.build()` until
724    /// `.end_load_balance()` closes the load balance scope (enforced by the type system).
725    pub fn load_balance(self) -> LoadBalancerBuilder {
726        LoadBalancerBuilder {
727            parent: self,
728            config: LoadBalancerConfig::round_robin(),
729            steps: Vec::new(),
730        }
731    }
732
733    /// Add a dynamic router step that routes exchanges dynamically based on
734    /// expression evaluation at runtime.
735    ///
736    /// The expression source resolves each exchange to `Some(uri)` to route
737    /// to the next endpoint, or `None` to stop routing. Language-backed
738    /// fallible sources are built with `TargetSource::Async`; evaluation
739    /// failures fail the step instead of silently stopping the route.
740    ///
741    /// # Example
742    /// ```ignore
743    /// RouteBuilder::from("timer:tick")
744    ///     .route_id("test-route")
745    ///     .dynamic_router(TargetSource::Sync(Arc::new(|ex| {
746    ///         ex.input.header("dest").and_then(|v| v.as_str().map(|s| s.to_string()))
747    ///     })))
748    ///     .build()
749    /// ```
750    pub fn dynamic_router(self, expression: impl Into<camel_api::TargetSource>) -> Self {
751        self.dynamic_router_with_config(DynamicRouterConfig::new(expression))
752    }
753
754    /// Add a dynamic router step with full configuration.
755    ///
756    /// Allows customization of URI delimiter, cache size, timeout, and other options.
757    pub fn dynamic_router_with_config(mut self, config: DynamicRouterConfig) -> Self {
758        self.steps.push(BuilderStep::DynamicRouter { config });
759        self
760    }
761
762    pub fn routing_slip(self, expression: impl Into<camel_api::TargetSource>) -> Self {
763        self.routing_slip_with_config(RoutingSlipConfig::new(expression))
764    }
765
766    pub fn routing_slip_with_config(mut self, config: RoutingSlipConfig) -> Self {
767        self.steps.push(BuilderStep::RoutingSlip { config });
768        self
769    }
770
771    pub fn recipient_list(self, expression: impl Into<camel_api::RecipientSource>) -> Self {
772        self.recipient_list_with_config(RecipientListConfig::new(expression))
773    }
774
775    pub fn recipient_list_with_config(mut self, config: RecipientListConfig) -> Self {
776        self.steps.push(BuilderStep::RecipientList { config });
777        self
778    }
779
780    /// Consume the builder and produce a [`RouteDefinition`].
781    // Duplicate route IDs are detected at `CamelContext::add_route_definition` time
782    // (RouteController rejects atomically with CamelError::RouteError).
783    pub fn build(mut self) -> Result<RouteDefinition, CamelError> {
784        validate_uri(&self.from_uri)?;
785        let route_id = self
786            .route_id
787            .filter(|s| !s.trim().is_empty())
788            .ok_or_else(|| {
789                CamelError::RouteError(
790                    "route must have a non-empty 'route_id' — call .route_id(\"name\") on the builder"
791                        .to_string(),
792                )
793            })?;
794        let resolved_error_handler = match self.error_handler_mode {
795            ErrorHandlerMode::None => self.error_handler,
796            ErrorHandlerMode::ExplicitConfig => self.error_handler,
797            ErrorHandlerMode::Mixed => {
798                return Err(CamelError::RouteError(
799                    "mixed error handler modes: cannot combine .error_handler(config) with shorthand methods".into(),
800                ));
801            }
802            ErrorHandlerMode::Shorthand { dlc_uri, specs } => {
803                let mut config = if let Some(uri) = dlc_uri {
804                    ErrorHandlerConfig::dead_letter_channel(uri)
805                } else {
806                    ErrorHandlerConfig::log_only()
807                };
808
809                for spec in specs {
810                    let matcher = spec.matches.clone();
811                    let mut builder = config.on_exception(move |e| matcher(e));
812
813                    if let Some(retry) = spec.retry {
814                        builder = builder.retry(retry.max_attempts).with_backoff(
815                            retry.initial_delay,
816                            retry.multiplier,
817                            retry.max_delay,
818                        );
819                        if retry.jitter_factor > 0.0 {
820                            builder = builder.with_jitter(retry.jitter_factor);
821                        }
822                    }
823
824                    if let Some(uri) = spec.handled_by {
825                        builder = builder.handled_by(uri);
826                    }
827
828                    config = builder.build();
829                }
830
831                Some(config)
832            }
833        };
834
835        // Deferred `.parameters()` validation and merge: misuse is a RouteError,
836        // URI merge failures surface as CamelError::EndpointUri via `?`.
837        apply_parameter_assignments(
838            &mut self.from_uri,
839            &mut self.steps,
840            std::mem::take(&mut self.parameter_assignments),
841            std::mem::take(&mut self.parameter_misuse),
842        )?;
843
844        let definition = RouteDefinition::new(self.from_uri, self.steps);
845        let definition = if let Some(eh) = resolved_error_handler {
846            definition.with_error_handler(eh)
847        } else {
848            definition
849        };
850        let definition = if let Some(cb) = self.circuit_breaker_config {
851            definition.with_circuit_breaker(cb)
852        } else {
853            definition
854        };
855        let definition = if let Some(sp) = self.security_policy_config {
856            definition.with_security_policy(sp)
857        } else {
858            definition
859        };
860        let definition = if let Some(auth) = self.security_authenticator {
861            definition.with_security_authenticator(auth)
862        } else {
863            definition
864        };
865        let definition = if let Some(registry) = self.provider_registry {
866            definition.with_provider_registry(registry)
867        } else {
868            definition
869        };
870        let definition = if let Some(concurrency) = self.concurrency {
871            definition.with_concurrency(concurrency)
872        } else {
873            definition
874        };
875        let definition = definition.with_route_id(route_id);
876        let definition = if let Some(auto) = self.auto_startup {
877            definition.with_auto_startup(auto)
878        } else {
879            definition
880        };
881        let definition = if let Some(order) = self.startup_order {
882            definition.with_startup_order(order)
883        } else {
884            definition
885        };
886        Ok(definition)
887    }
888
889    /// Compile this builder route into canonical spec.
890    pub fn build_canonical(mut self) -> Result<CanonicalRouteSpec, CamelError> {
891        validate_uri(&self.from_uri)?;
892        let route_id = self
893            .route_id
894            .filter(|s| !s.trim().is_empty())
895            .ok_or_else(|| {
896                CamelError::RouteError(
897                    "route must have a non-empty 'route_id' — call .route_id(\"name\") on the builder"
898                        .to_string(),
899                )
900            })?;
901
902        // Deferred `.parameters()` validation and merge (same path as `build()`).
903        apply_parameter_assignments(
904            &mut self.from_uri,
905            &mut self.steps,
906            std::mem::take(&mut self.parameter_assignments),
907            std::mem::take(&mut self.parameter_misuse),
908        )?;
909
910        let steps = canonicalize_steps(self.steps)?;
911        let circuit_breaker = self
912            .circuit_breaker_config
913            .map(canonicalize_circuit_breaker)
914            .transpose()?;
915
916        if self.security_policy_config.is_some() {
917            return Err(CamelError::RouteError(
918                "routes with security_policy cannot use the canonical/hot-reload path (not yet supported)"
919                    .into(),
920            ));
921        }
922
923        let spec = CanonicalRouteSpec {
924            route_id,
925            from: self.from_uri,
926            steps,
927            circuit_breaker,
928            auto_startup: None,
929            startup_order: None,
930            concurrency: None,
931            version: camel_api::CANONICAL_CONTRACT_VERSION,
932        };
933        spec.validate_contract()?;
934        Ok(spec)
935    }
936}
937
938pub struct OnExceptionBuilder {
939    parent: RouteBuilder,
940    policy: OnExceptionSpec,
941}
942
943impl OnExceptionBuilder {
944    pub fn retry(mut self, max_attempts: u32) -> Self {
945        self.policy.retry = Some(RedeliveryPolicy::new(max_attempts));
946        self
947    }
948
949    pub fn with_backoff(
950        mut self,
951        initial: std::time::Duration,
952        multiplier: f64,
953        max: std::time::Duration,
954    ) -> Self {
955        if let Some(ref mut retry) = self.policy.retry {
956            retry.initial_delay = initial;
957            retry.multiplier = multiplier;
958            retry.max_delay = max;
959        } else {
960            tracing::warn!("backoff/jitter configuration has no effect when retry_count is 0");
961        }
962        self
963    }
964
965    pub fn with_jitter(mut self, jitter_factor: f64) -> Self {
966        if let Some(ref mut retry) = self.policy.retry {
967            retry.jitter_factor = jitter_factor.clamp(0.0, 1.0);
968        } else {
969            tracing::warn!("backoff/jitter configuration has no effect when retry_count is 0");
970        }
971        self
972    }
973
974    pub fn handled_by(mut self, uri: impl Into<String>) -> Self {
975        self.policy.handled_by = Some(uri.into());
976        self
977    }
978
979    pub fn end_on_exception(mut self) -> RouteBuilder {
980        if let ErrorHandlerMode::Shorthand { ref mut specs, .. } = self.parent.error_handler_mode {
981            specs.push(self.policy);
982        }
983        self.parent
984    }
985}
986
987/// True when `slot` names an endpoint-bearing position: the `from` endpoint, or
988/// a step of one of the endpoint kinds (`To`, `WireTap`, `Enrich`, `PollEnrich`).
989fn endpoint_slot_bears_uri(slot: &EndpointSlot, steps: &[BuilderStep]) -> bool {
990    match slot {
991        EndpointSlot::From => true,
992        EndpointSlot::Step(i) => steps.get(*i).is_some_and(|step| {
993            matches!(
994                step,
995                BuilderStep::To(_)
996                    | BuilderStep::WireTap { .. }
997                    | BuilderStep::Enrich { .. }
998                    | BuilderStep::PollEnrich { .. }
999            )
1000        }),
1001    }
1002}
1003
1004/// Merge each pending `.parameters()` assignment into its endpoint slot's URI.
1005///
1006/// A recorded misuse flag aborts first (builder misuse → `RouteError`). Each
1007/// endpoint slot is then verified endpoint-bearing and its URI re-rendered via
1008/// [`EndpointUri::try_from_uri_and_params`]; URI merge failures propagate as
1009/// `CamelError::EndpointUri` (never folded into `RouteError`). Empty parameter
1010/// maps are skipped entirely, preserving URI bytes (parity with the DSL
1011/// lowering); misuse for such calls is still surfaced via `misuse`.
1012fn apply_parameter_assignments(
1013    from_uri: &mut String,
1014    steps: &mut [BuilderStep],
1015    assignments: Vec<(EndpointSlot, BTreeMap<String, String>)>,
1016    misuse: Option<String>,
1017) -> Result<(), CamelError> {
1018    if let Some(reason) = misuse {
1019        return Err(CamelError::RouteError(reason));
1020    }
1021
1022    for (slot, params) in assignments {
1023        // Empty maps are no-ops: re-rendering would still route through
1024        // `EndpointUri` and could normalize URI bytes, diverging from the DSL
1025        // lowering which skips empty maps for byte-identity/passthrough.
1026        if params.is_empty() {
1027            continue;
1028        }
1029
1030        let uri = match slot {
1031            EndpointSlot::From => &mut *from_uri,
1032            EndpointSlot::Step(i) => match steps.get_mut(i) {
1033                Some(BuilderStep::To(uri)) => uri,
1034                Some(BuilderStep::WireTap { uri, .. }) => uri,
1035                Some(BuilderStep::Enrich { uri, .. }) => uri,
1036                Some(BuilderStep::PollEnrich { uri, .. }) => uri,
1037                Some(other) => {
1038                    return Err(CamelError::RouteError(format!(
1039                        ".parameters() attached to a non-endpoint step (`{}`)",
1040                        canonical_step_name(other)
1041                    )));
1042                }
1043                None => {
1044                    return Err(CamelError::RouteError(
1045                        ".parameters() attached to an out-of-range step index".to_string(),
1046                    ));
1047                }
1048            },
1049        };
1050        let merged = EndpointUri::try_from_uri_and_params(uri, params)?.to_canonical_string();
1051        *uri = merged;
1052    }
1053
1054    Ok(())
1055}
1056
1057/// Validate that a URI is non-empty and contains a scheme component.
1058fn validate_uri(uri: &str) -> Result<(), CamelError> {
1059    let trimmed = uri.trim();
1060    if trimmed.is_empty() {
1061        return Err(CamelError::RouteError(
1062            "route must have a 'from' URI".to_string(),
1063        ));
1064    }
1065    if !trimmed.contains(':') {
1066        return Err(CamelError::RouteError(
1067            "URI must have a scheme (e.g. 'timer:tick')".to_string(),
1068        ));
1069    }
1070    let scheme = trimmed.split(':').next().unwrap_or("");
1071    if scheme.trim().is_empty() {
1072        return Err(CamelError::RouteError(
1073            "URI scheme must not be empty".to_string(),
1074        ));
1075    }
1076    Ok(())
1077}
1078
1079fn canonicalize_steps(steps: Vec<BuilderStep>) -> Result<Vec<CanonicalStepSpec>, CamelError> {
1080    let mut canonical = Vec::with_capacity(steps.len());
1081    for step in steps {
1082        canonical.push(canonicalize_step(step)?);
1083    }
1084    Ok(canonical)
1085}
1086
1087fn canonicalize_step(step: BuilderStep) -> Result<CanonicalStepSpec, CamelError> {
1088    match step {
1089        BuilderStep::To(uri) => Ok(CanonicalStepSpec::To { uri }),
1090        BuilderStep::Log { message, .. } => Ok(CanonicalStepSpec::Log { message }),
1091        BuilderStep::Stop => Ok(CanonicalStepSpec::Stop),
1092        BuilderStep::WireTap { uri } => Ok(CanonicalStepSpec::WireTap { uri }),
1093        BuilderStep::Delay { config } => Ok(CanonicalStepSpec::Delay {
1094            delay_ms: config.delay_ms,
1095            dynamic_header: config.dynamic_header,
1096        }),
1097        BuilderStep::DeclarativeScript { expression } => {
1098            Ok(CanonicalStepSpec::Script { expression })
1099        }
1100        BuilderStep::DeclarativeFilter { predicate, steps } => Ok(CanonicalStepSpec::Filter {
1101            predicate,
1102            steps: canonicalize_steps(steps)?,
1103        }),
1104        BuilderStep::DeclarativeChoice { whens, otherwise } => {
1105            let mut canonical_whens = Vec::with_capacity(whens.len());
1106            for DeclarativeWhenStep { predicate, steps } in whens {
1107                canonical_whens.push(CanonicalWhenSpec {
1108                    predicate,
1109                    steps: canonicalize_steps(steps)?,
1110                });
1111            }
1112            let otherwise = match otherwise {
1113                Some(steps) => Some(canonicalize_steps(steps)?),
1114                None => None,
1115            };
1116            Ok(CanonicalStepSpec::Choice {
1117                whens: canonical_whens,
1118                otherwise,
1119            })
1120        }
1121        BuilderStep::DeclarativeSplit {
1122            expression,
1123            aggregation,
1124            parallel,
1125            parallel_limit,
1126            // The programmatic language-split builder has no knob source;
1127            // the default applies at wrap time (asymmetry deferred).
1128            trace_item_threshold: _,
1129            stop_on_exception,
1130            steps,
1131        } => Ok(CanonicalStepSpec::Split {
1132            expression: CanonicalSplitExpressionSpec::Language(expression),
1133            aggregation: canonicalize_split_aggregation(aggregation)?,
1134            parallel,
1135            parallel_limit,
1136            trace_item_threshold: None,
1137            stop_on_exception,
1138            steps: canonicalize_steps(steps)?,
1139        }),
1140        BuilderStep::Aggregate { config } => Ok(CanonicalStepSpec::Aggregate(
1141            canonicalize_aggregate(config)?,
1142        )),
1143        other => {
1144            let step_name = canonical_step_name(&other);
1145            let detail = camel_api::canonical_contract_rejection_reason(step_name)
1146                .unwrap_or("not included in canonical v2");
1147            Err(CamelError::RouteError(format!(
1148                "canonical v2 does not support step `{step_name}`: {detail}"
1149            )))
1150        }
1151    }
1152}
1153
1154fn canonicalize_split_aggregation(
1155    strategy: camel_api::splitter::AggregationStrategy,
1156) -> Result<CanonicalSplitAggregationSpec, CamelError> {
1157    match strategy {
1158        camel_api::splitter::AggregationStrategy::LastWins => {
1159            Ok(CanonicalSplitAggregationSpec::LastWins)
1160        }
1161        camel_api::splitter::AggregationStrategy::CollectAll => {
1162            Ok(CanonicalSplitAggregationSpec::CollectAll)
1163        }
1164        camel_api::splitter::AggregationStrategy::Custom(_) => Err(CamelError::RouteError(
1165            "canonical v2 does not support custom split aggregation".to_string(),
1166        )),
1167        camel_api::splitter::AggregationStrategy::Original => {
1168            Ok(CanonicalSplitAggregationSpec::Original)
1169        }
1170        _ => Err(CamelError::RouteError(
1171            "canonical v2 does not support this split aggregation strategy".to_string(),
1172        )),
1173    }
1174}
1175
1176fn extract_completion_fields(
1177    mode: &CompletionMode,
1178) -> Result<(Option<usize>, Option<u64>), CamelError> {
1179    match mode {
1180        CompletionMode::Single(cond) => match cond {
1181            CompletionCondition::Size(n) => Ok((Some(*n), None)),
1182            CompletionCondition::Timeout(d) => Ok((None, Some(d.as_millis() as u64))),
1183            CompletionCondition::Predicate(_) | CompletionCondition::PredicateExpr { .. } => {
1184                Err(CamelError::RouteError(
1185                    "aggregate PredicateExpr/Predicate completion cannot reverse-map to canonical \
1186                     (forward-only in rc-zit); build the canonical spec directly"
1187                        .to_string(),
1188                ))
1189            }
1190            _ => Err(CamelError::RouteError(
1191                "unsupported completion condition".to_string(),
1192            )),
1193        },
1194        CompletionMode::Any(conds) => {
1195            let mut size = None;
1196            let mut timeout_ms = None;
1197            for cond in conds {
1198                match cond {
1199                    CompletionCondition::Size(n) => size = Some(*n),
1200                    CompletionCondition::Timeout(d) => timeout_ms = Some(d.as_millis() as u64),
1201                    CompletionCondition::Predicate(_)
1202                    | CompletionCondition::PredicateExpr { .. } => {
1203                        return Err(CamelError::RouteError(
1204                            "aggregate PredicateExpr/Predicate completion cannot reverse-map to \
1205                             canonical (forward-only in rc-zit); build the canonical spec directly"
1206                                .to_string(),
1207                        ));
1208                    }
1209                    _ => {
1210                        return Err(CamelError::RouteError(
1211                            "unsupported completion condition".to_string(),
1212                        ));
1213                    }
1214                }
1215            }
1216            Ok((size, timeout_ms))
1217        }
1218        _ => Err(CamelError::RouteError(
1219            "unsupported completion mode".to_string(),
1220        )),
1221    }
1222}
1223
1224fn canonicalize_aggregate(config: AggregatorConfig) -> Result<CanonicalAggregateSpec, CamelError> {
1225    let (completion_size, completion_timeout_ms) = extract_completion_fields(&config.completion)?;
1226
1227    let header = match &config.correlation {
1228        CorrelationStrategy::HeaderName(h) => h.clone(),
1229        CorrelationStrategy::Expression { expr, .. } => expr.clone(),
1230        CorrelationStrategy::Fn(_) => {
1231            return Err(CamelError::RouteError(
1232                "canonical v2 does not support Fn correlation strategy".to_string(),
1233            ));
1234        }
1235        _ => {
1236            return Err(CamelError::RouteError(
1237                "canonical v2 does not support this correlation strategy".to_string(),
1238            ));
1239        }
1240    };
1241
1242    let correlation_key = match &config.correlation {
1243        CorrelationStrategy::HeaderName(_) => None,
1244        CorrelationStrategy::Expression { expr, .. } => Some(expr.clone()),
1245        // INVARIANT: every other correlation variant (Fn and any future
1246        // variant) is rejected by the `header` match above, which returns
1247        // early — so this branch is unreachable here.
1248        _ => unreachable!(),
1249    };
1250
1251    let strategy = match config.strategy {
1252        AggregationStrategy::CollectAll => CanonicalAggregateStrategySpec::CollectAll,
1253        AggregationStrategy::Custom(_) => {
1254            return Err(CamelError::RouteError(
1255                "canonical v2 does not support custom aggregate strategy".to_string(),
1256            ));
1257        }
1258        _ => {
1259            return Err(CamelError::RouteError(
1260                "canonical v2 does not support this aggregate strategy".to_string(),
1261            ));
1262        }
1263    };
1264    let bucket_ttl_ms = config
1265        .bucket_ttl
1266        .map(|ttl| u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX));
1267
1268    Ok(CanonicalAggregateSpec {
1269        header,
1270        completion_size,
1271        completion_timeout_ms,
1272        correlation_key,
1273        force_completion_on_stop: if config.force_completion_on_stop {
1274            Some(true)
1275        } else {
1276            None
1277        },
1278        discard_on_timeout: if config.discard_on_timeout {
1279            Some(true)
1280        } else {
1281            None
1282        },
1283        strategy,
1284        max_buckets: config.max_buckets,
1285        max_bucket_size: config.max_bucket_size,
1286        bucket_ttl_ms,
1287        completion_predicate: None,
1288    })
1289}
1290
1291fn canonicalize_circuit_breaker(
1292    config: CircuitBreakerConfig,
1293) -> Result<CanonicalCircuitBreakerSpec, CamelError> {
1294    if config.fallback.is_some() {
1295        return Err(CamelError::RouteError(
1296            "canonical v2 does not support circuit breaker `fallback` (opaque BoxProcessor \
1297             cannot reverse-map to canonical steps); build the canonical spec directly"
1298                .to_string(),
1299        ));
1300    }
1301    Ok(CanonicalCircuitBreakerSpec {
1302        failure_threshold: config.failure_threshold,
1303        open_duration_ms: u64::try_from(config.open_duration.as_millis()).unwrap_or(u64::MAX),
1304        fallback: Vec::new(),
1305    })
1306}
1307
1308fn canonical_step_name(step: &BuilderStep) -> &'static str {
1309    match step {
1310        BuilderStep::Processor(_) => "processor",
1311        BuilderStep::To(_) => "to",
1312        BuilderStep::Stop => "stop",
1313        BuilderStep::Log { .. } => "log",
1314        BuilderStep::DeclarativeSetHeader { .. } => "set_header",
1315        BuilderStep::DeclarativeSetHeaderIfAbsent { .. } => "set_header_if_absent",
1316        BuilderStep::DeclarativeRemoveHeader { .. } => "remove_header",
1317        BuilderStep::DeclarativeSetBody { .. } => "set_body",
1318        BuilderStep::DeclarativeFilter { .. } => "filter",
1319        BuilderStep::DeclarativeChoice { .. } => "choice",
1320        BuilderStep::DeclarativeScript { .. } => "script",
1321        BuilderStep::DeclarativeFunction { .. } => "function",
1322        BuilderStep::DeclarativeSplit { .. } => "split",
1323        BuilderStep::Split { .. } => "split",
1324        BuilderStep::Loop { .. } | BuilderStep::DeclarativeLoop { .. } => "loop",
1325        BuilderStep::Aggregate { .. } => "aggregate",
1326        BuilderStep::Filter { .. } => "filter",
1327        BuilderStep::Choice { .. } => "choice",
1328        BuilderStep::WireTap { .. } => "wire_tap",
1329        BuilderStep::Delay { .. } => "delay",
1330        BuilderStep::Multicast { .. } => "multicast",
1331        BuilderStep::DeclarativeLog { .. } => "log",
1332        BuilderStep::Bean { .. } => "bean",
1333        BuilderStep::Script { .. } => "script",
1334        BuilderStep::Throttle { .. } => "throttle",
1335        BuilderStep::LoadBalance { .. } => "load_balancer",
1336        BuilderStep::DynamicRouter { .. } => "dynamic_router",
1337        BuilderStep::RoutingSlip { .. } => "routing_slip",
1338        BuilderStep::DeclarativeDynamicRouter { .. } => "declarative_dynamic_router",
1339        BuilderStep::DeclarativeRoutingSlip { .. } => "declarative_routing_slip",
1340        BuilderStep::RecipientList { .. } => "recipient_list",
1341        BuilderStep::DeclarativeRecipientList { .. } => "declarative_recipient_list",
1342        BuilderStep::DeclarativeSetProperty { .. } => "set_property",
1343        BuilderStep::DeclarativeStreamSplit { .. } => "stream_split",
1344        BuilderStep::Enrich { .. } => "enrich",
1345        BuilderStep::PollEnrich { .. } => "poll_enrich",
1346        BuilderStep::Validate { .. } => "validate",
1347        BuilderStep::IdempotentConsumer { .. } => "idempotent_consumer",
1348        BuilderStep::ClaimCheck { .. } => "claim_check",
1349        BuilderStep::Cache { .. } => "cache",
1350        BuilderStep::CacheInvalidate { .. } => "cache_invalidate",
1351        BuilderStep::CacheClear { .. } => "cache_clear",
1352        BuilderStep::CacheStats { .. } => "cache_stats",
1353        BuilderStep::CachePeekStale { .. } => "cache_peek_stale",
1354        BuilderStep::Sampling { .. } => "sampling",
1355        BuilderStep::Sort { .. } => "sort",
1356        BuilderStep::DeclarativeDoTry { .. } => "do_try",
1357        BuilderStep::Resequence { .. } => "resequence",
1358    }
1359}
1360
1361impl StepAccumulator for RouteBuilder {
1362    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1363        &mut self.steps
1364    }
1365}
1366
1367/// Builder for the sub-pipeline within a `.split()` ... `.end_split()` block.
1368///
1369/// Exposes the same step methods as `RouteBuilder` (to, process, filter, etc.)
1370/// but NOT `.build()` and NOT `.split()` (no nested splits).
1371///
1372/// Calling `.end_split()` packages the sub-steps into a `BuilderStep::Split`
1373/// and returns the parent `RouteBuilder`.
1374pub struct SplitBuilder {
1375    parent: RouteBuilder,
1376    config: SplitterConfig,
1377    steps: Vec<BuilderStep>,
1378}
1379
1380impl SplitBuilder {
1381    /// Open a filter scope within the split sub-pipeline.
1382    pub fn filter<F>(self, predicate: F) -> FilterInSplitBuilder
1383    where
1384        F: Fn(&Exchange) -> bool + Send + Sync + 'static,
1385    {
1386        FilterInSplitBuilder {
1387            parent: self,
1388            predicate: camel_api::FilterPredicate::new(predicate),
1389            steps: vec![],
1390        }
1391    }
1392
1393    /// Close the split scope. Packages the accumulated sub-steps into a
1394    /// `BuilderStep::Split` and returns the parent `RouteBuilder`.
1395    pub fn end_split(mut self) -> RouteBuilder {
1396        let split_step = BuilderStep::Split {
1397            config: self.config,
1398            steps: self.steps,
1399        };
1400        self.parent.steps.push(split_step);
1401        self.parent
1402    }
1403}
1404
1405impl StepAccumulator for SplitBuilder {
1406    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1407        &mut self.steps
1408    }
1409}
1410
1411/// Builder for the sub-pipeline within a `.filter()` ... `.end_filter()` block.
1412pub struct FilterBuilder {
1413    parent: RouteBuilder,
1414    predicate: FilterPredicate,
1415    steps: Vec<BuilderStep>,
1416}
1417
1418impl FilterBuilder {
1419    /// Close the filter scope. Packages the accumulated sub-steps into a
1420    /// `BuilderStep::Filter` and returns the parent `RouteBuilder`.
1421    pub fn end_filter(mut self) -> RouteBuilder {
1422        let step = BuilderStep::Filter {
1423            predicate: self.predicate,
1424            steps: self.steps,
1425        };
1426        self.parent.steps.push(step);
1427        self.parent
1428    }
1429}
1430
1431impl StepAccumulator for FilterBuilder {
1432    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1433        &mut self.steps
1434    }
1435}
1436
1437/// Builder for a filter scope nested inside a `.split()` block.
1438pub struct FilterInSplitBuilder {
1439    parent: SplitBuilder,
1440    predicate: FilterPredicate,
1441    steps: Vec<BuilderStep>,
1442}
1443
1444impl FilterInSplitBuilder {
1445    /// Close the filter scope and return the parent `SplitBuilder`.
1446    pub fn end_filter(mut self) -> SplitBuilder {
1447        let step = BuilderStep::Filter {
1448            predicate: self.predicate,
1449            steps: self.steps,
1450        };
1451        self.parent.steps.push(step);
1452        self.parent
1453    }
1454}
1455
1456impl StepAccumulator for FilterInSplitBuilder {
1457    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1458        &mut self.steps
1459    }
1460}
1461
1462// ── Choice/When/Otherwise builders ─────────────────────────────────────────
1463
1464/// Builder for a `.choice()` ... `.end_choice()` block.
1465///
1466/// Accumulates `when` clauses and an optional `otherwise` clause.
1467/// Cannot call `.build()` until `.end_choice()` is called.
1468pub struct ChoiceBuilder {
1469    parent: RouteBuilder,
1470    whens: Vec<WhenStep>,
1471    _otherwise: Option<Vec<BuilderStep>>,
1472}
1473
1474impl ChoiceBuilder {
1475    /// Open a `when` clause. Only exchanges matching `predicate` will be
1476    /// processed by the steps inside the `.when()` ... `.end_when()` scope.
1477    pub fn when<F>(self, predicate: F) -> WhenBuilder
1478    where
1479        F: Fn(&Exchange) -> bool + Send + Sync + 'static,
1480    {
1481        WhenBuilder {
1482            parent: self,
1483            predicate: camel_api::FilterPredicate::new(predicate),
1484            steps: vec![],
1485        }
1486    }
1487
1488    /// Open an `otherwise` clause. Executed when no `when` predicate matched.
1489    ///
1490    /// Only one `otherwise` is allowed per `choice`. Call this after all `.when()` clauses.
1491    pub fn otherwise(self) -> OtherwiseBuilder {
1492        OtherwiseBuilder {
1493            parent: self,
1494            steps: vec![],
1495        }
1496    }
1497
1498    /// Close the choice scope. Packages all accumulated `when` clauses and
1499    /// optional `otherwise` into a `BuilderStep::Choice` and returns the
1500    /// parent `RouteBuilder`.
1501    pub fn end_choice(mut self) -> RouteBuilder {
1502        let step = BuilderStep::Choice {
1503            whens: self.whens,
1504            otherwise: self._otherwise,
1505        };
1506        self.parent.steps.push(step);
1507        self.parent
1508    }
1509}
1510
1511/// Builder for the sub-pipeline within a `.when()` ... `.end_when()` block.
1512pub struct WhenBuilder {
1513    parent: ChoiceBuilder,
1514    predicate: camel_api::FilterPredicate,
1515    steps: Vec<BuilderStep>,
1516}
1517
1518impl WhenBuilder {
1519    /// Close the when scope. Packages the accumulated sub-steps into a
1520    /// `WhenStep` and returns the parent `ChoiceBuilder`.
1521    pub fn end_when(mut self) -> ChoiceBuilder {
1522        self.parent.whens.push(WhenStep {
1523            predicate: self.predicate,
1524            steps: self.steps,
1525        });
1526        self.parent
1527    }
1528}
1529
1530impl StepAccumulator for WhenBuilder {
1531    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1532        &mut self.steps
1533    }
1534}
1535
1536/// Builder for the sub-pipeline within an `.otherwise()` ... `.end_otherwise()` block.
1537pub struct OtherwiseBuilder {
1538    parent: ChoiceBuilder,
1539    steps: Vec<BuilderStep>,
1540}
1541
1542impl OtherwiseBuilder {
1543    /// Close the otherwise scope and return the parent `ChoiceBuilder`.
1544    pub fn end_otherwise(self) -> ChoiceBuilder {
1545        let OtherwiseBuilder { mut parent, steps } = self;
1546        parent._otherwise = Some(steps);
1547        parent
1548    }
1549}
1550
1551impl StepAccumulator for OtherwiseBuilder {
1552    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1553        &mut self.steps
1554    }
1555}
1556
1557/// Builder for the sub-pipeline within a `.multicast()` ... `.end_multicast()` block.
1558///
1559/// Exposes the same step methods as `RouteBuilder` (to, process, filter, etc.)
1560/// but NOT `.build()` and NOT `.multicast()` (no nested multicasts).
1561///
1562/// Calling `.end_multicast()` packages the sub-steps into a `BuilderStep::Multicast`
1563/// and returns the parent `RouteBuilder`.
1564pub struct MulticastBuilder {
1565    parent: RouteBuilder,
1566    steps: Vec<BuilderStep>,
1567    config: MulticastConfig,
1568}
1569
1570impl MulticastBuilder {
1571    pub fn parallel(mut self, parallel: bool) -> Self {
1572        self.config = self.config.parallel(parallel);
1573        self
1574    }
1575
1576    pub fn parallel_limit(mut self, limit: usize) -> Self {
1577        self.config = self.config.parallel_limit(limit);
1578        self
1579    }
1580
1581    pub fn stop_on_exception(mut self, stop: bool) -> Self {
1582        self.config = self.config.stop_on_exception(stop);
1583        self
1584    }
1585
1586    pub fn timeout(mut self, duration: std::time::Duration) -> Self {
1587        self.config = self.config.timeout(duration);
1588        self
1589    }
1590
1591    pub fn aggregation(mut self, strategy: MulticastStrategy) -> Self {
1592        self.config = self.config.aggregation(strategy);
1593        self
1594    }
1595
1596    pub fn end_multicast(mut self) -> RouteBuilder {
1597        let step = BuilderStep::Multicast {
1598            steps: self.steps,
1599            config: self.config,
1600        };
1601        self.parent.steps.push(step);
1602        self.parent
1603    }
1604}
1605
1606impl StepAccumulator for MulticastBuilder {
1607    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1608        &mut self.steps
1609    }
1610}
1611
1612/// Builder for the sub-pipeline within a `.throttle()` ... `.end_throttle()` block.
1613///
1614/// Exposes the same step methods as `RouteBuilder` (to, process, filter, etc.)
1615/// but NOT `.build()` and NOT `.throttle()` (no nested throttles).
1616///
1617/// Calling `.end_throttle()` packages the sub-steps into a `BuilderStep::Throttle`
1618/// and returns the parent `RouteBuilder`.
1619pub struct ThrottleBuilder {
1620    parent: RouteBuilder,
1621    config: ThrottlerConfig,
1622    steps: Vec<BuilderStep>,
1623}
1624
1625impl ThrottleBuilder {
1626    /// Set the throttle strategy. Default is `Delay`.
1627    ///
1628    /// - `Delay`: Queue messages until capacity available
1629    /// - `Reject`: Return error immediately when throttled
1630    /// - `Drop`: Silently discard excess messages
1631    pub fn strategy(mut self, strategy: ThrottleStrategy) -> Self {
1632        self.config = self.config.strategy(strategy);
1633        self
1634    }
1635
1636    /// Close the throttle scope. Packages the accumulated sub-steps into a
1637    /// `BuilderStep::Throttle` and returns the parent `RouteBuilder`.
1638    pub fn end_throttle(mut self) -> RouteBuilder {
1639        let step = BuilderStep::Throttle {
1640            config: self.config,
1641            steps: self.steps,
1642        };
1643        self.parent.steps.push(step);
1644        self.parent
1645    }
1646}
1647
1648impl StepAccumulator for ThrottleBuilder {
1649    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1650        &mut self.steps
1651    }
1652}
1653
1654/// Builder for the sub-pipeline within a `.loop_count()` / `.loop_while()` ... `.end_loop()` block.
1655pub struct LoopBuilder {
1656    parent: RouteBuilder,
1657    config: LoopConfig,
1658    steps: Vec<BuilderStep>,
1659}
1660
1661impl LoopBuilder {
1662    pub fn loop_count(self, count: usize) -> LoopInLoopBuilder {
1663        LoopInLoopBuilder {
1664            parent: self,
1665            config: LoopConfig::new(LoopMode::Count(count)),
1666            steps: vec![],
1667        }
1668    }
1669
1670    pub fn loop_while<F>(self, predicate: F) -> LoopInLoopBuilder
1671    where
1672        F: Fn(&Exchange) -> bool + Send + Sync + 'static,
1673    {
1674        LoopInLoopBuilder {
1675            parent: self,
1676            config: LoopConfig::new(LoopMode::While(camel_api::PredicateSource::Sync(
1677                camel_api::FilterPredicate::new(predicate),
1678            ))),
1679            steps: vec![],
1680        }
1681    }
1682
1683    pub fn end_loop(mut self) -> RouteBuilder {
1684        let step = BuilderStep::Loop {
1685            config: self.config,
1686            steps: self.steps,
1687        };
1688        self.parent.steps.push(step);
1689        self.parent
1690    }
1691}
1692
1693impl StepAccumulator for LoopBuilder {
1694    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1695        &mut self.steps
1696    }
1697}
1698
1699pub struct LoopInLoopBuilder {
1700    parent: LoopBuilder,
1701    config: LoopConfig,
1702    steps: Vec<BuilderStep>,
1703}
1704
1705impl LoopInLoopBuilder {
1706    pub fn end_loop(mut self) -> LoopBuilder {
1707        let step = BuilderStep::Loop {
1708            config: self.config,
1709            steps: self.steps,
1710        };
1711        self.parent.steps.push(step);
1712        self.parent
1713    }
1714}
1715
1716impl StepAccumulator for LoopInLoopBuilder {
1717    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1718        &mut self.steps
1719    }
1720}
1721
1722/// Builder for the sub-pipeline within a `.load_balance()` ... `.end_load_balance()` block.
1723///
1724/// Exposes the same step methods as `RouteBuilder` (to, process, filter, etc.)
1725/// but NOT `.build()` and NOT `.load_balance()` (no nested load balancers).
1726///
1727/// Calling `.end_load_balance()` packages the sub-steps into a `BuilderStep::LoadBalance`
1728/// and returns the parent `RouteBuilder`.
1729pub struct LoadBalancerBuilder {
1730    parent: RouteBuilder,
1731    config: LoadBalancerConfig,
1732    steps: Vec<BuilderStep>,
1733}
1734
1735impl LoadBalancerBuilder {
1736    /// Set the load balance strategy to round-robin (default).
1737    pub fn round_robin(mut self) -> Self {
1738        self.config = LoadBalancerConfig::round_robin();
1739        self
1740    }
1741
1742    /// Set the load balance strategy to random selection.
1743    pub fn random(mut self) -> Self {
1744        self.config = LoadBalancerConfig::random();
1745        self
1746    }
1747
1748    /// Set the load balance strategy to weighted selection.
1749    ///
1750    /// Each endpoint is assigned a weight that determines its probability
1751    /// of being selected.
1752    pub fn weighted(mut self, weights: Vec<(String, u32)>) -> Self {
1753        self.config = LoadBalancerConfig::weighted(weights);
1754        self
1755    }
1756
1757    /// Set the load balance strategy to failover.
1758    ///
1759    /// Exchanges are sent to the first endpoint; on failure, the next endpoint
1760    /// is tried.
1761    pub fn failover(mut self) -> Self {
1762        self.config = LoadBalancerConfig::failover();
1763        self
1764    }
1765
1766    /// Close the load balance scope. Packages the accumulated sub-steps into a
1767    /// `BuilderStep::LoadBalance` and returns the parent `RouteBuilder`.
1768    pub fn end_load_balance(mut self) -> RouteBuilder {
1769        let step = BuilderStep::LoadBalance {
1770            config: self.config,
1771            steps: self.steps,
1772        };
1773        self.parent.steps.push(step);
1774        self.parent
1775    }
1776}
1777
1778impl StepAccumulator for LoadBalancerBuilder {
1779    fn steps_mut(&mut self) -> &mut Vec<BuilderStep> {
1780        &mut self.steps
1781    }
1782}
1783
1784// ---------------------------------------------------------------------------
1785// Tests
1786// ---------------------------------------------------------------------------
1787
1788#[cfg(test)]
1789mod tests {
1790    use super::*;
1791    use camel_api::SpanKindHint;
1792    use camel_api::error_handler::ErrorHandlerConfig;
1793    use camel_api::load_balancer::LoadBalanceStrategy;
1794    use camel_api::{Exchange, Message};
1795    use camel_core::route::BuilderStep;
1796    use std::sync::Arc;
1797    use std::time::Duration;
1798    use tower::{Service, ServiceExt};
1799
1800    #[test]
1801    fn test_builder_from_creates_definition() {
1802        let definition = RouteBuilder::from("timer:tick")
1803            .route_id("test-route")
1804            .build()
1805            .unwrap();
1806        assert_eq!(definition.from_uri(), "timer:tick");
1807    }
1808
1809    #[test]
1810    fn test_builder_empty_from_uri_errors() {
1811        let result = RouteBuilder::from("").route_id("test-route").build();
1812        assert!(result.is_err());
1813    }
1814
1815    #[test]
1816    fn test_build_rejects_schemeless_uri() {
1817        let result = RouteBuilder::from("no-scheme-here")
1818            .route_id("test-route")
1819            .build();
1820        match result {
1821            Err(err) => {
1822                let err_msg = format!("{err}");
1823                assert!(
1824                    err_msg.contains("scheme"),
1825                    "expected scheme-related error, got: {err_msg}"
1826                );
1827            }
1828            Ok(_) => panic!("schemeless URI should fail"),
1829        }
1830    }
1831
1832    #[test]
1833    fn test_build_rejects_empty_scheme_uri() {
1834        let result = RouteBuilder::from(":missing-scheme")
1835            .route_id("test-route")
1836            .build();
1837        match result {
1838            Err(err) => {
1839                let err_msg = format!("{err}");
1840                assert!(
1841                    err_msg.contains("scheme"),
1842                    "expected scheme-related error, got: {err_msg}"
1843                );
1844            }
1845            Ok(_) => panic!("empty-scheme URI should fail"),
1846        }
1847    }
1848
1849    #[test]
1850    fn test_build_accepts_valid_uri() {
1851        let result = RouteBuilder::from("timer:tick")
1852            .route_id("test-route")
1853            .build();
1854        assert!(result.is_ok());
1855    }
1856
1857    #[test]
1858    fn test_build_canonical_rejects_schemeless_uri() {
1859        let result = RouteBuilder::from("no-scheme-here")
1860            .route_id("test-route")
1861            .build_canonical();
1862        assert!(result.is_err());
1863    }
1864
1865    #[test]
1866    fn test_builder_to_adds_step() {
1867        let definition = RouteBuilder::from("timer:tick")
1868            .route_id("test-route")
1869            .to("log:info")
1870            .build()
1871            .unwrap();
1872
1873        assert_eq!(definition.from_uri(), "timer:tick");
1874        // We can verify steps were added by checking the structure
1875        assert!(matches!(&definition.steps()[0], BuilderStep::To(uri) if uri == "log:info"));
1876    }
1877
1878    #[test]
1879    fn test_builder_filter_adds_filter_step() {
1880        let definition = RouteBuilder::from("timer:tick")
1881            .route_id("test-route")
1882            .filter(|_ex| true)
1883            .to("mock:result")
1884            .end_filter()
1885            .build()
1886            .unwrap();
1887
1888        assert!(matches!(&definition.steps()[0], BuilderStep::Filter { .. }));
1889    }
1890
1891    #[test]
1892    fn test_builder_set_header_adds_processor_step() {
1893        let definition = RouteBuilder::from("timer:tick")
1894            .route_id("test-route")
1895            .set_header("key", Value::String("value".into()))
1896            .build()
1897            .unwrap();
1898
1899        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
1900    }
1901
1902    #[test]
1903    fn test_builder_map_body_adds_processor_step() {
1904        let definition = RouteBuilder::from("timer:tick")
1905            .route_id("test-route")
1906            .map_body(|body| body)
1907            .build()
1908            .unwrap();
1909
1910        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
1911    }
1912
1913    #[test]
1914    fn test_builder_process_adds_processor_step() {
1915        let definition = RouteBuilder::from("timer:tick")
1916            .route_id("test-route")
1917            .process(|ex| async move { Ok(ex) })
1918            .build()
1919            .unwrap();
1920
1921        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
1922    }
1923
1924    #[test]
1925    fn test_builder_chain_multiple_steps() {
1926        let definition = RouteBuilder::from("timer:tick")
1927            .route_id("test-route")
1928            .set_header("source", Value::String("timer".into()))
1929            .filter(|ex| ex.input.header("source").is_some())
1930            .to("log:info")
1931            .end_filter()
1932            .to("mock:result")
1933            .build()
1934            .unwrap();
1935
1936        assert_eq!(definition.steps().len(), 3); // set_header + Filter + To("mock:result")
1937        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_))); // set_header
1938        assert!(matches!(&definition.steps()[1], BuilderStep::Filter { .. })); // filter
1939        assert!(matches!(&definition.steps()[2], BuilderStep::To(uri) if uri == "mock:result"));
1940    }
1941
1942    #[test]
1943    fn test_loop_count_builder() {
1944        use camel_api::loop_eip::LoopMode;
1945
1946        let def = RouteBuilder::from("direct:start")
1947            .route_id("loop-test")
1948            .loop_count(3)
1949            .to("mock:inside")
1950            .end_loop()
1951            .to("mock:after")
1952            .build()
1953            .unwrap();
1954
1955        assert_eq!(def.steps().len(), 2);
1956        match &def.steps()[0] {
1957            BuilderStep::Loop { config, steps } => {
1958                assert!(matches!(config.mode, LoopMode::Count(3)));
1959                assert_eq!(steps.len(), 1);
1960            }
1961            other => panic!("Expected Loop, got {:?}", other),
1962        }
1963        assert!(matches!(def.steps()[1], BuilderStep::To(_)));
1964    }
1965
1966    #[test]
1967    fn test_loop_while_builder() {
1968        use camel_api::loop_eip::LoopMode;
1969
1970        let def = RouteBuilder::from("direct:start")
1971            .route_id("loop-while-test")
1972            .loop_while(|_ex| true)
1973            .to("mock:retry")
1974            .end_loop()
1975            .build()
1976            .unwrap();
1977
1978        assert_eq!(def.steps().len(), 1);
1979        match &def.steps()[0] {
1980            BuilderStep::Loop { config, steps } => {
1981                assert!(matches!(config.mode, LoopMode::While(_)));
1982                assert_eq!(steps.len(), 1);
1983            }
1984            other => panic!("Expected Loop, got {:?}", other),
1985        }
1986    }
1987
1988    #[test]
1989    fn test_nested_loop_builder() {
1990        use camel_api::loop_eip::LoopMode;
1991
1992        let def = RouteBuilder::from("direct:start")
1993            .route_id("nested-loop-test")
1994            .loop_count(2)
1995            .to("mock:outer")
1996            .loop_count(3)
1997            .to("mock:inner")
1998            .end_loop()
1999            .end_loop()
2000            .to("mock:after")
2001            .build()
2002            .unwrap();
2003
2004        assert_eq!(def.steps().len(), 2);
2005        match &def.steps()[0] {
2006            BuilderStep::Loop { steps, .. } => {
2007                assert_eq!(steps.len(), 2);
2008                match &steps[1] {
2009                    BuilderStep::Loop {
2010                        config,
2011                        steps: inner_steps,
2012                    } => {
2013                        assert!(matches!(config.mode, LoopMode::Count(3)));
2014                        assert_eq!(inner_steps.len(), 1);
2015                    }
2016                    other => panic!("Expected nested Loop, got {:?}", other),
2017                }
2018            }
2019            other => panic!("Expected outer Loop, got {:?}", other),
2020        }
2021    }
2022
2023    // -----------------------------------------------------------------------
2024    // Processor behavior tests — exercise the real Tower services directly
2025    // -----------------------------------------------------------------------
2026
2027    #[tokio::test]
2028    async fn test_set_header_processor_works() {
2029        let mut svc = SetHeader::new(IdentityProcessor, "greeting", Value::String("hello".into()));
2030        let exchange = Exchange::new(Message::new("test"));
2031        let result = svc.call(exchange).await.unwrap();
2032        assert_eq!(
2033            result.input.header("greeting"),
2034            Some(&Value::String("hello".into()))
2035        );
2036    }
2037
2038    #[tokio::test]
2039    async fn test_filter_processor_passes() {
2040        use camel_api::BoxProcessorExt;
2041        use camel_processor::FilterService;
2042
2043        let sub = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
2044        let mut svc =
2045            FilterService::new(|ex: &Exchange| ex.input.body.as_text() == Some("pass"), sub);
2046        let exchange = Exchange::new(Message::new("pass"));
2047        let result = svc.ready().await.unwrap().call(exchange).await.unwrap();
2048        assert_eq!(result.input.body.as_text(), Some("pass"));
2049    }
2050
2051    #[tokio::test]
2052    async fn test_filter_processor_blocks() {
2053        use camel_api::BoxProcessorExt;
2054        use camel_processor::FilterService;
2055
2056        let sub = BoxProcessor::from_fn(|_ex| {
2057            Box::pin(async move { Err(CamelError::ProcessorError("should not reach".into())) })
2058        });
2059        let mut svc =
2060            FilterService::new(|ex: &Exchange| ex.input.body.as_text() == Some("pass"), sub);
2061        let exchange = Exchange::new(Message::new("reject"));
2062        let result = svc.ready().await.unwrap().call(exchange).await.unwrap();
2063        assert_eq!(result.input.body.as_text(), Some("reject"));
2064    }
2065
2066    #[tokio::test]
2067    async fn test_map_body_processor_works() {
2068        let mapper = MapBody::new(IdentityProcessor, |body: Body| {
2069            if let Some(text) = body.as_text() {
2070                Body::Text(text.to_uppercase())
2071            } else {
2072                body
2073            }
2074        });
2075        let exchange = Exchange::new(Message::new("hello"));
2076        let result = mapper.oneshot(exchange).await.unwrap();
2077        assert_eq!(result.input.body.as_text(), Some("HELLO"));
2078    }
2079
2080    #[tokio::test]
2081    async fn test_process_custom_processor_works() {
2082        let processor = ProcessorFn::new(|mut ex: Exchange| async move {
2083            ex.set_property("custom", Value::Bool(true));
2084            Ok(ex)
2085        });
2086        let exchange = Exchange::new(Message::default());
2087        let result = processor.oneshot(exchange).await.unwrap();
2088        assert_eq!(result.property("custom"), Some(&Value::Bool(true)));
2089    }
2090
2091    // -----------------------------------------------------------------------
2092    // Sequential pipeline test
2093    // -----------------------------------------------------------------------
2094
2095    #[tokio::test]
2096    async fn test_compose_pipeline_runs_steps_in_order() {
2097        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline};
2098
2099        let processors = vec![
2100            CompiledStep::Process {
2101                kind_hint: SpanKindHint::Internal,
2102                processor: BoxProcessor::new(SetHeader::new(
2103                    IdentityProcessor,
2104                    "step",
2105                    Value::String("one".into()),
2106                )),
2107                body_contract: None,
2108                lifecycle: None,
2109                label: None,
2110                to_uri: None,
2111            },
2112            CompiledStep::Process {
2113                kind_hint: SpanKindHint::Internal,
2114                processor: BoxProcessor::new(MapBody::new(IdentityProcessor, |body: Body| {
2115                    if let Some(text) = body.as_text() {
2116                        Body::Text(format!("{}-processed", text))
2117                    } else {
2118                        body
2119                    }
2120                })),
2121                body_contract: None,
2122                lifecycle: None,
2123                label: None,
2124                to_uri: None,
2125            },
2126        ];
2127
2128        let pipeline = compose_pipeline(processors, PipelineRuntimeCtx::compile_time());
2129        let exchange = Exchange::new(Message::new("hello"));
2130        let result = pipeline.oneshot(exchange).await.unwrap();
2131
2132        assert_eq!(
2133            result.input.header("step"),
2134            Some(&Value::String("one".into()))
2135        );
2136        assert_eq!(result.input.body.as_text(), Some("hello-processed"));
2137    }
2138
2139    #[tokio::test]
2140    async fn test_compose_pipeline_empty_is_identity() {
2141        use camel_core::route::{PipelineRuntimeCtx, compose_pipeline};
2142
2143        let pipeline = compose_pipeline(vec![], PipelineRuntimeCtx::compile_time());
2144        let exchange = Exchange::new(Message::new("unchanged"));
2145        let result = pipeline.oneshot(exchange).await.unwrap();
2146        assert_eq!(result.input.body.as_text(), Some("unchanged"));
2147    }
2148
2149    // -----------------------------------------------------------------------
2150    // Circuit breaker builder tests
2151    // -----------------------------------------------------------------------
2152
2153    #[test]
2154    fn test_builder_circuit_breaker_sets_config() {
2155        use camel_api::circuit_breaker::CircuitBreakerConfig;
2156
2157        let config = CircuitBreakerConfig::new().failure_threshold(5);
2158        let definition = RouteBuilder::from("timer:tick")
2159            .route_id("test-route")
2160            .circuit_breaker(config)
2161            .build()
2162            .unwrap();
2163
2164        let cb = definition
2165            .circuit_breaker_config()
2166            .expect("circuit breaker should be set");
2167        assert_eq!(cb.failure_threshold, 5);
2168    }
2169
2170    #[test]
2171    fn test_builder_circuit_breaker_with_error_handler() {
2172        use camel_api::circuit_breaker::CircuitBreakerConfig;
2173        use camel_api::error_handler::ErrorHandlerConfig;
2174
2175        let cb_config = CircuitBreakerConfig::new().failure_threshold(3);
2176        let eh_config = ErrorHandlerConfig::log_only();
2177
2178        let definition = RouteBuilder::from("timer:tick")
2179            .route_id("test-route")
2180            .to("log:info")
2181            .circuit_breaker(cb_config)
2182            .error_handler(eh_config)
2183            .build()
2184            .unwrap();
2185
2186        assert!(
2187            definition.circuit_breaker_config().is_some(),
2188            "circuit breaker config should be set"
2189        );
2190        // Route definition was built successfully with both configs.
2191    }
2192
2193    #[test]
2194    fn test_builder_on_exception_shorthand_multiple_clauses_preserve_order() {
2195        let definition = RouteBuilder::from("direct:start")
2196            .route_id("test-route")
2197            .dead_letter_channel("log:dlc")
2198            .on_exception(|e| matches!(e, CamelError::Io(_)))
2199            .retry(3)
2200            .handled_by("log:io")
2201            .end_on_exception()
2202            .on_exception(|e| matches!(e, CamelError::ProcessorError(_)))
2203            .retry(1)
2204            .end_on_exception()
2205            .to("mock:out")
2206            .build()
2207            .expect("route should build");
2208
2209        let cfg = definition
2210            .error_handler_config()
2211            .expect("error handler should be set");
2212        assert_eq!(cfg.policies.len(), 2);
2213        assert_eq!(cfg.dlc_uri.as_deref(), Some("log:dlc"));
2214        assert_eq!(
2215            cfg.policies[0].retry.as_ref().map(|p| p.max_attempts),
2216            Some(3)
2217        );
2218        assert_eq!(cfg.policies[0].handled_by.as_deref(), Some("log:io"));
2219        assert_eq!(
2220            cfg.policies[1].retry.as_ref().map(|p| p.max_attempts),
2221            Some(1)
2222        );
2223    }
2224
2225    #[test]
2226    fn test_builder_on_exception_mixed_mode_rejected() {
2227        let result = RouteBuilder::from("direct:start")
2228            .route_id("test-route")
2229            .error_handler(ErrorHandlerConfig::log_only())
2230            .on_exception(|_e| true)
2231            .end_on_exception()
2232            .to("mock:out")
2233            .build();
2234
2235        let err = result.err().expect("mixed mode should fail with an error");
2236
2237        assert!(
2238            format!("{err}").contains("mixed error handler modes"),
2239            "unexpected error: {err}"
2240        );
2241    }
2242
2243    #[test]
2244    fn test_builder_on_exception_backoff_and_jitter_without_retry_noop() {
2245        let definition = RouteBuilder::from("direct:start")
2246            .route_id("test-route")
2247            .on_exception(|_e| true)
2248            .with_backoff(Duration::from_millis(5), 3.0, Duration::from_millis(100))
2249            .with_jitter(0.5)
2250            .end_on_exception()
2251            .to("mock:out")
2252            .build()
2253            .expect("route should build");
2254
2255        let cfg = definition
2256            .error_handler_config()
2257            .expect("error handler should be set");
2258        assert_eq!(cfg.policies.len(), 1);
2259        assert!(cfg.policies[0].retry.is_none());
2260    }
2261
2262    #[test]
2263    fn test_builder_dead_letter_channel_without_on_exception_sets_dlc() {
2264        let definition = RouteBuilder::from("direct:start")
2265            .route_id("test-route")
2266            .dead_letter_channel("log:dlc")
2267            .to("mock:out")
2268            .build()
2269            .expect("route should build");
2270
2271        let cfg = definition
2272            .error_handler_config()
2273            .expect("error handler should be set");
2274        assert_eq!(cfg.dlc_uri.as_deref(), Some("log:dlc"));
2275        assert!(cfg.policies.is_empty());
2276    }
2277
2278    #[test]
2279    fn test_builder_dead_letter_channel_called_twice_uses_latest_and_keeps_policies() {
2280        let definition = RouteBuilder::from("direct:start")
2281            .route_id("test-route")
2282            .dead_letter_channel("log:first")
2283            .on_exception(|e| matches!(e, CamelError::Io(_)))
2284            .retry(2)
2285            .end_on_exception()
2286            .dead_letter_channel("log:second")
2287            .to("mock:out")
2288            .build()
2289            .expect("route should build");
2290
2291        let cfg = definition
2292            .error_handler_config()
2293            .expect("error handler should be set");
2294        assert_eq!(cfg.dlc_uri.as_deref(), Some("log:second"));
2295        assert_eq!(cfg.policies.len(), 1);
2296        assert_eq!(
2297            cfg.policies[0].retry.as_ref().map(|p| p.max_attempts),
2298            Some(2)
2299        );
2300    }
2301
2302    #[test]
2303    fn test_builder_on_exception_without_dlc_defaults_to_log_only() {
2304        let definition = RouteBuilder::from("direct:start")
2305            .route_id("test-route")
2306            .on_exception(|e| matches!(e, CamelError::ProcessorError(_)))
2307            .retry(1)
2308            .end_on_exception()
2309            .to("mock:out")
2310            .build()
2311            .expect("route should build");
2312
2313        let cfg = definition
2314            .error_handler_config()
2315            .expect("error handler should be set");
2316        assert!(cfg.dlc_uri.is_none());
2317        assert_eq!(cfg.policies.len(), 1);
2318    }
2319
2320    #[test]
2321    fn test_builder_error_handler_explicit_overwrite_stays_explicit_mode() {
2322        let first = ErrorHandlerConfig::dead_letter_channel("log:first");
2323        let second = ErrorHandlerConfig::dead_letter_channel("log:second");
2324
2325        let definition = RouteBuilder::from("direct:start")
2326            .route_id("test-route")
2327            .error_handler(first)
2328            .error_handler(second)
2329            .to("mock:out")
2330            .build()
2331            .expect("route should build");
2332
2333        let cfg = definition
2334            .error_handler_config()
2335            .expect("error handler should be set");
2336        assert_eq!(cfg.dlc_uri.as_deref(), Some("log:second"));
2337    }
2338
2339    // --- Splitter builder tests ---
2340
2341    #[test]
2342    fn test_split_builder_typestate() {
2343        use camel_api::splitter::{SplitterConfig, split_body_lines};
2344
2345        // .split() returns SplitBuilder, .end_split() returns RouteBuilder
2346        let definition = RouteBuilder::from("timer:test?period=1000")
2347            .route_id("test-route")
2348            .split(SplitterConfig::new(split_body_lines()))
2349            .to("mock:per-fragment")
2350            .end_split()
2351            .to("mock:final")
2352            .build()
2353            .unwrap();
2354
2355        // Should have 2 top-level steps: Split + To("mock:final")
2356        assert_eq!(definition.steps().len(), 2);
2357    }
2358
2359    #[test]
2360    fn test_split_builder_steps_collected() {
2361        use camel_api::splitter::{SplitterConfig, split_body_lines};
2362
2363        let definition = RouteBuilder::from("timer:test?period=1000")
2364            .route_id("test-route")
2365            .split(SplitterConfig::new(split_body_lines()))
2366            .set_header("fragment", Value::String("yes".into()))
2367            .to("mock:per-fragment")
2368            .end_split()
2369            .build()
2370            .unwrap();
2371
2372        // Should have 1 top-level step: Split (containing 2 sub-steps)
2373        assert_eq!(definition.steps().len(), 1);
2374        match &definition.steps()[0] {
2375            BuilderStep::Split { steps, .. } => {
2376                assert_eq!(steps.len(), 2); // SetHeader + To
2377            }
2378            other => panic!("Expected Split, got {:?}", other),
2379        }
2380    }
2381
2382    #[test]
2383    fn test_split_builder_config_propagated() {
2384        use camel_api::splitter::{AggregationStrategy, SplitterConfig, split_body_lines};
2385
2386        let definition = RouteBuilder::from("timer:test?period=1000")
2387            .route_id("test-route")
2388            .split(
2389                SplitterConfig::new(split_body_lines())
2390                    .parallel(true)
2391                    .parallel_limit(4)
2392                    .aggregation(AggregationStrategy::CollectAll),
2393            )
2394            .to("mock:per-fragment")
2395            .end_split()
2396            .build()
2397            .unwrap();
2398
2399        match &definition.steps()[0] {
2400            BuilderStep::Split { config, .. } => {
2401                assert!(config.parallel);
2402                assert_eq!(config.parallel_limit, Some(4));
2403                assert!(matches!(
2404                    config.aggregation,
2405                    AggregationStrategy::CollectAll
2406                ));
2407            }
2408            other => panic!("Expected Split, got {:?}", other),
2409        }
2410    }
2411
2412    #[test]
2413    fn test_aggregate_builder_adds_step() {
2414        use camel_api::aggregator::AggregatorConfig;
2415        use camel_core::route::BuilderStep;
2416
2417        let definition = RouteBuilder::from("timer:tick")
2418            .route_id("test-route")
2419            .aggregate(
2420                AggregatorConfig::correlate_by("key")
2421                    .complete_when_size(2)
2422                    .build()
2423                    .unwrap(),
2424            )
2425            .build()
2426            .unwrap();
2427
2428        assert_eq!(definition.steps().len(), 1);
2429        assert!(matches!(
2430            definition.steps()[0],
2431            BuilderStep::Aggregate { .. }
2432        ));
2433    }
2434
2435    #[test]
2436    fn test_aggregate_in_split_builder() {
2437        use camel_api::aggregator::AggregatorConfig;
2438        use camel_api::splitter::{SplitterConfig, split_body_lines};
2439        use camel_core::route::BuilderStep;
2440
2441        let definition = RouteBuilder::from("timer:tick")
2442            .route_id("test-route")
2443            .split(SplitterConfig::new(split_body_lines()))
2444            .aggregate(
2445                AggregatorConfig::correlate_by("key")
2446                    .complete_when_size(1)
2447                    .build()
2448                    .unwrap(),
2449            )
2450            .end_split()
2451            .build()
2452            .unwrap();
2453
2454        assert_eq!(definition.steps().len(), 1);
2455        if let BuilderStep::Split { steps, .. } = &definition.steps()[0] {
2456            assert!(matches!(steps[0], BuilderStep::Aggregate { .. }));
2457        } else {
2458            panic!("expected Split step");
2459        }
2460    }
2461
2462    // ── set_body / set_body_fn / set_header_fn builder tests ────────────────────
2463
2464    #[test]
2465    fn test_builder_set_body_static_adds_processor() {
2466        let definition = RouteBuilder::from("timer:tick")
2467            .route_id("test-route")
2468            .set_body("fixed")
2469            .build()
2470            .unwrap();
2471        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
2472    }
2473
2474    #[test]
2475    fn test_builder_set_body_fn_adds_processor() {
2476        let definition = RouteBuilder::from("timer:tick")
2477            .route_id("test-route")
2478            .set_body_fn(|_ex: &Exchange| Body::Text("dynamic".into()))
2479            .build()
2480            .unwrap();
2481        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
2482    }
2483
2484    #[test]
2485    fn transform_alias_produces_same_as_set_body() {
2486        let route_transform = RouteBuilder::from("timer:tick")
2487            .route_id("test-route")
2488            .transform("hello")
2489            .build()
2490            .unwrap();
2491
2492        let route_set_body = RouteBuilder::from("timer:tick")
2493            .route_id("test-route")
2494            .set_body("hello")
2495            .build()
2496            .unwrap();
2497
2498        assert_eq!(route_transform.steps().len(), route_set_body.steps().len());
2499    }
2500
2501    #[test]
2502    fn test_builder_set_header_fn_adds_processor() {
2503        let definition = RouteBuilder::from("timer:tick")
2504            .route_id("test-route")
2505            .set_header_fn("k", |_ex: &Exchange| Value::String("v".into()))
2506            .build()
2507            .unwrap();
2508        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
2509    }
2510
2511    #[tokio::test]
2512    async fn test_set_body_static_processor_works() {
2513        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline};
2514        let def = RouteBuilder::from("t:t")
2515            .route_id("test-route")
2516            .set_body("replaced")
2517            .build()
2518            .unwrap();
2519        let pipeline = compose_pipeline(
2520            def.steps()
2521                .iter()
2522                .filter_map(|s| {
2523                    if let BuilderStep::Processor(op) = s {
2524                        Some(op.0.clone())
2525                    } else {
2526                        None
2527                    }
2528                })
2529                .map(|p| CompiledStep::Process {
2530                    kind_hint: SpanKindHint::Internal,
2531                    processor: p,
2532                    body_contract: None,
2533                    lifecycle: None,
2534                    label: None,
2535                    to_uri: None,
2536                })
2537                .collect(),
2538            PipelineRuntimeCtx::compile_time(),
2539        );
2540        let exchange = Exchange::new(Message::new("original"));
2541        let result = pipeline.oneshot(exchange).await.unwrap();
2542        assert_eq!(result.input.body.as_text(), Some("replaced"));
2543    }
2544
2545    #[tokio::test]
2546    async fn test_set_body_fn_processor_works() {
2547        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline};
2548        let def = RouteBuilder::from("t:t")
2549            .route_id("test-route")
2550            .set_body_fn(|ex: &Exchange| {
2551                Body::Text(ex.input.body.as_text().unwrap_or("").to_uppercase())
2552            })
2553            .build()
2554            .unwrap();
2555        let pipeline = compose_pipeline(
2556            def.steps()
2557                .iter()
2558                .filter_map(|s| {
2559                    if let BuilderStep::Processor(op) = s {
2560                        Some(op.0.clone())
2561                    } else {
2562                        None
2563                    }
2564                })
2565                .map(|p| CompiledStep::Process {
2566                    kind_hint: SpanKindHint::Internal,
2567                    processor: p,
2568                    body_contract: None,
2569                    lifecycle: None,
2570                    label: None,
2571                    to_uri: None,
2572                })
2573                .collect(),
2574            PipelineRuntimeCtx::compile_time(),
2575        );
2576        let exchange = Exchange::new(Message::new("hello"));
2577        let result = pipeline.oneshot(exchange).await.unwrap();
2578        assert_eq!(result.input.body.as_text(), Some("HELLO"));
2579    }
2580
2581    #[tokio::test]
2582    async fn test_set_header_fn_processor_works() {
2583        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline};
2584        let def = RouteBuilder::from("t:t")
2585            .route_id("test-route")
2586            .set_header_fn("echo", |ex: &Exchange| {
2587                ex.input
2588                    .body
2589                    .as_text()
2590                    .map(|t| Value::String(t.into()))
2591                    .unwrap_or(Value::Null)
2592            })
2593            .build()
2594            .unwrap();
2595        let pipeline = compose_pipeline(
2596            def.steps()
2597                .iter()
2598                .filter_map(|s| {
2599                    if let BuilderStep::Processor(op) = s {
2600                        Some(op.0.clone())
2601                    } else {
2602                        None
2603                    }
2604                })
2605                .map(|p| CompiledStep::Process {
2606                    kind_hint: SpanKindHint::Internal,
2607                    processor: p,
2608                    body_contract: None,
2609                    lifecycle: None,
2610                    label: None,
2611                    to_uri: None,
2612                })
2613                .collect(),
2614            PipelineRuntimeCtx::compile_time(),
2615        );
2616        let exchange = Exchange::new(Message::new("ping"));
2617        let result = pipeline.oneshot(exchange).await.unwrap();
2618        assert_eq!(
2619            result.input.header("echo"),
2620            Some(&Value::String("ping".into()))
2621        );
2622    }
2623
2624    // ── FilterBuilder typestate tests ─────────────────────────────────────
2625
2626    #[test]
2627    fn test_filter_builder_typestate() {
2628        let result = RouteBuilder::from("timer:tick?period=50&repeatCount=1")
2629            .route_id("test-route")
2630            .filter(|_ex| true)
2631            .to("mock:inner")
2632            .end_filter()
2633            .to("mock:outer")
2634            .build();
2635        assert!(result.is_ok());
2636    }
2637
2638    #[test]
2639    fn test_filter_builder_steps_collected() {
2640        let definition = RouteBuilder::from("timer:tick?period=50&repeatCount=1")
2641            .route_id("test-route")
2642            .filter(|_ex| true)
2643            .to("mock:inner")
2644            .end_filter()
2645            .build()
2646            .unwrap();
2647
2648        assert_eq!(definition.steps().len(), 1);
2649        assert!(matches!(&definition.steps()[0], BuilderStep::Filter { .. }));
2650    }
2651
2652    #[test]
2653    fn test_wire_tap_builder_adds_step() {
2654        let definition = RouteBuilder::from("timer:tick")
2655            .route_id("test-route")
2656            .wire_tap("mock:tap")
2657            .to("mock:result")
2658            .build()
2659            .unwrap();
2660
2661        assert_eq!(definition.steps().len(), 2);
2662        assert!(
2663            matches!(&definition.steps()[0], BuilderStep::WireTap { uri } if uri == "mock:tap")
2664        );
2665        assert!(matches!(&definition.steps()[1], BuilderStep::To(uri) if uri == "mock:result"));
2666    }
2667
2668    // ── MulticastBuilder typestate tests ─────────────────────────────────────
2669
2670    #[test]
2671    fn test_multicast_builder_typestate() {
2672        let definition = RouteBuilder::from("timer:tick")
2673            .route_id("test-route")
2674            .multicast()
2675            .to("direct:a")
2676            .to("direct:b")
2677            .end_multicast()
2678            .to("mock:result")
2679            .build()
2680            .unwrap();
2681
2682        assert_eq!(definition.steps().len(), 2); // Multicast + To("mock:result")
2683    }
2684
2685    #[test]
2686    fn test_multicast_builder_steps_collected() {
2687        let definition = RouteBuilder::from("timer:tick")
2688            .route_id("test-route")
2689            .multicast()
2690            .to("direct:a")
2691            .to("direct:b")
2692            .end_multicast()
2693            .build()
2694            .unwrap();
2695
2696        match &definition.steps()[0] {
2697            BuilderStep::Multicast { steps, .. } => {
2698                assert_eq!(steps.len(), 2);
2699            }
2700            other => panic!("Expected Multicast, got {:?}", other),
2701        }
2702    }
2703
2704    // ── Concurrency builder tests ─────────────────────────────────────
2705
2706    #[test]
2707    fn test_builder_concurrent_sets_concurrency() {
2708        use camel_component_api::ConcurrencyModel;
2709
2710        let definition = RouteBuilder::from("http://0.0.0.0:8080/test")
2711            .route_id("test-route")
2712            .concurrent(16)
2713            .to("log:info")
2714            .build()
2715            .unwrap();
2716
2717        assert_eq!(
2718            definition.concurrency_override(),
2719            Some(&ConcurrencyModel::Concurrent { max: Some(16) })
2720        );
2721    }
2722
2723    #[test]
2724    fn test_builder_concurrent_zero_means_unbounded() {
2725        use camel_component_api::ConcurrencyModel;
2726
2727        let definition = RouteBuilder::from("http://0.0.0.0:8080/test")
2728            .route_id("test-route")
2729            .concurrent(0)
2730            .to("log:info")
2731            .build()
2732            .unwrap();
2733
2734        assert_eq!(
2735            definition.concurrency_override(),
2736            Some(&ConcurrencyModel::Concurrent { max: None })
2737        );
2738    }
2739
2740    #[test]
2741    fn test_builder_sequential_sets_concurrency() {
2742        use camel_component_api::ConcurrencyModel;
2743
2744        let definition = RouteBuilder::from("http://0.0.0.0:8080/test")
2745            .route_id("test-route")
2746            .sequential()
2747            .to("log:info")
2748            .build()
2749            .unwrap();
2750
2751        assert_eq!(
2752            definition.concurrency_override(),
2753            Some(&ConcurrencyModel::Sequential)
2754        );
2755    }
2756
2757    #[test]
2758    fn test_builder_default_concurrency_is_none() {
2759        let definition = RouteBuilder::from("timer:tick")
2760            .route_id("test-route")
2761            .to("log:info")
2762            .build()
2763            .unwrap();
2764
2765        assert_eq!(definition.concurrency_override(), None);
2766    }
2767
2768    // ── Route lifecycle builder tests ─────────────────────────────────────
2769
2770    #[test]
2771    fn test_builder_route_id_sets_id() {
2772        let definition = RouteBuilder::from("timer:tick")
2773            .route_id("my-route")
2774            .build()
2775            .unwrap();
2776
2777        assert_eq!(definition.route_id(), "my-route");
2778    }
2779
2780    #[test]
2781    fn test_build_without_route_id_fails() {
2782        let result = RouteBuilder::from("timer:tick?period=1000")
2783            .to("log:info")
2784            .build();
2785        let err = match result {
2786            Err(e) => e.to_string(),
2787            Ok(_) => panic!("build() should fail without route_id"),
2788        };
2789        assert!(
2790            err.contains("route_id"),
2791            "error should mention route_id, got: {}",
2792            err
2793        );
2794    }
2795
2796    #[test]
2797    fn test_builder_empty_route_id_rejected() {
2798        let result = RouteBuilder::from("timer:tick").route_id("").build();
2799        let err = result.err().expect("empty route_id should be rejected");
2800        assert!(matches!(err, CamelError::RouteError(_)));
2801    }
2802
2803    #[test]
2804    fn test_builder_whitespace_route_id_rejected() {
2805        let result = RouteBuilder::from("timer:tick").route_id("   ").build();
2806        assert!(result.is_err());
2807    }
2808
2809    #[test]
2810    fn test_builder_auto_startup_false() {
2811        let definition = RouteBuilder::from("timer:tick")
2812            .route_id("test-route")
2813            .auto_startup(false)
2814            .build()
2815            .unwrap();
2816
2817        assert!(!definition.auto_startup());
2818    }
2819
2820    #[test]
2821    fn test_builder_startup_order_custom() {
2822        let definition = RouteBuilder::from("timer:tick")
2823            .route_id("test-route")
2824            .startup_order(50)
2825            .build()
2826            .unwrap();
2827
2828        assert_eq!(definition.startup_order(), 50);
2829    }
2830
2831    #[test]
2832    fn test_builder_defaults() {
2833        let definition = RouteBuilder::from("timer:tick")
2834            .route_id("test-route")
2835            .build()
2836            .unwrap();
2837
2838        assert_eq!(definition.route_id(), "test-route");
2839        assert!(definition.auto_startup());
2840        assert_eq!(definition.startup_order(), 1000);
2841    }
2842
2843    // ── Choice typestate tests ──────────────────────────────────────────────────
2844
2845    #[test]
2846    fn test_choice_builder_single_when() {
2847        let definition = RouteBuilder::from("timer:tick")
2848            .route_id("test-route")
2849            .choice()
2850            .when(|ex: &Exchange| ex.input.header("type").is_some())
2851            .to("mock:typed")
2852            .end_when()
2853            .end_choice()
2854            .build()
2855            .unwrap();
2856        assert_eq!(definition.steps().len(), 1);
2857        assert!(
2858            matches!(&definition.steps()[0], BuilderStep::Choice { whens, otherwise }
2859            if whens.len() == 1 && otherwise.is_none())
2860        );
2861    }
2862
2863    #[test]
2864    fn test_choice_builder_when_otherwise() {
2865        let definition = RouteBuilder::from("timer:tick")
2866            .route_id("test-route")
2867            .choice()
2868            .when(|ex: &Exchange| ex.input.header("a").is_some())
2869            .to("mock:a")
2870            .end_when()
2871            .otherwise()
2872            .to("mock:fallback")
2873            .end_otherwise()
2874            .end_choice()
2875            .build()
2876            .unwrap();
2877        assert!(
2878            matches!(&definition.steps()[0], BuilderStep::Choice { whens, otherwise }
2879            if whens.len() == 1 && otherwise.is_some())
2880        );
2881    }
2882
2883    #[test]
2884    fn test_choice_builder_multiple_whens() {
2885        let definition = RouteBuilder::from("timer:tick")
2886            .route_id("test-route")
2887            .choice()
2888            .when(|ex: &Exchange| ex.input.header("a").is_some())
2889            .to("mock:a")
2890            .end_when()
2891            .when(|ex: &Exchange| ex.input.header("b").is_some())
2892            .to("mock:b")
2893            .end_when()
2894            .end_choice()
2895            .build()
2896            .unwrap();
2897        assert!(
2898            matches!(&definition.steps()[0], BuilderStep::Choice { whens, .. }
2899            if whens.len() == 2)
2900        );
2901    }
2902
2903    #[test]
2904    fn test_choice_step_after_choice() {
2905        // Steps after end_choice() are added to the outer pipeline, not inside choice.
2906        let definition = RouteBuilder::from("timer:tick")
2907            .route_id("test-route")
2908            .choice()
2909            .when(|_ex: &Exchange| true)
2910            .to("mock:inner")
2911            .end_when()
2912            .end_choice()
2913            .to("mock:outer") // must be step[1], not inside choice
2914            .build()
2915            .unwrap();
2916        assert_eq!(definition.steps().len(), 2);
2917        assert!(matches!(&definition.steps()[1], BuilderStep::To(uri) if uri == "mock:outer"));
2918    }
2919
2920    // ── Throttle typestate tests ──────────────────────────────────────────────────
2921
2922    #[test]
2923    fn test_throttle_builder_typestate() {
2924        let definition = RouteBuilder::from("timer:tick")
2925            .route_id("test-route")
2926            .throttle(10, std::time::Duration::from_secs(1))
2927            .to("mock:result")
2928            .end_throttle()
2929            .build()
2930            .unwrap();
2931
2932        assert_eq!(definition.steps().len(), 1);
2933        assert!(matches!(
2934            &definition.steps()[0],
2935            BuilderStep::Throttle { .. }
2936        ));
2937    }
2938
2939    #[test]
2940    fn test_throttle_builder_with_strategy() {
2941        let definition = RouteBuilder::from("timer:tick")
2942            .route_id("test-route")
2943            .throttle(10, std::time::Duration::from_secs(1))
2944            .strategy(ThrottleStrategy::Reject)
2945            .to("mock:result")
2946            .end_throttle()
2947            .build()
2948            .unwrap();
2949
2950        if let BuilderStep::Throttle { config, .. } = &definition.steps()[0] {
2951            assert_eq!(config.strategy, ThrottleStrategy::Reject);
2952        } else {
2953            panic!("Expected Throttle step");
2954        }
2955    }
2956
2957    #[test]
2958    fn test_throttle_builder_steps_collected() {
2959        let definition = RouteBuilder::from("timer:tick")
2960            .route_id("test-route")
2961            .throttle(5, std::time::Duration::from_secs(1))
2962            .set_header("throttled", Value::Bool(true))
2963            .to("mock:throttled")
2964            .end_throttle()
2965            .build()
2966            .unwrap();
2967
2968        match &definition.steps()[0] {
2969            BuilderStep::Throttle { steps, .. } => {
2970                assert_eq!(steps.len(), 2); // SetHeader + To
2971            }
2972            other => panic!("Expected Throttle, got {:?}", other),
2973        }
2974    }
2975
2976    #[test]
2977    fn test_throttle_step_after_throttle() {
2978        // Steps after end_throttle() are added to the outer pipeline, not inside throttle.
2979        let definition = RouteBuilder::from("timer:tick")
2980            .route_id("test-route")
2981            .throttle(10, std::time::Duration::from_secs(1))
2982            .to("mock:inner")
2983            .end_throttle()
2984            .to("mock:outer")
2985            .build()
2986            .unwrap();
2987
2988        assert_eq!(definition.steps().len(), 2);
2989        assert!(matches!(&definition.steps()[1], BuilderStep::To(uri) if uri == "mock:outer"));
2990    }
2991
2992    // ── LoadBalance typestate tests ──────────────────────────────────────────────────
2993
2994    #[test]
2995    fn test_load_balance_builder_typestate() {
2996        let definition = RouteBuilder::from("timer:tick")
2997            .route_id("test-route")
2998            .load_balance()
2999            .round_robin()
3000            .to("mock:a")
3001            .to("mock:b")
3002            .end_load_balance()
3003            .build()
3004            .unwrap();
3005
3006        assert_eq!(definition.steps().len(), 1);
3007        assert!(matches!(
3008            &definition.steps()[0],
3009            BuilderStep::LoadBalance { .. }
3010        ));
3011    }
3012
3013    #[test]
3014    fn test_load_balance_builder_with_strategy() {
3015        let definition = RouteBuilder::from("timer:tick")
3016            .route_id("test-route")
3017            .load_balance()
3018            .random()
3019            .to("mock:result")
3020            .end_load_balance()
3021            .build()
3022            .unwrap();
3023
3024        if let BuilderStep::LoadBalance { config, .. } = &definition.steps()[0] {
3025            assert_eq!(config.strategy, LoadBalanceStrategy::Random);
3026        } else {
3027            panic!("Expected LoadBalance step");
3028        }
3029    }
3030
3031    #[test]
3032    fn test_load_balance_builder_steps_collected() {
3033        let definition = RouteBuilder::from("timer:tick")
3034            .route_id("test-route")
3035            .load_balance()
3036            .set_header("lb", Value::Bool(true))
3037            .to("mock:a")
3038            .end_load_balance()
3039            .build()
3040            .unwrap();
3041
3042        match &definition.steps()[0] {
3043            BuilderStep::LoadBalance { steps, .. } => {
3044                assert_eq!(steps.len(), 2); // SetHeader + To
3045            }
3046            other => panic!("Expected LoadBalance, got {:?}", other),
3047        }
3048    }
3049
3050    #[test]
3051    fn test_load_balance_step_after_load_balance() {
3052        // Steps after end_load_balance() are added to the outer pipeline, not inside load_balance.
3053        let definition = RouteBuilder::from("timer:tick")
3054            .route_id("test-route")
3055            .load_balance()
3056            .to("mock:inner")
3057            .end_load_balance()
3058            .to("mock:outer")
3059            .build()
3060            .unwrap();
3061
3062        assert_eq!(definition.steps().len(), 2);
3063        assert!(matches!(&definition.steps()[1], BuilderStep::To(uri) if uri == "mock:outer"));
3064    }
3065
3066    // ── DynamicRouter typestate tests ──────────────────────────────────────────────────
3067
3068    #[test]
3069    fn test_dynamic_router_builder() {
3070        let definition = RouteBuilder::from("timer:tick")
3071            .route_id("test-route")
3072            .dynamic_router(camel_api::TargetSource::Sync(Arc::new(|_| {
3073                Some("mock:result".to_string())
3074            })))
3075            .build()
3076            .unwrap();
3077
3078        assert_eq!(definition.steps().len(), 1);
3079        assert!(matches!(
3080            &definition.steps()[0],
3081            BuilderStep::DynamicRouter { .. }
3082        ));
3083    }
3084
3085    #[test]
3086    fn test_dynamic_router_builder_with_config() {
3087        let config = DynamicRouterConfig::new(camel_api::TargetSource::Sync(Arc::new(|_| {
3088            Some("mock:a".to_string())
3089        })))
3090        .max_iterations(100)
3091        .cache_size(500);
3092
3093        let definition = RouteBuilder::from("timer:tick")
3094            .route_id("test-route")
3095            .dynamic_router_with_config(config)
3096            .build()
3097            .unwrap();
3098
3099        assert_eq!(definition.steps().len(), 1);
3100        if let BuilderStep::DynamicRouter { config } = &definition.steps()[0] {
3101            assert_eq!(config.max_iterations, 100);
3102            assert_eq!(config.cache_size, 500);
3103        } else {
3104            panic!("Expected DynamicRouter step");
3105        }
3106    }
3107
3108    #[test]
3109    fn test_dynamic_router_step_after_router() {
3110        // Steps after dynamic_router() are added to the outer pipeline.
3111        let definition = RouteBuilder::from("timer:tick")
3112            .route_id("test-route")
3113            .dynamic_router(camel_api::TargetSource::Sync(Arc::new(|_| {
3114                Some("mock:inner".to_string())
3115            })))
3116            .to("mock:outer")
3117            .build()
3118            .unwrap();
3119
3120        assert_eq!(definition.steps().len(), 2);
3121        assert!(matches!(
3122            &definition.steps()[0],
3123            BuilderStep::DynamicRouter { .. }
3124        ));
3125        assert!(matches!(&definition.steps()[1], BuilderStep::To(uri) if uri == "mock:outer"));
3126    }
3127
3128    #[test]
3129    fn routing_slip_builder_creates_step() {
3130        use camel_api::TargetSource;
3131
3132        let expression = TargetSource::Sync(Arc::new(|_| Some("direct:a,direct:b".to_string())));
3133
3134        let route = RouteBuilder::from("direct:start")
3135            .route_id("routing-slip-test")
3136            .routing_slip(expression)
3137            .build()
3138            .unwrap();
3139
3140        assert!(
3141            matches!(route.steps()[0], BuilderStep::RoutingSlip { .. }),
3142            "Expected RoutingSlip step"
3143        );
3144    }
3145
3146    #[test]
3147    fn routing_slip_with_config_builder_creates_step() {
3148        use camel_api::RoutingSlipConfig;
3149
3150        let config = RoutingSlipConfig::new(camel_api::TargetSource::Sync(Arc::new(|_| {
3151            Some("mock:a".to_string())
3152        })))
3153        .uri_delimiter("|")
3154        .cache_size(50)
3155        .ignore_invalid_endpoints(true);
3156
3157        let route = RouteBuilder::from("direct:start")
3158            .route_id("routing-slip-config-test")
3159            .routing_slip_with_config(config)
3160            .build()
3161            .unwrap();
3162
3163        if let BuilderStep::RoutingSlip { config } = &route.steps()[0] {
3164            assert_eq!(config.uri_delimiter, "|");
3165            assert_eq!(config.cache_size, 50);
3166            assert!(config.ignore_invalid_endpoints);
3167        } else {
3168            panic!("Expected RoutingSlip step");
3169        }
3170    }
3171
3172    #[test]
3173    fn test_builder_marshal_adds_processor_step() {
3174        let definition = RouteBuilder::from("timer:tick")
3175            .route_id("test-route")
3176            .marshal("json")
3177            .unwrap()
3178            .build()
3179            .unwrap();
3180        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
3181    }
3182
3183    #[test]
3184    fn test_builder_unmarshal_adds_processor_step() {
3185        let definition = RouteBuilder::from("timer:tick")
3186            .route_id("test-route")
3187            .unmarshal("json")
3188            .unwrap()
3189            .build()
3190            .unwrap();
3191        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
3192    }
3193
3194    #[test]
3195    fn test_builder_stream_cache_adds_processor_step() {
3196        let definition = RouteBuilder::from("timer:tick")
3197            .route_id("test-route")
3198            .stream_cache(1024)
3199            .build()
3200            .unwrap();
3201        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
3202    }
3203
3204    #[test]
3205    fn validate_adds_validate_step() {
3206        let def = RouteBuilder::from("direct:in")
3207            .route_id("test")
3208            .validate("schemas/order.xsd")
3209            .build()
3210            .unwrap();
3211        let steps = def.steps();
3212        assert_eq!(steps.len(), 1);
3213        assert!(
3214            matches!(&steps[0], BuilderStep::Validate { predicate } if predicate.language == "simple" && predicate.source == "schemas/order.xsd"),
3215            "got: {:?}",
3216            steps[0]
3217        );
3218    }
3219
3220    #[test]
3221    fn test_builder_marshal_returns_err_for_unknown_format() {
3222        let result = RouteBuilder::from("timer:tick")
3223            .route_id("test-route")
3224            .marshal("protobuf");
3225        let err = match result {
3226            Err(e) => e,
3227            Ok(_) => panic!("marshal with unknown format should return Err"),
3228        };
3229        let msg = err.to_string();
3230        assert!(
3231            msg.contains("unknown data format"),
3232            "error should mention unknown format, got: {msg}"
3233        );
3234        assert!(
3235            msg.contains("protobuf"),
3236            "error should mention format name, got: {msg}"
3237        );
3238    }
3239
3240    #[test]
3241    fn test_builder_unmarshal_returns_err_for_unknown_format() {
3242        let result = RouteBuilder::from("timer:tick")
3243            .route_id("test-route")
3244            .unmarshal("protobuf");
3245        let err = match result {
3246            Err(e) => e,
3247            Ok(_) => panic!("unmarshal with unknown format should return Err"),
3248        };
3249        let msg = err.to_string();
3250        assert!(
3251            msg.contains("unknown data format"),
3252            "error should mention unknown format, got: {msg}"
3253        );
3254        assert!(
3255            msg.contains("protobuf"),
3256            "error should mention format name, got: {msg}"
3257        );
3258    }
3259
3260    #[test]
3261    fn test_builder_recipient_list_creates_step() {
3262        let route = RouteBuilder::from("direct:start")
3263            .route_id("recipient-list-test")
3264            .recipient_list(camel_api::RecipientSource::Sync(Arc::new(|_| {
3265                "direct:a,direct:b".to_string()
3266            })))
3267            .build()
3268            .unwrap();
3269
3270        assert!(matches!(
3271            &route.steps()[0],
3272            BuilderStep::RecipientList { .. }
3273        ));
3274    }
3275
3276    #[test]
3277    fn test_builder_recipient_list_with_config_creates_step() {
3278        let config = RecipientListConfig::new(camel_api::RecipientSource::Sync(Arc::new(|_| {
3279            "mock:a".to_string()
3280        })));
3281
3282        let route = RouteBuilder::from("direct:start")
3283            .route_id("recipient-list-config-test")
3284            .recipient_list_with_config(config)
3285            .build()
3286            .unwrap();
3287
3288        assert!(matches!(
3289            &route.steps()[0],
3290            BuilderStep::RecipientList { .. }
3291        ));
3292    }
3293
3294    #[test]
3295    fn test_builder_script_adds_script_step() {
3296        let route = RouteBuilder::from("direct:start")
3297            .route_id("script-test")
3298            .script("rhai", "headers[\"x\"] = \"y\"")
3299            .build()
3300            .unwrap();
3301
3302        assert!(matches!(
3303            &route.steps()[0],
3304            BuilderStep::Script { language, script }
3305            if language == "rhai" && script == "headers[\"x\"] = \"y\""
3306        ));
3307    }
3308
3309    #[test]
3310    fn test_builder_delay_and_delay_with_header_add_steps() {
3311        let route = RouteBuilder::from("direct:start")
3312            .route_id("delay-test")
3313            .delay(Duration::from_millis(250))
3314            .delay_with_header(Duration::from_millis(500), "x-delay")
3315            .build()
3316            .unwrap();
3317
3318        assert_eq!(route.steps().len(), 2);
3319        assert!(matches!(&route.steps()[0], BuilderStep::Delay { .. }));
3320        assert!(matches!(&route.steps()[1], BuilderStep::Delay { .. }));
3321    }
3322
3323    #[test]
3324    fn test_builder_log_and_stop_add_steps_in_order() {
3325        let route = RouteBuilder::from("direct:start")
3326            .route_id("log-stop-test")
3327            .log("hello", LogLevel::Info)
3328            .stop()
3329            .to("mock:after")
3330            .build()
3331            .unwrap();
3332
3333        assert_eq!(route.steps().len(), 3);
3334        assert!(matches!(
3335            &route.steps()[0],
3336            BuilderStep::Log { message, .. } if message == "hello"
3337        ));
3338        assert!(matches!(&route.steps()[1], BuilderStep::Stop));
3339        assert!(matches!(&route.steps()[2], BuilderStep::To(uri) if uri == "mock:after"));
3340    }
3341
3342    #[test]
3343    fn test_builder_stream_cache_default_adds_processor_step() {
3344        let route = RouteBuilder::from("direct:start")
3345            .route_id("stream-cache-default-test")
3346            .stream_cache_default()
3347            .build()
3348            .unwrap();
3349
3350        assert!(matches!(&route.steps()[0], BuilderStep::Processor(_)));
3351    }
3352
3353    #[test]
3354    fn test_validate_creates_validate_step_with_expression() {
3355        let route = RouteBuilder::from("direct:in")
3356            .route_id("validate-prefix-test")
3357            .validate("${body.size()} > 0")
3358            .build()
3359            .unwrap();
3360
3361        assert!(matches!(
3362            &route.steps()[0],
3363            BuilderStep::Validate { predicate } if predicate.language == "simple" && predicate.source == "${body.size()} > 0"
3364        ));
3365    }
3366
3367    #[test]
3368    fn test_load_balance_builder_weighted_failover_config() {
3369        let route = RouteBuilder::from("direct:start")
3370            .route_id("lb-weighted-failover")
3371            .load_balance()
3372            .weighted(vec![
3373                ("direct:a".to_string(), 3),
3374                ("direct:b".to_string(), 1),
3375            ])
3376            .failover()
3377            .to("mock:result")
3378            .end_load_balance()
3379            .build()
3380            .unwrap();
3381
3382        if let BuilderStep::LoadBalance { config, .. } = &route.steps()[0] {
3383            assert_eq!(config.strategy, LoadBalanceStrategy::Failover);
3384        } else {
3385            panic!("Expected LoadBalance step");
3386        }
3387    }
3388
3389    #[test]
3390    fn test_multicast_builder_all_config_setters() {
3391        let route = RouteBuilder::from("direct:start")
3392            .route_id("multicast-config-test")
3393            .multicast()
3394            .parallel(true)
3395            .parallel_limit(4)
3396            .stop_on_exception(true)
3397            .timeout(Duration::from_millis(300))
3398            .aggregation(MulticastStrategy::Original)
3399            .to("mock:a")
3400            .end_multicast()
3401            .build()
3402            .unwrap();
3403
3404        if let BuilderStep::Multicast { config, .. } = &route.steps()[0] {
3405            assert!(config.parallel);
3406            assert_eq!(config.parallel_limit, Some(4));
3407            assert!(config.stop_on_exception);
3408            assert_eq!(config.timeout, Some(Duration::from_millis(300)));
3409            assert!(matches!(config.aggregation, MulticastStrategy::Original));
3410        } else {
3411            panic!("Expected Multicast step");
3412        }
3413    }
3414
3415    #[test]
3416    fn test_build_canonical_rejects_unsupported_processor_step() {
3417        let err = RouteBuilder::from("direct:start")
3418            .route_id("canonical-reject")
3419            .set_header("k", Value::String("v".into()))
3420            .build_canonical()
3421            .unwrap_err();
3422
3423        assert!(format!("{err}").contains("does not support step `processor`"));
3424    }
3425
3426    // ── LoadBalance strategy-specific tests ─────────────────────────────────────
3427
3428    #[test]
3429    fn test_load_balance_builder_weighted_strategy() {
3430        let route = RouteBuilder::from("direct:start")
3431            .route_id("lb-weighted")
3432            .load_balance()
3433            .weighted(vec![
3434                ("direct:a".to_string(), 5),
3435                ("direct:b".to_string(), 2),
3436                ("direct:c".to_string(), 1),
3437            ])
3438            .to("mock:result")
3439            .end_load_balance()
3440            .build()
3441            .unwrap();
3442
3443        if let BuilderStep::LoadBalance { config, .. } = &route.steps()[0] {
3444            assert!(matches!(config.strategy, LoadBalanceStrategy::Weighted(_)));
3445        } else {
3446            panic!("Expected LoadBalance step");
3447        }
3448    }
3449
3450    #[test]
3451    fn test_load_balance_builder_failover_strategy() {
3452        let route = RouteBuilder::from("direct:start")
3453            .route_id("lb-failover")
3454            .load_balance()
3455            .failover()
3456            .to("mock:primary")
3457            .end_load_balance()
3458            .build()
3459            .unwrap();
3460
3461        if let BuilderStep::LoadBalance { config, .. } = &route.steps()[0] {
3462            assert_eq!(config.strategy, LoadBalanceStrategy::Failover);
3463        } else {
3464            panic!("Expected LoadBalance step");
3465        }
3466    }
3467
3468    // ── FilterInSplitBuilder tests ──────────────────────────────────────────────
3469
3470    #[test]
3471    fn test_filter_in_split_builder_typestate() {
3472        use camel_api::splitter::{SplitterConfig, split_body_lines};
3473
3474        let definition = RouteBuilder::from("timer:test")
3475            .route_id("filter-in-split")
3476            .split(SplitterConfig::new(split_body_lines()))
3477            .filter(|_ex| true)
3478            .to("mock:filtered")
3479            .end_filter()
3480            .end_split()
3481            .build()
3482            .unwrap();
3483
3484        assert_eq!(definition.steps().len(), 1);
3485        if let BuilderStep::Split { steps, .. } = &definition.steps()[0] {
3486            assert_eq!(steps.len(), 1);
3487            assert!(matches!(&steps[0], BuilderStep::Filter { .. }));
3488        } else {
3489            panic!("Expected Split step");
3490        }
3491    }
3492
3493    #[test]
3494    fn test_filter_in_split_builder_multiple_steps() {
3495        use camel_api::splitter::{SplitterConfig, split_body_lines};
3496
3497        let definition = RouteBuilder::from("timer:test")
3498            .route_id("filter-in-split-multi")
3499            .split(SplitterConfig::new(split_body_lines()))
3500            .to("mock:before-filter")
3501            .filter(|_ex| true)
3502            .to("mock:inside-filter")
3503            .end_filter()
3504            .to("mock:after-filter")
3505            .end_split()
3506            .build()
3507            .unwrap();
3508
3509        if let BuilderStep::Split { steps, .. } = &definition.steps()[0] {
3510            // To("before-filter") + Filter{...} + To("after-filter") = 3
3511            assert_eq!(steps.len(), 3);
3512        } else {
3513            panic!("Expected Split step");
3514        }
3515    }
3516
3517    // ── build_canonical tests ───────────────────────────────────────────────────
3518
3519    #[test]
3520    fn test_build_canonical_with_circuit_breaker() {
3521        use camel_api::circuit_breaker::CircuitBreakerConfig;
3522
3523        let spec = RouteBuilder::from("direct:start")
3524            .route_id("canonical-cb")
3525            .circuit_breaker(CircuitBreakerConfig::new().failure_threshold(10))
3526            .to("mock:result")
3527            .build_canonical()
3528            .unwrap();
3529
3530        let cb = spec.circuit_breaker.expect("circuit breaker should be set");
3531        assert_eq!(cb.failure_threshold, 10);
3532    }
3533
3534    #[test]
3535    fn test_build_canonical_rejects_custom_split_aggregation() {
3536        use camel_api::splitter::{SplitterConfig, split_body_lines};
3537
3538        let err = RouteBuilder::from("direct:start")
3539            .route_id("canonical-custom-split")
3540            .split(SplitterConfig::new(split_body_lines()).aggregation(
3541                camel_api::splitter::AggregationStrategy::Custom(Arc::new(|_, ex| ex)),
3542            ))
3543            .to("mock:frag")
3544            .end_split()
3545            .build_canonical()
3546            .unwrap_err();
3547
3548        // Split with closure-based expression is rejected in canonical v2.
3549        assert!(format!("{err}").contains("canonical v2 does not support step `split`"));
3550    }
3551
3552    #[test]
3553    fn test_build_canonical_rejects_custom_aggregate_strategy() {
3554        let err = RouteBuilder::from("direct:start")
3555            .route_id("canonical-custom-agg")
3556            .aggregate(
3557                AggregatorConfig::correlate_by("key")
3558                    .complete_when_size(2)
3559                    .strategy(AggregationStrategy::Custom(Arc::new(|_, ex| ex)))
3560                    .build()
3561                    .unwrap(),
3562            )
3563            .build_canonical()
3564            .unwrap_err();
3565
3566        assert!(format!("{err}").contains("custom aggregate strategy"));
3567    }
3568
3569    #[test]
3570    fn test_build_canonical_rejects_fn_correlation_strategy() {
3571        let err = RouteBuilder::from("direct:start")
3572            .route_id("canonical-fn-corr")
3573            .aggregate(AggregatorConfig {
3574                header_name: "key".to_string(),
3575                completion: CompletionMode::Single(CompletionCondition::Size(1)),
3576                correlation: CorrelationStrategy::Fn(Arc::new(|_| Some("key".to_string()))),
3577                strategy: AggregationStrategy::CollectAll,
3578                max_buckets: None,
3579                max_bucket_size: None,
3580                bucket_ttl: None,
3581                force_completion_on_stop: false,
3582                discard_on_timeout: false,
3583                max_timeout_tasks: 1024,
3584            })
3585            .build_canonical()
3586            .unwrap_err();
3587
3588        assert!(format!("{err}").contains("Fn correlation strategy"));
3589    }
3590
3591    #[test]
3592    fn test_build_canonical_rejects_predicate_completion() {
3593        let err = RouteBuilder::from("direct:start")
3594            .route_id("canonical-pred-completion")
3595            .aggregate(AggregatorConfig {
3596                header_name: "key".to_string(),
3597                completion: CompletionMode::Single(CompletionCondition::Predicate(Arc::new(
3598                    |_| false,
3599                ))),
3600                correlation: CorrelationStrategy::HeaderName("key".to_string()),
3601                strategy: AggregationStrategy::CollectAll,
3602                max_buckets: None,
3603                max_bucket_size: None,
3604                bucket_ttl: None,
3605                force_completion_on_stop: false,
3606                discard_on_timeout: false,
3607                max_timeout_tasks: 1024,
3608            })
3609            .build_canonical()
3610            .unwrap_err();
3611
3612        assert!(
3613            format!("{err}").contains("cannot reverse-map"),
3614            "reject message must explain forward-only: {}",
3615            err
3616        );
3617    }
3618
3619    #[test]
3620    fn extract_completion_fields_rejects_predicate_expr() {
3621        let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
3622            expr: "${body} == 'DONE'".to_string(),
3623            language: "simple".to_string(),
3624        });
3625        let result = extract_completion_fields(&mode);
3626        assert!(
3627            result.is_err(),
3628            "PredicateExpr must be rejected (forward-only)"
3629        );
3630        let msg = format!("{}", result.unwrap_err());
3631        assert!(
3632            msg.contains("cannot reverse-map"),
3633            "reject message must explain forward-only: {}",
3634            msg
3635        );
3636    }
3637
3638    #[test]
3639    fn extract_completion_fields_rejects_predicate_expr_any_mode() {
3640        let mode = CompletionMode::Any(vec![
3641            CompletionCondition::Size(5),
3642            CompletionCondition::PredicateExpr {
3643                expr: "${body} == 'DONE'".to_string(),
3644                language: "simple".to_string(),
3645            },
3646        ]);
3647        let result = extract_completion_fields(&mode);
3648        assert!(
3649            result.is_err(),
3650            "PredicateExpr in Any must be rejected (forward-only)"
3651        );
3652        let msg = format!("{}", result.unwrap_err());
3653        assert!(
3654            msg.contains("cannot reverse-map"),
3655            "reject message must explain forward-only: {}",
3656            msg
3657        );
3658    }
3659
3660    #[test]
3661    fn test_build_canonical_with_expression_correlation() {
3662        let spec = RouteBuilder::from("direct:start")
3663            .route_id("canonical-expr-corr")
3664            .aggregate(AggregatorConfig {
3665                header_name: "key".to_string(),
3666                completion: CompletionMode::Single(CompletionCondition::Size(1)),
3667                correlation: CorrelationStrategy::Expression {
3668                    expr: "header.key".to_string(),
3669                    language: "simple".to_string(),
3670                },
3671                strategy: AggregationStrategy::CollectAll,
3672                max_buckets: None,
3673                max_bucket_size: None,
3674                bucket_ttl: None,
3675                force_completion_on_stop: false,
3676                discard_on_timeout: false,
3677                max_timeout_tasks: 1024,
3678            })
3679            .build_canonical()
3680            .unwrap();
3681
3682        assert!(spec.steps.iter().any(|s| matches!(s, CanonicalStepSpec::Aggregate(a) if a.correlation_key == Some("header.key".to_string()))));
3683    }
3684
3685    #[test]
3686    fn test_build_canonical_split_rejected_with_closure_expression() {
3687        use camel_api::splitter::{AggregationStrategy, SplitterConfig, split_body_lines};
3688
3689        // Builder-based split uses closure expressions, which are not serializable.
3690        let err = RouteBuilder::from("direct:start")
3691            .route_id("canonical-split-last")
3692            .split(
3693                SplitterConfig::new(split_body_lines()).aggregation(AggregationStrategy::LastWins),
3694            )
3695            .to("mock:frag")
3696            .end_split()
3697            .build_canonical()
3698            .unwrap_err();
3699
3700        assert!(format!("{err}").contains("canonical v2 does not support step `split`"));
3701    }
3702
3703    // ── OnExceptionBuilder full chain tests ─────────────────────────────────────
3704
3705    #[test]
3706    fn test_on_exception_full_chain_retry_backoff_jitter_handled_by() {
3707        let definition = RouteBuilder::from("direct:start")
3708            .route_id("on-exception-full")
3709            .dead_letter_channel("log:dlc")
3710            .on_exception(|e| matches!(e, CamelError::Io(_)))
3711            .retry(5)
3712            .with_backoff(Duration::from_millis(10), 2.0, Duration::from_millis(500))
3713            .with_jitter(0.3)
3714            .handled_by("log:io-handler")
3715            .end_on_exception()
3716            .to("mock:out")
3717            .build()
3718            .unwrap();
3719
3720        let cfg = definition
3721            .error_handler_config()
3722            .expect("error handler should be set");
3723        assert_eq!(cfg.policies.len(), 1);
3724        let policy = &cfg.policies[0];
3725        let retry = policy.retry.as_ref().expect("retry should be set");
3726        assert_eq!(retry.max_attempts, 5);
3727        assert_eq!(retry.initial_delay, Duration::from_millis(10));
3728        assert_eq!(retry.multiplier, 2.0);
3729        assert_eq!(retry.max_delay, Duration::from_millis(500));
3730        assert!((retry.jitter_factor - 0.3).abs() < f64::EPSILON);
3731        assert_eq!(policy.handled_by.as_deref(), Some("log:io-handler"));
3732    }
3733
3734    #[test]
3735    fn test_on_exception_jitter_clamped_to_valid_range() {
3736        let definition = RouteBuilder::from("direct:start")
3737            .route_id("jitter-clamp")
3738            .on_exception(|_e| true)
3739            .retry(1)
3740            .with_jitter(5.0)
3741            .end_on_exception()
3742            .to("mock:out")
3743            .build()
3744            .unwrap();
3745
3746        let cfg = definition.error_handler_config().unwrap();
3747        let retry = cfg.policies[0].retry.as_ref().unwrap();
3748        assert!((retry.jitter_factor - 1.0).abs() < f64::EPSILON);
3749    }
3750
3751    // ── StepAccumulator: process_fn, convert_body_to, bean ──────────────────────
3752
3753    #[test]
3754    fn test_builder_process_fn_adds_processor_step() {
3755        use camel_api::BoxProcessorExt;
3756        let processor = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
3757        let definition = RouteBuilder::from("timer:tick")
3758            .route_id("process-fn-test")
3759            .process_fn(processor)
3760            .build()
3761            .unwrap();
3762
3763        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
3764    }
3765
3766    #[test]
3767    fn test_builder_convert_body_to_adds_processor_step() {
3768        let definition = RouteBuilder::from("timer:tick")
3769            .route_id("convert-body-test")
3770            .convert_body_to(BodyType::Json)
3771            .build()
3772            .unwrap();
3773
3774        assert!(matches!(&definition.steps()[0], BuilderStep::Processor(_)));
3775    }
3776
3777    #[test]
3778    fn test_builder_bean_adds_bean_step() {
3779        let definition = RouteBuilder::from("timer:tick")
3780            .route_id("bean-test")
3781            .bean("myBean", "process")
3782            .build()
3783            .unwrap();
3784
3785        assert!(
3786            matches!(&definition.steps()[0], BuilderStep::Bean { name, method }
3787            if name == "myBean" && method == "process")
3788        );
3789    }
3790
3791    // ── Throttle strategy-specific tests ────────────────────────────────────────
3792
3793    #[test]
3794    fn test_throttle_builder_delay_strategy() {
3795        let definition = RouteBuilder::from("timer:tick")
3796            .route_id("throttle-delay")
3797            .throttle(10, Duration::from_secs(1))
3798            .strategy(ThrottleStrategy::Delay)
3799            .to("mock:result")
3800            .end_throttle()
3801            .build()
3802            .unwrap();
3803
3804        if let BuilderStep::Throttle { config, .. } = &definition.steps()[0] {
3805            assert_eq!(config.strategy, ThrottleStrategy::Delay);
3806        } else {
3807            panic!("Expected Throttle step");
3808        }
3809    }
3810
3811    #[test]
3812    fn test_throttle_builder_drop_strategy() {
3813        let definition = RouteBuilder::from("timer:tick")
3814            .route_id("throttle-drop")
3815            .throttle(10, Duration::from_secs(1))
3816            .strategy(ThrottleStrategy::Drop)
3817            .to("mock:result")
3818            .end_throttle()
3819            .build()
3820            .unwrap();
3821
3822        if let BuilderStep::Throttle { config, .. } = &definition.steps()[0] {
3823            assert_eq!(config.strategy, ThrottleStrategy::Drop);
3824        } else {
3825            panic!("Expected Throttle step");
3826        }
3827    }
3828
3829    // ── LoopInLoopBuilder with loop_while ───────────────────────────────────────
3830
3831    #[test]
3832    fn test_nested_loop_while_builder() {
3833        use camel_api::loop_eip::LoopMode;
3834
3835        let def = RouteBuilder::from("direct:start")
3836            .route_id("nested-loop-while")
3837            .loop_count(2)
3838            .to("mock:outer")
3839            .loop_while(|_ex| true)
3840            .to("mock:inner")
3841            .end_loop()
3842            .end_loop()
3843            .build()
3844            .unwrap();
3845
3846        assert_eq!(def.steps().len(), 1);
3847        if let BuilderStep::Loop { steps, .. } = &def.steps()[0] {
3848            assert_eq!(steps.len(), 2);
3849            if let BuilderStep::Loop { config, .. } = &steps[1] {
3850                assert!(matches!(config.mode, LoopMode::While(_)));
3851            } else {
3852                panic!("Expected inner Loop step");
3853            }
3854        } else {
3855            panic!("Expected outer Loop step");
3856        }
3857    }
3858
3859    // ── Choice with multiple whens + otherwise ──────────────────────────────────
3860
3861    #[test]
3862    fn test_choice_builder_multiple_whens_with_otherwise() {
3863        let definition = RouteBuilder::from("timer:tick")
3864            .route_id("choice-multi-otherwise")
3865            .choice()
3866            .when(|ex: &Exchange| ex.input.header("a").is_some())
3867            .to("mock:a")
3868            .end_when()
3869            .when(|ex: &Exchange| ex.input.header("b").is_some())
3870            .to("mock:b")
3871            .end_when()
3872            .when(|ex: &Exchange| ex.input.header("c").is_some())
3873            .to("mock:c")
3874            .end_when()
3875            .otherwise()
3876            .to("mock:fallback")
3877            .end_otherwise()
3878            .end_choice()
3879            .build()
3880            .unwrap();
3881
3882        if let BuilderStep::Choice { whens, otherwise } = &definition.steps()[0] {
3883            assert_eq!(whens.len(), 3);
3884            assert!(otherwise.is_some());
3885            assert_eq!(otherwise.as_ref().unwrap().len(), 1);
3886        } else {
3887            panic!("Expected Choice step");
3888        }
3889    }
3890
3891    // ── Multicast individual config tests ───────────────────────────────────────
3892
3893    #[test]
3894    fn test_multicast_builder_parallel_only() {
3895        let route = RouteBuilder::from("direct:start")
3896            .route_id("multicast-parallel")
3897            .multicast()
3898            .parallel(true)
3899            .to("mock:a")
3900            .end_multicast()
3901            .build()
3902            .unwrap();
3903
3904        if let BuilderStep::Multicast { config, .. } = &route.steps()[0] {
3905            assert!(config.parallel);
3906            assert_eq!(config.parallel_limit, None);
3907        } else {
3908            panic!("Expected Multicast step");
3909        }
3910    }
3911
3912    #[test]
3913    fn test_multicast_builder_timeout_only() {
3914        let route = RouteBuilder::from("direct:start")
3915            .route_id("multicast-timeout")
3916            .multicast()
3917            .timeout(Duration::from_secs(5))
3918            .to("mock:a")
3919            .end_multicast()
3920            .build()
3921            .unwrap();
3922
3923        if let BuilderStep::Multicast { config, .. } = &route.steps()[0] {
3924            assert_eq!(config.timeout, Some(Duration::from_secs(5)));
3925        } else {
3926            panic!("Expected Multicast step");
3927        }
3928    }
3929
3930    #[test]
3931    fn test_multicast_builder_aggregation_collect_all() {
3932        let route = RouteBuilder::from("direct:start")
3933            .route_id("multicast-collect")
3934            .multicast()
3935            .aggregation(MulticastStrategy::CollectAll)
3936            .to("mock:a")
3937            .end_multicast()
3938            .build()
3939            .unwrap();
3940
3941        if let BuilderStep::Multicast { config, .. } = &route.steps()[0] {
3942            assert!(matches!(config.aggregation, MulticastStrategy::CollectAll));
3943        } else {
3944            panic!("Expected Multicast step");
3945        }
3946    }
3947
3948    // ── extract_completion_fields: Any mode with multiple conditions ────────────
3949
3950    #[test]
3951    fn test_build_canonical_aggregate_any_completion_mode() {
3952        let spec = RouteBuilder::from("direct:start")
3953            .route_id("canonical-any-completion")
3954            .aggregate(
3955                AggregatorConfig::correlate_by("key")
3956                    .complete_on_size_or_timeout(10, Duration::from_secs(30))
3957                    .build()
3958                    .unwrap(),
3959            )
3960            .build_canonical()
3961            .unwrap();
3962
3963        if let CanonicalStepSpec::Aggregate(agg) = &spec.steps[0] {
3964            assert_eq!(agg.completion_size, Some(10));
3965            assert_eq!(agg.completion_timeout_ms, Some(30_000));
3966        } else {
3967            panic!("Expected Aggregate step");
3968        }
3969    }
3970
3971    #[test]
3972    fn test_build_canonical_aggregate_timeout_completion() {
3973        let spec = RouteBuilder::from("direct:start")
3974            .route_id("canonical-timeout-completion")
3975            .aggregate(
3976                AggregatorConfig::correlate_by("key")
3977                    .complete_on_timeout(Duration::from_millis(500))
3978                    .build()
3979                    .unwrap(),
3980            )
3981            .build_canonical()
3982            .unwrap();
3983
3984        if let CanonicalStepSpec::Aggregate(agg) = &spec.steps[0] {
3985            assert_eq!(agg.completion_size, None);
3986            assert_eq!(agg.completion_timeout_ms, Some(500));
3987        } else {
3988            panic!("Expected Aggregate step");
3989        }
3990    }
3991
3992    // ── canonicalize_aggregate: discard_on_timeout and force_completion_on_stop ─
3993
3994    #[test]
3995    fn test_build_canonical_aggregate_discard_on_timeout() {
3996        use camel_api::aggregator::AggregatorConfig;
3997
3998        let spec = RouteBuilder::from("direct:start")
3999            .route_id("canonical-discard-timeout")
4000            .aggregate(
4001                AggregatorConfig::correlate_by("key")
4002                    .complete_when_size(1)
4003                    .discard_on_timeout(true)
4004                    .build()
4005                    .unwrap(),
4006            )
4007            .build_canonical()
4008            .unwrap();
4009
4010        if let CanonicalStepSpec::Aggregate(agg) = &spec.steps[0] {
4011            assert_eq!(agg.discard_on_timeout, Some(true));
4012        } else {
4013            panic!("Expected Aggregate step");
4014        }
4015    }
4016
4017    #[test]
4018    fn test_build_canonical_aggregate_force_completion_on_stop() {
4019        use camel_api::aggregator::AggregatorConfig;
4020
4021        let spec = RouteBuilder::from("direct:start")
4022            .route_id("canonical-force-stop")
4023            .aggregate(
4024                AggregatorConfig::correlate_by("key")
4025                    .complete_when_size(1)
4026                    .force_completion_on_stop(true)
4027                    .build()
4028                    .unwrap(),
4029            )
4030            .build_canonical()
4031            .unwrap();
4032
4033        if let CanonicalStepSpec::Aggregate(agg) = &spec.steps[0] {
4034            assert_eq!(agg.force_completion_on_stop, Some(true));
4035        } else {
4036            panic!("Expected Aggregate step");
4037        }
4038    }
4039
4040    // ── build_canonical: max_buckets and bucket_ttl ─────────────────────────────
4041
4042    #[test]
4043    fn test_build_canonical_aggregate_max_buckets_and_ttl() {
4044        use camel_api::aggregator::AggregatorConfig;
4045
4046        let spec = RouteBuilder::from("direct:start")
4047            .route_id("canonical-buckets-ttl")
4048            .aggregate(
4049                AggregatorConfig::correlate_by("key")
4050                    .complete_when_size(1)
4051                    .max_buckets(100)
4052                    .bucket_ttl(Duration::from_secs(60))
4053                    .build()
4054                    .unwrap(),
4055            )
4056            .build_canonical()
4057            .unwrap();
4058
4059        if let CanonicalStepSpec::Aggregate(agg) = &spec.steps[0] {
4060            assert_eq!(agg.max_buckets, Some(100));
4061            assert_eq!(agg.bucket_ttl_ms, Some(60_000));
4062        } else {
4063            panic!("Expected Aggregate step");
4064        }
4065    }
4066
4067    // ── canonicalize_aggregate: correlation strategy → correlation_key mapping ──
4068    //
4069    // These tests pin the canonicalize half of the expression round-trip
4070    // scenario. The recompile half is
4071    // `canonical_recompile_of_canonicalized_expression_config_yields_expression`
4072    // in camel-dsl compile.rs (task 2.4) — camel-builder has no camel-dsl
4073    // dependency, so the two halves live in their own crates.
4074
4075    #[test]
4076    fn canonicalize_aggregate_expression_maps_correlation_key() {
4077        use camel_api::aggregator::AggregatorConfig;
4078
4079        let config = AggregatorConfig::correlate_by("seed")
4080            .correlate_by_expr("${header.orderId}", "simple")
4081            .complete_when_size(2)
4082            .build()
4083            .unwrap();
4084
4085        let spec = canonicalize_aggregate(config).unwrap();
4086
4087        assert_eq!(spec.correlation_key, Some("${header.orderId}".to_string()));
4088        assert_eq!(spec.header, "${header.orderId}");
4089    }
4090
4091    #[test]
4092    fn canonicalize_aggregate_header_only_maps_no_correlation_key() {
4093        use camel_api::aggregator::AggregatorConfig;
4094
4095        let config = AggregatorConfig::correlate_by("orderId")
4096            .complete_when_size(2)
4097            .build()
4098            .unwrap();
4099
4100        let spec = canonicalize_aggregate(config).unwrap();
4101
4102        assert_eq!(spec.correlation_key, None);
4103        assert_eq!(spec.header, "orderId");
4104    }
4105
4106    // ── SplitBuilder with filter inside ─────────────────────────────────────────
4107
4108    #[test]
4109    fn test_split_builder_with_filter_inside() {
4110        use camel_api::splitter::{SplitterConfig, split_body_lines};
4111
4112        let definition = RouteBuilder::from("timer:test")
4113            .route_id("split-with-filter")
4114            .split(SplitterConfig::new(split_body_lines()))
4115            .filter(|_ex| true)
4116            .to("mock:filtered-frag")
4117            .end_filter()
4118            .end_split()
4119            .build()
4120            .unwrap();
4121
4122        if let BuilderStep::Split { steps, .. } = &definition.steps()[0] {
4123            assert_eq!(steps.len(), 1);
4124            assert!(matches!(&steps[0], BuilderStep::Filter { .. }));
4125        } else {
4126            panic!("Expected Split step");
4127        }
4128    }
4129
4130    // ── WireTap additional tests ────────────────────────────────────────────────
4131
4132    #[test]
4133    fn test_wire_tap_multiple_taps() {
4134        let definition = RouteBuilder::from("timer:tick")
4135            .route_id("multi-wire-tap")
4136            .wire_tap("mock:tap1")
4137            .wire_tap("mock:tap2")
4138            .to("mock:result")
4139            .build()
4140            .unwrap();
4141
4142        assert_eq!(definition.steps().len(), 3);
4143        assert!(
4144            matches!(&definition.steps()[0], BuilderStep::WireTap { uri } if uri == "mock:tap1")
4145        );
4146        assert!(
4147            matches!(&definition.steps()[1], BuilderStep::WireTap { uri } if uri == "mock:tap2")
4148        );
4149    }
4150
4151    // ── Error handler: explicit config after shorthand → Mixed mode ─────────────
4152
4153    #[test]
4154    fn test_builder_shorthand_then_explicit_mixed_mode() {
4155        let result = RouteBuilder::from("direct:start")
4156            .route_id("mixed-mode-2")
4157            .dead_letter_channel("log:dlc")
4158            .error_handler(ErrorHandlerConfig::log_only())
4159            .to("mock:out")
4160            .build();
4161
4162        let err = result.err().expect("mixed mode should fail");
4163        assert!(format!("{err}").contains("mixed error handler modes"));
4164    }
4165
4166    // ── build_canonical: empty from_uri error ───────────────────────────────────
4167
4168    #[test]
4169    fn test_build_canonical_empty_from_uri_errors() {
4170        let result = RouteBuilder::from("").route_id("test").build_canonical();
4171        assert!(result.is_err());
4172    }
4173
4174    #[test]
4175    fn test_build_canonical_missing_route_id_errors() {
4176        let result = RouteBuilder::from("direct:start").build_canonical();
4177        assert!(result.is_err());
4178        let err = result.unwrap_err().to_string();
4179        assert!(err.contains("route_id"));
4180    }
4181
4182    // ── SplitBuilder: aggregate inside split ────────────────────────────────────
4183
4184    #[test]
4185    fn test_split_builder_with_aggregate_inside() {
4186        use camel_api::aggregator::AggregatorConfig;
4187        use camel_api::splitter::{SplitterConfig, split_body_lines};
4188
4189        let definition = RouteBuilder::from("timer:test")
4190            .route_id("split-agg")
4191            .split(SplitterConfig::new(split_body_lines()))
4192            .aggregate(
4193                AggregatorConfig::correlate_by("frag-key")
4194                    .complete_when_size(3)
4195                    .build()
4196                    .unwrap(),
4197            )
4198            .end_split()
4199            .build()
4200            .unwrap();
4201
4202        if let BuilderStep::Split { steps, .. } = &definition.steps()[0] {
4203            assert_eq!(steps.len(), 1);
4204            assert!(matches!(&steps[0], BuilderStep::Aggregate { .. }));
4205        } else {
4206            panic!("Expected Split step");
4207        }
4208    }
4209
4210    // ── Throttle: steps collected inside throttle scope ─────────────────────────
4211
4212    #[test]
4213    fn test_throttle_builder_with_steps_inside() {
4214        let definition = RouteBuilder::from("timer:tick")
4215            .route_id("throttle-steps")
4216            .throttle(10, Duration::from_secs(1))
4217            .set_header("throttled", Value::Bool(true))
4218            .to("mock:throttled")
4219            .end_throttle()
4220            .build()
4221            .unwrap();
4222
4223        if let BuilderStep::Throttle { steps, .. } = &definition.steps()[0] {
4224            assert_eq!(steps.len(), 2);
4225        } else {
4226            panic!("Expected Throttle step");
4227        }
4228    }
4229
4230    // ── LoadBalance: steps collected inside scope ───────────────────────────────
4231
4232    #[test]
4233    fn test_load_balance_builder_with_steps_inside() {
4234        let definition = RouteBuilder::from("timer:tick")
4235            .route_id("lb-steps")
4236            .load_balance()
4237            .round_robin()
4238            .set_header("lb", Value::Bool(true))
4239            .to("mock:lb")
4240            .end_load_balance()
4241            .build()
4242            .unwrap();
4243
4244        if let BuilderStep::LoadBalance { steps, .. } = &definition.steps()[0] {
4245            assert_eq!(steps.len(), 2);
4246        } else {
4247            panic!("Expected LoadBalance step");
4248        }
4249    }
4250
4251    // ── Multicast: steps collected inside scope ─────────────────────────────────
4252
4253    #[test]
4254    fn test_multicast_builder_with_steps_inside() {
4255        let definition = RouteBuilder::from("timer:tick")
4256            .route_id("multicast-steps")
4257            .multicast()
4258            .set_header("mc", Value::Bool(true))
4259            .to("mock:multicast")
4260            .end_multicast()
4261            .build()
4262            .unwrap();
4263
4264        if let BuilderStep::Multicast { steps, .. } = &definition.steps()[0] {
4265            assert_eq!(steps.len(), 2);
4266        } else {
4267            panic!("Expected Multicast step");
4268        }
4269    }
4270
4271    // ── LoopBuilder: steps collected inside loop scope ──────────────────────────
4272
4273    #[test]
4274    fn test_loop_builder_with_steps_inside() {
4275        let definition = RouteBuilder::from("timer:tick")
4276            .route_id("loop-steps")
4277            .loop_count(3)
4278            .set_header("loop", Value::Bool(true))
4279            .to("mock:loop")
4280            .end_loop()
4281            .build()
4282            .unwrap();
4283
4284        if let BuilderStep::Loop { steps, .. } = &definition.steps()[0] {
4285            assert_eq!(steps.len(), 2);
4286        } else {
4287            panic!("Expected Loop step");
4288        }
4289    }
4290
4291    // ── canonical_step_name coverage for remaining variants ─────────────────────
4292
4293    #[test]
4294    fn test_build_canonical_rejects_loop_step() {
4295        let err = RouteBuilder::from("direct:start")
4296            .route_id("canonical-loop")
4297            .loop_count(3)
4298            .to("mock:loop")
4299            .end_loop()
4300            .build_canonical()
4301            .unwrap_err();
4302
4303        assert!(format!("{err}").contains("does not support step `loop`"));
4304    }
4305
4306    #[test]
4307    fn test_build_canonical_rejects_multicast_step() {
4308        let err = RouteBuilder::from("direct:start")
4309            .route_id("canonical-multicast")
4310            .multicast()
4311            .to("mock:a")
4312            .end_multicast()
4313            .build_canonical()
4314            .unwrap_err();
4315
4316        assert!(format!("{err}").contains("does not support step `multicast`"));
4317    }
4318
4319    #[test]
4320    fn test_build_canonical_rejects_throttle_step() {
4321        let err = RouteBuilder::from("direct:start")
4322            .route_id("canonical-throttle")
4323            .throttle(10, Duration::from_secs(1))
4324            .to("mock:result")
4325            .end_throttle()
4326            .build_canonical()
4327            .unwrap_err();
4328
4329        assert!(format!("{err}").contains("does not support step `throttle`"));
4330    }
4331
4332    #[test]
4333    fn test_build_canonical_rejects_load_balancer_step() {
4334        let err = RouteBuilder::from("direct:start")
4335            .route_id("canonical-lb")
4336            .load_balance()
4337            .round_robin()
4338            .to("mock:result")
4339            .end_load_balance()
4340            .build_canonical()
4341            .unwrap_err();
4342
4343        assert!(format!("{err}").contains("does not support step `load_balancer`"));
4344    }
4345
4346    #[test]
4347    fn test_build_canonical_rejects_bean_step() {
4348        let err = RouteBuilder::from("direct:start")
4349            .route_id("canonical-bean")
4350            .bean("myBean", "process")
4351            .build_canonical()
4352            .unwrap_err();
4353
4354        assert!(format!("{err}").contains("does not support step `bean`"));
4355    }
4356
4357    #[test]
4358    fn test_build_canonical_rejects_script_step() {
4359        let err = RouteBuilder::from("direct:start")
4360            .route_id("canonical-script")
4361            .script("rhai", "x = 1")
4362            .build_canonical()
4363            .unwrap_err();
4364
4365        assert!(format!("{err}").contains("does not support step `script`"));
4366    }
4367
4368    #[test]
4369    fn test_build_canonical_accepts_delay_step() {
4370        let spec = RouteBuilder::from("direct:start")
4371            .route_id("canonical-delay")
4372            .delay(Duration::from_millis(100))
4373            .build_canonical()
4374            .unwrap();
4375
4376        assert!(
4377            spec.steps.iter().any(
4378                |s| matches!(s, CanonicalStepSpec::Delay { delay_ms, .. } if *delay_ms == 100)
4379            )
4380        );
4381    }
4382
4383    #[test]
4384    fn test_build_canonical_accepts_wire_tap_step() {
4385        let spec = RouteBuilder::from("direct:start")
4386            .route_id("canonical-wiretap")
4387            .wire_tap("mock:tap")
4388            .build_canonical()
4389            .unwrap();
4390
4391        assert!(
4392            spec.steps
4393                .iter()
4394                .any(|s| matches!(s, CanonicalStepSpec::WireTap { uri } if uri == "mock:tap"))
4395        );
4396    }
4397
4398    #[test]
4399    fn test_build_canonical_rejects_dynamic_router_step() {
4400        let err = RouteBuilder::from("direct:start")
4401            .route_id("canonical-dyn-router")
4402            .dynamic_router(camel_api::TargetSource::Sync(Arc::new(|_| {
4403                Some("mock:a".to_string())
4404            })))
4405            .build_canonical()
4406            .unwrap_err();
4407
4408        assert!(format!("{err}").contains("does not support step `dynamic_router`"));
4409    }
4410
4411    #[test]
4412    fn test_build_canonical_rejects_routing_slip_step() {
4413        let err = RouteBuilder::from("direct:start")
4414            .route_id("canonical-routing-slip")
4415            .routing_slip(camel_api::TargetSource::Sync(Arc::new(|_| {
4416                Some("mock:a".to_string())
4417            })))
4418            .build_canonical()
4419            .unwrap_err();
4420
4421        assert!(format!("{err}").contains("does not support step `routing_slip`"));
4422    }
4423
4424    #[test]
4425    fn test_build_canonical_rejects_recipient_list_step() {
4426        let err = RouteBuilder::from("direct:start")
4427            .route_id("canonical-recipient")
4428            .recipient_list(camel_api::RecipientSource::Sync(Arc::new(|_| {
4429                "mock:a".to_string()
4430            })))
4431            .build_canonical()
4432            .unwrap_err();
4433
4434        assert!(format!("{err}").contains("does not support step `recipient_list`"));
4435    }
4436
4437    // ── extract_completion_fields: Any mode with predicate → error ──────────────
4438
4439    #[test]
4440    fn test_build_canonical_rejects_any_mode_with_predicate() {
4441        let err = RouteBuilder::from("direct:start")
4442            .route_id("canonical-any-pred")
4443            .aggregate(AggregatorConfig {
4444                header_name: "key".to_string(),
4445                completion: CompletionMode::Any(vec![
4446                    CompletionCondition::Size(5),
4447                    CompletionCondition::Predicate(Arc::new(|_| false)),
4448                ]),
4449                correlation: CorrelationStrategy::HeaderName("key".to_string()),
4450                strategy: AggregationStrategy::CollectAll,
4451                max_buckets: None,
4452                max_bucket_size: None,
4453                bucket_ttl: None,
4454                force_completion_on_stop: false,
4455                discard_on_timeout: false,
4456                max_timeout_tasks: 1024,
4457            })
4458            .build_canonical()
4459            .unwrap_err();
4460
4461        assert!(
4462            format!("{err}").contains("cannot reverse-map"),
4463            "reject message must explain forward-only: {}",
4464            err
4465        );
4466    }
4467
4468    // ── BUILDER-004: Validation errors for missing required fields ────────────
4469
4470    #[test]
4471    fn test_builder_validation_missing_from_uri() {
4472        let result = RouteBuilder::from("")
4473            .route_id("missing-uri-route")
4474            .to("log:info")
4475            .build();
4476        assert!(result.is_err(), "empty from URI should fail validation");
4477        let err = result.err().unwrap().to_string();
4478        assert!(
4479            err.contains("'from'") || err.contains("URI"),
4480            "error should mention from/URI, got: {err}"
4481        );
4482    }
4483
4484    #[test]
4485    fn test_builder_validation_invalid_step_uri_scheme() {
4486        let result = RouteBuilder::from("timer:tick")
4487            .route_id("bad-step-route")
4488            .to("not-a-valid-uri") // no scheme
4489            .build();
4490        // The builder itself accepts any URI string; validation happens at
4491        // resolution time. Verify the build succeeds (step URI is deferred).
4492        assert!(
4493            result.is_ok(),
4494            "builder should accept opaque step URIs; resolution happens later"
4495        );
4496    }
4497
4498    // ── rc-p9vq: Duplicate route IDs ──────────────────────────────────────
4499
4500    #[test]
4501    fn test_builder_duplicate_route_ids_produce_identical_definitions() {
4502        // The builder itself doesn't check for duplicates (that's context-level).
4503        // Verify both builds succeed with the same ID — detection is TODO(rc-p9vq).
4504        let route1 = RouteBuilder::from("direct:a")
4505            .route_id("dup-route")
4506            .to("mock:out")
4507            .build();
4508        let route2 = RouteBuilder::from("direct:b")
4509            .route_id("dup-route")
4510            .to("mock:out")
4511            .build();
4512
4513        assert!(route1.is_ok());
4514        assert!(route2.is_ok());
4515        assert_eq!(route1.unwrap().route_id(), route2.unwrap().route_id());
4516    }
4517
4518    #[test]
4519    fn test_builder_clone_reuse_as_template() {
4520        // rc-8m5o: a partially-built RouteBuilder can be cloned and reused as a
4521        // template, then each clone varied independently before build().
4522        let template = RouteBuilder::from("direct:in")
4523            .set_header("stage", Value::String("shared".into()))
4524            .log("shared prefix", LogLevel::Info);
4525
4526        let route_a = template
4527            .clone()
4528            .route_id("route-a")
4529            .to("mock:a")
4530            .build()
4531            .expect("clone A builds");
4532        let route_b = template
4533            .route_id("route-b")
4534            .to("mock:b")
4535            .build()
4536            .expect("clone B builds");
4537
4538        assert_eq!(route_a.route_id(), "route-a");
4539        assert_eq!(route_b.route_id(), "route-b");
4540        // Shared template steps (set_header + log) are present in both, plus the
4541        // per-clone `to` step: the clone is a deep copy, not an alias.
4542        assert_eq!(route_a.steps().len(), route_b.steps().len());
4543        assert_eq!(route_a.from_uri(), route_b.from_uri());
4544    }
4545}