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
12pub type SplitExpression =
18 Arc<dyn Fn(&Exchange) -> Result<Vec<Exchange>, CamelError> + Send + Sync>;
19
20#[derive(Clone)]
28#[non_exhaustive]
29pub enum SplitSource {
30 Sync(SplitExpression),
32 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 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
52fn 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
87pub type StreamingSplitExpression = Arc<
94 dyn Fn(Exchange) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>>
95 + Send
96 + Sync,
97>;
98
99pub 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#[derive(Clone, Default)]
111#[non_exhaustive]
112pub enum AggregationStrategy {
113 #[default]
115 LastWins,
116 CollectAll,
118 Original,
120 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#[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 #[default]
153 Auto,
154 Ndjson,
156 Lines,
158 Chunks,
160 Zip,
162 Tar,
164 #[serde(rename = "tar.gz")]
166 #[ts(rename = "tar.gz")]
167 TarGz,
168 Mime,
170}
171
172#[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 pub format: StreamSplitFormat,
191 pub max_record_bytes: usize,
193 pub batch_size: usize,
195 pub chunk_size: Option<usize>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub max_parts: Option<usize>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub max_part_size: Option<usize>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub content_types: Option<Vec<String>>,
206 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 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 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 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
333pub const DEFAULT_TRACE_ITEM_THRESHOLD: usize = 100;
335
336pub const DEFAULT_MAX_FRAGMENTS: usize = 100_000;
338
339#[derive(Clone)]
341pub struct SplitterConfig {
342 pub expression: SplitSource,
344 pub aggregation: AggregationStrategy,
346 pub parallel: bool,
348 pub parallel_limit: Option<usize>,
350 pub stop_on_exception: bool,
356 pub max_fragments: usize,
365 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 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 pub fn aggregation(mut self, strategy: AggregationStrategy) -> Self {
399 self.aggregation = strategy;
400 self
401 }
402
403 pub fn parallel(mut self, parallel: bool) -> Self {
405 self.parallel = parallel;
406 self
407 }
408
409 pub fn parallel_limit(mut self, limit: usize) -> Self {
411 self.parallel_limit = Some(limit);
412 self
413 }
414
415 pub fn stop_on_exception(mut self, stop: bool) -> Self {
420 self.stop_on_exception = stop;
421 self
422 }
423
424 pub fn max_fragments(mut self, max: usize) -> Self {
426 self.max_fragments = max;
427 self
428 }
429
430 pub fn trace_item_threshold(mut self, threshold: usize) -> Self {
432 self.trace_item_threshold = threshold;
433 self
434 }
435
436 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
455pub 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 ex.otel_context = parent.otel_context.clone();
487 ex.in_flight_claim = parent.in_flight_claim.as_ref().map(|claim| claim.split());
491 ex
492}
493
494pub 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
518pub 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
556pub 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 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()); 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 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 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()); 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()); 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 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 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 let fragments = split_body_lines()(&parent).unwrap();
867 assert!(!fragments.is_empty(), "Should have at least one fragment");
868
869 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 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 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 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}