Skip to main content

camel_core/
startup_validation.rs

1//! ADR-0033 startup-validation phase.
2//!
3//! `ConfigCheck` impls are registered for Require-Explicit-Choice security
4//! defaults. The fail-closed `run_startup_validation()` walks the registry
5//! and returns `Err(CamelError::Config)` if any check fails. See ADR-0033 in
6//! `docs/adr/0033-…`.
7
8use crate::lifecycle::application::route_definition::{BuilderStep, RouteDefinition};
9use camel_api::{CamelError, ConfigValidationError};
10use camel_endpoint::uri::parse_bool_param;
11
12/// A single startup-time configuration check.
13///
14/// Each `ConfigCheck` is run as part of the fail-closed startup-validation phase
15/// established by ADR-0033. The check owns a name (for error reporting), a human
16/// description, and a synchronous `run` that returns `Ok(())` if the check passes
17/// or `Err(CamelError::Config)` if the check fails and the operator config is
18/// invalid.
19pub trait ConfigCheck: Send + Sync {
20    /// Stable identifier for this check (e.g. `"grpc-tls"`, `"sql-dynamic-query"`).
21    /// Used in error messages and `camel doctor` output.
22    fn name(&self) -> &'static str;
23
24    /// One-line human description of what this check enforces.
25    fn description(&self) -> &'static str;
26
27    /// Run the check. Returns `Err(CamelError::Config(_))` to fail-closed.
28    fn run(&self) -> Result<(), CamelError>;
29}
30
31/// Aggregated result of running every registered `ConfigCheck`.
32///
33/// Batch 1 ships the type so later batches can populate `failures`. Skeleton
34/// phase always produces a `StartupValidationReport { failures: vec![] }`.
35#[derive(Debug, Default, Clone)]
36pub struct StartupValidationReport {
37    /// Names of every `ConfigCheck` that returned `Err`, in registration order.
38    pub failures: Vec<String>,
39}
40
41impl StartupValidationReport {
42    /// True when no check failed. Caller MUST refuse to start the runtime when
43    /// this is `false`.
44    pub fn is_ok(&self) -> bool {
45        self.failures.is_empty()
46    }
47}
48
49/// Entry point for the startup-validation phase.
50///
51/// Walks the supplied `ConfigCheck` registry, collects failures, and fails closed
52/// when any check returns `Err`. Established by ADR-0033.
53pub fn run_startup_validation(
54    checks: Vec<Box<dyn ConfigCheck>>,
55) -> Result<StartupValidationReport, CamelError> {
56    let mut report = StartupValidationReport::default();
57    for check in &checks {
58        if let Err(e) = check.run() {
59            report.failures.push(format!("{}: {}", check.name(), e));
60        }
61    }
62    if !report.is_ok() {
63        return Err(CamelError::Config(report.failures.join("; ")));
64    }
65    Ok(report)
66}
67
68/// ConfigCheck: SQL dynamic-query intent must match capability (H7).
69///
70/// If `use_message_body_for_sql=true` but `allow_dynamic_query=false`,
71/// the operator intent (use body as query) is blocked by the capability
72/// gate — this is a misconfiguration that would silently produce empty
73/// queries. Fail closed at startup instead.
74pub struct SqlDynamicQueryCheck {
75    pub use_message_body_for_sql: bool,
76    pub allow_dynamic_query: bool,
77}
78
79impl ConfigCheck for SqlDynamicQueryCheck {
80    fn name(&self) -> &'static str {
81        "sql-dynamic-query"
82    }
83    fn description(&self) -> &'static str {
84        "SQL use_message_body_for_sql requires allow_dynamic_query=true"
85    }
86    fn run(&self) -> Result<(), CamelError> {
87        if self.use_message_body_for_sql && !self.allow_dynamic_query {
88            return Err(CamelError::from(
89                ConfigValidationError::SqlDynamicQueryWithoutAllowDynamic,
90            ));
91        }
92        Ok(())
93    }
94}
95
96/// Scan a list of `RouteDefinition`s and emit a `SqlDynamicQueryCheck` for
97/// every `sql:` URI whose parameters declare the dynamic-query intent
98/// (`useMessageBodyForSql` / `allowDynamicQuery`).
99///
100/// Walked URIs: the route's `from_uri` plus every `BuilderStep::To` URI
101/// reachable through structural step variants (filter, choice, split,
102/// multicast, throttle, etc.). Dynamic-URI steps (routing slip, recipient
103/// list, dynamic router) are skipped — their URIs are runtime-resolved and
104/// cannot be statically validated.
105///
106/// Invalid URIs are silently ignored: this scanner is best-effort defense in
107/// depth, not a primary security gate. The authoritative runtime check
108/// (`SqlProducer::resolve_query_source`) closes the SQLi vector regardless
109/// of what the static scanner finds here.
110pub fn scan_route_definitions_for_sql_checks(
111    routes: &[RouteDefinition],
112) -> Vec<Box<dyn ConfigCheck>> {
113    let mut out: Vec<Box<dyn ConfigCheck>> = Vec::new();
114    for route in routes {
115        collect_sql_checks_for_uri(route.from_uri(), &mut out);
116        for step in route.steps() {
117            for_each_step_uri(step, &mut |uri| collect_sql_checks_for_uri(uri, &mut out));
118        }
119    }
120    out
121}
122
123/// Walk every statically declared URI reachable through `step` and invoke
124/// `f` for each one. Variant coverage mirrors the prior
125/// `walk_step_uris`: `To`, `WireTap`, `Enrich`, `PollEnrich`, plus all
126/// structural sub-pipelines whose `steps` are known at parse time
127/// (`Filter` / `DeclarativeFilter`, `Split` / `DeclarativeSplit` /
128/// `DeclarativeStreamSplit`, `Multicast`, `Throttle`, `LoadBalance`,
129/// `Loop` / `DeclarativeLoop`, `IdempotentConsumer`), the `Choice` and
130/// `DeclarativeChoice` `whens` (and `otherwise`), and the
131/// `DeclarativeDoTry` `try_steps` / `catch` / `finally` blocks.
132///
133/// Dynamic-URI steps (RoutingSlip, RecipientList, DynamicRouter, and the
134/// declarative equivalents) are intentionally skipped — their URIs are
135/// resolved at runtime and cannot be statically validated.
136fn for_each_step_uri<F: FnMut(&str)>(step: &BuilderStep, f: &mut F) {
137    match step {
138        BuilderStep::To(uri) => f(uri),
139        BuilderStep::WireTap { uri } | BuilderStep::Enrich { uri, .. } => {
140            f(uri);
141        }
142        BuilderStep::PollEnrich { uri, .. } => {
143            f(uri);
144        }
145        BuilderStep::Filter { steps, .. }
146        | BuilderStep::DeclarativeFilter { steps, .. }
147        | BuilderStep::Split { steps, .. }
148        | BuilderStep::DeclarativeSplit { steps, .. }
149        | BuilderStep::DeclarativeStreamSplit { steps, .. }
150        | BuilderStep::Multicast { steps, .. }
151        | BuilderStep::Throttle { steps, .. }
152        | BuilderStep::LoadBalance { steps, .. }
153        | BuilderStep::Loop { steps, .. }
154        | BuilderStep::DeclarativeLoop { steps, .. }
155        | BuilderStep::IdempotentConsumer { steps, .. } => {
156            for s in steps {
157                for_each_step_uri(s, f);
158            }
159        }
160        BuilderStep::Choice { whens, otherwise } => {
161            for when in whens {
162                for s in &when.steps {
163                    for_each_step_uri(s, f);
164                }
165            }
166            if let Some(ow) = otherwise {
167                for s in ow {
168                    for_each_step_uri(s, f);
169                }
170            }
171        }
172        BuilderStep::DeclarativeChoice { whens, otherwise } => {
173            for when in whens {
174                for s in &when.steps {
175                    for_each_step_uri(s, f);
176                }
177            }
178            if let Some(ow) = otherwise {
179                for s in ow {
180                    for_each_step_uri(s, f);
181                }
182            }
183        }
184        BuilderStep::DeclarativeDoTry {
185            try_steps,
186            catch,
187            finally,
188        } => {
189            for s in try_steps {
190                for_each_step_uri(s, f);
191            }
192            for clause in catch {
193                for s in &clause.steps {
194                    for_each_step_uri(s, f);
195                }
196            }
197            if let Some(fin) = finally {
198                for s in &fin.steps {
199                    for_each_step_uri(s, f);
200                }
201            }
202        }
203        // All remaining variants are process-mode (no static URI) or
204        // runtime-resolved dynamic URIs (routing slip, recipient list,
205        // dynamic router) — skip.
206        _ => {}
207    }
208}
209
210/// Return `true` if any route statically declares the given URI scheme.
211///
212/// Scans each route's `from_uri` plus every URI reachable through the
213/// structural `BuilderStep` tree (see `for_each_step_uri`). Dynamic-URI
214/// steps (RoutingSlip, RecipientList, DynamicRouter) do not contribute —
215/// their URIs are runtime-resolved and cannot be statically validated.
216///
217/// Invalid URIs (those that fail `camel_endpoint::parse_uri`) are silently
218/// skipped, matching the best-effort semantics of the SQL scanner. An
219/// invalid `from` URI does NOT cause the route to be reported as
220/// referencing the scheme; an invalid step URI is just ignored.
221///
222/// Used by the `camel run` startup guard to decide whether a route
223/// references `exec` (a feature-gated component) so it can register the
224/// `ExecBundle` only when needed — fixing the fail-closed
225/// `ExecBundle::validate()` that previously aborted startup for any
226/// route, even ones that never used `exec:`.
227pub fn route_definitions_reference_scheme(routes: &[RouteDefinition], scheme: &str) -> bool {
228    use std::cell::Cell;
229    let found = Cell::new(false);
230    let mut check = |uri: &str| {
231        if found.get() {
232            return;
233        }
234        if let Ok(parts) = camel_endpoint::parse_uri(uri)
235            && parts.scheme == scheme
236        {
237            found.set(true);
238        }
239    };
240    for route in routes {
241        if found.get() {
242            break;
243        }
244        check(route.from_uri());
245        if found.get() {
246            break;
247        }
248        for step in route.steps() {
249            for_each_step_uri(step, &mut check);
250            if found.get() {
251                break;
252            }
253        }
254    }
255    found.get()
256}
257
258fn collect_sql_checks_for_uri(uri: &str, out: &mut Vec<Box<dyn ConfigCheck>>) {
259    let Ok(parts) = camel_endpoint::parse_uri(uri) else {
260        return;
261    };
262    if parts.scheme != "sql" {
263        return;
264    }
265    // Match the camel-sql parser: keys are case-preserved by parse_uri (it
266    // only decodes percent-encoding), and the SQL config looks them up by
267    // their camelCase name (`useMessageBodyForSql`, `allowDynamicQuery`).
268    let use_body = parts
269        .params
270        .get("useMessageBodyForSql")
271        .and_then(|v| parse_bool_param(v).ok())
272        .unwrap_or(false);
273    let allow_dynamic = parts
274        .params
275        .get("allowDynamicQuery")
276        .and_then(|v| parse_bool_param(v).ok())
277        .unwrap_or(false);
278    if use_body || allow_dynamic {
279        // Only emit a check when the operator declared an intent that
280        // participates in the matrix. Endpoints with neither flag set
281        // cannot be misconfigured in the H7 sense — skip the noise.
282        out.push(Box::new(SqlDynamicQueryCheck {
283            use_message_body_for_sql: use_body,
284            allow_dynamic_query: allow_dynamic,
285        }));
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    /// Smoke test: the empty registry returns `Ok` with an empty failure list.
294    #[test]
295    fn empty_registry_returns_empty_ok_report() {
296        let report = run_startup_validation(vec![]).expect("empty registry must return Ok");
297        assert!(report.is_ok());
298        assert!(report.failures.is_empty());
299    }
300
301    /// Trait smoke test: a trivial `ConfigCheck` impl can be defined by callers
302    /// and dispatched dynamically. This guards the trait surface against
303    /// accidental breaking changes between batches.
304    #[test]
305    fn trait_is_object_safe_and_dispatchable() {
306        struct AlwaysOk;
307        impl ConfigCheck for AlwaysOk {
308            fn name(&self) -> &'static str {
309                "always-ok"
310            }
311            fn description(&self) -> &'static str {
312                "always-ok skeleton check"
313            }
314            fn run(&self) -> Result<(), CamelError> {
315                Ok(())
316            }
317        }
318
319        let check: Box<dyn ConfigCheck> = Box::new(AlwaysOk);
320        assert_eq!(check.name(), "always-ok");
321        assert!(check.run().is_ok());
322    }
323
324    /// H7: SQL endpoint with `use_message_body_for_sql=true` and
325    /// `allow_dynamic_query=false` is a misconfiguration that would silently
326    /// produce empty queries — fail closed at startup.
327    #[test]
328    fn sql_dynamic_query_check_refuses_startup() {
329        let check = SqlDynamicQueryCheck {
330            use_message_body_for_sql: true,
331            allow_dynamic_query: false,
332        };
333        let result = run_startup_validation(vec![Box::new(check)]);
334        match result {
335            Err(CamelError::Config(msg)) => {
336                assert!(msg.contains("sql-dynamic-query"));
337                assert!(msg.contains("allow_dynamic_query"));
338            }
339            other => panic!("expected CamelError::Config, got {other:?}"),
340        }
341    }
342
343    /// H7: the inner `SqlDynamicQueryCheck::run()` returns the typed
344    /// `ConfigValidationError::SqlDynamicQueryWithoutAllowDynamic` so
345    /// operators can match on the variant directly. The outer
346    /// `run_startup_validation` wraps it into a `Config(String)` joined
347    /// report (asserted above); this test guards the typed inner path
348    /// so the promotion to typed errors (rc-r8fd) doesn't regress.
349    #[test]
350    fn sql_dynamic_query_check_run_returns_typed_error() {
351        let check = SqlDynamicQueryCheck {
352            use_message_body_for_sql: true,
353            allow_dynamic_query: false,
354        };
355        let result = check.run();
356        assert!(
357            matches!(
358                result,
359                Err(CamelError::ConfigValidation(
360                    ConfigValidationError::SqlDynamicQueryWithoutAllowDynamic,
361                ))
362            ),
363            "expected ConfigValidation(SqlDynamicQueryWithoutAllowDynamic), got: {result:?}"
364        );
365    }
366
367    /// H7: explicit opt-in satisfies the check — startup proceeds.
368    #[test]
369    fn sql_dynamic_query_check_passes_with_opt_in() {
370        let check = SqlDynamicQueryCheck {
371            use_message_body_for_sql: true,
372            allow_dynamic_query: true,
373        };
374        let report =
375            run_startup_validation(vec![Box::new(check)]).expect("opt-in must satisfy the check");
376        assert!(report.is_ok());
377    }
378
379    /// H7: when `use_message_body_for_sql=false`, the dynamic-query gate
380    /// is irrelevant and the check passes regardless of the opt-in flag.
381    #[test]
382    fn sql_dynamic_query_check_passes_without_body_sql() {
383        let check = SqlDynamicQueryCheck {
384            use_message_body_for_sql: false,
385            allow_dynamic_query: false,
386        };
387        let report = run_startup_validation(vec![Box::new(check)])
388            .expect("no body-sourced queries → no fail-closed condition");
389        assert!(report.is_ok());
390    }
391
392    /// Scanner: a route with a `from: sql:...` URI whose params declare
393    /// `useMessageBodyForSql=true` but no `allowDynamicQuery` must produce
394    /// a failing `SqlDynamicQueryCheck`.
395    #[test]
396    fn scanner_flags_from_sql_endpoint_with_body_but_no_allow() {
397        let route = RouteDefinition::new(
398            "sql:select * from t?db_url=postgres://x/y&useMessageBodyForSql=true",
399            vec![],
400        )
401        .with_route_id("r".to_string());
402        let checks = scan_route_definitions_for_sql_checks(&[route]);
403        assert_eq!(checks.len(), 1);
404        assert!(run_startup_validation(checks).is_err());
405    }
406
407    /// Scanner: a route with a `to: sql:...` step in the top-level step
408    /// vector is found.
409    #[test]
410    fn scanner_walks_top_level_to_sql_step() {
411        let route = RouteDefinition::new(
412            "direct:start",
413            vec![BuilderStep::To(
414                "sql:select 1?db_url=postgres://x/y&useMessageBodyForSql=true".to_string(),
415            )],
416        )
417        .with_route_id("r".to_string());
418        let checks = scan_route_definitions_for_sql_checks(&[route]);
419        assert_eq!(checks.len(), 1);
420        assert!(run_startup_validation(checks).is_err());
421    }
422
423    /// Scanner: a route with no SQL URIs produces no checks.
424    #[test]
425    fn scanner_emits_nothing_for_non_sql_route() {
426        let route = RouteDefinition::new("timer:tick?period=1000", vec![]);
427        let checks = scan_route_definitions_for_sql_checks(&[route]);
428        assert!(checks.is_empty());
429    }
430
431    /// Scanner: a SQL endpoint without the body-mode flag is not flagged
432    /// (the operator has not opted into dynamic queries, so there is no
433    /// intent to validate against the capability gate).
434    #[test]
435    fn scanner_skips_sql_endpoint_without_dynamic_intent() {
436        let route = RouteDefinition::new("sql:select 1?db_url=postgres://x/y", vec![]);
437        let checks = scan_route_definitions_for_sql_checks(&[route]);
438        assert!(checks.is_empty());
439    }
440
441    // -- exec-scheme scanner tests (Task 1.1) ---------------------------------
442    //
443    // These tests exercise `route_definitions_reference_scheme`, the reusable
444    // scanner that the `camel run` startup guard calls to decide whether a
445    // route statically references the `exec` scheme. The scanner walks every
446    // structural `BuilderStep` variant whose URI is known at parse time, so
447    // each variant needs a coverage test. Dynamic-URI steps
448    // (RoutingSlip/RecipientList/DynamicRouter) must report `false` because
449    // their URIs are resolved at runtime and cannot be statically validated.
450
451    /// From-URI match: a route whose `from` is `exec:...` must be detected.
452    #[test]
453    fn scheme_scanner_detects_exec_from_uri() {
454        let route = RouteDefinition::new("exec:echo", vec![]).with_route_id("r".to_string());
455        assert!(route_definitions_reference_scheme(&[route], "exec"));
456    }
457
458    /// Top-level `To` step: a non-exec source with a `To("exec:...")` step.
459    #[test]
460    fn scheme_scanner_detects_exec_in_to_step() {
461        let route = RouteDefinition::new(
462            "timer:tick?period=500",
463            vec![BuilderStep::To("exec:echo".to_string())],
464        )
465        .with_route_id("r".to_string());
466        assert!(route_definitions_reference_scheme(&[route], "exec"));
467    }
468
469    /// `WireTap` variant — static URI in named field.
470    #[test]
471    fn scheme_scanner_detects_exec_in_wiretap() {
472        let route = RouteDefinition::new(
473            "timer:tick",
474            vec![BuilderStep::WireTap {
475                uri: "exec:audit".to_string(),
476            }],
477        )
478        .with_route_id("r".to_string());
479        assert!(route_definitions_reference_scheme(&[route], "exec"));
480    }
481
482    /// `Enrich` variant — static URI in named field alongside strategy/timeout.
483    #[test]
484    fn scheme_scanner_detects_exec_in_enrich() {
485        let route = RouteDefinition::new(
486            "direct:start",
487            vec![BuilderStep::Enrich {
488                uri: "exec:enricher".to_string(),
489                strategy: Some("agg".to_string()),
490                timeout_ms: Some(1000),
491            }],
492        )
493        .with_route_id("r".to_string());
494        assert!(route_definitions_reference_scheme(&[route], "exec"));
495    }
496
497    /// `PollEnrich` variant — static URI in named field.
498    #[test]
499    fn scheme_scanner_detects_exec_in_pollenrich() {
500        let route = RouteDefinition::new(
501            "direct:start",
502            vec![BuilderStep::PollEnrich {
503                uri: "exec:poller".to_string(),
504                strategy: None,
505                timeout_ms: Some(500),
506            }],
507        )
508        .with_route_id("r".to_string());
509        assert!(route_definitions_reference_scheme(&[route], "exec"));
510    }
511
512    /// `Filter` sub-pipeline — recurse into `steps`.
513    #[test]
514    fn scheme_scanner_detects_exec_in_filter() {
515        use camel_api::FilterPredicate;
516        let route = RouteDefinition::new(
517            "direct:start",
518            vec![BuilderStep::Filter {
519                predicate: FilterPredicate::new(|_| true),
520                steps: vec![BuilderStep::To("exec:echo".to_string())],
521            }],
522        )
523        .with_route_id("r".to_string());
524        assert!(route_definitions_reference_scheme(&[route], "exec"));
525    }
526
527    /// `Split` sub-pipeline — recurse into `steps`.
528    #[test]
529    fn scheme_scanner_detects_exec_in_split() {
530        use camel_api::splitter::{AggregationStrategy, SplitterConfig, split_body_lines};
531        let route = RouteDefinition::new(
532            "direct:start",
533            vec![BuilderStep::Split {
534                config: SplitterConfig::new(split_body_lines())
535                    .aggregation(AggregationStrategy::Original),
536                steps: vec![BuilderStep::To("exec:echo".to_string())],
537            }],
538        )
539        .with_route_id("r".to_string());
540        assert!(route_definitions_reference_scheme(&[route], "exec"));
541    }
542
543    /// `Multicast` sub-pipeline — recurse into `steps`.
544    #[test]
545    fn scheme_scanner_detects_exec_in_multicast() {
546        use camel_api::MulticastConfig;
547        let route = RouteDefinition::new(
548            "direct:start",
549            vec![BuilderStep::Multicast {
550                steps: vec![BuilderStep::To("exec:echo".to_string())],
551                config: MulticastConfig::new(),
552            }],
553        )
554        .with_route_id("r".to_string());
555        assert!(route_definitions_reference_scheme(&[route], "exec"));
556    }
557
558    /// `Loop` sub-pipeline — recurse into `steps`.
559    #[test]
560    fn scheme_scanner_detects_exec_in_loop() {
561        use camel_api::loop_eip::{LoopConfig, LoopMode};
562        let route = RouteDefinition::new(
563            "direct:start",
564            vec![BuilderStep::Loop {
565                config: LoopConfig::new(LoopMode::Count(3)),
566                steps: vec![BuilderStep::To("exec:echo".to_string())],
567            }],
568        )
569        .with_route_id("r".to_string());
570        assert!(route_definitions_reference_scheme(&[route], "exec"));
571    }
572
573    /// `IdempotentConsumer` sub-pipeline — recurse into `steps`.
574    #[test]
575    fn scheme_scanner_detects_exec_in_idempotent_consumer() {
576        use crate::lifecycle::application::route_definition::LanguageExpressionDef;
577        let expr = LanguageExpressionDef {
578            language: "simple".into(),
579            source: "${header.id}".into(),
580        };
581        let route = RouteDefinition::new(
582            "direct:start",
583            vec![BuilderStep::IdempotentConsumer {
584                repository: "myRepo".to_string(),
585                expression: expr,
586                steps: vec![BuilderStep::To("exec:echo".to_string())],
587                eager: false,
588                remove_on_failure: false,
589            }],
590        )
591        .with_route_id("r".to_string());
592        assert!(route_definitions_reference_scheme(&[route], "exec"));
593    }
594
595    /// `Throttle` sub-pipeline — recurse into `steps`.
596    #[test]
597    fn scheme_scanner_detects_exec_in_throttle() {
598        use camel_api::ThrottlerConfig;
599        let route = RouteDefinition::new(
600            "direct:start",
601            vec![BuilderStep::Throttle {
602                config: ThrottlerConfig::new(10, std::time::Duration::from_millis(10)),
603                steps: vec![BuilderStep::To("exec:echo".to_string())],
604            }],
605        )
606        .with_route_id("r".to_string());
607        assert!(route_definitions_reference_scheme(&[route], "exec"));
608    }
609
610    /// `LoadBalance` sub-pipeline — recurse into `steps`.
611    #[test]
612    fn scheme_scanner_detects_exec_in_loadbalance() {
613        use camel_api::LoadBalancerConfig;
614        let route = RouteDefinition::new(
615            "direct:start",
616            vec![BuilderStep::LoadBalance {
617                config: LoadBalancerConfig::round_robin(),
618                steps: vec![BuilderStep::To("exec:echo".to_string())],
619            }],
620        )
621        .with_route_id("r".to_string());
622        assert!(route_definitions_reference_scheme(&[route], "exec"));
623    }
624
625    /// `DeclarativeFilter` sub-pipeline — recurse into `steps`.
626    #[test]
627    fn scheme_scanner_detects_exec_in_declarative_filter() {
628        use crate::lifecycle::application::route_definition::LanguageExpressionDef;
629        let expr = LanguageExpressionDef {
630            language: "simple".into(),
631            source: "${body}".into(),
632        };
633        let route = RouteDefinition::new(
634            "direct:start",
635            vec![BuilderStep::DeclarativeFilter {
636                predicate: expr,
637                steps: vec![BuilderStep::To("exec:echo".to_string())],
638            }],
639        )
640        .with_route_id("r".to_string());
641        assert!(route_definitions_reference_scheme(&[route], "exec"));
642    }
643
644    /// `DeclarativeSplit` sub-pipeline — recurse into `steps`.
645    #[test]
646    fn scheme_scanner_detects_exec_in_declarative_split() {
647        use crate::lifecycle::application::route_definition::LanguageExpressionDef;
648        use camel_api::splitter::AggregationStrategy;
649        let expr = LanguageExpressionDef {
650            language: "simple".into(),
651            source: "${body}".into(),
652        };
653        let route = RouteDefinition::new(
654            "direct:start",
655            vec![BuilderStep::DeclarativeSplit {
656                expression: expr,
657                aggregation: AggregationStrategy::Original,
658                parallel: false,
659                parallel_limit: None,
660                stop_on_exception: true,
661                steps: vec![BuilderStep::To("exec:echo".to_string())],
662            }],
663        )
664        .with_route_id("r".to_string());
665        assert!(route_definitions_reference_scheme(&[route], "exec"));
666    }
667
668    /// `DeclarativeStreamSplit` sub-pipeline — recurse into `steps`.
669    #[test]
670    fn scheme_scanner_detects_exec_in_declarative_stream_split() {
671        use camel_api::splitter::{AggregationStrategy, StreamSplitConfig, StreamSplitFormat};
672        let route = RouteDefinition::new(
673            "direct:start",
674            vec![BuilderStep::DeclarativeStreamSplit {
675                stream_config: StreamSplitConfig {
676                    format: StreamSplitFormat::Ndjson,
677                    max_record_bytes: 1024,
678                    batch_size: 1,
679                    chunk_size: None,
680                    include_origin: true,
681                },
682                aggregation: AggregationStrategy::Original,
683                stop_on_exception: true,
684                steps: vec![BuilderStep::To("exec:echo".to_string())],
685            }],
686        )
687        .with_route_id("r".to_string());
688        assert!(route_definitions_reference_scheme(&[route], "exec"));
689    }
690
691    /// `DeclarativeLoop` sub-pipeline — recurse into `steps`.
692    #[test]
693    fn scheme_scanner_detects_exec_in_declarative_loop() {
694        use crate::lifecycle::application::route_definition::LanguageExpressionDef;
695        let expr = LanguageExpressionDef {
696            language: "simple".into(),
697            source: "${body}".into(),
698        };
699        let route = RouteDefinition::new(
700            "direct:start",
701            vec![BuilderStep::DeclarativeLoop {
702                count: Some(5),
703                while_predicate: Some(expr),
704                steps: vec![BuilderStep::To("exec:echo".to_string())],
705                max_iterations: Some(100),
706            }],
707        )
708        .with_route_id("r".to_string());
709        assert!(route_definitions_reference_scheme(&[route], "exec"));
710    }
711
712    /// `Choice` with a `when` branch that contains exec.
713    #[test]
714    fn scheme_scanner_detects_exec_in_choice_branch() {
715        use crate::lifecycle::application::route_definition::WhenStep;
716        use camel_api::FilterPredicate;
717        let route = RouteDefinition::new(
718            "direct:start",
719            vec![BuilderStep::Choice {
720                whens: vec![WhenStep {
721                    predicate: FilterPredicate::new(|_| true),
722                    steps: vec![BuilderStep::To("exec:echo".to_string())],
723                }],
724                otherwise: None,
725            }],
726        )
727        .with_route_id("r".to_string());
728        assert!(route_definitions_reference_scheme(&[route], "exec"));
729    }
730
731    /// `Choice` with exec in the `otherwise` branch only.
732    #[test]
733    fn scheme_scanner_detects_exec_in_choice_otherwise() {
734        use crate::lifecycle::application::route_definition::WhenStep;
735        use camel_api::FilterPredicate;
736        let route = RouteDefinition::new(
737            "direct:start",
738            vec![BuilderStep::Choice {
739                whens: vec![WhenStep {
740                    predicate: FilterPredicate::new(|_| false),
741                    steps: vec![BuilderStep::To("log:info".to_string())],
742                }],
743                otherwise: Some(vec![BuilderStep::To("exec:echo".to_string())]),
744            }],
745        )
746        .with_route_id("r".to_string());
747        assert!(route_definitions_reference_scheme(&[route], "exec"));
748    }
749
750    /// `DeclarativeChoice` with exec in a `when` branch.
751    #[test]
752    fn scheme_scanner_detects_exec_in_declarative_choice_when() {
753        use crate::lifecycle::application::route_definition::{
754            DeclarativeWhenStep, LanguageExpressionDef,
755        };
756        let expr = LanguageExpressionDef {
757            language: "simple".into(),
758            source: "${body}".into(),
759        };
760        let route = RouteDefinition::new(
761            "direct:start",
762            vec![BuilderStep::DeclarativeChoice {
763                whens: vec![DeclarativeWhenStep {
764                    predicate: expr,
765                    steps: vec![BuilderStep::To("exec:echo".to_string())],
766                }],
767                otherwise: None,
768            }],
769        )
770        .with_route_id("r".to_string());
771        assert!(route_definitions_reference_scheme(&[route], "exec"));
772    }
773
774    /// `DeclarativeChoice` with exec in `otherwise` only.
775    #[test]
776    fn scheme_scanner_detects_exec_in_declarative_choice_otherwise() {
777        use crate::lifecycle::application::route_definition::{
778            DeclarativeWhenStep, LanguageExpressionDef,
779        };
780        let expr = LanguageExpressionDef {
781            language: "simple".into(),
782            source: "${body}".into(),
783        };
784        let route = RouteDefinition::new(
785            "direct:start",
786            vec![BuilderStep::DeclarativeChoice {
787                whens: vec![DeclarativeWhenStep {
788                    predicate: expr,
789                    steps: vec![BuilderStep::To("log:info".to_string())],
790                }],
791                otherwise: Some(vec![BuilderStep::To("exec:echo".to_string())]),
792            }],
793        )
794        .with_route_id("r".to_string());
795        assert!(route_definitions_reference_scheme(&[route], "exec"));
796    }
797
798    /// `DeclarativeDoTry` with exec in `try_steps`.
799    #[test]
800    fn scheme_scanner_detects_exec_in_dotry() {
801        let route = RouteDefinition::new(
802            "direct:start",
803            vec![BuilderStep::DeclarativeDoTry {
804                try_steps: vec![BuilderStep::To("exec:echo".to_string())],
805                catch: vec![],
806                finally: None,
807            }],
808        )
809        .with_route_id("r".to_string());
810        assert!(route_definitions_reference_scheme(&[route], "exec"));
811    }
812
813    /// `DeclarativeDoTry` with exec in a `catch` clause.
814    #[test]
815    fn scheme_scanner_detects_exec_in_dotry_catch() {
816        use crate::lifecycle::application::route_definition::DoTryCatchClauseBuilder;
817        let route = RouteDefinition::new(
818            "direct:start",
819            vec![BuilderStep::DeclarativeDoTry {
820                try_steps: vec![BuilderStep::To("log:info".to_string())],
821                catch: vec![DoTryCatchClauseBuilder {
822                    exception: None,
823                    when: None,
824                    on_when: None,
825                    disposition: camel_api::error_handler::ExceptionDisposition::Propagate,
826                    steps: vec![BuilderStep::To("exec:echo".to_string())],
827                }],
828                finally: None,
829            }],
830        )
831        .with_route_id("r".to_string());
832        assert!(route_definitions_reference_scheme(&[route], "exec"));
833    }
834
835    /// `DeclarativeDoTry` with exec in `finally`.
836    #[test]
837    fn scheme_scanner_detects_exec_in_dotry_finally() {
838        use crate::lifecycle::application::route_definition::DoTryFinallyBuilder;
839        let route = RouteDefinition::new(
840            "direct:start",
841            vec![BuilderStep::DeclarativeDoTry {
842                try_steps: vec![BuilderStep::To("log:info".to_string())],
843                catch: vec![],
844                finally: Some(DoTryFinallyBuilder {
845                    on_when: None,
846                    steps: vec![BuilderStep::To("exec:echo".to_string())],
847                }),
848            }],
849        )
850        .with_route_id("r".to_string());
851        assert!(route_definitions_reference_scheme(&[route], "exec"));
852    }
853
854    /// Multi-route scan: only the second route references exec.
855    #[test]
856    fn scheme_scanner_detects_exec_across_multiple_routes() {
857        let r1 = RouteDefinition::new("timer:tick", vec![]).with_route_id("r1".to_string());
858        let r2 = RouteDefinition::new(
859            "direct:start",
860            vec![BuilderStep::To("exec:echo".to_string())],
861        )
862        .with_route_id("r2".to_string());
863        assert!(route_definitions_reference_scheme(&[r1, r2], "exec"));
864    }
865
866    /// Negative: a non-exec route produces `false`.
867    #[test]
868    fn scheme_scanner_false_for_non_exec_route() {
869        let route =
870            RouteDefinition::new("timer:tick", vec![BuilderStep::To("log:info".to_string())])
871                .with_route_id("r".to_string());
872        assert!(!route_definitions_reference_scheme(&[route], "exec"));
873    }
874
875    /// Negative: dynamic-URI step (RoutingSlip) with a closure that returns
876    /// `exec:echo` at runtime — the scanner cannot see through the closure.
877    #[test]
878    fn scheme_scanner_false_for_dynamic_uri_only() {
879        use camel_api::RoutingSlipConfig;
880        use std::sync::Arc;
881        let route = RouteDefinition::new(
882            "timer:tick",
883            vec![BuilderStep::RoutingSlip {
884                config: RoutingSlipConfig::new(Arc::new(|_| Some("exec:echo".to_string()))),
885            }],
886        )
887        .with_route_id("r".to_string());
888        assert!(!route_definitions_reference_scheme(&[route], "exec"));
889    }
890}