Skip to main content

camel_core/lifecycle/application/
route_definition.rs

1// lifecycle/application/route_definition.rs
2// Route definition and builder-step types. Route (compiled artifact) lives in adapters.
3
4use std::sync::Arc;
5
6use camel_api::UnitOfWorkConfig;
7use camel_api::circuit_breaker::CircuitBreakerConfig;
8use camel_api::error_handler::ErrorHandlerConfig;
9use camel_api::loop_eip::LoopConfig;
10use camel_api::security_policy::SecurityPolicyConfig;
11use camel_api::{
12    AggregatorConfig, FilterPredicate, MulticastConfig, OpaqueProcessor, ResequencePolicyConfig,
13    SplitterConfig,
14};
15use camel_auth::TokenAuthenticator;
16use camel_component_api::ConcurrencyModel;
17
18/// An unresolved when-clause: predicate + nested steps for the sub-pipeline.
19#[derive(Clone)]
20pub struct WhenStep {
21    pub predicate: FilterPredicate,
22    pub steps: Vec<BuilderStep>,
23}
24
25impl std::fmt::Debug for WhenStep {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("WhenStep")
28            .field("predicate", &self.predicate)
29            .field("steps", &self.steps)
30            .finish()
31    }
32}
33
34pub use camel_api::declarative::{LanguageExpressionDef, ValueSourceDef};
35
36/// Declarative `when` clause resolved later by the runtime.
37#[derive(Debug, Clone)]
38pub struct DeclarativeWhenStep {
39    pub predicate: LanguageExpressionDef,
40    pub steps: Vec<BuilderStep>,
41}
42
43/// Builder struct for a single `doCatch` clause in the declarative pipeline.
44#[derive(Debug, Clone)]
45pub struct DoTryCatchClauseBuilder {
46    pub exception: Option<Vec<String>>,
47    pub when: Option<LanguageExpressionDef>,
48    pub on_when: Option<LanguageExpressionDef>,
49    pub disposition: camel_api::error_handler::ExceptionDisposition,
50    pub steps: Vec<BuilderStep>,
51}
52
53/// Builder struct for the `doFinally` block in the declarative pipeline.
54#[derive(Debug, Clone)]
55pub struct DoTryFinallyBuilder {
56    pub on_when: Option<LanguageExpressionDef>,
57    pub steps: Vec<BuilderStep>,
58}
59
60/// A step in an unresolved route definition.
61#[derive(Debug, Clone)]
62pub enum BuilderStep {
63    /// A pre-built Tower processor service.
64    Processor(OpaqueProcessor),
65    /// A destination URI — resolved at start time by CamelContext.
66    To(String),
67    /// A stop step that halts processing immediately.
68    Stop,
69    /// A static log step.
70    Log {
71        level: camel_processor::LogLevel,
72        message: String,
73    },
74    /// Declarative set_header (literal or language-based value), resolved at route-add time.
75    DeclarativeSetHeader {
76        key: String,
77        value: ValueSourceDef,
78    },
79    /// Declarative set_header_if_absent (internal-only), resolved at route-add time.
80    DeclarativeSetHeaderIfAbsent {
81        key: String,
82        value: ValueSourceDef,
83    },
84    DeclarativeSetProperty {
85        key: String,
86        value_source: ValueSourceDef,
87    },
88    /// Declarative set_body (literal or language-based value), resolved at route-add time.
89    DeclarativeSetBody {
90        value: ValueSourceDef,
91    },
92    /// Declarative filter using a language predicate, resolved at route-add time.
93    DeclarativeFilter {
94        predicate: LanguageExpressionDef,
95        steps: Vec<BuilderStep>,
96    },
97    /// Declarative choice/when/otherwise using language predicates, resolved at route-add time.
98    DeclarativeChoice {
99        whens: Vec<DeclarativeWhenStep>,
100        otherwise: Option<Vec<BuilderStep>>,
101    },
102    /// Declarative script step evaluated by language and written to body.
103    DeclarativeScript {
104        expression: LanguageExpressionDef,
105    },
106    DeclarativeFunction {
107        definition: camel_api::FunctionDefinition,
108    },
109    /// Declarative split using a language expression, resolved at route-add time.
110    DeclarativeSplit {
111        expression: LanguageExpressionDef,
112        aggregation: camel_api::splitter::AggregationStrategy,
113        parallel: bool,
114        parallel_limit: Option<usize>,
115        stop_on_exception: bool,
116        steps: Vec<BuilderStep>,
117    },
118    /// Declarative stream split using a streaming split expression, resolved at route-add time.
119    DeclarativeStreamSplit {
120        stream_config: camel_api::StreamSplitConfig,
121        aggregation: camel_api::splitter::AggregationStrategy,
122        stop_on_exception: bool,
123        steps: Vec<BuilderStep>,
124    },
125    DeclarativeDynamicRouter {
126        expression: LanguageExpressionDef,
127        uri_delimiter: String,
128        cache_size: i32,
129        ignore_invalid_endpoints: bool,
130        max_iterations: usize,
131    },
132    DeclarativeRoutingSlip {
133        expression: LanguageExpressionDef,
134        uri_delimiter: String,
135        cache_size: i32,
136        ignore_invalid_endpoints: bool,
137    },
138    /// A Splitter sub-pipeline: config + nested steps to execute per fragment.
139    Split {
140        config: SplitterConfig,
141        steps: Vec<BuilderStep>,
142    },
143    /// An Aggregator step: collects exchanges by correlation key, emits when complete.
144    Aggregate {
145        config: AggregatorConfig,
146    },
147    /// A Filter sub-pipeline: predicate + nested steps executed only when predicate is true.
148    Filter {
149        predicate: FilterPredicate,
150        steps: Vec<BuilderStep>,
151    },
152    /// A Choice step: evaluates when-clauses in order, routes to the first match.
153    /// If no when matches, the optional otherwise branch is used.
154    Choice {
155        whens: Vec<WhenStep>,
156        otherwise: Option<Vec<BuilderStep>>,
157    },
158    /// A WireTap step: sends a clone of the exchange to a tap endpoint (fire-and-forget).
159    WireTap {
160        uri: String,
161    },
162    /// A Multicast step: sends the same exchange to multiple destinations.
163    Multicast {
164        steps: Vec<BuilderStep>,
165        config: MulticastConfig,
166    },
167    /// Declarative log step with a language-evaluated message, resolved at route-add time.
168    DeclarativeLog {
169        level: camel_processor::LogLevel,
170        message: ValueSourceDef,
171    },
172    /// Bean invocation step — resolved at route-add time.
173    Bean {
174        name: String,
175        method: String,
176    },
177    /// Script step: executes a script that can mutate the exchange.
178    /// The script has access to `headers`, `properties`, and `body`.
179    Script {
180        language: String,
181        script: String,
182    },
183    /// Throttle step: rate limiting with configurable behavior when limit exceeded.
184    Throttle {
185        config: camel_api::ThrottlerConfig,
186        steps: Vec<BuilderStep>,
187    },
188    /// LoadBalance step: distributes exchanges across multiple endpoints using a strategy.
189    LoadBalance {
190        config: camel_api::LoadBalancerConfig,
191        steps: Vec<BuilderStep>,
192    },
193    /// DynamicRouter step: routes exchanges dynamically based on expression evaluation.
194    DynamicRouter {
195        config: camel_api::DynamicRouterConfig,
196    },
197    RoutingSlip {
198        config: camel_api::RoutingSlipConfig,
199    },
200    RecipientList {
201        config: camel_api::recipient_list::RecipientListConfig,
202    },
203    DeclarativeRecipientList {
204        expression: LanguageExpressionDef,
205        delimiter: String,
206        parallel: bool,
207        parallel_limit: Option<usize>,
208        stop_on_exception: bool,
209        aggregation: String,
210    },
211    Delay {
212        config: camel_api::DelayConfig,
213    },
214    /// Runtime loop with closure-based predicate (programmatic DSL).
215    Loop {
216        config: LoopConfig,
217        steps: Vec<BuilderStep>,
218    },
219    /// Declarative loop with optional language-based while predicate (YAML DSL).
220    DeclarativeLoop {
221        count: Option<usize>,
222        while_predicate: Option<LanguageExpressionDef>,
223        steps: Vec<BuilderStep>,
224        max_iterations: Option<usize>,
225    },
226    /// EIP-7 enrich: synchronous content enrichment via a resolved producer.
227    Enrich {
228        uri: String,
229        strategy: Option<String>,
230        timeout_ms: Option<u64>,
231    },
232    /// EIP-7 pollEnrich: blocking poll of a PollingConsumer with timeout.
233    PollEnrich {
234        uri: String,
235        strategy: Option<String>,
236        timeout_ms: Option<u64>,
237    },
238    /// Validate step: evaluates a language expression as predicate.
239    /// Exchange passes if predicate returns true; else CamelError::ValidationError.
240    Validate {
241        predicate: LanguageExpressionDef,
242    },
243    /// Claim Check step (EIP). Transforms the exchange body to/from a
244    /// `ClaimCheckRepository` by key. Process-mode, no child pipeline.
245    /// `filter` enables selective merge-back of body/headers during checkout.
246    ClaimCheck {
247        repository: String,
248        operation: String,
249        key: LanguageExpressionDef,
250        filter: Option<String>,
251    },
252    /// Sampling step (EIP). Passes 1 of every N exchanges (counter-based,
253    /// deterministic). Non-sampled exchanges get CamelStop=true (drop semantics).
254    /// Process-mode, stateless. No StepLifecycle — counter is route-scoped.
255    Sampling {
256        period: usize,
257    },
258    /// Sort step (EIP). Orders a body array by extracting a sort key
259    /// from each element via a language expression. Process-mode, stateless.
260    Sort {
261        expression: LanguageExpressionDef,
262        reverse: bool,
263    },
264    /// Idempotent Consumer step (EIP). Wraps a child sub-pipeline that runs
265    /// only when the message-id is NOT present in the named repository.
266    /// Compiled to a `IdempotentConsumerSegment` (OutcomePipeline, segment-mode).
267    IdempotentConsumer {
268        repository: String,
269        expression: LanguageExpressionDef,
270        steps: Vec<BuilderStep>,
271        eager: bool,
272        remove_on_failure: bool,
273    },
274    /// Cache step (EIP). Wraps a child sub-pipeline that runs only on cache
275    /// miss; on hit the stored body is reconstructed and the child is skipped.
276    /// Compiled to a `CacheService` (OutcomePipeline, segment-mode).
277    Cache {
278        repository: Option<String>,
279        key: LanguageExpressionDef,
280        ttl: Option<String>,
281        max_entry_bytes: Option<usize>,
282        on_miss: Vec<BuilderStep>,
283    },
284    /// Cache Invalidate step (EIP). Removes a single cache entry by key.
285    /// Compiled to a `CacheInvalidateService` (OutcomePipeline, segment-mode).
286    CacheInvalidate {
287        repository: Option<String>,
288        key: LanguageExpressionDef,
289    },
290    /// Cache Peek Stale step (EIP). Serves a stale (post-expiry) cache entry.
291    /// Compiled to a `CachePeekStaleService` (OutcomePipeline, segment-mode).
292    CachePeekStale {
293        repository: Option<String>,
294        key: LanguageExpressionDef,
295    },
296    /// Declarative doTry/doCatch/doFinally, resolved at route-add time.
297    DeclarativeDoTry {
298        try_steps: Vec<BuilderStep>,
299        catch: Vec<DoTryCatchClauseBuilder>,
300        finally: Option<DoTryFinallyBuilder>,
301    },
302    /// Resequencer EIP: resequences exchanges by sequence number.
303    /// Must be a top-level step (not nested inside structural EIPs).
304    Resequence {
305        policy_config: ResequencePolicyConfig,
306    },
307}
308
309/// An unresolved route definition. "to" URIs have not been resolved to producers yet.
310pub struct RouteDefinition {
311    pub(crate) from_uri: String,
312    pub(crate) steps: Vec<BuilderStep>,
313    /// Optional per-route error handler config. Takes precedence over the global one.
314    pub(crate) error_handler: Option<ErrorHandlerConfig>,
315    /// Optional circuit breaker config. Applied between error handler and step pipeline.
316    pub(crate) circuit_breaker: Option<CircuitBreakerConfig>,
317    pub(crate) security_policy: Option<SecurityPolicyConfig>,
318    /// Optional token authenticator for validating JWT/OAuth tokens.
319    pub(crate) security_authenticator: Option<Arc<dyn TokenAuthenticator>>,
320    /// Optional Unit of Work config for in-flight tracking and completion hooks.
321    pub(crate) unit_of_work: Option<UnitOfWorkConfig>,
322    /// User override for the consumer's concurrency model. `None` means
323    /// "use whatever the consumer declares".
324    pub(crate) concurrency: Option<ConcurrencyModel>,
325    /// Unique identifier for this route. Required.
326    pub(crate) route_id: String,
327    /// Whether this route should start automatically when the context starts.
328    pub(crate) auto_startup: bool,
329    /// Order in which routes are started. Lower values start first.
330    pub(crate) startup_order: i32,
331    pub(crate) source_hash: Option<u64>,
332}
333
334impl RouteDefinition {
335    /// Create a new route definition with the required route ID.
336    pub fn new(from_uri: impl Into<String>, steps: Vec<BuilderStep>) -> Self {
337        Self {
338            from_uri: from_uri.into(),
339            steps,
340            error_handler: None,
341            circuit_breaker: None,
342            security_policy: None,
343            security_authenticator: None,
344            unit_of_work: None,
345            concurrency: None,
346            route_id: String::new(), // Will be set by with_route_id()
347            auto_startup: true,
348            startup_order: 1000,
349            source_hash: None,
350        }
351    }
352
353    /// The source endpoint URI.
354    pub fn from_uri(&self) -> &str {
355        &self.from_uri
356    }
357
358    /// The steps in this route definition.
359    pub fn steps(&self) -> &[BuilderStep] {
360        &self.steps
361    }
362
363    /// Transform the step list, consuming and returning self.
364    /// Used for post-parse instrumentation (e.g. benchmark timing injection
365    /// around `To` steps). The field stays private; callers rebuild via this
366    /// consuming method rather than holding a `&mut` alias.
367    pub fn map_steps(mut self, f: impl FnOnce(Vec<BuilderStep>) -> Vec<BuilderStep>) -> Self {
368        self.steps = f(self.steps);
369        self
370    }
371
372    /// Set a per-route error handler, overriding the global one.
373    pub fn with_error_handler(mut self, config: ErrorHandlerConfig) -> Self {
374        self.error_handler = Some(config);
375        self
376    }
377
378    /// Get the route-level error handler config, if set.
379    pub fn error_handler_config(&self) -> Option<&ErrorHandlerConfig> {
380        self.error_handler.as_ref()
381    }
382
383    /// Set a circuit breaker for this route.
384    pub fn with_circuit_breaker(mut self, config: CircuitBreakerConfig) -> Self {
385        self.circuit_breaker = Some(config);
386        self
387    }
388
389    /// Set a security policy for this route.
390    pub fn with_security_policy(mut self, config: SecurityPolicyConfig) -> Self {
391        self.security_policy = Some(config);
392        self
393    }
394
395    /// Set a token authenticator for this route.
396    pub fn with_security_authenticator(
397        mut self,
398        authenticator: Arc<dyn TokenAuthenticator>,
399    ) -> Self {
400        self.security_authenticator = Some(authenticator);
401        self
402    }
403
404    /// Set a unit of work config for this route.
405    pub fn with_unit_of_work(mut self, config: UnitOfWorkConfig) -> Self {
406        self.unit_of_work = Some(config);
407        self
408    }
409
410    /// Get the unit of work config, if set.
411    pub fn unit_of_work_config(&self) -> Option<&UnitOfWorkConfig> {
412        self.unit_of_work.as_ref()
413    }
414
415    /// Get the circuit breaker config, if set.
416    pub fn circuit_breaker_config(&self) -> Option<&CircuitBreakerConfig> {
417        self.circuit_breaker.as_ref()
418    }
419
420    pub fn security_policy_config(&self) -> Option<&SecurityPolicyConfig> {
421        self.security_policy.as_ref()
422    }
423
424    pub fn security_authenticator(&self) -> Option<&Arc<dyn TokenAuthenticator>> {
425        self.security_authenticator.as_ref()
426    }
427
428    /// User-specified concurrency override, if any.
429    pub fn concurrency_override(&self) -> Option<&ConcurrencyModel> {
430        self.concurrency.as_ref()
431    }
432
433    /// Override the consumer's concurrency model for this route.
434    pub fn with_concurrency(mut self, model: ConcurrencyModel) -> Self {
435        self.concurrency = Some(model);
436        self
437    }
438
439    /// Get the route ID.
440    pub fn route_id(&self) -> &str {
441        &self.route_id
442    }
443
444    /// Whether this route should start automatically when the context starts.
445    pub fn auto_startup(&self) -> bool {
446        self.auto_startup
447    }
448
449    /// Order in which routes are started. Lower values start first.
450    pub fn startup_order(&self) -> i32 {
451        self.startup_order
452    }
453
454    /// Set a unique identifier for this route.
455    pub fn with_route_id(mut self, id: impl Into<String>) -> Self {
456        self.route_id = id.into();
457        self
458    }
459
460    /// Set whether this route should start automatically.
461    pub fn with_auto_startup(mut self, auto: bool) -> Self {
462        self.auto_startup = auto;
463        self
464    }
465
466    /// Set the startup order. Lower values start first.
467    pub fn with_startup_order(mut self, order: i32) -> Self {
468        self.startup_order = order;
469        self
470    }
471
472    pub fn with_source_hash(mut self, hash: u64) -> Self {
473        self.source_hash = Some(hash);
474        self
475    }
476
477    pub fn source_hash(&self) -> Option<u64> {
478        self.source_hash
479    }
480
481    /// Extract the metadata fields needed for introspection.
482    /// This is used by RouteController to store route info without the non-Sync steps.
483    pub fn to_info(&self) -> RouteDefinitionInfo {
484        RouteDefinitionInfo {
485            route_id: self.route_id.clone(),
486            auto_startup: self.auto_startup,
487            startup_order: self.startup_order,
488            source_hash: self.source_hash,
489        }
490    }
491}
492
493/// Minimal route definition metadata for introspection.
494///
495/// This struct contains only the metadata fields from [`RouteDefinition`]
496/// that are needed for route lifecycle management, without the `steps` field
497/// (which contains non-Sync types and cannot be stored in a Sync struct).
498#[derive(Clone)]
499pub struct RouteDefinitionInfo {
500    route_id: String,
501    auto_startup: bool,
502    startup_order: i32,
503    pub(crate) source_hash: Option<u64>,
504}
505
506impl RouteDefinitionInfo {
507    /// Get the route ID.
508    pub fn route_id(&self) -> &str {
509        &self.route_id
510    }
511
512    /// Whether this route should start automatically when the context starts.
513    pub fn auto_startup(&self) -> bool {
514        self.auto_startup
515    }
516
517    /// Order in which routes are started. Lower values start first.
518    pub fn startup_order(&self) -> i32 {
519        self.startup_order
520    }
521
522    pub fn source_hash(&self) -> Option<u64> {
523        self.source_hash
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    /// Golden debug output for EVERY BuilderStep variant.
532    ///
533    /// Before refactoring the manual `Debug` impl to `#[derive(Debug)]`, this test
534    /// locks the exact output of every variant so the refactor can be verified.
535    #[test]
536    fn golden_debug_output_all_variants() {
537        use camel_api::declarative::LanguageExpressionDef;
538        use camel_api::loop_eip::LoopMode;
539        use camel_api::recipient_list::RecipientListConfig;
540        use camel_api::splitter::{AggregationStrategy, StreamSplitConfig, StreamSplitFormat};
541        use camel_api::{
542            BoxProcessor, DynamicRouterConfig, Exchange, FilterPredicate, FunctionDefinition,
543            FunctionId, IdentityProcessor, MulticastConfig, OpaqueProcessor, RoutingSlipConfig,
544            Value,
545        };
546        use std::sync::Arc;
547
548        let expr = LanguageExpressionDef {
549            language: "simple".into(),
550            source: "${body}".into(),
551        };
552
553        // -- group A: trivial / non-recursive ----------------------------------
554        // derive(Debug) emits the variant without the enum name for unit/tuple variants.
555        assert_eq!(format!("{:?}", BuilderStep::Stop), "Stop");
556        assert_eq!(
557            format!(
558                "{:?}",
559                BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(IdentityProcessor)))
560            ),
561            "Processor(BoxProcessor(...))"
562        );
563        assert_eq!(
564            format!("{:?}", BuilderStep::To("mock:out".into())),
565            "To(\"mock:out\")"
566        );
567
568        // -- group B: variant with named fields (no sub-steps) -----------------
569        // derive(Debug) emits ALL fields, not the redacted `..` form.
570        assert_eq!(
571            format!(
572                "{:?}",
573                BuilderStep::Log {
574                    level: camel_processor::LogLevel::Info,
575                    message: "hello".into(),
576                }
577            ),
578            "Log { level: Info, message: \"hello\" }"
579        );
580
581        assert_eq!(
582            format!(
583                "{:?}",
584                BuilderStep::DeclarativeSetHeader {
585                    key: "k".into(),
586                    value: ValueSourceDef::Literal(Value::String("v".into())),
587                }
588            ),
589            "DeclarativeSetHeader { key: \"k\", value: Literal(String(\"v\")) }"
590        );
591
592        assert_eq!(
593            format!(
594                "{:?}",
595                BuilderStep::DeclarativeSetHeaderIfAbsent {
596                    key: "k".into(),
597                    value: ValueSourceDef::Literal(Value::String("v".into())),
598                }
599            ),
600            "DeclarativeSetHeaderIfAbsent { key: \"k\", value: Literal(String(\"v\")) }"
601        );
602
603        assert_eq!(
604            format!(
605                "{:?}",
606                BuilderStep::DeclarativeSetBody {
607                    value: ValueSourceDef::Literal(Value::String("v".into())),
608                }
609            ),
610            "DeclarativeSetBody { value: Literal(String(\"v\")) }"
611        );
612
613        assert_eq!(
614            format!(
615                "{:?}",
616                BuilderStep::DeclarativeSetProperty {
617                    key: "prop".into(),
618                    value_source: ValueSourceDef::Literal(Value::String("v".into())),
619                }
620            ),
621            "DeclarativeSetProperty { key: \"prop\", value_source: Literal(String(\"v\")) }"
622        );
623
624        assert_eq!(
625            format!(
626                "{:?}",
627                BuilderStep::DeclarativeScript {
628                    expression: expr.clone(),
629                }
630            ),
631            "DeclarativeScript { expression: LanguageExpressionDef { language: \"simple\", source: \"${body}\" } }"
632        );
633
634        // DeclarativeFunction needs a FunctionDefinition
635        let func_def = FunctionDefinition {
636            id: FunctionId("test-id".into()),
637            runtime: "my_runtime".into(),
638            source: "${body}".into(),
639            timeout_ms: 5000,
640            route_id: None,
641            step_index: None,
642        };
643        assert_eq!(
644            format!(
645                "{:?}",
646                BuilderStep::DeclarativeFunction {
647                    definition: func_def,
648                }
649            ),
650            "DeclarativeFunction { definition: FunctionDefinition { id: FunctionId(\"test-id\"), runtime: \"my_runtime\", source: \"${body}\", timeout_ms: 5000, route_id: None, step_index: None } }"
651        );
652
653        assert_eq!(
654            format!(
655                "{:?}",
656                BuilderStep::WireTap {
657                    uri: "mock:tap".into(),
658                }
659            ),
660            "WireTap { uri: \"mock:tap\" }"
661        );
662
663        assert_eq!(
664            format!(
665                "{:?}",
666                BuilderStep::DeclarativeLog {
667                    level: camel_processor::LogLevel::Info,
668                    message: ValueSourceDef::Expression(expr.clone()),
669                }
670            ),
671            "DeclarativeLog { level: Info, message: Expression(LanguageExpressionDef { language: \"simple\", source: \"${body}\" }) }"
672        );
673
674        assert_eq!(
675            format!(
676                "{:?}",
677                BuilderStep::Bean {
678                    name: "myBean".into(),
679                    method: "process".into(),
680                }
681            ),
682            "Bean { name: \"myBean\", method: \"process\" }"
683        );
684
685        assert_eq!(
686            format!(
687                "{:?}",
688                BuilderStep::Script {
689                    language: "js".into(),
690                    script: "body".into(),
691                }
692            ),
693            "Script { language: \"js\", script: \"body\" }"
694        );
695
696        assert_eq!(
697            format!(
698                "{:?}",
699                BuilderStep::Aggregate {
700                    config: camel_api::AggregatorConfig::correlate_by("id")
701                        .complete_when_size(1)
702                        .build()
703                        .unwrap(),
704                }
705            ),
706            "Aggregate { config: AggregatorConfig { header_name: \"id\", completion: Single(Size(1)), correlation: HeaderName(\"id\"), strategy: CollectAll, max_buckets: Some(10000), bucket_ttl: Some(300s), force_completion_on_stop: false, discard_on_timeout: false, max_timeout_tasks: 1024 } }"
707        );
708
709        assert_eq!(
710            format!(
711                "{:?}",
712                BuilderStep::DynamicRouter {
713                    config: DynamicRouterConfig::new(Arc::new(|_: &Exchange| Some(
714                        "mock:dr".into()
715                    ))),
716                }
717            ),
718            "DynamicRouter { config: DynamicRouterConfig { uri_delimiter: \",\", cache_size: 1000, ignore_invalid_endpoints: false, max_iterations: 1000, timeout: Some(60s) } }"
719        );
720
721        assert_eq!(
722            format!(
723                "{:?}",
724                BuilderStep::RoutingSlip {
725                    config: RoutingSlipConfig::new(Arc::new(|_: &Exchange| Some("mock:rs".into()))),
726                }
727            ),
728            "RoutingSlip { config: RoutingSlipConfig { uri_delimiter: \",\", cache_size: 1000, ignore_invalid_endpoints: false } }"
729        );
730
731        assert_eq!(
732            format!(
733                "{:?}",
734                BuilderStep::RecipientList {
735                    config: RecipientListConfig::new(Arc::new(|_: &Exchange| String::new())),
736                }
737            ),
738            "RecipientList { config: RecipientListConfig { delimiter: \",\", parallel: false, parallel_limit: None, stop_on_exception: false, max_recipients: 1000 } }"
739        );
740
741        assert_eq!(
742            format!(
743                "{:?}",
744                BuilderStep::Enrich {
745                    uri: "mock:enrich".into(),
746                    strategy: Some("agg".into()),
747                    timeout_ms: Some(1000),
748                }
749            ),
750            "Enrich { uri: \"mock:enrich\", strategy: Some(\"agg\"), timeout_ms: Some(1000) }"
751        );
752
753        assert_eq!(
754            format!(
755                "{:?}",
756                BuilderStep::PollEnrich {
757                    uri: "mock:poll".into(),
758                    strategy: None,
759                    timeout_ms: None,
760                }
761            ),
762            "PollEnrich { uri: \"mock:poll\", strategy: None, timeout_ms: None }"
763        );
764
765        assert_eq!(
766            format!(
767                "{:?}",
768                BuilderStep::Validate {
769                    predicate: expr.clone(),
770                }
771            ),
772            "Validate { predicate: LanguageExpressionDef { language: \"simple\", source: \"${body}\" } }"
773        );
774
775        assert_eq!(
776            format!("{:?}", BuilderStep::Sampling { period: 100 }),
777            "Sampling { period: 100 }"
778        );
779
780        assert_eq!(
781            format!(
782                "{:?}",
783                BuilderStep::Resequence {
784                    policy_config: Default::default(),
785                }
786            ),
787            "Resequence { policy_config: ResequencePolicyConfig { mode: Batch { correlation: \"header.id\", sort: \"header.id\", completion: SizeOrTimeout(100, 30000) } } }"
788        );
789
790        // -- group C: variants with named fields (sub-steps is Vec) ------------
791        assert_eq!(
792            format!(
793                "{:?}",
794                BuilderStep::DeclarativeFilter {
795                    predicate: expr.clone(),
796                    steps: vec![BuilderStep::Stop],
797                }
798            ),
799            "DeclarativeFilter { predicate: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, steps: [Stop] }"
800        );
801
802        assert_eq!(
803            format!(
804                "{:?}",
805                BuilderStep::DeclarativeSplit {
806                    expression: expr.clone(),
807                    aggregation: AggregationStrategy::Original,
808                    parallel: false,
809                    parallel_limit: Some(2),
810                    stop_on_exception: true,
811                    steps: vec![BuilderStep::Stop],
812                }
813            ),
814            "DeclarativeSplit { expression: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, aggregation: Original, parallel: false, parallel_limit: Some(2), stop_on_exception: true, steps: [Stop] }"
815        );
816
817        assert_eq!(
818            format!(
819                "{:?}",
820                BuilderStep::Split {
821                    config: camel_api::splitter::SplitterConfig::new(
822                        camel_api::splitter::split_body_lines()
823                    ),
824                    steps: vec![BuilderStep::Stop],
825                }
826            ),
827            "Split { config: SplitterConfig { expression: \"<split-expression>\", aggregation: LastWins, parallel: false, parallel_limit: None, stop_on_exception: true, max_fragments: 100000 }, steps: [Stop] }"
828        );
829
830        assert_eq!(
831            format!(
832                "{:?}",
833                BuilderStep::Filter {
834                    predicate: FilterPredicate::new(|_: &Exchange| true),
835                    steps: vec![BuilderStep::Stop],
836                }
837            ),
838            "Filter { predicate: FilterPredicate(..), steps: [Stop] }"
839        );
840
841        assert_eq!(
842            format!(
843                "{:?}",
844                BuilderStep::Throttle {
845                    config: camel_api::ThrottlerConfig::new(
846                        10,
847                        std::time::Duration::from_millis(10)
848                    ),
849                    steps: vec![BuilderStep::Stop],
850                }
851            ),
852            "Throttle { config: ThrottlerConfig { max_requests: 10, period: 10ms, strategy: Delay }, steps: [Stop] }"
853        );
854
855        assert_eq!(
856            format!(
857                "{:?}",
858                BuilderStep::LoadBalance {
859                    config: camel_api::LoadBalancerConfig::round_robin(),
860                    steps: vec![BuilderStep::To("mock:l1".into())],
861                }
862            ),
863            "LoadBalance { config: LoadBalancerConfig { strategy: RoundRobin }, steps: [To(\"mock:l1\")] }"
864        );
865
866        assert_eq!(
867            format!(
868                "{:?}",
869                BuilderStep::Delay {
870                    config: camel_api::DelayConfig::new(500),
871                }
872            ),
873            "Delay { config: DelayConfig { delay_ms: 500, dynamic_header: None, max_delay_ms: 3600000 } }"
874        );
875
876        // -- group D: Choice / DeclarativeChoice --------------------------------
877        // derive(Debug) emits full field enumeration; WhenStep Debug is a
878        // full struct listing; nested BuilderStep::Stop stays as `Stop`.
879        assert_eq!(
880            format!(
881                "{:?}",
882                BuilderStep::Choice {
883                    whens: vec![WhenStep {
884                        predicate: FilterPredicate::new(|_: &Exchange| true),
885                        steps: vec![BuilderStep::To("mock:a".into())],
886                    }],
887                    otherwise: None,
888                }
889            ),
890            "Choice { whens: [WhenStep { predicate: FilterPredicate(..), steps: [To(\"mock:a\")] }], otherwise: None }"
891        );
892
893        assert_eq!(
894            format!(
895                "{:?}",
896                BuilderStep::DeclarativeChoice {
897                    whens: vec![DeclarativeWhenStep {
898                        predicate: expr.clone(),
899                        steps: vec![BuilderStep::Stop],
900                    }],
901                    otherwise: Some(vec![BuilderStep::Stop]),
902                }
903            ),
904            "DeclarativeChoice { whens: [DeclarativeWhenStep { predicate: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, steps: [Stop] }], otherwise: Some([Stop]) }"
905        );
906
907        // -- group E: Multicast -------------------------------------------------
908        assert_eq!(
909            format!(
910                "{:?}",
911                BuilderStep::Multicast {
912                    steps: vec![BuilderStep::To("direct:a".into())],
913                    config: MulticastConfig::new(),
914                }
915            ),
916            "Multicast { steps: [To(\"direct:a\")], config: MulticastConfig { parallel: false, parallel_limit: None, stop_on_exception: false, timeout: None, aggregation: LastWins } }"
917        );
918
919        // -- group F: DeclarativeDynamicRouter / DeclarativeRoutingSlip --------
920        assert_eq!(
921            format!(
922                "{:?}",
923                BuilderStep::DeclarativeDynamicRouter {
924                    expression: expr.clone(),
925                    uri_delimiter: ",".into(),
926                    cache_size: 1000,
927                    ignore_invalid_endpoints: false,
928                    max_iterations: 1000,
929                }
930            ),
931            "DeclarativeDynamicRouter { expression: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, uri_delimiter: \",\", cache_size: 1000, ignore_invalid_endpoints: false, max_iterations: 1000 }"
932        );
933
934        assert_eq!(
935            format!(
936                "{:?}",
937                BuilderStep::DeclarativeRoutingSlip {
938                    expression: expr.clone(),
939                    uri_delimiter: ",".into(),
940                    cache_size: 1000,
941                    ignore_invalid_endpoints: false,
942                }
943            ),
944            "DeclarativeRoutingSlip { expression: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, uri_delimiter: \",\", cache_size: 1000, ignore_invalid_endpoints: false }"
945        );
946
947        // -- group G: DeclarativeRecipientList ---------------------------------
948        assert_eq!(
949            format!(
950                "{:?}",
951                BuilderStep::DeclarativeRecipientList {
952                    expression: expr.clone(),
953                    delimiter: ",".into(),
954                    parallel: false,
955                    parallel_limit: None,
956                    stop_on_exception: false,
957                    aggregation: "original".into(),
958                }
959            ),
960            "DeclarativeRecipientList { expression: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, delimiter: \",\", parallel: false, parallel_limit: None, stop_on_exception: false, aggregation: \"original\" }"
961        );
962
963        // -- group H: Loop / DeclarativeLoop -----------------------------------
964        assert_eq!(
965            format!(
966                "{:?}",
967                BuilderStep::Loop {
968                    config: camel_api::loop_eip::LoopConfig::new(LoopMode::Count(3)),
969                    steps: vec![],
970                }
971            ),
972            "Loop { config: LoopConfig { mode: Count(3), max_iterations: 10000 }, steps: [] }"
973        );
974
975        assert_eq!(
976            format!(
977                "{:?}",
978                BuilderStep::DeclarativeLoop {
979                    count: Some(5),
980                    while_predicate: None,
981                    steps: vec![],
982                    max_iterations: Some(100),
983                }
984            ),
985            "DeclarativeLoop { count: Some(5), while_predicate: None, steps: [], max_iterations: Some(100) }"
986        );
987
988        // -- group I: ClaimCheck -----------------------------------------------
989        assert_eq!(
990            format!(
991                "{:?}",
992                BuilderStep::ClaimCheck {
993                    repository: "myRepo".into(),
994                    operation: "checkout".into(),
995                    key: expr.clone(),
996                    filter: None,
997                }
998            ),
999            "ClaimCheck { repository: \"myRepo\", operation: \"checkout\", key: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, filter: None }"
1000        );
1001
1002        // -- group J: Sort -----------------------------------------------------
1003        assert_eq!(
1004            format!(
1005                "{:?}",
1006                BuilderStep::Sort {
1007                    expression: expr.clone(),
1008                    reverse: false,
1009                }
1010            ),
1011            "Sort { expression: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, reverse: false }"
1012        );
1013
1014        // -- group K: IdempotentConsumer ---------------------------------------
1015        assert_eq!(
1016            format!(
1017                "{:?}",
1018                BuilderStep::IdempotentConsumer {
1019                    repository: "myRepo".into(),
1020                    expression: expr.clone(),
1021                    steps: vec![],
1022                    eager: true,
1023                    remove_on_failure: false,
1024                }
1025            ),
1026            "IdempotentConsumer { repository: \"myRepo\", expression: LanguageExpressionDef { language: \"simple\", source: \"${body}\" }, steps: [], eager: true, remove_on_failure: false }"
1027        );
1028
1029        // -- group L: DeclarativeDoTry -----------------------------------------
1030        assert_eq!(
1031            format!(
1032                "{:?}",
1033                BuilderStep::DeclarativeDoTry {
1034                    try_steps: vec![BuilderStep::Stop],
1035                    catch: vec![],
1036                    finally: None,
1037                }
1038            ),
1039            "DeclarativeDoTry { try_steps: [Stop], catch: [], finally: None }"
1040        );
1041
1042        // -- group M: DeclarativeStreamSplit -----------------------------------
1043        assert_eq!(
1044            format!(
1045                "{:?}",
1046                BuilderStep::DeclarativeStreamSplit {
1047                    stream_config: StreamSplitConfig {
1048                        format: StreamSplitFormat::Ndjson,
1049                        max_record_bytes: 1024 * 1024,
1050                        batch_size: 1,
1051                        chunk_size: None,
1052                        include_origin: true,
1053                    },
1054                    aggregation: AggregationStrategy::Original,
1055                    stop_on_exception: true,
1056                    steps: vec![BuilderStep::Stop],
1057                }
1058            ),
1059            "DeclarativeStreamSplit { stream_config: StreamSplitConfig { format: Ndjson, max_record_bytes: 1048576, batch_size: 1, chunk_size: None, include_origin: true }, aggregation: Original, stop_on_exception: true, steps: [Stop] }"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_builder_step_multicast_variant() {
1065        use camel_api::MulticastConfig;
1066
1067        let step = BuilderStep::Multicast {
1068            steps: vec![BuilderStep::To("direct:a".into())],
1069            config: MulticastConfig::new(),
1070        };
1071
1072        assert!(matches!(step, BuilderStep::Multicast { .. }));
1073    }
1074
1075    #[test]
1076    fn test_route_definition_defaults() {
1077        let def = RouteDefinition::new("direct:test", vec![]).with_route_id("test-route");
1078        assert_eq!(def.route_id(), "test-route");
1079        assert!(def.auto_startup());
1080        assert_eq!(def.startup_order(), 1000);
1081    }
1082
1083    #[test]
1084    fn test_route_definition_builders() {
1085        let def = RouteDefinition::new("direct:test", vec![])
1086            .with_route_id("my-route")
1087            .with_auto_startup(false)
1088            .with_startup_order(50);
1089        assert_eq!(def.route_id(), "my-route");
1090        assert!(!def.auto_startup());
1091        assert_eq!(def.startup_order(), 50);
1092    }
1093
1094    #[test]
1095    fn test_route_definition_accessors_cover_core_fields() {
1096        let def = RouteDefinition::new("direct:in", vec![BuilderStep::To("mock:out".into())])
1097            .with_route_id("accessor-route");
1098
1099        assert_eq!(def.from_uri(), "direct:in");
1100        assert_eq!(def.steps().len(), 1);
1101        assert!(matches!(def.steps()[0], BuilderStep::To(_)));
1102    }
1103
1104    #[test]
1105    fn test_route_definition_error_handler_circuit_breaker_and_concurrency_accessors() {
1106        use camel_api::circuit_breaker::CircuitBreakerConfig;
1107        use camel_api::error_handler::ErrorHandlerConfig;
1108        use camel_component_api::ConcurrencyModel;
1109
1110        let def = RouteDefinition::new("direct:test", vec![])
1111            .with_route_id("eh-route")
1112            .with_error_handler(ErrorHandlerConfig::dead_letter_channel("log:dlc"))
1113            .with_circuit_breaker(CircuitBreakerConfig::new())
1114            .with_concurrency(ConcurrencyModel::Concurrent { max: Some(4) });
1115
1116        let eh = def
1117            .error_handler_config()
1118            .expect("error handler should be set");
1119        assert_eq!(eh.dlc_uri.as_deref(), Some("log:dlc"));
1120        assert!(def.circuit_breaker_config().is_some());
1121        assert!(matches!(
1122            def.concurrency_override(),
1123            Some(ConcurrencyModel::Concurrent { max: Some(4) })
1124        ));
1125    }
1126
1127    #[test]
1128    fn test_builder_step_debug_covers_many_variants() {
1129        use camel_api::splitter::{AggregationStrategy, SplitterConfig, split_body_lines};
1130        use camel_api::{
1131            BoxProcessor, DynamicRouterConfig, Exchange, FilterPredicate, IdentityProcessor,
1132            OpaqueProcessor, RoutingSlipConfig, Value,
1133        };
1134        use std::sync::Arc;
1135
1136        let expr = LanguageExpressionDef {
1137            language: "simple".into(),
1138            source: "${body}".into(),
1139        };
1140
1141        let steps = vec![
1142            BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(IdentityProcessor))),
1143            BuilderStep::To("mock:out".into()),
1144            BuilderStep::Stop,
1145            BuilderStep::Log {
1146                level: camel_processor::LogLevel::Info,
1147                message: "hello".into(),
1148            },
1149            BuilderStep::DeclarativeSetHeader {
1150                key: "k".into(),
1151                value: ValueSourceDef::Literal(Value::String("v".into())),
1152            },
1153            BuilderStep::DeclarativeSetBody {
1154                value: ValueSourceDef::Expression(expr.clone()),
1155            },
1156            BuilderStep::DeclarativeFilter {
1157                predicate: expr.clone(),
1158                steps: vec![BuilderStep::Stop],
1159            },
1160            BuilderStep::DeclarativeChoice {
1161                whens: vec![DeclarativeWhenStep {
1162                    predicate: expr.clone(),
1163                    steps: vec![BuilderStep::Stop],
1164                }],
1165                otherwise: Some(vec![BuilderStep::Stop]),
1166            },
1167            BuilderStep::DeclarativeScript {
1168                expression: expr.clone(),
1169            },
1170            BuilderStep::DeclarativeSplit {
1171                expression: expr.clone(),
1172                aggregation: AggregationStrategy::Original,
1173                parallel: false,
1174                parallel_limit: Some(2),
1175                stop_on_exception: true,
1176                steps: vec![BuilderStep::Stop],
1177            },
1178            BuilderStep::Split {
1179                config: SplitterConfig::new(split_body_lines()),
1180                steps: vec![BuilderStep::Stop],
1181            },
1182            BuilderStep::Aggregate {
1183                config: camel_api::AggregatorConfig::correlate_by("id")
1184                    .complete_when_size(1)
1185                    .build()
1186                    .unwrap(),
1187            },
1188            BuilderStep::Filter {
1189                predicate: FilterPredicate::new(|_: &Exchange| true),
1190                steps: vec![BuilderStep::Stop],
1191            },
1192            BuilderStep::WireTap {
1193                uri: "mock:tap".into(),
1194            },
1195            BuilderStep::DeclarativeLog {
1196                level: camel_processor::LogLevel::Info,
1197                message: ValueSourceDef::Expression(expr.clone()),
1198            },
1199            BuilderStep::Bean {
1200                name: "bean".into(),
1201                method: "call".into(),
1202            },
1203            BuilderStep::Script {
1204                language: "rhai".into(),
1205                script: "body".into(),
1206            },
1207            BuilderStep::Throttle {
1208                config: camel_api::ThrottlerConfig::new(10, std::time::Duration::from_millis(10)),
1209                steps: vec![BuilderStep::Stop],
1210            },
1211            BuilderStep::LoadBalance {
1212                config: camel_api::LoadBalancerConfig::round_robin(),
1213                steps: vec![BuilderStep::To("mock:l1".into())],
1214            },
1215            BuilderStep::DynamicRouter {
1216                config: DynamicRouterConfig::new(Arc::new(|_| Some("mock:dr".into()))),
1217            },
1218            BuilderStep::RoutingSlip {
1219                config: RoutingSlipConfig::new(Arc::new(|_| Some("mock:rs".into()))),
1220            },
1221        ];
1222
1223        for step in steps {
1224            let dbg = format!("{step:?}");
1225            assert!(!dbg.is_empty());
1226        }
1227    }
1228
1229    #[test]
1230    fn test_route_definition_to_info_preserves_metadata() {
1231        let info = RouteDefinition::new("direct:test", vec![])
1232            .with_route_id("meta-route")
1233            .with_auto_startup(false)
1234            .with_startup_order(7)
1235            .to_info();
1236
1237        assert_eq!(info.route_id(), "meta-route");
1238        assert!(!info.auto_startup());
1239        assert_eq!(info.startup_order(), 7);
1240    }
1241
1242    #[test]
1243    fn test_choice_builder_step_debug() {
1244        use camel_api::FilterPredicate;
1245
1246        fn always_true(_: &camel_api::Exchange) -> bool {
1247            true
1248        }
1249
1250        let step = BuilderStep::Choice {
1251            whens: vec![WhenStep {
1252                predicate: FilterPredicate::new(always_true),
1253                steps: vec![BuilderStep::To("mock:a".into())],
1254            }],
1255            otherwise: None,
1256        };
1257        let debug = format!("{step:?}");
1258        assert!(debug.contains("Choice"));
1259    }
1260
1261    #[test]
1262    fn test_route_definition_unit_of_work() {
1263        use camel_api::UnitOfWorkConfig;
1264        let config = UnitOfWorkConfig {
1265            on_complete: Some("log:complete".into()),
1266            on_failure: Some("log:failed".into()),
1267        };
1268        let def = RouteDefinition::new("direct:test", vec![])
1269            .with_route_id("uow-test")
1270            .with_unit_of_work(config.clone());
1271        assert_eq!(
1272            def.unit_of_work_config().unwrap().on_complete.as_deref(),
1273            Some("log:complete")
1274        );
1275        assert_eq!(
1276            def.unit_of_work_config().unwrap().on_failure.as_deref(),
1277            Some("log:failed")
1278        );
1279
1280        let def_no_uow = RouteDefinition::new("direct:test", vec![]).with_route_id("no-uow");
1281        assert!(def_no_uow.unit_of_work_config().is_none());
1282    }
1283
1284    #[test]
1285    fn test_route_definition_security_policy_accessor() {
1286        use async_trait::async_trait;
1287        use camel_api::CamelError;
1288        use camel_api::Exchange;
1289        use camel_api::security_policy::{
1290            AuthorizationDecision, Principal, SecurityPolicy, SecurityPolicyConfig,
1291        };
1292
1293        struct StubPolicy;
1294        #[async_trait]
1295        impl SecurityPolicy for StubPolicy {
1296            async fn evaluate(
1297                &self,
1298                _exchange: &mut Exchange,
1299            ) -> Result<AuthorizationDecision, CamelError> {
1300                Ok(AuthorizationDecision::Granted {
1301                    principal: Principal {
1302                        subject: "test".into(),
1303                        issuer: "test".into(),
1304                        audience: vec![],
1305                        scopes: vec![],
1306                        roles: vec![],
1307                        claims: serde_json::Value::Null,
1308                    },
1309                })
1310            }
1311        }
1312
1313        let def_no_sp = RouteDefinition::new("direct:test", vec![]).with_route_id("no-sp");
1314        assert!(def_no_sp.security_policy_config().is_none());
1315
1316        let def = RouteDefinition::new("direct:test", vec![])
1317            .with_route_id("sp-test")
1318            .with_security_policy(SecurityPolicyConfig::new(StubPolicy));
1319        assert!(def.security_policy_config().is_some());
1320    }
1321
1322    #[test]
1323    fn test_route_definition_security_authenticator_accessor() {
1324        use camel_api::security_policy::Principal;
1325
1326        struct TestAuth;
1327        #[async_trait::async_trait]
1328        impl TokenAuthenticator for TestAuth {
1329            async fn authenticate_bearer(
1330                &self,
1331                _token: &str,
1332            ) -> Result<Principal, camel_api::CamelError> {
1333                Ok(Principal {
1334                    subject: "test".into(),
1335                    issuer: "test".into(),
1336                    audience: vec![],
1337                    scopes: vec![],
1338                    roles: vec![],
1339                    claims: serde_json::Value::Null,
1340                })
1341            }
1342        }
1343
1344        let def_no_auth = RouteDefinition::new("direct:test".to_string(), vec![]);
1345        assert!(def_no_auth.security_authenticator().is_none());
1346
1347        let auth = Arc::new(TestAuth);
1348        let def = RouteDefinition::new("direct:test".to_string(), vec![])
1349            .with_security_authenticator(auth);
1350        assert!(def.security_authenticator().is_some());
1351    }
1352
1353    #[test]
1354    fn test_map_steps_swaps_steps_and_preserves_other_fields() {
1355        let original = RouteDefinition::new(
1356            "direct:test".to_string(),
1357            vec![BuilderStep::To("mock:a".into()), BuilderStep::Stop],
1358        )
1359        .with_route_id("my-route");
1360
1361        let mapped = original.map_steps(|steps| {
1362            let mut out = Vec::with_capacity(steps.len() + 1);
1363            out.push(BuilderStep::To("mock:prefix".into()));
1364            out.extend(steps);
1365            out
1366        });
1367
1368        // Steps were transformed.
1369        assert_eq!(mapped.steps().len(), 3);
1370        assert!(matches!(mapped.steps()[0], BuilderStep::To(ref s) if s == "mock:prefix"));
1371        assert!(matches!(mapped.steps()[1], BuilderStep::To(ref s) if s == "mock:a"));
1372        // Other fields preserved.
1373        assert_eq!(mapped.route_id(), "my-route");
1374    }
1375}