1use camel_api::Exchange;
2use camel_api::body::Body;
3use camel_api::data_format::DataFormat;
4use camel_api::error::CamelError;
5use serde::Deserialize;
6
7pub const CAMEL_CSV_HEADER_RECORD: &str = "CamelCsvHeaderRecord";
8
9#[derive(Clone, Copy, Debug, Default, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum RecordSeparator {
12 #[default]
13 Crlf,
14 Lf,
15}
16
17#[derive(Clone, Copy, Debug, Default, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum QuoteMode {
20 All,
21 #[default]
22 Minimal,
23 NonNumeric,
24 None,
25}
26
27#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum WidthMismatchPolicy {
31 Lenient,
33 #[default]
35 Warn,
36 Error,
38}
39
40#[derive(Clone, Debug, Deserialize)]
41#[serde(default, deny_unknown_fields)]
42pub struct CsvConfig {
43 pub delimiter: char,
44 pub quote: char,
45 pub quote_mode: QuoteMode,
46 pub double_quote: bool,
47 pub escape: Option<char>,
48 pub record_separator: RecordSeparator,
49 pub comment_marker: Option<char>,
50 pub null_string: Option<String>,
51 pub headers: Option<Vec<String>>,
52 pub has_headers: bool,
53 pub skip_header_record: bool,
54 pub capture_header_record: bool,
55 pub ignore_surrounding_spaces: bool,
56 pub trim: bool,
57 pub use_maps: bool,
58 pub max_records: Option<usize>,
61 pub max_field_size: Option<usize>,
64 pub width_mismatch: WidthMismatchPolicy,
66}
67
68impl Default for CsvConfig {
69 fn default() -> Self {
70 Self {
71 delimiter: ',',
72 quote: '"',
73 quote_mode: QuoteMode::Minimal,
74 double_quote: true,
75 escape: None,
76 record_separator: RecordSeparator::Crlf,
77 comment_marker: None,
78 null_string: None,
79 headers: None,
80 has_headers: true,
81 skip_header_record: false,
82 capture_header_record: false,
83 ignore_surrounding_spaces: true,
84 trim: false,
85 use_maps: true,
86 max_records: Some(100_000),
87 max_field_size: Some(1_048_576),
88 width_mismatch: WidthMismatchPolicy::Warn,
89 }
90 }
91}
92
93impl CsvConfig {
94 pub fn excel() -> Self {
95 Self::default()
96 }
97
98 pub fn tdf() -> Self {
99 Self {
100 delimiter: '\t',
101 record_separator: RecordSeparator::Lf,
102 ..Self::default()
103 }
104 }
105
106 pub fn mysql() -> Self {
107 Self {
108 delimiter: '\t',
109 quote: '\0',
110 record_separator: RecordSeparator::Lf,
111 escape: Some('\\'),
112 ..Self::default()
113 }
114 }
115
116 pub fn delimiter(mut self, c: char) -> Self {
117 self.delimiter = c;
118 self
119 }
120 pub fn quote(mut self, c: char) -> Self {
121 self.quote = c;
122 self
123 }
124 pub fn quote_mode(mut self, m: QuoteMode) -> Self {
125 self.quote_mode = m;
126 self
127 }
128 pub fn double_quote(mut self, b: bool) -> Self {
129 self.double_quote = b;
130 self
131 }
132 pub fn escape(mut self, c: Option<char>) -> Self {
133 self.escape = c;
134 self
135 }
136 pub fn record_separator(mut self, s: RecordSeparator) -> Self {
137 self.record_separator = s;
138 self
139 }
140 pub fn comment_marker(mut self, c: Option<char>) -> Self {
141 self.comment_marker = c;
142 self
143 }
144 pub fn null_string(mut self, s: Option<String>) -> Self {
145 self.null_string = s;
146 self
147 }
148 pub fn headers(mut self, h: Vec<String>) -> Self {
149 self.headers = Some(h);
150 self
151 }
152 pub fn has_headers(mut self, b: bool) -> Self {
153 self.has_headers = b;
154 self
155 }
156 pub fn skip_header_record(mut self, b: bool) -> Self {
157 self.skip_header_record = b;
158 self
159 }
160 pub fn capture_header_record(mut self, b: bool) -> Self {
161 self.capture_header_record = b;
162 self
163 }
164 pub fn ignore_surrounding_spaces(mut self, b: bool) -> Self {
165 self.ignore_surrounding_spaces = b;
166 self
167 }
168 pub fn trim(mut self, b: bool) -> Self {
169 self.trim = b;
170 self
171 }
172 pub fn use_maps(mut self, b: bool) -> Self {
173 self.use_maps = b;
174 self
175 }
176 pub fn max_records(mut self, max: usize) -> Self {
177 self.max_records = Some(max);
178 self
179 }
180 pub fn max_field_size(mut self, max: usize) -> Self {
181 self.max_field_size = Some(max);
182 self
183 }
184 pub fn width_mismatch(mut self, policy: WidthMismatchPolicy) -> Self {
185 self.width_mismatch = policy;
186 self
187 }
188}
189
190#[derive(Clone, Default)]
191pub struct CsvDataFormat {
192 config: CsvConfig,
193}
194
195impl CsvDataFormat {
196 pub fn new(config: CsvConfig) -> Self {
197 Self { config }
198 }
199
200 pub fn config(&self) -> &CsvConfig {
201 &self.config
202 }
203
204 fn field_to_string(value: &serde_json::Value) -> String {
206 match value {
207 serde_json::Value::String(s) => s.clone(),
208 serde_json::Value::Number(n) => n.to_string(),
209 serde_json::Value::Bool(b) => b.to_string(),
210 serde_json::Value::Null => String::new(),
211 serde_json::Value::Array(arr) => arr
212 .iter()
213 .map(|v| v.to_string())
214 .collect::<Vec<_>>()
215 .join(","),
216 serde_json::Value::Object(_) => value.to_string(),
217 }
218 }
219
220 fn neutralize_formula_injection(s: &str) -> String {
224 if s.starts_with('=')
225 || s.starts_with('+')
226 || s.starts_with('-')
227 || s.starts_with('@')
228 || s.starts_with('\t')
229 || s.starts_with('\r')
230 {
231 let mut out = String::with_capacity(s.len() + 1);
232 out.push('\'');
233 out.push_str(s);
234 out
235 } else {
236 s.to_string()
237 }
238 }
239
240 fn write_err(e: csv::Error) -> CamelError {
241 CamelError::TypeConversionFailed(format!("CSV write error: {e}"))
242 }
243
244 fn check_record_caps(
247 record: &csv::StringRecord,
248 count: usize,
249 max_records: Option<usize>,
250 max_field_size: Option<usize>,
251 ) -> Result<(), CamelError> {
252 if let Some(max) = max_records
253 && count > max
254 {
255 return Err(CamelError::TypeConversionFailed(format!(
256 "CSV unmarshal exceeded max_records {max}"
257 )));
258 }
259 if let Some(max_field) = max_field_size {
260 for s in record.iter() {
261 if s.len() > max_field {
262 return Err(CamelError::TypeConversionFailed(format!(
263 "CSV unmarshal field length {} exceeds max_field_size {max_field}",
264 s.len()
265 )));
266 }
267 }
268 }
269 Ok(())
270 }
271
272 fn unmarshal_internal(&self, body: Body) -> Result<(Body, Option<Vec<String>>), CamelError> {
273 let input_text: String =
274 match body {
275 Body::Text(s) => s,
276 Body::Bytes(b) => String::from_utf8(b.to_vec()).map_err(|e| {
277 CamelError::TypeConversionFailed(format!("CSV bytes not UTF-8: {e}"))
278 })?,
279 Body::Json(v) => return Ok((Body::Json(v), None)),
280 Body::Stream(_) => return Err(CamelError::TypeConversionFailed(
281 "cannot unmarshal Body::Stream directly — add 'stream_cache' before this step"
282 .into(),
283 )),
284 Body::Empty => {
285 return Err(CamelError::TypeConversionFailed(
286 "CsvDataFormat::unmarshal expects Body::Text or Body::Bytes".into(),
287 ));
288 }
289 Body::Xml(_) => {
290 return Err(CamelError::TypeConversionFailed(
291 "CsvDataFormat::unmarshal does not accept Body::Xml".into(),
292 ));
293 }
294 _ => {
295 return Err(CamelError::TypeConversionFailed(
296 "CsvDataFormat::unmarshal expects Body::Text or Body::Bytes".into(),
297 ));
298 }
299 };
300
301 let mut rdr_builder = csv::ReaderBuilder::new();
302 let effective_has_headers = if self.config.headers.is_some() {
303 false
304 } else {
305 self.config.has_headers
306 };
307 rdr_builder
308 .delimiter(self.config.delimiter as u8)
309 .has_headers(effective_has_headers)
310 .flexible(true)
311 .trim(if self.config.trim {
312 csv::Trim::All
313 } else if self.config.ignore_surrounding_spaces {
314 csv::Trim::Fields
315 } else {
316 csv::Trim::None
317 });
318 if let Some(c) = self.config.comment_marker {
319 rdr_builder.comment(Some(c as u8));
320 }
321 let mut rdr = rdr_builder.from_reader(input_text.as_bytes());
322
323 let header_keys: Option<Vec<String>> = if let Some(h) = &self.config.headers {
324 Some(h.clone())
325 } else if self.config.has_headers {
326 Some(
327 rdr.headers()
328 .map_err(|e| CamelError::TypeConversionFailed(format!("CSV header read: {e}")))?
329 .iter()
330 .map(|s| s.to_string())
331 .collect(),
332 )
333 } else {
334 None
335 };
336
337 let mut records: Vec<serde_json::Value> = Vec::new();
338 if self.config.use_maps {
339 let keys = match &header_keys {
340 Some(k) => k.clone(),
341 None => {
342 return Err(CamelError::TypeConversionFailed(
343 "CsvDataFormat::unmarshal with use_maps=true requires has_headers=true or configured headers".into(),
344 ));
345 }
346 };
347 let mut iter = rdr.records();
348 if self.config.skip_header_record && self.config.headers.is_some() {
349 iter.next();
350 }
351 let mut count: usize = 0;
352 let mut mismatch_count: usize = 0;
353 let mut first_mismatch: Option<(usize, usize, usize)> = None;
354 for result in iter {
355 let record = result
356 .map_err(|e| CamelError::TypeConversionFailed(format!("CSV parse: {e}")))?;
357 count += 1;
358 Self::check_record_caps(
359 &record,
360 count,
361 self.config.max_records,
362 self.config.max_field_size,
363 )?;
364 if record.len() != keys.len() {
366 match self.config.width_mismatch {
367 WidthMismatchPolicy::Error => {
368 return Err(CamelError::TypeConversionFailed(format!(
369 "CSV record {} width {} differs from header width {}",
370 count,
371 record.len(),
372 keys.len()
373 )));
374 }
375 WidthMismatchPolicy::Warn => {
376 mismatch_count += 1;
377 if first_mismatch.is_none() {
378 first_mismatch = Some((count, keys.len(), record.len()));
379 }
380 }
381 WidthMismatchPolicy::Lenient => {}
382 }
383 }
384 let mut obj = serde_json::Map::new();
385 for (i, key) in keys.iter().enumerate() {
386 let val = record.get(i).unwrap_or("");
387 let parsed = if let Some(ns) = &self.config.null_string {
388 if val == ns {
389 serde_json::Value::Null
390 } else {
391 serde_json::Value::String(val.to_string())
392 }
393 } else {
394 serde_json::Value::String(val.to_string())
395 };
396 obj.insert(key.clone(), parsed);
397 }
398 records.push(serde_json::Value::Object(obj));
399 }
400 if let (
402 WidthMismatchPolicy::Warn,
403 Some((first_record, header_width, first_row_width)),
404 ) = (self.config.width_mismatch, first_mismatch)
405 {
406 tracing::warn!(
407 first_record,
408 header_width,
409 first_row_width,
410 total_mismatches = mismatch_count,
411 "CSV records had width mismatch with header; fields were padded or truncated"
412 );
413 }
414 } else {
415 let mut count: usize = 0;
416 for result in rdr.records() {
417 let record = result
418 .map_err(|e| CamelError::TypeConversionFailed(format!("CSV parse: {e}")))?;
419 count += 1;
420 Self::check_record_caps(
421 &record,
422 count,
423 self.config.max_records,
424 self.config.max_field_size,
425 )?;
426 let arr: Vec<serde_json::Value> = record
427 .iter()
428 .map(|s| serde_json::Value::String(s.to_string()))
429 .collect();
430 records.push(serde_json::Value::Array(arr));
431 }
432 }
433
434 Ok((Body::Json(serde_json::Value::Array(records)), header_keys))
435 }
436}
437
438impl DataFormat for CsvDataFormat {
439 fn name(&self) -> &str {
440 "csv"
441 }
442
443 fn marshal(&self, body: Body) -> Result<Body, CamelError> {
444 let json = match body {
445 Body::Json(v) => v,
446 Body::Text(_) => {
447 return Err(CamelError::TypeConversionFailed(
448 "CsvDataFormat::marshal expects Body::Json; use convert_body_to first".into(),
449 ));
450 }
451 Body::Bytes(_) => {
452 return Err(CamelError::TypeConversionFailed(
453 "CsvDataFormat::marshal expects Body::Json; use convert_body_to first".into(),
454 ));
455 }
456 Body::Stream(_) => {
457 return Err(CamelError::TypeConversionFailed(
458 "cannot marshal Body::Stream — add 'stream_cache' before this step".into(),
459 ));
460 }
461 Body::Empty => {
462 return Err(CamelError::TypeConversionFailed(
463 "CsvDataFormat::marshal expects Body::Json".into(),
464 ));
465 }
466 Body::Xml(_) => return Err(CamelError::TypeConversionFailed(
467 "CsvDataFormat::marshal does not accept Body::Xml — use unmarshal(\"xml\") first"
468 .into(),
469 )),
470 _ => {
471 return Err(CamelError::TypeConversionFailed(
472 "CsvDataFormat::marshal expects Body::Json".into(),
473 ));
474 }
475 };
476
477 let mut wtr_builder = csv::WriterBuilder::new();
478 wtr_builder
479 .delimiter(self.config.delimiter as u8)
480 .quote(self.config.quote as u8)
481 .double_quote(self.config.double_quote);
482 if let Some(esc) = self.config.escape {
483 wtr_builder.escape(esc as u8);
484 }
485 let quote_style = match self.config.quote_mode {
486 QuoteMode::All => csv::QuoteStyle::Always,
487 QuoteMode::Minimal => csv::QuoteStyle::Necessary,
488 QuoteMode::NonNumeric => csv::QuoteStyle::NonNumeric,
489 QuoteMode::None => csv::QuoteStyle::Never,
490 };
491 wtr_builder.quote_style(quote_style);
492 let terminator = match self.config.record_separator {
493 RecordSeparator::Crlf => csv::Terminator::CRLF,
494 RecordSeparator::Lf => csv::Terminator::Any(b'\n'),
495 };
496 wtr_builder.terminator(terminator);
497
498 let mut output = Vec::new();
499 {
500 let mut wtr = wtr_builder.from_writer(&mut output);
501 match &json {
502 serde_json::Value::Array(arr) => {
503 if arr.is_empty() {
504 if let (true, Some(h)) = (self.config.has_headers, &self.config.headers) {
505 let neutralized: Vec<String> = h
506 .iter()
507 .map(|s| Self::neutralize_formula_injection(s))
508 .collect();
509 wtr.write_record(neutralized.iter().map(String::as_str))
510 .map_err(Self::write_err)?;
511 }
512 } else {
513 match &arr[0] {
514 serde_json::Value::Object(first_obj) => {
515 let header_keys: Vec<String> = self
516 .config
517 .headers
518 .clone()
519 .unwrap_or_else(|| first_obj.keys().cloned().collect());
520 if self.config.has_headers {
521 let neutralized: Vec<String> = header_keys
522 .iter()
523 .map(|s| Self::neutralize_formula_injection(s))
524 .collect();
525 wtr.write_record(neutralized.iter().map(String::as_str))
526 .map_err(Self::write_err)?;
527 }
528 for row in arr {
529 if let Some(obj) = row.as_object() {
530 let record: Vec<String> = header_keys
531 .iter()
532 .map(|k| {
533 Self::neutralize_formula_injection(
534 &Self::field_to_string(
535 obj.get(k)
536 .unwrap_or(&serde_json::Value::Null),
537 ),
538 )
539 })
540 .collect();
541 wtr.write_record(record.iter().map(String::as_str))
542 .map_err(Self::write_err)?;
543 } else {
544 return Err(CamelError::TypeConversionFailed(format!(
545 "CsvDataFormat::marshal expected all array elements to be objects, got {:?}",
546 row
547 )));
548 }
549 }
550 }
551 _ => {
552 for row in arr {
553 if let Some(items) = row.as_array() {
554 let record: Vec<String> = items
555 .iter()
556 .map(|v| {
557 Self::neutralize_formula_injection(
558 &Self::field_to_string(v),
559 )
560 })
561 .collect();
562 wtr.write_record(record.iter().map(String::as_str))
563 .map_err(Self::write_err)?;
564 } else {
565 return Err(CamelError::TypeConversionFailed(format!(
566 "CsvDataFormat::marshal expected all array elements to be arrays, got {:?}",
567 row
568 )));
569 }
570 }
571 }
572 }
573 }
574 }
575 serde_json::Value::Object(obj) => {
576 let header_keys: Vec<String> = self
577 .config
578 .headers
579 .clone()
580 .unwrap_or_else(|| obj.keys().cloned().collect());
581 if self.config.has_headers {
582 let neutralized: Vec<String> = header_keys
583 .iter()
584 .map(|s| Self::neutralize_formula_injection(s))
585 .collect();
586 wtr.write_record(neutralized.iter().map(String::as_str))
587 .map_err(Self::write_err)?;
588 }
589 let record: Vec<String> = header_keys
590 .iter()
591 .map(|k| {
592 Self::neutralize_formula_injection(&Self::field_to_string(
593 obj.get(k).unwrap_or(&serde_json::Value::Null),
594 ))
595 })
596 .collect();
597 wtr.write_record(record.iter().map(String::as_str))
598 .map_err(Self::write_err)?;
599 }
600 _ => {
601 return Err(CamelError::TypeConversionFailed(format!(
602 "CsvDataFormat::marshal only supports Json Array or Object, got {json:?}"
603 )));
604 }
605 }
606 wtr.flush()
607 .map_err(|e| CamelError::TypeConversionFailed(format!("CSV flush: {e}")))?;
608 }
609
610 let text = String::from_utf8(output)
611 .map_err(|e| CamelError::TypeConversionFailed(format!("CSV output not UTF-8: {e}")))?;
612 Ok(Body::Text(text))
613 }
614
615 fn unmarshal(&self, body: Body) -> Result<Body, CamelError> {
616 self.unmarshal_internal(body).map(|(b, _)| b)
617 }
618
619 fn unmarshal_in_exchange(
620 &self,
621 exchange: &mut Exchange,
622 body: Body,
623 ) -> Result<Body, CamelError> {
624 let (result, header_keys) = self.unmarshal_internal(body)?;
625 if let Some(keys) = header_keys
626 .filter(|_| self.config.capture_header_record)
627 .filter(|k| !k.is_empty())
628 {
629 exchange.input.headers.insert(
630 CAMEL_CSV_HEADER_RECORD.to_string(),
631 serde_json::Value::Array(keys.into_iter().map(serde_json::Value::String).collect()),
632 );
633 }
634 Ok(result)
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use serde_json::json;
642
643 #[test]
644 fn test_default_config_values() {
645 let cfg = CsvConfig::default();
646 assert_eq!(cfg.delimiter, ',');
647 assert_eq!(cfg.quote, '"');
648 assert!(matches!(cfg.quote_mode, QuoteMode::Minimal));
649 assert!(cfg.double_quote);
650 assert!(cfg.escape.is_none());
651 assert!(matches!(cfg.record_separator, RecordSeparator::Crlf));
652 assert!(cfg.comment_marker.is_none());
653 assert!(cfg.null_string.is_none());
654 assert!(cfg.headers.is_none());
655 assert!(cfg.has_headers);
656 assert!(!cfg.skip_header_record);
657 assert!(!cfg.capture_header_record);
658 assert!(cfg.ignore_surrounding_spaces);
659 assert!(!cfg.trim);
660 assert!(cfg.use_maps);
661 }
662
663 #[test]
664 fn test_tdf_preset_uses_tab() {
665 let cfg = CsvConfig::tdf();
666 assert_eq!(cfg.delimiter, '\t');
667 assert!(matches!(cfg.record_separator, RecordSeparator::Lf));
668 }
669
670 #[test]
671 fn test_mysql_preset_uses_tab_and_escape() {
672 let cfg = CsvConfig::mysql();
673 assert_eq!(cfg.delimiter, '\t');
674 assert_eq!(cfg.escape, Some('\\'));
675 }
676
677 #[test]
678 fn test_builder_methods() {
679 let cfg = CsvConfig::default()
680 .delimiter('|')
681 .headers(vec!["a".into(), "b".into()])
682 .use_maps(false);
683 assert_eq!(cfg.delimiter, '|');
684 assert_eq!(cfg.headers, Some(vec!["a".into(), "b".into()]));
685 assert!(!cfg.use_maps);
686 }
687
688 #[test]
689 fn test_marshal_array_of_objects_with_header() {
690 let df = CsvDataFormat::default();
691 let body = Body::Json(json!([
692 { "name": "Alice", "age": 30 },
693 { "name": "Bob", "age": 25 }
694 ]));
695 let result = df.marshal(body).unwrap();
696 match result {
697 Body::Text(s) => {
698 let lines: Vec<&str> = s.lines().collect();
699 let header_fields: Vec<&str> = lines[0].split(',').collect();
702 assert_eq!(header_fields.len(), 2);
703 assert!(header_fields.contains(&"name"));
704 assert!(header_fields.contains(&"age"));
705 assert!(lines[1].contains("Alice"));
706 assert!(lines[2].contains("Bob"));
707 }
708 _ => panic!("expected Body::Text"),
709 }
710 }
711
712 #[test]
713 fn test_marshal_array_of_arrays_no_header() {
714 let df = CsvDataFormat::default();
715 let body = Body::Json(json!([["a", "b", "c"], [1, 2, 3]]));
716 let result = df.marshal(body).unwrap();
717 match result {
718 Body::Text(s) => {
719 let lines: Vec<&str> = s.lines().collect();
720 assert_eq!(lines.len(), 2);
721 assert!(lines[0].contains("a"));
722 }
723 _ => panic!("expected Body::Text"),
724 }
725 }
726
727 #[test]
728 fn test_marshal_single_object() {
729 let df = CsvDataFormat::default();
730 let body = Body::Json(json!({ "x": 1, "y": 2 }));
731 let result = df.marshal(body).unwrap();
732 match result {
733 Body::Text(s) => {
734 let lines: Vec<&str> = s.lines().collect();
735 assert_eq!(lines.len(), 2);
736 }
737 _ => panic!("expected Body::Text"),
738 }
739 }
740
741 #[test]
742 fn test_marshal_empty_array_with_headers() {
743 let cfg = CsvConfig::default().headers(vec!["h1".into(), "h2".into()]);
744 let df = CsvDataFormat::new(cfg);
745 let body = Body::Json(json!([]));
746 let result = df.marshal(body).unwrap();
747 match result {
748 Body::Text(s) => {
749 assert_eq!(s.trim(), "h1,h2");
750 }
751 _ => panic!("expected Body::Text"),
752 }
753 }
754
755 #[test]
756 fn test_marshal_empty_array_no_headers() {
757 let cfg = CsvConfig::default().has_headers(false);
758 let df = CsvDataFormat::new(cfg);
759 let body = Body::Json(json!([]));
760 let result = df.marshal(body).unwrap();
761 match result {
762 Body::Text(s) => assert!(s.is_empty()),
763 _ => panic!("expected Body::Text"),
764 }
765 }
766
767 #[test]
768 fn test_marshal_quoted_fields_with_commas() {
769 let df = CsvDataFormat::default();
770 let body = Body::Json(json!([
771 { "name": "Doe, John", "age": 30 }
772 ]));
773 let result = df.marshal(body).unwrap();
774 match result {
775 Body::Text(s) => assert!(s.contains("\"Doe, John\"")),
776 _ => panic!("expected Body::Text"),
777 }
778 }
779
780 #[test]
781 fn test_marshal_text_returns_error() {
782 let df = CsvDataFormat::default();
783 let result = df.marshal(Body::Text("already text".into()));
784 assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
785 }
786
787 #[test]
788 fn test_marshal_stream_returns_error() {
789 use bytes::Bytes;
790 use camel_api::body::{StreamBody, StreamMetadata};
791 let df = CsvDataFormat::default();
792 let empty_stream = futures::stream::empty::<Result<Bytes, CamelError>>();
793 let stream_body = StreamBody {
794 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(empty_stream)))),
795 metadata: StreamMetadata::default(),
796 };
797 let result = df.marshal(Body::Stream(stream_body));
798 assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
799 }
800
801 #[test]
802 fn test_marshal_quote_mode_all() {
803 let cfg = CsvConfig::default().quote_mode(QuoteMode::All);
804 let df = CsvDataFormat::new(cfg);
805 let body = Body::Json(json!([{ "x": 1 }]));
806 let result = df.marshal(body).unwrap();
807 match result {
808 Body::Text(s) => {
809 assert!(s.contains("\"x\""));
810 }
811 _ => panic!("expected Body::Text"),
812 }
813 }
814
815 #[test]
816 fn test_marshal_mysql_preset_quote_char_null() {
817 let cfg = CsvConfig::mysql();
818 let df = CsvDataFormat::new(cfg);
819 let body = Body::Json(json!([["a", "b"]]));
820 let result = df.marshal(body);
821 assert!(result.is_ok(), "mysql preset should marshal: {result:?}");
822 }
823
824 #[test]
825 fn test_unmarshal_text_to_json_maps() {
826 let df = CsvDataFormat::default();
827 let body = Body::Text("name,age\nAlice,30\nBob,25".into());
828 let result = df.unmarshal(body).unwrap();
829 match result {
830 Body::Json(v) => {
831 let arr = v.as_array().expect("expected array");
832 assert_eq!(arr.len(), 2);
833 assert_eq!(arr[0]["name"], json!("Alice"));
834 assert_eq!(arr[0]["age"], json!("30"));
835 }
836 _ => panic!("expected Body::Json"),
837 }
838 }
839
840 #[test]
841 fn test_unmarshal_text_to_json_lists() {
842 let cfg = CsvConfig::default().use_maps(false);
843 let df = CsvDataFormat::new(cfg);
844 let body = Body::Text("a,b,c\n1,2,3".into());
845 let result = df.unmarshal(body).unwrap();
846 match result {
847 Body::Json(v) => {
848 let arr = v.as_array().expect("expected array");
849 assert_eq!(arr.len(), 1);
850 assert_eq!(arr[0][0], json!("1"));
851 }
852 _ => panic!("expected Body::Json"),
853 }
854 }
855
856 #[test]
857 fn test_unmarshal_bytes_to_json() {
858 let df = CsvDataFormat::default();
859 let body = Body::Bytes(bytes::Bytes::from_static(b"x,y\n1,2"));
860 let result = df.unmarshal(body).unwrap();
861 match result {
862 Body::Json(v) => {
863 let arr = v.as_array().unwrap();
864 assert_eq!(arr[0]["x"], json!("1"));
865 }
866 _ => panic!("expected Body::Json"),
867 }
868 }
869
870 #[test]
871 fn test_unmarshal_configured_headers() {
872 let cfg = CsvConfig::default().headers(vec!["col1".into(), "col2".into()]);
873 let df = CsvDataFormat::new(cfg);
874 let body = Body::Text("val1,val2".into());
875 let result = df.unmarshal(body).unwrap();
876 match result {
877 Body::Json(v) => {
878 let arr = v.as_array().unwrap();
879 assert_eq!(arr[0]["col1"], json!("val1"));
880 }
881 _ => panic!("expected Body::Json"),
882 }
883 }
884
885 #[test]
886 fn test_unmarshal_skip_header_record_with_configured_headers() {
887 let cfg = CsvConfig::default()
888 .headers(vec!["col1".into(), "col2".into()])
889 .skip_header_record(true);
890 let df = CsvDataFormat::new(cfg);
891 let body = Body::Text("ignored1,ignored2\nval1,val2".into());
892 let result = df.unmarshal(body).unwrap();
893 match result {
894 Body::Json(v) => {
895 let arr = v.as_array().unwrap();
896 assert_eq!(arr.len(), 1);
897 assert_eq!(arr[0]["col1"], json!("val1"));
898 }
899 _ => panic!("expected Body::Json"),
900 }
901 }
902
903 #[test]
904 fn test_unmarshal_quoted_fields() {
905 let df = CsvDataFormat::default();
906 let body = Body::Text("name,note\n\"Doe, John\",hi".into());
907 let result = df.unmarshal(body).unwrap();
908 match result {
909 Body::Json(v) => {
910 let arr = v.as_array().unwrap();
911 assert_eq!(arr[0]["name"], json!("Doe, John"));
912 }
913 _ => panic!("expected Body::Json"),
914 }
915 }
916
917 #[test]
918 fn test_unmarshal_delimiter_tdf() {
919 let cfg = CsvConfig::tdf();
920 let df = CsvDataFormat::new(cfg);
921 let body = Body::Text("a\tb\n1\t2".into());
922 let result = df.unmarshal(body).unwrap();
923 match result {
924 Body::Json(v) => {
925 let arr = v.as_array().unwrap();
926 assert_eq!(arr[0]["a"], json!("1"));
927 }
928 _ => panic!("expected Body::Json"),
929 }
930 }
931
932 #[test]
933 fn test_unmarshal_comment_marker() {
934 let cfg = CsvConfig::default().comment_marker(Some('#'));
935 let df = CsvDataFormat::new(cfg);
936 let body = Body::Text("# this is a comment\na,b\n1,2".into());
937 let result = df.unmarshal(body).unwrap();
938 match result {
939 Body::Json(v) => {
940 let arr = v.as_array().unwrap();
941 assert_eq!(arr.len(), 1);
942 }
943 _ => panic!("expected Body::Json"),
944 }
945 }
946
947 #[test]
948 fn test_unmarshal_empty_returns_error() {
949 let df = CsvDataFormat::default();
950 let result = df.unmarshal(Body::Empty);
951 assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
952 }
953
954 #[test]
955 fn test_unmarshal_stream_returns_error() {
956 use bytes::Bytes;
957 use camel_api::body::{StreamBody, StreamMetadata};
958 let df = CsvDataFormat::default();
959 let empty_stream = futures::stream::empty::<Result<Bytes, CamelError>>();
960 let stream_body = StreamBody {
961 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(empty_stream)))),
962 metadata: StreamMetadata::default(),
963 };
964 let result = df.unmarshal(Body::Stream(stream_body));
965 let msg = match result {
966 Err(CamelError::TypeConversionFailed(m)) => m,
967 _ => panic!("expected TypeConversionFailed"),
968 };
969 assert!(msg.contains("stream_cache"));
970 }
971
972 #[test]
973 fn test_unmarshal_no_header_map_mode_error() {
974 let cfg = CsvConfig::default().has_headers(false);
975 let df = CsvDataFormat::new(cfg);
976 let body = Body::Text("1,2,3".into());
977 let result = df.unmarshal(body);
978 let msg = match result {
979 Err(CamelError::TypeConversionFailed(m)) => m,
980 _ => panic!("expected TypeConversionFailed"),
981 };
982 assert!(msg.contains("has_headers") || msg.contains("headers"));
983 }
984
985 #[test]
986 fn test_unmarshal_json_identity() {
987 let df = CsvDataFormat::default();
988 let input = json!([{"x": 1}]);
989 let body = Body::Json(input.clone());
990 let result = df.unmarshal(body).unwrap();
991 assert!(matches!(result, Body::Json(_)));
992 }
993
994 #[test]
995 fn test_unmarshal_null_string() {
996 let cfg = CsvConfig::default().null_string(Some("NULL".into()));
997 let df = CsvDataFormat::new(cfg);
998 let body = Body::Text("a,b\n1,NULL".into());
999 let result = df.unmarshal(body).unwrap();
1000 match result {
1001 Body::Json(v) => {
1002 let arr = v.as_array().unwrap();
1003 assert_eq!(arr[0]["b"], serde_json::Value::Null);
1004 }
1005 _ => panic!("expected Body::Json"),
1006 }
1007 }
1008
1009 #[test]
1010 fn test_marshal_roundtrip() {
1011 let df = CsvDataFormat::default();
1012 let original = "name,age\nAlice,30\nBob,25";
1013 let json = df.unmarshal(Body::Text(original.into())).unwrap();
1014 let back = df.marshal(json).unwrap();
1015 match back {
1016 Body::Text(s) => {
1017 assert!(s.contains("Alice"));
1018 assert!(s.contains("name"));
1019 }
1020 _ => panic!("expected Body::Text"),
1021 }
1022 }
1023
1024 #[test]
1025 fn test_unmarshal_capture_header_maps_mode() {
1026 let cfg = CsvConfig::default().capture_header_record(true);
1027 let df = CsvDataFormat::new(cfg);
1028 let body = Body::Text("name,age\nAlice,30".into());
1029
1030 let mut ex = Exchange::default();
1031 let result = df.unmarshal_in_exchange(&mut ex, body).unwrap();
1032 assert!(matches!(result, Body::Json(_)));
1033
1034 let captured = ex.input.headers.get(CAMEL_CSV_HEADER_RECORD);
1035 assert_eq!(captured, Some(&serde_json::json!(["name", "age"])));
1036 }
1037
1038 #[test]
1039 fn test_unmarshal_capture_header_lists_mode() {
1040 let cfg = CsvConfig::default()
1041 .capture_header_record(true)
1042 .use_maps(false);
1043 let df = CsvDataFormat::new(cfg);
1044 let body = Body::Text("a,b\n1,2".into());
1045
1046 let mut ex = Exchange::default();
1047 let result = df.unmarshal_in_exchange(&mut ex, body).unwrap();
1048 assert!(matches!(result, Body::Json(_)));
1049
1050 let captured = ex.input.headers.get(CAMEL_CSV_HEADER_RECORD);
1051 assert_eq!(captured, Some(&serde_json::json!(["a", "b"])));
1052 }
1053
1054 #[test]
1055 fn test_unmarshal_capture_header_configured_headers() {
1056 let cfg = CsvConfig::default()
1057 .capture_header_record(true)
1058 .headers(vec!["col1".into(), "col2".into()])
1059 .skip_header_record(true);
1060 let df = CsvDataFormat::new(cfg);
1061 let body = Body::Text("ignored1,ignored2\nval1,val2".into());
1062
1063 let mut ex = Exchange::default();
1064 let _ = df.unmarshal_in_exchange(&mut ex, body).unwrap();
1065
1066 let captured = ex.input.headers.get(CAMEL_CSV_HEADER_RECORD);
1067 assert_eq!(captured, Some(&serde_json::json!(["col1", "col2"])));
1068 }
1069
1070 #[test]
1071 fn test_unmarshal_no_capture_default() {
1072 let df = CsvDataFormat::default();
1073 let body = Body::Text("a,b\n1,2".into());
1074
1075 let mut ex = Exchange::default();
1076 let _ = df.unmarshal_in_exchange(&mut ex, body).unwrap();
1077
1078 assert!(!ex.input.headers.contains_key(CAMEL_CSV_HEADER_RECORD));
1079 }
1080
1081 #[test]
1082 fn test_marshal_mixed_array_objects_returns_error() {
1083 let df = CsvDataFormat::default();
1084 let body = Body::Json(json!([
1085 { "name": "Alice" },
1086 "scalar string",
1087 { "name": "Bob" }
1088 ]));
1089 let result = df.marshal(body);
1090 assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
1091 }
1092
1093 #[test]
1094 fn test_marshal_mixed_array_lists_returns_error() {
1095 let cfg = CsvConfig::default().use_maps(false);
1096 let df = CsvDataFormat::new(cfg);
1097 let body = Body::Json(json!([
1098 ["a", "b"],
1099 { "not": "array" },
1100 ["c", "d"]
1101 ]));
1102 let result = df.marshal(body);
1103 assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
1104 }
1105
1106 #[test]
1107 fn test_unmarshal_max_records_exceeded() {
1108 let cfg = CsvConfig::default().max_records(3);
1109 let df = CsvDataFormat::new(cfg);
1110 let body = Body::Text("a,b\n1,2\n3,4\n5,6\n7,8".into());
1111 let result = df.unmarshal(body);
1112 let msg = match result {
1113 Err(CamelError::TypeConversionFailed(m)) => m,
1114 other => panic!("expected TypeConversionFailed, got {other:?}"),
1115 };
1116 assert!(
1117 msg.contains("max_records"),
1118 "msg should mention max_records: {msg}"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_unmarshal_max_records_exceeded_lists_mode() {
1124 let cfg = CsvConfig::default().use_maps(false).max_records(2);
1125 let df = CsvDataFormat::new(cfg);
1126 let body = Body::Text("a,b,c\n1,2,3\n4,5,6\n7,8,9".into());
1128 let result = df.unmarshal(body);
1129 let msg = match result {
1130 Err(CamelError::TypeConversionFailed(m)) => m,
1131 other => panic!("expected TypeConversionFailed, got {other:?}"),
1132 };
1133 assert!(
1134 msg.contains("max_records"),
1135 "msg should mention max_records: {msg}"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_unmarshal_max_field_size_exceeded() {
1141 let cfg = CsvConfig::default().max_field_size(4);
1142 let df = CsvDataFormat::new(cfg);
1143 let body = Body::Text("a,b\n12345,1".into());
1144 let result = df.unmarshal(body);
1145 let msg = match result {
1146 Err(CamelError::TypeConversionFailed(m)) => m,
1147 other => panic!("expected TypeConversionFailed, got {other:?}"),
1148 };
1149 assert!(
1150 msg.contains("max_field_size"),
1151 "msg should mention max_field_size: {msg}"
1152 );
1153 }
1154
1155 #[test]
1156 fn test_unmarshal_default_caps_accept_normal_input() {
1157 let df = CsvDataFormat::default();
1159 let body = Body::Text("a,b\n1,2\n3,4".into());
1160 let result = df.unmarshal(body).unwrap();
1161 assert!(matches!(result, Body::Json(_)));
1162 }
1163
1164 #[test]
1165 fn marshal_neutralizes_formula_injection_cells() {
1166 let df = CsvDataFormat::default();
1167 let body = Body::Json(json!([
1168 { "name": "=SUM(1,1)", "note": "+danger", "val": "-1", "at": "@foo", "tab": "\t=cmd", "cr": "\r=cmd" }
1169 ]));
1170 let result = df.marshal(body).unwrap();
1171 match result {
1172 Body::Text(s) => {
1173 assert!(
1175 s.contains("'=SUM(1,1)"),
1176 "cell starting with = should be neutralized: {s:?}"
1177 );
1178 assert!(
1179 s.contains("'+danger"),
1180 "cell starting with + should be neutralized: {s:?}"
1181 );
1182 assert!(
1183 s.contains("'-1"),
1184 "cell starting with - should be neutralized: {s:?}"
1185 );
1186 assert!(
1187 s.contains("'@foo"),
1188 "cell starting with @ should be neutralized: {s:?}"
1189 );
1190 assert!(
1191 s.contains("'\t=cmd"),
1192 "cell starting with tab should be neutralized: {s:?}"
1193 );
1194 assert!(
1195 s.contains("'\r=cmd"),
1196 "cell starting with CR should be neutralized: {s:?}"
1197 );
1198 }
1199 _ => panic!("expected Body::Text"),
1200 }
1201 }
1202
1203 #[test]
1204 fn test_unmarshal_width_mismatch_does_not_error() {
1205 let df = CsvDataFormat::default();
1207 let body = Body::Text("a,b,c\n1,2".into());
1208 let result = df.unmarshal(body).unwrap();
1209 match result {
1210 Body::Json(serde_json::Value::Array(rows)) => {
1211 assert_eq!(rows.len(), 1);
1212 assert_eq!(rows[0]["a"], json!("1"));
1213 assert_eq!(rows[0]["b"], json!("2"));
1214 assert_eq!(rows[0]["c"], json!(""));
1215 }
1216 other => panic!("expected JSON array, got {other:?}"),
1217 }
1218 }
1219
1220 #[test]
1221 fn test_csv_config_deserialize_from_json() {
1222 let json = serde_json::json!({
1223 "delimiter": "|",
1224 "quote_mode": "non_numeric",
1225 "record_separator": "lf",
1226 "max_records": 5000000
1227 });
1228 let cfg: CsvConfig = serde_json::from_value(json).unwrap();
1229 assert_eq!(cfg.delimiter, '|');
1230 assert!(matches!(cfg.quote_mode, QuoteMode::NonNumeric));
1231 assert!(matches!(cfg.record_separator, RecordSeparator::Lf));
1232 assert_eq!(cfg.max_records, Some(5000000));
1233 }
1234
1235 #[test]
1236 fn test_csv_config_deny_unknown_fields() {
1237 let json = serde_json::json!({"unknown_key": 42});
1238 let result: Result<CsvConfig, _> = serde_json::from_value(json);
1239 assert!(result.is_err(), "unknown field should fail closed");
1240 }
1241
1242 #[test]
1243 fn csv_width_default_is_warn() {
1244 assert_eq!(
1245 CsvConfig::default().width_mismatch,
1246 WidthMismatchPolicy::Warn
1247 );
1248 }
1249
1250 #[test]
1251 fn csv_width_warn_default_aggregate_single_log() {
1252 let df = CsvDataFormat::default();
1255 let body = Body::Text("a,b\n1,2,3\n4".into());
1256 let result = df.unmarshal(body).unwrap();
1257 match result {
1258 Body::Json(serde_json::Value::Array(rows)) => {
1259 assert_eq!(rows.len(), 2);
1260 assert_eq!(rows[0]["a"], json!("1"));
1262 assert_eq!(rows[0]["b"], json!("2"));
1263 assert_eq!(rows[1]["a"], json!("4"));
1265 assert_eq!(rows[1]["b"], json!(""));
1266 }
1267 other => panic!("expected JSON array, got {other:?}"),
1268 }
1269 }
1270
1271 #[test]
1272 fn csv_width_error_returns_err() {
1273 let cfg = CsvConfig::default().width_mismatch(WidthMismatchPolicy::Error);
1274 let df = CsvDataFormat::new(cfg);
1275 let body = Body::Text("a,b\n1,2,3".into());
1276 let result = df.unmarshal(body);
1277 let msg = match result {
1278 Err(CamelError::TypeConversionFailed(m)) => m,
1279 other => panic!("expected TypeConversionFailed, got {other:?}"),
1280 };
1281 assert!(
1282 msg.contains("width") && msg.contains("differs"),
1283 "error should mention width mismatch: {msg}"
1284 );
1285 }
1286
1287 #[test]
1288 fn csv_width_lenient_no_warn() {
1289 let cfg = CsvConfig::default().width_mismatch(WidthMismatchPolicy::Lenient);
1290 let df = CsvDataFormat::new(cfg);
1291 let body = Body::Text("a,b\n1,2,3\n4".into());
1292 let result = df.unmarshal(body).unwrap();
1293 match result {
1294 Body::Json(serde_json::Value::Array(rows)) => {
1295 assert_eq!(rows.len(), 2);
1296 assert_eq!(rows[0]["a"], json!("1"));
1297 assert_eq!(rows[1]["a"], json!("4"));
1298 }
1299 other => panic!("expected JSON array, got {other:?}"),
1300 }
1301 }
1302
1303 #[test]
1304 fn csv_width_unknown_enum_rejected() {
1305 let json = serde_json::json!({"width_mismatch": "invalid"});
1306 let result: Result<CsvConfig, _> = serde_json::from_value(json);
1307 assert!(
1308 result.is_err(),
1309 "invalid enum variant should fail deserialization"
1310 );
1311 }
1312}