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