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