Skip to main content

camel_api/
splitter.rs

1use std::pin::Pin;
2use std::sync::Arc;
3
4use futures::Stream;
5
6use crate::body::{Body, body_type_name};
7use crate::error::CamelError;
8use crate::exchange::Exchange;
9use crate::message::Message;
10use crate::value::Value;
11
12/// A function that splits a single exchange into multiple fragment exchanges.
13///
14/// Built-in expressions return [`CamelError::TypeConversionFailed`] when the
15/// body type does not match their input contract; empty-content bodies yield
16/// `Ok(Vec::new())` (pass-through).
17pub type SplitExpression =
18    Arc<dyn Fn(&Exchange) -> Result<Vec<Exchange>, CamelError> + Send + Sync>;
19
20/// Fallible split source: a synchronous [`SplitExpression`] or a
21/// language-backed asynchronous expression.
22///
23/// The async arm evaluates to a [`Value`]; fragments are derived with the
24/// canonical split rules (string → non-empty line fragments, array → one
25/// fragment per element). Evaluation errors propagate before any fragment
26/// is materialized.
27#[derive(Clone)]
28#[non_exhaustive]
29pub enum SplitSource {
30    /// Programmatic synchronous splitter.
31    Sync(SplitExpression),
32    /// Language-backed asynchronous expression.
33    Async(Arc<dyn Fn(&Exchange) -> crate::filter::BoxValueFuture + Send + Sync>),
34}
35
36impl From<SplitExpression> for SplitSource {
37    fn from(f: SplitExpression) -> Self {
38        Self::Sync(f)
39    }
40}
41
42impl SplitSource {
43    /// Split `exchange` into fragments, propagating evaluation failures.
44    pub async fn split(&self, exchange: &Exchange) -> Result<Vec<Exchange>, CamelError> {
45        match self {
46            Self::Sync(f) => f(exchange),
47            Self::Async(f) => derive_fragments(exchange, f(exchange).await?),
48        }
49    }
50}
51
52/// Derive fragment exchanges from an evaluated language value.
53///
54/// Mirrors the rules previously inlined in camel-core `splitting.rs`
55/// (DeclarativeSplit): a string yields one fragment per non-empty line; an
56/// array yields one fragment per element (string elements become text
57/// bodies, everything else JSON); anything else is a type error.
58fn derive_fragments(exchange: &Exchange, value: Value) -> Result<Vec<Exchange>, CamelError> {
59    match value {
60        Value::String(s) => Ok(s
61            .lines()
62            .filter(|line| !line.is_empty())
63            .map(|line| {
64                let mut fragment = exchange.clone();
65                fragment.input.body = Body::from(line.to_string());
66                fragment
67            })
68            .collect()),
69        Value::Array(arr) => Ok(arr
70            .into_iter()
71            .map(|v| {
72                let mut fragment = exchange.clone();
73                fragment.input.body = match v {
74                    Value::String(s) => Body::Text(s),
75                    other => Body::Json(other),
76                };
77                fragment
78            })
79            .collect()),
80        other => Err(CamelError::TypeConversionFailed(format!(
81            "declarative split requires a text or array value, got {received}; add an unmarshal step before split",
82            received = crate::value_type_name(&other)
83        ))),
84    }
85}
86
87/// A function that lazily produces a stream of exchange fragments.
88///
89/// Used by the streaming split segment (camel-processor) for v1 sequential streaming split
90/// (e.g., ZIP entry extraction, CSV/JSON streaming in future work).
91///
92/// Each call returns a `Stream` that yields fragments one at a time.
93pub type StreamingSplitExpression = Arc<
94    dyn Fn(Exchange) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>>
95        + Send
96        + Sync,
97>;
98
99/// Typed error returned when a streaming split receives a body that is not
100/// `Body::Stream`. Shared by the compiled production expression, the test
101/// mirrors, and examples so the message cannot drift between copies.
102pub fn streaming_split_type_error(body: &Body) -> CamelError {
103    CamelError::TypeConversionFailed(format!(
104        "streaming split requires body type stream, got {}; add an unmarshal step before split",
105        body_type_name(body)
106    ))
107}
108
109/// Strategy for aggregating fragment results back into a single exchange.
110#[derive(Clone, Default)]
111#[non_exhaustive]
112pub enum AggregationStrategy {
113    /// Result is the last fragment's exchange (default).
114    #[default]
115    LastWins,
116    /// Collects all fragment bodies into a JSON array.
117    CollectAll,
118    /// Returns the original exchange unchanged.
119    Original,
120    /// Custom aggregation function: `(accumulated, next) -> merged`.
121    Custom(Arc<dyn Fn(Exchange, Exchange) -> Exchange + Send + Sync>),
122}
123
124impl std::fmt::Debug for AggregationStrategy {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            AggregationStrategy::LastWins => f.write_str("LastWins"),
128            AggregationStrategy::CollectAll => f.write_str("CollectAll"),
129            AggregationStrategy::Original => f.write_str("Original"),
130            AggregationStrategy::Custom(_) => f.write_str("Custom(..)"),
131        }
132    }
133}
134
135/// The streaming format to use when splitting a stream body.
136#[derive(
137    Clone,
138    Debug,
139    Default,
140    PartialEq,
141    Eq,
142    serde::Serialize,
143    serde::Deserialize,
144    schemars::JsonSchema,
145    ts_rs::TS,
146)]
147#[serde(rename_all = "snake_case")]
148#[ts(rename_all = "snake_case")]
149#[non_exhaustive]
150pub enum StreamSplitFormat {
151    /// Auto-detect the format from the body content.
152    #[default]
153    Auto,
154    /// Newline-delimited JSON — each line is a complete JSON value.
155    Ndjson,
156    /// Split by newlines, each line becomes a text fragment.
157    Lines,
158    /// Split into fixed-size byte chunks.
159    Chunks,
160    /// ZIP archive — materialized format, each entry becomes a fragment exchange.
161    Zip,
162    /// TAR archive — materialized format, each regular-file entry becomes a fragment exchange.
163    Tar,
164    /// GZIP-compressed TAR archive — materialized format, each regular-file entry becomes a fragment exchange.
165    #[serde(rename = "tar.gz")]
166    #[ts(rename = "tar.gz")]
167    TarGz,
168    /// Materialized MIME message — one fragment per attachment part.
169    Mime,
170}
171
172/// Configuration for splitting a streaming body into fragments.
173///
174/// Controls how the stream splitter processes the body, including format
175/// detection, sizing limits, and metadata propagation.
176#[derive(
177    Clone,
178    Debug,
179    PartialEq,
180    Eq,
181    serde::Serialize,
182    serde::Deserialize,
183    schemars::JsonSchema,
184    ts_rs::TS,
185)]
186#[serde(rename_all = "snake_case")]
187#[ts(rename_all = "snake_case")]
188pub struct StreamSplitConfig {
189    /// The streaming format to use.
190    pub format: StreamSplitFormat,
191    /// Maximum size (in bytes) of a single record or chunk.
192    pub max_record_bytes: usize,
193    /// Number of records/chunks to collect into a single exchange batch.
194    pub batch_size: usize,
195    /// Explicit chunk size in bytes (required when format is [`Chunks`](StreamSplitFormat::Chunks)).
196    pub chunk_size: Option<usize>,
197    /// Maximum number of attachment parts to materialize (MIME only).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub max_parts: Option<usize>,
200    /// Maximum size (in bytes) of a single attachment part (MIME only).
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub max_part_size: Option<usize>,
203    /// Content types to include when splitting a MIME message (MIME only).
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub content_types: Option<Vec<String>>,
206    /// Whether to include origin metadata in each fragment.
207    pub include_origin: bool,
208}
209
210impl Default for StreamSplitConfig {
211    fn default() -> Self {
212        Self {
213            format: StreamSplitFormat::Auto,
214            max_record_bytes: 1024 * 1024,
215            batch_size: 1,
216            chunk_size: None,
217            max_parts: None,
218            max_part_size: None,
219            content_types: None,
220            include_origin: true,
221        }
222    }
223}
224
225impl StreamSplitConfig {
226    /// Validates the configuration.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`CamelError::Config`] if:
231    /// - `batch_size` is `0`
232    /// - `max_record_bytes` is `0`
233    /// - `format` is [`Chunks`](StreamSplitFormat::Chunks) but `chunk_size` is `None`
234    /// - `format` is a materialized archive format ([`Zip`](StreamSplitFormat::Zip),
235    ///   [`Tar`](StreamSplitFormat::Tar), [`TarGz`](StreamSplitFormat::TarGz)) but
236    ///   `chunk_size` is `Some(...)`
237    /// - `chunk_size` is `Some(0)`
238    pub fn validate(&self) -> Result<(), CamelError> {
239        if self.batch_size == 0 {
240            return Err(CamelError::Config(
241                "stream split batch_size must be > 0".into(),
242            ));
243        }
244        if self.max_record_bytes == 0 {
245            return Err(CamelError::Config(
246                "stream split max_record_bytes must be > 0".into(),
247            ));
248        }
249        if self.format == StreamSplitFormat::Chunks && self.chunk_size.is_none() {
250            return Err(CamelError::Config(
251                "stream split format=Chunks requires chunk_size".into(),
252            ));
253        }
254        // Materialized archive formats split whole entries, not byte chunks,
255        // so chunk_size is meaningless. The Zip+chunk_size check must come
256        // before the generic chunk_size zero/exceeds checks so that
257        // `Zip + Some(0)` yields the more specific error.
258        if self.format == StreamSplitFormat::Zip && self.chunk_size.is_some() {
259            return Err(CamelError::Config(
260                "stream split format=Zip does not support chunk_size".into(),
261            ));
262        }
263        if matches!(
264            self.format,
265            StreamSplitFormat::Tar | StreamSplitFormat::TarGz
266        ) && self.chunk_size.is_some()
267        {
268            return Err(CamelError::Config(format!(
269                "stream split format={:?} is a materialized archive format and does not support chunk_size",
270                self.format
271            )));
272        }
273        if let Some(cs) = self.chunk_size
274            && cs == 0
275        {
276            return Err(CamelError::Config(
277                "stream split chunk_size must be > 0".into(),
278            ));
279        }
280        if self.format == StreamSplitFormat::Chunks
281            && let Some(cs) = self.chunk_size
282            && cs > self.max_record_bytes
283        {
284            return Err(CamelError::Config(
285                "stream split chunk_size must be <= max_record_bytes".into(),
286            ));
287        }
288        // MIME materializes attachment parts, not byte chunks or record
289        // batches. The mime-only options are rejected on every other format
290        // so a stray option cannot silently do nothing.
291        if self.format == StreamSplitFormat::Mime {
292            if self.chunk_size.is_some() {
293                return Err(CamelError::Config(
294                    "stream split format=Mime does not support chunk_size".into(),
295                ));
296            }
297            if self.batch_size != 1 {
298                return Err(CamelError::Config(
299                    "stream split format=Mime requires batch_size=1".into(),
300                ));
301            }
302            if self.max_parts == Some(0) {
303                return Err(CamelError::Config(
304                    "stream split max_parts must be > 0".into(),
305                ));
306            }
307            if self.max_part_size == Some(0) {
308                return Err(CamelError::Config(
309                    "stream split max_part_size must be > 0".into(),
310                ));
311            }
312        } else {
313            if self.max_parts.is_some() {
314                return Err(CamelError::Config(
315                    "stream split max_parts is only supported for format=Mime".into(),
316                ));
317            }
318            if self.max_part_size.is_some() {
319                return Err(CamelError::Config(
320                    "stream split max_part_size is only supported for format=Mime".into(),
321                ));
322            }
323            if self.content_types.is_some() {
324                return Err(CamelError::Config(
325                    "stream split content_types is only supported for format=Mime".into(),
326                ));
327            }
328        }
329        Ok(())
330    }
331}
332
333/// Default threshold above which split fragments start new traces (0 = off).
334pub const DEFAULT_TRACE_ITEM_THRESHOLD: usize = 100;
335
336/// Default cap on the number of fragments an eager split may materialize.
337pub const DEFAULT_MAX_FRAGMENTS: usize = 100_000;
338
339/// Configuration for the Splitter EIP.
340#[derive(Clone)]
341pub struct SplitterConfig {
342    /// Expression that splits an exchange into fragments.
343    pub expression: SplitSource,
344    /// How to aggregate fragment results.
345    pub aggregation: AggregationStrategy,
346    /// Whether to process fragments in parallel.
347    pub parallel: bool,
348    /// Maximum number of parallel fragments (None = unlimited).
349    pub parallel_limit: Option<usize>,
350    /// Whether to stop processing on the first exception.
351    ///
352    /// In parallel mode this only affects aggregation (the first error is
353    /// propagated), **not** in-flight futures — `join_all` cannot cancel
354    /// already-spawned work.
355    pub stop_on_exception: bool,
356    /// Maximum number of fragments materialized by `expression` (DoS cap, R3-M4).
357    ///
358    /// The eager splitter materializes the whole `Vec<Exchange>` before
359    /// processing; this cap is enforced by `SplitSegment` after eager
360    /// materialization, rejecting a split that would explode memory. Default
361    /// [`DEFAULT_MAX_FRAGMENTS`]. A segment constructed directly with `0`
362    /// fails closed for any non-empty split. For unbounded/lazy input use
363    /// `StreamingSplitter`.
364    pub max_fragments: usize,
365    /// Threshold above which split fragments start new traces (0 = off).
366    pub trace_item_threshold: usize,
367}
368
369impl std::fmt::Debug for SplitterConfig {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        f.debug_struct("SplitterConfig")
372            .field("expression", &"<split-expression>")
373            .field("aggregation", &self.aggregation)
374            .field("parallel", &self.parallel)
375            .field("parallel_limit", &self.parallel_limit)
376            .field("stop_on_exception", &self.stop_on_exception)
377            .field("max_fragments", &self.max_fragments)
378            .field("trace_item_threshold", &self.trace_item_threshold)
379            .finish()
380    }
381}
382
383impl SplitterConfig {
384    /// Create a new splitter config with the given split expression.
385    pub fn new(expression: impl Into<SplitSource>) -> Self {
386        Self {
387            expression: expression.into(),
388            aggregation: AggregationStrategy::default(),
389            parallel: false,
390            parallel_limit: None,
391            stop_on_exception: true,
392            max_fragments: DEFAULT_MAX_FRAGMENTS,
393            trace_item_threshold: DEFAULT_TRACE_ITEM_THRESHOLD,
394        }
395    }
396
397    /// Set the aggregation strategy for combining fragment results.
398    pub fn aggregation(mut self, strategy: AggregationStrategy) -> Self {
399        self.aggregation = strategy;
400        self
401    }
402
403    /// Enable or disable parallel fragment processing.
404    pub fn parallel(mut self, parallel: bool) -> Self {
405        self.parallel = parallel;
406        self
407    }
408
409    /// Set the maximum number of concurrent fragments in parallel mode.
410    pub fn parallel_limit(mut self, limit: usize) -> Self {
411        self.parallel_limit = Some(limit);
412        self
413    }
414
415    /// Control whether processing stops on the first fragment error.
416    ///
417    /// In parallel mode this only affects aggregation — see the field-level
418    /// doc comment for details.
419    pub fn stop_on_exception(mut self, stop: bool) -> Self {
420        self.stop_on_exception = stop;
421        self
422    }
423
424    /// Set the maximum number of fragments the eager splitter will materialize.
425    pub fn max_fragments(mut self, max: usize) -> Self {
426        self.max_fragments = max;
427        self
428    }
429
430    /// Set the threshold above which split fragments start new traces (0 = off).
431    pub fn trace_item_threshold(mut self, threshold: usize) -> Self {
432        self.trace_item_threshold = threshold;
433        self
434    }
435
436    /// Validates the configuration.
437    ///
438    /// Returns `Err(CamelError::Config)` if `parallel_limit` is set to 0,
439    /// which would cause a `Semaphore::new(0)` panic at runtime.
440    pub fn validate(&self) -> Result<(), CamelError> {
441        if self.parallel && self.parallel_limit == Some(0) {
442            return Err(CamelError::Config(
443                "splitter parallel_limit must be > 0".to_string(),
444            ));
445        }
446        if self.max_fragments == 0 {
447            return Err(CamelError::Config(
448                "splitter max_fragments must be > 0".to_string(),
449            ));
450        }
451        Ok(())
452    }
453}
454
455// ---------------------------------------------------------------------------
456// Helpers
457// ---------------------------------------------------------------------------
458
459/// Create a fragment exchange that inherits headers, properties, and OTel context
460/// from the parent, but with a new body.
461///
462/// # OpenTelemetry Trace Propagation
463///
464/// Each fragment inherits the live segment step span context: the splitter step's
465/// span, not the route root and not a previous fragment's span. A fragment-driven
466/// sub-route root therefore opens as a child of that segment span in the same trace,
467/// creating a natural fan-out relationship in the distributed trace:
468///
469/// ```text
470/// Segment step (span A)
471///   ├─ Fragment 1 sub-route root (span B, child of A)
472///   ├─ Fragment 2 sub-route root (span C, child of A)
473///   └─ Fragment N sub-route root (span N, child of A)
474/// ```
475///
476/// Restoring the entry context after the segment completes is the segment wrapper's
477/// job (`route_compiler::TracedSegmentStep` in `camel-core`), not
478/// `fragment_exchange`'s job.
479pub fn fragment_exchange(parent: &Exchange, body: Body) -> Exchange {
480    let mut msg = Message::new(body);
481    msg.headers = parent.input.headers.clone();
482    let mut ex = Exchange::new(msg);
483    ex.properties = parent.properties.clone();
484    ex.pattern = parent.pattern;
485    // Inherit OTel context so fragment spans are children of the parent span
486    ex.otel_context = parent.otel_context.clone();
487    // claimfamily: each fragment is a fanout branch exchange of the
488    // parent — split a sibling claim onto it so drain stays covered
489    // while the fragment is in flight.
490    ex.in_flight_claim = parent.in_flight_claim.as_ref().map(|claim| claim.split());
491    ex
492}
493
494/// Split the exchange body by newlines. Returns one fragment per line.
495///
496/// Empty bodies pass through with zero fragments. Wrong-type bodies return a
497/// [`CamelError::TypeConversionFailed`] naming the received body type and the
498/// expected `text` input.
499pub fn split_body_lines() -> SplitExpression {
500    Arc::new(|exchange: &Exchange| {
501        let text = match &exchange.input.body {
502            Body::Text(s) => s.as_str(),
503            Body::Empty => return Ok(Vec::new()),
504            _ => {
505                return Err(CamelError::TypeConversionFailed(format!(
506                    "split expression 'body_lines' requires body type text, got {received}; add an unmarshal step before split",
507                    received = body_type_name(&exchange.input.body)
508                )));
509            }
510        };
511        Ok(text
512            .lines()
513            .map(|line| fragment_exchange(exchange, Body::Text(line.to_string())))
514            .collect())
515    })
516}
517
518/// Split a JSON array body into one fragment per element.
519///
520/// Fragment typing is element-driven: string elements produce [`Body::Text`]
521/// fragments carrying the raw string (so `${body}` renders it unquoted);
522/// number, boolean, object, nested-array, and null elements produce
523/// [`Body::Json`] fragments.
524///
525/// Empty bodies and empty arrays pass through with zero fragments. Non-array
526/// JSON and wrong-type bodies return a [`CamelError::TypeConversionFailed`]
527/// naming the received body type and the expected `json (array)` input.
528pub fn split_body_json_array() -> SplitExpression {
529    Arc::new(|exchange: &Exchange| {
530        let arr = match &exchange.input.body {
531            Body::Json(serde_json::Value::Array(arr)) => arr,
532            Body::Empty => return Ok(Vec::new()),
533            Body::Json(_) => {
534                return Err(CamelError::TypeConversionFailed(
535                    "split expression 'body_json_array' requires body type json (array), got json (non-array); add an unmarshal step before split"
536                        .to_string(),
537                ))
538            }
539            _ => {
540                return Err(CamelError::TypeConversionFailed(format!(
541                    "split expression 'body_json_array' requires body type json (array), got {received}; add an unmarshal step before split",
542                    received = body_type_name(&exchange.input.body)
543                )))
544            }
545        };
546        Ok(arr
547            .iter()
548            .map(|val| match val {
549                serde_json::Value::String(s) => fragment_exchange(exchange, Body::Text(s.clone())),
550                other => fragment_exchange(exchange, Body::Json(other.clone())),
551            })
552            .collect())
553    })
554}
555
556/// Split the exchange body using a custom function that operates on the body.
557///
558/// Custom closures stay infallible: they own their body-type policy and an
559/// empty `Vec` keeps pass-through semantics.
560pub fn split_body<F>(f: F) -> SplitExpression
561where
562    F: Fn(&Body) -> Vec<Body> + Send + Sync + 'static,
563{
564    Arc::new(move |exchange: &Exchange| {
565        Ok(f(&exchange.input.body)
566            .into_iter()
567            .map(|body| fragment_exchange(exchange, body))
568            .collect())
569    })
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::in_flight::{InFlightClaim, InFlightGauge};
576    use crate::value::Value;
577    use std::sync::Arc;
578
579    #[test]
580    fn test_split_body_lines() {
581        let mut ex = Exchange::new(Message::new("a\nb\nc"));
582        ex.input.set_header("source", Value::String("test".into()));
583        ex.set_property("trace", Value::Bool(true));
584
585        let fragments = split_body_lines()(&ex).unwrap();
586        assert_eq!(fragments.len(), 3);
587        assert_eq!(fragments[0].input.body.as_text(), Some("a"));
588        assert_eq!(fragments[1].input.body.as_text(), Some("b"));
589        assert_eq!(fragments[2].input.body.as_text(), Some("c"));
590
591        // Verify headers and properties inherited
592        for frag in &fragments {
593            assert_eq!(
594                frag.input.header("source"),
595                Some(&Value::String("test".into()))
596            );
597            assert_eq!(frag.property("trace"), Some(&Value::Bool(true)));
598        }
599    }
600
601    #[test]
602    fn test_split_body_lines_empty() {
603        let ex = Exchange::new(Message::default()); // Body::Empty
604        let fragments = split_body_lines()(&ex).unwrap();
605        assert!(fragments.is_empty());
606    }
607
608    #[test]
609    fn fragment_exchange_splits_parent_claim() {
610        let gauge = Arc::new(InFlightGauge::new());
611        let mut parent = Exchange::new(Message::new(Body::Text("a,b".into())));
612        parent.in_flight_claim = Some(InFlightClaim::attach(&gauge));
613
614        let frag = fragment_exchange(&parent, Body::Text("a".into()));
615        assert_eq!(gauge.total(), 2);
616        assert!(frag.in_flight_claim.is_some());
617        assert!(parent.in_flight_claim.is_some());
618
619        drop(frag);
620        assert_eq!(gauge.total(), 1);
621    }
622
623    #[test]
624    fn test_split_body_json_array() {
625        let arr = serde_json::json!([1, 2, 3]);
626        let ex = Exchange::new(Message::new(arr));
627
628        let fragments = split_body_json_array()(&ex).unwrap();
629        assert_eq!(fragments.len(), 3);
630        assert!(matches!(&fragments[0].input.body, Body::Json(v) if *v == serde_json::json!(1)));
631        assert!(matches!(&fragments[1].input.body, Body::Json(v) if *v == serde_json::json!(2)));
632        assert!(matches!(&fragments[2].input.body, Body::Json(v) if *v == serde_json::json!(3)));
633    }
634
635    #[test]
636    fn split_body_json_array_string_elements_become_text() {
637        // The leading empty-string element pins the accepted delta from
638        // mission 251 (bd rc-etf0q): an empty element yields an empty
639        // `Body::Text`, not a quoted `""` JSON string.
640        let ex = Exchange::new(Message::new(serde_json::json!(["", "a", "b"])));
641
642        let fragments = split_body_json_array()(&ex).unwrap();
643        assert_eq!(fragments.len(), 3);
644        assert!(
645            matches!(&fragments[0].input.body, Body::Text(s) if s.is_empty()),
646            "fragment 0 must be Body::Text(\"\"), got {:?}",
647            fragments[0].input.body
648        );
649        assert!(matches!(&fragments[1].input.body, Body::Text(s) if s == "a"));
650        assert!(matches!(&fragments[2].input.body, Body::Text(s) if s == "b"));
651        // No fragment body carries a literal quote character: string elements
652        // are raw text, so `${body}` renders them unquoted.
653        for frag in &fragments {
654            let text = match &frag.input.body {
655                Body::Text(s) => s.as_str(),
656                other => panic!("expected Body::Text fragment, got {other:?}"),
657            };
658            assert!(
659                !text.contains('"'),
660                "fragment body must not carry a quote character, got {text:?}"
661            );
662        }
663    }
664
665    #[test]
666    fn test_split_body_json_array_non_string_elements_stay_json() {
667        let ex = Exchange::new(Message::new(serde_json::json!([1, {"k": "v"}, null])));
668
669        let fragments = split_body_json_array()(&ex).unwrap();
670        assert_eq!(fragments.len(), 3);
671        assert!(matches!(&fragments[0].input.body, Body::Json(v) if *v == serde_json::json!(1)));
672        assert!(matches!(&fragments[1].input.body, Body::Json(v)
673                if *v == serde_json::json!({"k": "v"})));
674        assert!(matches!(&fragments[2].input.body, Body::Json(v) if v.is_null()));
675    }
676
677    #[test]
678    fn test_split_body_json_array_not_array() {
679        let obj = serde_json::json!({"not": "array"});
680        let ex = Exchange::new(Message::new(obj));
681
682        let err = split_body_json_array()(&ex).unwrap_err();
683        assert!(matches!(err, CamelError::TypeConversionFailed(_)));
684        assert!(err.to_string().contains("json (non-array)"));
685    }
686
687    #[test]
688    fn test_split_body_lines_wrong_type_json_errors() {
689        let ex = Exchange::new(Message::new(serde_json::json!({"a": 1})));
690
691        let err = split_body_lines()(&ex).unwrap_err();
692        let msg = err.to_string();
693        assert!(matches!(err, CamelError::TypeConversionFailed(_)));
694        for needle in [
695            "body_lines",
696            "json",
697            "text",
698            "add an unmarshal step before split",
699        ] {
700            assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
701        }
702    }
703
704    #[test]
705    fn test_split_body_json_array_wrong_type_text_errors() {
706        let ex = Exchange::new(Message::new("x"));
707
708        let err = split_body_json_array()(&ex).unwrap_err();
709        let msg = err.to_string();
710        assert!(matches!(err, CamelError::TypeConversionFailed(_)));
711        for needle in [
712            "body_json_array",
713            "text",
714            "json (array)",
715            "add an unmarshal step before split",
716        ] {
717            assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
718        }
719    }
720
721    #[test]
722    fn test_split_body_json_array_non_array_json_errors() {
723        let ex = Exchange::new(Message::new(serde_json::json!({"o": 1})));
724
725        let err = split_body_json_array()(&ex).unwrap_err();
726        let msg = err.to_string();
727        assert!(matches!(err, CamelError::TypeConversionFailed(_)));
728        assert!(msg.contains("json (non-array)"));
729    }
730
731    #[test]
732    fn test_split_body_lines_empty_body_ok() {
733        let ex = Exchange::new(Message::default()); // Body::Empty
734        let fragments = split_body_lines()(&ex).unwrap();
735        assert!(fragments.is_empty());
736    }
737
738    #[test]
739    fn test_split_body_json_array_empty_body_ok() {
740        let ex = Exchange::new(Message::default()); // Body::Empty
741        let fragments = split_body_json_array()(&ex).unwrap();
742        assert!(fragments.is_empty());
743    }
744
745    #[test]
746    fn test_split_body_json_array_empty_array_ok() {
747        let ex = Exchange::new(Message::new(serde_json::json!([])));
748        let fragments = split_body_json_array()(&ex).unwrap();
749        assert!(fragments.is_empty());
750    }
751
752    #[test]
753    fn test_split_body_lines_empty_text_ok() {
754        let ex = Exchange::new(Message::new(""));
755        let fragments = split_body_lines()(&ex).unwrap();
756        assert!(fragments.is_empty());
757    }
758
759    #[test]
760    fn test_split_error_omits_payload() {
761        let ex = Exchange::new(Message::new(serde_json::json!({
762            "secret": "SECRET-8f31a"
763        })));
764
765        let err = split_body_lines()(&ex).unwrap_err();
766        let msg = err.to_string();
767        assert!(matches!(err, CamelError::TypeConversionFailed(_)));
768        for needle in [
769            "body_lines",
770            "json",
771            "text",
772            "add an unmarshal step before split",
773        ] {
774            assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
775        }
776        assert!(
777            !msg.contains("SECRET-8f31a"),
778            "message '{msg}' leaks payload"
779        );
780    }
781
782    #[test]
783    fn test_split_body_custom() {
784        let splitter = split_body(|body: &Body| match body {
785            Body::Text(s) => s
786                .split(',')
787                .map(|part| Body::Text(part.trim().to_string()))
788                .collect(),
789            _ => Vec::new(),
790        });
791
792        let mut ex = Exchange::new(Message::new("x, y, z"));
793        ex.set_property("id", Value::from(42));
794
795        let fragments = splitter(&ex).unwrap();
796        assert_eq!(fragments.len(), 3);
797        assert_eq!(fragments[0].input.body.as_text(), Some("x"));
798        assert_eq!(fragments[1].input.body.as_text(), Some("y"));
799        assert_eq!(fragments[2].input.body.as_text(), Some("z"));
800
801        // Properties inherited
802        for frag in &fragments {
803            assert_eq!(frag.property("id"), Some(&Value::from(42)));
804        }
805    }
806
807    #[test]
808    fn test_splitter_config_defaults() {
809        let config = SplitterConfig::new(split_body_lines());
810        assert!(matches!(config.aggregation, AggregationStrategy::LastWins));
811        assert!(!config.parallel);
812        assert!(config.parallel_limit.is_none());
813        assert!(config.stop_on_exception);
814    }
815
816    #[test]
817    fn test_splitter_config_builder() {
818        let config = SplitterConfig::new(split_body_lines())
819            .aggregation(AggregationStrategy::CollectAll)
820            .parallel(true)
821            .parallel_limit(4)
822            .stop_on_exception(false);
823
824        assert!(matches!(
825            config.aggregation,
826            AggregationStrategy::CollectAll
827        ));
828        assert!(config.parallel);
829        assert_eq!(config.parallel_limit, Some(4));
830        assert!(!config.stop_on_exception);
831    }
832
833    #[test]
834    fn test_splitter_config_default_max_fragments() {
835        let cfg = SplitterConfig::new(Arc::new(|_: &Exchange| Ok(Vec::new())) as SplitExpression);
836        assert_eq!(cfg.max_fragments, 100_000);
837    }
838
839    #[test]
840    fn test_splitter_config_rejects_zero_max_fragments() {
841        let cfg = SplitterConfig::new(Arc::new(|_: &Exchange| Ok(Vec::new())) as SplitExpression)
842            .max_fragments(0);
843        assert!(cfg.validate().is_err());
844    }
845
846    #[test]
847    fn test_fragment_exchange_inherits_otel_context() {
848        use opentelemetry::Context;
849        use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId};
850
851        // Create parent exchange with a valid span context
852        let mut parent = Exchange::new(Message::new("test"));
853        let trace_id = TraceId::from_bytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 123]);
854        let span_id = SpanId::from_bytes([0, 0, 0, 0, 0, 0, 1, 200]);
855        let span_context = SpanContext::new(
856            trace_id,
857            span_id,
858            TraceFlags::SAMPLED,
859            true,
860            Default::default(),
861        );
862        let expected_trace_id = span_context.trace_id();
863        parent.otel_context = Context::current().with_remote_span_context(span_context);
864
865        // Create fragment via split_body_lines
866        let fragments = split_body_lines()(&parent).unwrap();
867        assert!(!fragments.is_empty(), "Should have at least one fragment");
868
869        // Verify each fragment has the same span context as parent
870        for fragment in &fragments {
871            let span = fragment.otel_context.span();
872            let frag_span_ctx = span.span_context();
873            assert!(
874                frag_span_ctx.is_valid(),
875                "Fragment should have valid span context"
876            );
877            assert_eq!(
878                frag_span_ctx.trace_id(),
879                expected_trace_id,
880                "Fragment should have same trace ID as parent"
881            );
882        }
883    }
884
885    #[test]
886    fn test_stream_split_config_defaults_valid() {
887        let config = StreamSplitConfig::default();
888        assert!(config.validate().is_ok());
889    }
890
891    #[test]
892    fn test_stream_split_config_batch_size_zero_rejected() {
893        let config = StreamSplitConfig {
894            batch_size: 0,
895            ..Default::default()
896        };
897        let err = config.validate().unwrap_err();
898        assert!(err.to_string().contains("batch_size"));
899    }
900
901    #[test]
902    fn test_stream_split_config_max_record_bytes_zero_rejected() {
903        let config = StreamSplitConfig {
904            max_record_bytes: 0,
905            ..Default::default()
906        };
907        let err = config.validate().unwrap_err();
908        assert!(err.to_string().contains("max_record_bytes"));
909    }
910
911    #[test]
912    fn test_stream_split_config_chunks_requires_chunk_size() {
913        let config = StreamSplitConfig {
914            format: StreamSplitFormat::Chunks,
915            chunk_size: None,
916            ..Default::default()
917        };
918        let err = config.validate().unwrap_err();
919        assert!(err.to_string().contains("Chunks requires chunk_size"));
920    }
921
922    #[test]
923    fn test_stream_split_config_chunk_size_zero_rejected() {
924        let config = StreamSplitConfig {
925            format: StreamSplitFormat::Chunks,
926            chunk_size: Some(0),
927            ..Default::default()
928        };
929        let err = config.validate().unwrap_err();
930        assert!(err.to_string().contains("chunk_size must be > 0"));
931    }
932
933    #[test]
934    fn test_stream_split_config_chunk_size_exceeds_max_record_bytes() {
935        let config = StreamSplitConfig {
936            format: StreamSplitFormat::Chunks,
937            chunk_size: Some(2000),
938            max_record_bytes: 1000,
939            ..Default::default()
940        };
941        let err = config.validate().unwrap_err();
942        assert!(
943            err.to_string()
944                .contains("chunk_size must be <= max_record_bytes")
945        );
946    }
947
948    #[test]
949    fn test_stream_split_config_zip_rejects_chunk_size() {
950        let config = StreamSplitConfig {
951            format: StreamSplitFormat::Zip,
952            chunk_size: Some(1024),
953            ..Default::default()
954        };
955        let err = config.validate().unwrap_err();
956        assert!(err.to_string().contains("Zip does not support chunk_size"));
957    }
958
959    #[test]
960    fn test_all_fragments_share_same_trace_context() {
961        use opentelemetry::Context;
962        use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId};
963
964        // Create parent with a specific trace ID
965        let mut parent = Exchange::new(Message::new("line1\nline2\nline3"));
966        let trace_id =
967            TraceId::from_bytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x3B, 0x9A, 0xCA, 0x09]);
968        let span_id = SpanId::from_bytes([0, 0, 0, 0, 0, 0, 0, 111]);
969        let span_context = SpanContext::new(
970            trace_id,
971            span_id,
972            TraceFlags::SAMPLED,
973            true,
974            Default::default(),
975        );
976        parent.otel_context = Context::current().with_remote_span_context(span_context);
977
978        let fragments = split_body_lines()(&parent).unwrap();
979        assert_eq!(fragments.len(), 3);
980
981        // All fragments should share the same trace ID
982        let trace_ids: Vec<_> = fragments
983            .iter()
984            .map(|f| {
985                let span = f.otel_context.span();
986                span.span_context().trace_id()
987            })
988            .collect();
989
990        assert!(
991            trace_ids.iter().all(|&id| id == trace_id),
992            "All fragments should have the same trace ID"
993        );
994    }
995
996    #[test]
997    fn trace_item_threshold_defaults_to_100() {
998        let config = SplitterConfig::new(split_body_lines());
999        assert_eq!(config.trace_item_threshold, 100);
1000    }
1001
1002    #[test]
1003    fn trace_item_threshold_builder_sets_value() {
1004        let config = SplitterConfig::new(split_body_lines()).trace_item_threshold(0);
1005        assert_eq!(config.trace_item_threshold, 0);
1006
1007        let config = SplitterConfig::new(split_body_lines()).trace_item_threshold(7);
1008        assert_eq!(config.trace_item_threshold, 7);
1009    }
1010
1011    #[test]
1012    fn canonical_split_spec_carries_trace_item_threshold() {
1013        use crate::runtime::{CanonicalSplitAggregationSpec, CanonicalSplitExpressionSpec};
1014
1015        let with_threshold = crate::runtime::CanonicalStepSpec::Split {
1016            expression: CanonicalSplitExpressionSpec::BodyLines,
1017            aggregation: CanonicalSplitAggregationSpec::CollectAll,
1018            parallel: false,
1019            parallel_limit: None,
1020            stop_on_exception: true,
1021            trace_item_threshold: Some(5),
1022            steps: Vec::new(),
1023        };
1024        let without_threshold = crate::runtime::CanonicalStepSpec::Split {
1025            expression: CanonicalSplitExpressionSpec::BodyLines,
1026            aggregation: CanonicalSplitAggregationSpec::CollectAll,
1027            parallel: false,
1028            parallel_limit: None,
1029            stop_on_exception: true,
1030            trace_item_threshold: None,
1031            steps: Vec::new(),
1032        };
1033
1034        // Mirror the derive(PartialEq)/destructure path `parallel_limit` uses.
1035        let crate::runtime::CanonicalStepSpec::Split {
1036            trace_item_threshold,
1037            ..
1038        } = &with_threshold
1039        else {
1040            unreachable!()
1041        };
1042        assert_eq!(trace_item_threshold, &Some(5));
1043
1044        let crate::runtime::CanonicalStepSpec::Split {
1045            trace_item_threshold,
1046            ..
1047        } = &without_threshold
1048        else {
1049            unreachable!()
1050        };
1051        assert_eq!(trace_item_threshold, &None);
1052
1053        assert_ne!(with_threshold, without_threshold);
1054    }
1055}