Skip to main content

camel_integration_test/
tier.rs

1//! Pure tier derivation (ADR-0069 section 1).
2//!
3//! A document's tier is a pure, total function of its content: no field
4//! declares it. A `scenario:` section forces [`Tier::Full`] without
5//! condition. Otherwise the function computes the endpoint-scheme closure
6//! over every route source (each route's `from`, its error handler's
7//! dead-letter URI, and every step URI, nested steps traversed
8//! recursively), subtracts endpoints exactly replaced by a `skipTo`
9//! intercept, adds the unit-tier schemes, and derives [`Tier::Lean`] only
10//! when the whole closure stays within the lean scheme set. The function
11//! does no I/O, reads no environment, and reads no clock.
12
13use std::collections::BTreeSet;
14
15use camel_core::intercept::InterceptAction;
16use camel_core::{BuilderStep, RouteDefinition};
17
18/// Schemes the lean boot registers (ADR-0064). The tier function never
19/// grows this set; a scheme outside it forces the full boot.
20const LEAN_SCHEMES: [&str; 5] = ["direct", "log", "mock", "seda", "timer"];
21
22/// The derived execution profile of a test document (ADR-0069 section 1).
23/// Content-derived: no field declares it, and the tier filters assert on
24/// it.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum Tier {
28    /// The document's closure stays within the lean scheme set; it boots
29    /// the lean boot, byte-identical registry.
30    Lean,
31    /// The document needs the full runtime boot.
32    Full,
33}
34
35/// The document-level inputs to tier derivation.
36///
37/// `intercepts` comes from the unit-tier document model
38/// (`parse_test_document` output); the scenario parser bans `intercepts`,
39/// but `derive_tier` serves both tiers' documents. `unit_schemes` are the
40/// schemes named by `inputs` and `expects`.
41#[derive(Debug, Clone, Copy)]
42pub struct DocumentInputs<'a> {
43    /// The document declares a `scenario:` section; forces
44    /// [`Tier::Full`] without condition.
45    pub has_scenario: bool,
46    /// Intercept map keyed by source URI, verbatim.
47    pub intercepts: &'a [(String, InterceptAction)],
48    /// Schemes named by `inputs`/`expects`.
49    pub unit_schemes: &'a [String],
50}
51
52/// Derives a document's tier (ADR-0069 section 1). Pure and total: no
53/// I/O, no environment reads, no clock.
54///
55/// A `scenario:` section forces [`Tier::Full`]. Otherwise the closure is
56/// the schemes of every endpoint the routes touch (each route's `from`,
57/// its error handler's dead-letter URI, and every step URI, nested steps
58/// traversed recursively), minus endpoints exactly replaced by a
59/// `skipTo` intercept, plus the unit schemes. Dynamic-dispatch steps
60/// force [`Tier::Full`] wherever they appear: their target scheme is not
61/// knowable before run time.
62pub fn derive_tier(routes: &[RouteDefinition], doc: &DocumentInputs<'_>) -> Tier {
63    if doc.has_scenario {
64        return Tier::Full;
65    }
66    let mut uris: Vec<&str> = Vec::new();
67    let mut dynamic_dispatch = false;
68    for route in routes {
69        uris.push(route.from_uri());
70        // The dead-letter URI runs on the error path; the lean boot holds
71        // no non-lean component, so it joins the closure like any other
72        // endpoint URI.
73        if let Some(dlc) = route
74            .error_handler_config()
75            .and_then(|handler| handler.dlc_uri.as_deref())
76        {
77            uris.push(dlc);
78        }
79        walk_steps(route.steps(), &mut uris, &mut dynamic_dispatch);
80        // Circuit-breaker fallback sub-pipelines compile alongside the
81        // main steps; walk them too.
82        walk_steps(
83            route.circuit_breaker_fallback(),
84            &mut uris,
85            &mut dynamic_dispatch,
86        );
87    }
88    // Only an exact `skipTo` replacement subtracts: the original send is
89    // skipped, so the intercepted endpoint never runs. A `divertCopyTo`
90    // delivers a copy while the real send continues, so it subtracts
91    // nothing. Matching is verbatim string equality; query parameters
92    // are significant.
93    let replaced_by_skip_to = |uri: &str| {
94        doc.intercepts
95            .iter()
96            .any(|(key, action)| key == uri && matches!(action, InterceptAction::SkipTo { .. }))
97    };
98    let mut schemes: BTreeSet<&str> = doc.unit_schemes.iter().map(String::as_str).collect();
99    // Explicit encoding of ADR-0069 section 1: "placeholder-in-scheme
100    // forces FULL". The outcome is subsumed by the lean-literal check (a
101    // placeholder scheme is never in the lean set); kept for spec
102    // traceability and as a named regression site.
103    let mut placeholder_in_scheme = false;
104    for uri in uris.into_iter().filter(|uri| !replaced_by_skip_to(uri)) {
105        let head = scheme_head(uri);
106        if head.contains("${") || head.contains("{{") {
107            placeholder_in_scheme = true;
108        }
109        schemes.insert(head);
110    }
111    if dynamic_dispatch || placeholder_in_scheme {
112        return Tier::Full;
113    }
114    if schemes.iter().any(|scheme| !LEAN_SCHEMES.contains(scheme)) {
115        return Tier::Full;
116    }
117    Tier::Lean
118}
119
120/// The scheme position of a URI: the text before the first `:`, or the
121/// whole text when the URI carries no colon (which never matches the
122/// lean set, so a scheme-less URI is conservatively full).
123fn scheme_head(uri: &str) -> &str {
124    match uri.split_once(':') {
125        Some((head, _)) => head,
126        None => uri,
127    }
128}
129
130/// Walks one step list, recursing into every nested step list.
131///
132/// The match is exhaustive with NO `_` catch-all arm, mirroring the
133/// bean-call walk: a future [`BuilderStep`] variant that holds a URI or
134/// nested steps becomes a compile error here instead of a silently
135/// un-walked location. A new dynamic-dispatch step must join the
136/// dispatch arm so it keeps forcing [`Tier::Full`] (ADR-0069
137/// consequences).
138fn walk_steps<'a>(steps: &'a [BuilderStep], uris: &mut Vec<&'a str>, dynamic_dispatch: &mut bool) {
139    for step in steps {
140        match step {
141            // Steps carrying a URI: the endpoint joins the closure.
142            BuilderStep::To(uri)
143            | BuilderStep::WireTap { uri }
144            | BuilderStep::Enrich { uri, .. }
145            | BuilderStep::PollEnrich { uri, .. } => uris.push(uri),
146
147            // Dynamic dispatch: the target scheme is computed from the
148            // exchange at run time and forces the full boot.
149            BuilderStep::RecipientList { .. }
150            | BuilderStep::DeclarativeRecipientList { .. }
151            | BuilderStep::RoutingSlip { .. }
152            | BuilderStep::DeclarativeRoutingSlip { .. }
153            | BuilderStep::DynamicRouter { .. }
154            | BuilderStep::DeclarativeDynamicRouter { .. } => *dynamic_dispatch = true,
155
156            // Single nested `steps` child list.
157            BuilderStep::DeclarativeFilter { steps, .. }
158            | BuilderStep::DeclarativeSplit { steps, .. }
159            | BuilderStep::DeclarativeStreamSplit { steps, .. }
160            | BuilderStep::Split { steps, .. }
161            | BuilderStep::Filter { steps, .. }
162            | BuilderStep::Multicast { steps, .. }
163            | BuilderStep::Throttle { steps, .. }
164            | BuilderStep::LoadBalance { steps, .. }
165            | BuilderStep::Loop { steps, .. }
166            | BuilderStep::DeclarativeLoop { steps, .. }
167            | BuilderStep::IdempotentConsumer { steps, .. } => {
168                walk_steps(steps, uris, dynamic_dispatch);
169            }
170
171            // Choice shapes: when-clause sub-pipelines plus optional
172            // otherwise branch (declarative and programmatic forms).
173            BuilderStep::DeclarativeChoice { whens, otherwise } => {
174                for when in whens {
175                    walk_steps(&when.steps, uris, dynamic_dispatch);
176                }
177                if let Some(steps) = otherwise {
178                    walk_steps(steps, uris, dynamic_dispatch);
179                }
180            }
181            BuilderStep::Choice { whens, otherwise } => {
182                for when in whens {
183                    walk_steps(&when.steps, uris, dynamic_dispatch);
184                }
185                if let Some(steps) = otherwise {
186                    walk_steps(steps, uris, dynamic_dispatch);
187                }
188            }
189
190            BuilderStep::Cache { on_miss, .. } => {
191                walk_steps(on_miss, uris, dynamic_dispatch);
192            }
193
194            BuilderStep::DeclarativeDoTry {
195                try_steps,
196                catch,
197                finally,
198            } => {
199                walk_steps(try_steps, uris, dynamic_dispatch);
200                for clause in catch {
201                    walk_steps(&clause.steps, uris, dynamic_dispatch);
202                }
203                if let Some(finally) = finally {
204                    walk_steps(&finally.steps, uris, dynamic_dispatch);
205                }
206            }
207
208            // Leaf variants: hold no URI and no nested step list.
209            BuilderStep::Processor(_)
210            | BuilderStep::Stop
211            | BuilderStep::Log { .. }
212            | BuilderStep::DeclarativeSetHeader { .. }
213            | BuilderStep::DeclarativeSetHeaderIfAbsent { .. }
214            | BuilderStep::DeclarativeRemoveHeader { .. }
215            | BuilderStep::DeclarativeSetProperty { .. }
216            | BuilderStep::DeclarativeSetBody { .. }
217            | BuilderStep::DeclarativeScript { .. }
218            | BuilderStep::DeclarativeFunction { .. }
219            | BuilderStep::Aggregate { .. }
220            | BuilderStep::DeclarativeLog { .. }
221            | BuilderStep::Bean { .. }
222            | BuilderStep::Script { .. }
223            | BuilderStep::Delay { .. }
224            | BuilderStep::Validate { .. }
225            | BuilderStep::ClaimCheck { .. }
226            | BuilderStep::Sampling { .. }
227            | BuilderStep::Sort { .. }
228            | BuilderStep::CacheInvalidate { .. }
229            | BuilderStep::CacheClear { .. }
230            | BuilderStep::CacheStats { .. }
231            | BuilderStep::CachePeekStale { .. }
232            | BuilderStep::Resequence { .. } => {}
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use std::sync::Arc;
240
241    use super::{DocumentInputs, Tier, derive_tier};
242    use camel_api::error_handler::ErrorHandlerConfig;
243    use camel_api::recipient_list::RecipientListConfig;
244    use camel_api::{DynamicRouterConfig, RoutingSlipConfig};
245    use camel_core::intercept::InterceptAction;
246    use camel_core::{BuilderStep, RouteDefinition};
247
248    /// Unit-tier defaults: `inputs` name `direct:`, `expects` name
249    /// `mock:`. Both schemes are in the lean set.
250    fn unit_schemes() -> Vec<String> {
251        vec!["direct".to_string(), "mock".to_string()]
252    }
253
254    fn route(from: &str, steps: Vec<BuilderStep>) -> RouteDefinition {
255        RouteDefinition::new(from, steps)
256    }
257
258    fn lean_route() -> RouteDefinition {
259        route("direct:start", vec![BuilderStep::To("mock:out".into())])
260    }
261
262    fn inputs<'a>(
263        has_scenario: bool,
264        intercepts: &'a [(String, InterceptAction)],
265        schemes: &'a [String],
266    ) -> DocumentInputs<'a> {
267        DocumentInputs {
268            has_scenario,
269            intercepts,
270            unit_schemes: schemes,
271        }
272    }
273
274    #[test]
275    fn tier_lean_document_stays_lean() {
276        let routes = [lean_route()];
277        let schemes = unit_schemes();
278        let intercepts = Vec::new();
279        let input = inputs(false, &intercepts, &schemes);
280        assert_eq!(derive_tier(&routes, &input), Tier::Lean);
281    }
282
283    #[test]
284    fn tier_skipto_subtracts_from_closure() {
285        let routes = [route(
286            "direct:start",
287            vec![BuilderStep::To("kafka:orders".into())],
288        )];
289        let schemes = unit_schemes();
290        let intercepts = vec![(
291            "kafka:orders".to_string(),
292            InterceptAction::SkipTo {
293                uri: "mock:orders".into(),
294            },
295        )];
296        let input = inputs(false, &intercepts, &schemes);
297        assert_eq!(derive_tier(&routes, &input), Tier::Lean);
298
299        // Verbatim key match, query parameters significant: an intercept
300        // keyed with a query string does not match a URI without one, so
301        // `kafka` stays in the closure.
302        let mismatched = vec![(
303            "kafka:orders?option=1".to_string(),
304            InterceptAction::SkipTo {
305                uri: "mock:orders".into(),
306            },
307        )];
308        let input = inputs(false, &mismatched, &schemes);
309        assert_eq!(derive_tier(&routes, &input), Tier::Full);
310    }
311
312    #[test]
313    fn tier_dlc_uri_counts_in_closure() {
314        let schemes = unit_schemes();
315        let intercepts = Vec::new();
316        // The dead-letter URI runs on the error path; the lean boot holds
317        // no non-lean component, so under-derivation here would fail at
318        // error time. The URI joins the closure like any other endpoint.
319        let kafka_dlq = [
320            route("direct:start", vec![BuilderStep::To("mock:out".into())])
321                .with_error_handler(ErrorHandlerConfig::dead_letter_channel("kafka:dlq")),
322        ];
323        let input = inputs(false, &intercepts, &schemes);
324        assert_eq!(derive_tier(&kafka_dlq, &input), Tier::Full);
325
326        // Closure inclusion is observable through the scheme rules: a
327        // placeholder in the dead-letter URI's scheme position forces the
328        // full tier exactly like a placeholder step URI.
329        let placeholder_dlq = [
330            route("direct:start", vec![BuilderStep::To("mock:out".into())])
331                .with_error_handler(ErrorHandlerConfig::dead_letter_channel("${env:DLQ}:dead")),
332        ];
333        let input = inputs(false, &intercepts, &schemes);
334        assert_eq!(derive_tier(&placeholder_dlq, &input), Tier::Full);
335
336        // A `mock:` dead-letter URI mirrors how from/step URIs treat
337        // `mock:`: it contributes to the closure and stays within the
338        // lean set.
339        let mock_dlq = [
340            route("direct:start", vec![BuilderStep::To("mock:out".into())])
341                .with_error_handler(ErrorHandlerConfig::dead_letter_channel("mock:dlc")),
342        ];
343        let input = inputs(false, &intercepts, &schemes);
344        assert_eq!(derive_tier(&mock_dlq, &input), Tier::Lean);
345    }
346
347    #[test]
348    fn tier_divertcopyto_does_not_subtract() {
349        let routes = [route(
350            "direct:start",
351            vec![BuilderStep::To("kafka:orders".into())],
352        )];
353        let schemes = unit_schemes();
354        let intercepts = vec![(
355            "kafka:orders".to_string(),
356            InterceptAction::DivertCopyTo {
357                uri: "mock:mirror".into(),
358            },
359        )];
360        let input = inputs(false, &intercepts, &schemes);
361        assert_eq!(derive_tier(&routes, &input), Tier::Full);
362    }
363
364    #[test]
365    fn tier_placeholder_in_scheme_forces_full() {
366        let routes = [route(
367            "direct:start",
368            vec![BuilderStep::To("${env:TARGET_SCHEME}:host".into())],
369        )];
370        let schemes = unit_schemes();
371        let intercepts = Vec::new();
372        let input = inputs(false, &intercepts, &schemes);
373        assert_eq!(derive_tier(&routes, &input), Tier::Full);
374    }
375
376    #[test]
377    fn tier_dynamic_dispatch_forces_full() {
378        let schemes = unit_schemes();
379        let intercepts = Vec::new();
380        // The DSL has no dedicated `toD` step yet; a toD-style target
381        // computed from the exchange at run time is a URI whose scheme is
382        // resolved at run time, represented here by a scheme placeholder.
383        let cases: [(&str, BuilderStep); 4] = [
384            (
385                "recipient_list",
386                BuilderStep::RecipientList {
387                    config: RecipientListConfig::new(Arc::new(|_| "mock:one".to_string())),
388                },
389            ),
390            (
391                "routing_slip",
392                BuilderStep::RoutingSlip {
393                    config: RoutingSlipConfig::new(Arc::new(|_| Some("mock:one".to_string()))),
394                },
395            ),
396            (
397                "dynamic_router",
398                BuilderStep::DynamicRouter {
399                    config: DynamicRouterConfig::new(Arc::new(|_| Some("mock:one".to_string()))),
400                },
401            ),
402            ("to_d", BuilderStep::To("${env:SCHEME}:orders".into())),
403        ];
404        for (name, step) in cases {
405            let routes = [route("direct:start", vec![step])];
406            let input = inputs(false, &intercepts, &schemes);
407            assert_eq!(derive_tier(&routes, &input), Tier::Full, "case {name}");
408        }
409    }
410
411    #[test]
412    fn tier_scenario_section_forces_full() {
413        let routes = [lean_route()];
414        let schemes = unit_schemes();
415        let intercepts = Vec::new();
416        let input = inputs(true, &intercepts, &schemes);
417        assert_eq!(derive_tier(&routes, &input), Tier::Full);
418    }
419
420    #[test]
421    fn tier_all_route_sources_count() {
422        let schemes = unit_schemes();
423        let intercepts = Vec::new();
424        // Every route source collapses to the same `[RouteDefinition]`
425        // slice before tier derivation runs; both halves assert that every
426        // route in the slice participates in the closure, identically to
427        // the `routeFiles` source.
428        for source in ["inline", "routeFilesFromRoot"] {
429            let all_lean = [
430                lean_route(),
431                route("direct:poll", vec![BuilderStep::To("seda:pool".into())]),
432            ];
433            let input = inputs(false, &intercepts, &schemes);
434            assert_eq!(
435                derive_tier(&all_lean, &input),
436                Tier::Lean,
437                "source {source}"
438            );
439
440            let one_full = [
441                lean_route(),
442                route("direct:ship", vec![BuilderStep::To("kafka:orders".into())]),
443            ];
444            let input = inputs(false, &intercepts, &schemes);
445            assert_eq!(
446                derive_tier(&one_full, &input),
447                Tier::Full,
448                "source {source}"
449            );
450        }
451    }
452}