1use crate::error::FaucetError;
50use schemars::JsonSchema;
51use serde::{Deserialize, Serialize};
52use serde_json::Value;
53
54#[cfg(feature = "file-format-avro")]
55pub mod avro;
56pub mod container;
57#[cfg(feature = "file-format-csv")]
58pub mod csv;
59#[cfg(feature = "file-format-excel")]
60pub mod excel;
61pub mod json;
62#[cfg(feature = "file-format-orc")]
63pub mod orc;
64pub mod parquet_io;
65#[cfg(feature = "file-format-xml")]
66pub mod xml;
67
68pub use container::{ContainerDecoder, FileInput};
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
76#[serde(rename_all = "snake_case")]
77pub enum FileFormat {
78 #[default]
81 JsonLines,
82 JsonArray,
84 Csv,
86 Xml,
88 Xlsx,
90 Parquet,
93 RawText,
95 Avro,
98 Orc,
101}
102
103impl FileFormat {
104 pub fn extension(self) -> &'static str {
107 match self {
108 Self::JsonLines => ".jsonl",
109 Self::JsonArray => ".json",
110 Self::Csv => ".csv",
111 Self::Xml => ".xml",
112 Self::Xlsx => ".xlsx",
113 Self::Parquet => ".parquet",
114 Self::RawText => ".txt",
115 Self::Avro => ".avro",
116 Self::Orc => ".orc",
117 }
118 }
119
120 pub fn requires_whole_object(self) -> bool {
127 matches!(self, Self::JsonArray | Self::Xml | Self::Xlsx | Self::Orc)
128 }
129
130 pub fn is_container(self) -> bool {
133 matches!(self, Self::Avro | Self::Orc)
134 }
135
136 pub fn is_writable(self) -> bool {
138 !matches!(self, Self::Orc | Self::Parquet)
139 }
140
141 pub fn from_path(path: &str) -> Option<Self> {
149 let name = path
150 .rsplit(['/', '\\'])
151 .next()
152 .unwrap_or(path)
153 .to_ascii_lowercase();
154 let stem = ["gz", "gzip", "zst", "zstd"]
155 .iter()
156 .find_map(|c| name.strip_suffix(&format!(".{c}")))
157 .unwrap_or(&name);
158 let ext = stem.rsplit_once('.')?.1;
159 Some(match ext {
160 "jsonl" | "ndjson" => Self::JsonLines,
161 "json" => Self::JsonArray,
162 "csv" => Self::Csv,
163 "xml" => Self::Xml,
164 "xlsx" => Self::Xlsx,
165 "parquet" => Self::Parquet,
166 "txt" => Self::RawText,
167 "avro" => Self::Avro,
168 "orc" => Self::Orc,
169 _ => return None,
170 })
171 }
172
173 pub fn as_str(self) -> &'static str {
175 match self {
176 Self::JsonLines => "json_lines",
177 Self::JsonArray => "json_array",
178 Self::Csv => "csv",
179 Self::Xml => "xml",
180 Self::Xlsx => "xlsx",
181 Self::Parquet => "parquet",
182 Self::RawText => "raw_text",
183 Self::Avro => "avro",
184 Self::Orc => "orc",
185 }
186 }
187}
188
189fn default_true() -> bool {
190 true
191}
192
193fn default_delimiter() -> String {
194 ",".into()
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
199#[serde(deny_unknown_fields)]
200#[schemars(extend("x-faucet-aliases" = ["write_headers"]))]
201pub struct CsvOptions {
202 #[serde(default = "default_delimiter")]
204 pub delimiter: String,
205 #[serde(default = "default_true", alias = "write_headers")]
210 pub has_headers: bool,
211 #[serde(default = "default_quote")]
213 pub quote: String,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub flexible: Option<bool>,
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub null_values: Vec<String>,
224 #[serde(default)]
228 pub on_unknown_field: CsvUnknownField,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
233#[serde(rename_all = "snake_case")]
234pub enum CsvUnknownField {
235 #[default]
237 Widen,
238 Warn,
241 Error,
243}
244
245fn default_quote() -> String {
246 "\"".into()
247}
248
249impl Default for CsvOptions {
250 fn default() -> Self {
251 Self {
252 delimiter: default_delimiter(),
253 has_headers: true,
254 quote: default_quote(),
255 flexible: None,
256 null_values: Vec::new(),
257 on_unknown_field: CsvUnknownField::Widen,
258 }
259 }
260}
261
262impl CsvOptions {
263 pub fn delimiter_byte(&self) -> Result<u8, FaucetError> {
269 single_byte("delimiter", &self.delimiter)
270 }
271
272 pub fn quote_byte(&self) -> Result<u8, FaucetError> {
274 single_byte("quote", &self.quote)
275 }
276
277 pub fn flexible_or(&self, default: bool) -> bool {
279 self.flexible.unwrap_or(default)
280 }
281
282 pub fn validate(&self) -> Result<(), FaucetError> {
284 self.delimiter_byte()?;
285 self.quote_byte()?;
286 Ok(())
287 }
288}
289
290fn single_byte(name: &str, value: &str) -> Result<u8, FaucetError> {
291 let d = match value {
292 "\\t" => "\t",
293 other => other,
294 };
295 match d.as_bytes() {
296 [b] => Ok(*b),
297 _ => Err(FaucetError::Config(format!(
298 "csv.{name} must be exactly one byte, got {value:?}"
299 ))),
300 }
301}
302
303#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305#[serde(deny_unknown_fields)]
306pub struct ExcelOptions {
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub sheet: Option<String>,
310 #[serde(default)]
312 pub header_row: usize,
313}
314
315fn default_record_element() -> String {
316 "record".into()
317}
318
319fn default_root_element() -> String {
320 "records".into()
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
331#[serde(deny_unknown_fields)]
332pub struct XmlOptions {
333 #[serde(default = "default_record_element")]
336 pub record_element: String,
337 #[serde(default = "default_root_element")]
339 pub root_element: String,
340}
341
342impl Default for XmlOptions {
343 fn default() -> Self {
344 Self {
345 record_element: default_record_element(),
346 root_element: default_root_element(),
347 }
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
353#[serde(rename_all = "snake_case")]
354pub enum AvroCodec {
355 #[default]
357 Null,
358 Deflate,
360 Snappy,
362 Zstd,
364}
365
366#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
368#[serde(deny_unknown_fields)]
369pub struct AvroOptions {
370 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub schema: Option<Value>,
377 #[serde(default)]
380 pub codec: AvroCodec,
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
385#[serde(deny_unknown_fields)]
386pub struct OrcOptions {
387 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub columns: Option<Vec<String>>,
391}
392
393#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
400pub struct FormatOptions {
401 #[serde(default)]
403 pub csv: CsvOptions,
404 #[serde(default)]
406 pub excel: ExcelOptions,
407 #[serde(default)]
409 pub xml: XmlOptions,
410 #[serde(default)]
412 pub avro: AvroOptions,
413 #[serde(default)]
415 pub orc: OrcOptions,
416}
417
418pub async fn decode(
424 bytes: &[u8],
425 format: FileFormat,
426 opts: &FormatOptions,
427) -> Result<Vec<Value>, FaucetError> {
428 match format {
429 FileFormat::JsonLines => json::decode_lines(bytes),
430 FileFormat::JsonArray => json::decode_array(bytes),
431 FileFormat::RawText => json::decode_raw_text(bytes),
432 FileFormat::Csv => decode_csv(bytes, opts).await,
433 FileFormat::Xml => decode_xml(bytes, opts),
434 FileFormat::Xlsx => decode_xlsx(bytes, opts),
435 FileFormat::Parquet => Err(columnar_refusal("decode")),
436 FileFormat::Avro => decode_avro(bytes, opts),
437 FileFormat::Orc => decode_orc(bytes, opts),
438 }
439}
440
441pub async fn decode_owned(
448 bytes: Vec<u8>,
449 format: FileFormat,
450 opts: &FormatOptions,
451) -> Result<Vec<Value>, FaucetError> {
452 let opts = opts.clone();
453 tokio::task::spawn_blocking(move || match format {
454 #[cfg(feature = "file-format-orc")]
455 FileFormat::Orc => orc::decode_owned(bytes, &opts.orc),
456 other => futures::executor::block_on(decode(&bytes, other, &opts)),
457 })
458 .await
459 .map_err(|e| FaucetError::Source(format!("file_format: decode task failed: {e}")))?
460}
461
462pub fn encode(
464 records: &[Value],
465 format: FileFormat,
466 opts: &FormatOptions,
467) -> Result<Vec<u8>, FaucetError> {
468 match format {
469 FileFormat::JsonLines => json::encode_lines(records),
470 FileFormat::JsonArray => json::encode_array(records),
471 FileFormat::RawText => json::encode_raw_text(records),
472 FileFormat::Csv => encode_csv(records, opts),
473 FileFormat::Xml => encode_xml(records, opts),
474 FileFormat::Xlsx => encode_xlsx(records, opts),
475 FileFormat::Parquet => Err(columnar_refusal("encode")),
476 FileFormat::Avro => encode_avro(records, opts),
477 FileFormat::Orc => Err(FaucetError::Config(
478 "`format: orc` is read-only — there is no ORC writer; write Parquet for a columnar \
479 output"
480 .into(),
481 )),
482 }
483}
484
485fn columnar_refusal(verb: &str) -> FaucetError {
490 FaucetError::Config(format!(
491 "file_format::{verb}: `parquet` is columnar and is handled by each connector's Arrow \
492 path, not the generic record helper"
493 ))
494}
495
496pub const DEFAULT_MAX_OBJECT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
499
500pub async fn read_to_end_capped<R>(reader: R, max: u64, what: &str) -> Result<Vec<u8>, FaucetError>
504where
505 R: tokio::io::AsyncRead + Unpin,
506{
507 use tokio::io::AsyncReadExt as _;
508 let mut buf = Vec::new();
509 reader
510 .take(max.saturating_add(1))
511 .read_to_end(&mut buf)
512 .await
513 .map_err(|e| FaucetError::Source(format!("read error for '{what}': {e}")))?;
514 check_object_size(buf.len() as u64, max, what)?;
515 Ok(buf)
516}
517
518pub async fn read_to_string_capped<R>(
520 reader: R,
521 max: u64,
522 what: &str,
523) -> Result<String, FaucetError>
524where
525 R: tokio::io::AsyncRead + Unpin,
526{
527 let bytes = read_to_end_capped(reader, max, what).await?;
528 String::from_utf8(bytes)
529 .map_err(|e| FaucetError::Source(format!("'{what}' is not valid UTF-8: {e}")))
530}
531
532pub fn check_object_size(len: u64, max: u64, what: &str) -> Result<(), FaucetError> {
534 if len > max {
535 return Err(FaucetError::Source(format!(
536 "'{what}' is larger than `max_object_bytes` ({max} bytes) once decompressed; \
537 raise `max_object_bytes` to read it"
538 )));
539 }
540 Ok(())
541}
542
543#[cfg_attr(
548 all(
549 feature = "file-format-csv",
550 feature = "file-format-xml",
551 feature = "file-format-excel",
552 feature = "file-format-avro",
553 feature = "file-format-orc"
554 ),
555 allow(dead_code)
556)]
557fn missing_feature(format: FileFormat, feature: &str) -> FaucetError {
558 FaucetError::Config(format!(
559 "`format: {}` requires the `{feature}` build feature — rebuild with \
560 `--features {feature}`",
561 format.as_str()
562 ))
563}
564
565#[cfg(feature = "file-format-csv")]
566async fn decode_csv(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
567 csv::decode_with(bytes, &opts.csv, true).await
568}
569
570#[cfg(not(feature = "file-format-csv"))]
571async fn decode_csv(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
572 Err(missing_feature(FileFormat::Csv, "file-format-csv"))
573}
574
575#[cfg(feature = "file-format-csv")]
576fn encode_csv(records: &[Value], opts: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
577 csv::encode_with(records, &opts.csv)
578}
579
580#[cfg(not(feature = "file-format-csv"))]
581fn encode_csv(_: &[Value], _: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
582 Err(missing_feature(FileFormat::Csv, "file-format-csv"))
583}
584
585#[cfg(feature = "file-format-xml")]
586fn decode_xml(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
587 xml::decode(bytes, &opts.xml.record_element)
588}
589
590#[cfg(not(feature = "file-format-xml"))]
591fn decode_xml(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
592 Err(missing_feature(FileFormat::Xml, "file-format-xml"))
593}
594
595#[cfg(feature = "file-format-xml")]
596fn encode_xml(records: &[Value], opts: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
597 xml::encode(records, &opts.xml.root_element, &opts.xml.record_element)
598}
599
600#[cfg(not(feature = "file-format-xml"))]
601fn encode_xml(_: &[Value], _: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
602 Err(missing_feature(FileFormat::Xml, "file-format-xml"))
603}
604
605#[cfg(feature = "file-format-excel")]
606fn decode_xlsx(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
607 excel::decode(bytes, opts.excel.sheet.as_deref(), opts.excel.header_row)
608}
609
610#[cfg(not(feature = "file-format-excel"))]
611fn decode_xlsx(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
612 Err(missing_feature(FileFormat::Xlsx, "file-format-excel"))
613}
614
615#[cfg(feature = "file-format-excel")]
616fn encode_xlsx(records: &[Value], opts: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
617 excel::encode(records, opts.excel.sheet.as_deref())
618}
619
620#[cfg(not(feature = "file-format-excel"))]
621fn encode_xlsx(_: &[Value], _: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
622 Err(missing_feature(FileFormat::Xlsx, "file-format-excel"))
623}
624
625#[cfg(feature = "file-format-avro")]
626fn decode_avro(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
627 avro::decode(bytes, &opts.avro)
628}
629
630#[cfg(not(feature = "file-format-avro"))]
631fn decode_avro(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
632 Err(missing_feature(FileFormat::Avro, "file-format-avro"))
633}
634
635#[cfg(feature = "file-format-avro")]
636fn encode_avro(records: &[Value], opts: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
637 avro::encode(records, &opts.avro)
638}
639
640#[cfg(not(feature = "file-format-avro"))]
641fn encode_avro(_: &[Value], _: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
642 Err(missing_feature(FileFormat::Avro, "file-format-avro"))
643}
644
645#[cfg(feature = "file-format-orc")]
646fn decode_orc(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
647 orc::decode(bytes, &opts.orc)
648}
649
650#[cfg(not(feature = "file-format-orc"))]
651fn decode_orc(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
652 Err(missing_feature(FileFormat::Orc, "file-format-orc"))
653}
654
655pub fn header_union(records: &[Value]) -> Vec<String> {
666 let mut seen = std::collections::HashSet::new();
667 let mut out = Vec::new();
668 for r in records {
669 if let Value::Object(map) = r {
670 for k in map.keys() {
671 if seen.insert(k.clone()) {
672 out.push(k.clone());
673 }
674 }
675 }
676 }
677 out
678}
679
680pub fn cell_text(v: &Value) -> String {
686 match v {
687 Value::Null => String::new(),
688 Value::String(s) => s.clone(),
689 Value::Bool(b) => b.to_string(),
690 Value::Number(n) => n.to_string(),
691 other => crate::util::canonical_json(other),
692 }
693}
694
695#[cfg(test)]
696mod capped_read_tests {
697 use super::*;
698
699 #[tokio::test]
700 async fn a_body_past_the_cap_fails_and_one_at_the_cap_reads() {
701 let body = [b'x'; 64];
702 let ok = read_to_end_capped(&body[..], 64, "f").await.unwrap();
703 assert_eq!(ok.len(), 64);
704 let err = read_to_end_capped(&body[..], 63, "big.json.gz")
705 .await
706 .unwrap_err();
707 assert!(err.to_string().contains("big.json.gz"), "{err}");
708 assert!(err.to_string().contains("max_object_bytes"), "{err}");
709 assert_eq!(
710 read_to_string_capped(&b"hi"[..], 8, "t").await.unwrap(),
711 "hi"
712 );
713 assert!(
714 read_to_string_capped(&[0xff, 0xfe][..], 8, "t")
715 .await
716 .is_err()
717 );
718 assert!(check_object_size(5, 4, "x").is_err());
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use serde_json::json;
726
727 #[tokio::test]
728 async fn decode_owned_matches_decode_and_runs_off_the_runtime() {
729 let body = b"[{\"a\":1},{\"a\":2}]".to_vec();
730 let opts = FormatOptions::default();
731 assert_eq!(
732 decode_owned(body.clone(), FileFormat::JsonArray, &opts)
733 .await
734 .unwrap(),
735 decode(&body, FileFormat::JsonArray, &opts).await.unwrap()
736 );
737 assert!(
738 decode_owned(Vec::new(), FileFormat::Parquet, &opts)
739 .await
740 .is_err()
741 );
742 }
743
744 #[cfg(feature = "file-format-orc")]
745 #[tokio::test]
746 async fn decode_owned_reads_orc_in_place() {
747 const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/orc/people.orc");
748 let opts = FormatOptions::default();
749 assert_eq!(
750 decode_owned(FIXTURE.to_vec(), FileFormat::Orc, &opts)
751 .await
752 .unwrap(),
753 decode(FIXTURE, FileFormat::Orc, &opts).await.unwrap()
754 );
755 }
756
757 #[test]
761 fn every_variant_has_an_extension_and_a_wire_name() {
762 let all = [
763 (FileFormat::JsonLines, ".jsonl", "json_lines"),
764 (FileFormat::JsonArray, ".json", "json_array"),
765 (FileFormat::Csv, ".csv", "csv"),
766 (FileFormat::Xml, ".xml", "xml"),
767 (FileFormat::Xlsx, ".xlsx", "xlsx"),
768 (FileFormat::Parquet, ".parquet", "parquet"),
769 (FileFormat::RawText, ".txt", "raw_text"),
770 (FileFormat::Avro, ".avro", "avro"),
771 (FileFormat::Orc, ".orc", "orc"),
772 ];
773 for (f, ext, name) in all {
774 assert_eq!(f.extension(), ext, "{f:?}");
775 assert_eq!(f.as_str(), name, "{f:?}");
776 }
777 }
778
779 #[tokio::test]
784 async fn raw_text_round_trips_through_the_top_level_dispatch() {
785 let opts = FormatOptions::default();
786 let recs = decode(b"hello", FileFormat::RawText, &opts)
787 .await
788 .expect("decode");
789 assert_eq!(recs, vec![json!({"text": "hello"})]);
790 let bytes = encode(&recs, FileFormat::RawText, &opts).expect("encode");
791 assert_eq!(bytes, b"hello\n");
792 }
793
794 #[test]
798 fn a_missing_feature_is_named_not_guessed() {
799 let err = missing_feature(FileFormat::Xlsx, "file-format-excel");
800 let msg = err.to_string();
801 assert!(msg.contains("xlsx"), "{msg}");
802 assert!(msg.contains("file-format-excel"), "{msg}");
803 }
804
805 #[test]
806 fn extensions_and_names_are_stable() {
807 assert_eq!(FileFormat::default(), FileFormat::JsonLines);
808 assert_eq!(FileFormat::Csv.extension(), ".csv");
809 assert_eq!(FileFormat::Xlsx.as_str(), "xlsx");
810 }
811
812 #[test]
813 fn whole_object_formats_are_the_ones_without_a_record_boundary() {
814 for f in [
815 FileFormat::JsonArray,
816 FileFormat::Xml,
817 FileFormat::Xlsx,
818 FileFormat::Orc,
819 ] {
820 assert!(f.requires_whole_object(), "{f:?}");
821 }
822 for f in [
823 FileFormat::JsonLines,
824 FileFormat::Csv,
825 FileFormat::RawText,
826 FileFormat::Parquet,
827 FileFormat::Avro,
828 ] {
829 assert!(!f.requires_whole_object(), "{f:?}");
830 }
831 }
832
833 #[test]
834 fn extensions_resolve_through_compression_suffixes() {
835 let cases = [
836 ("a.jsonl", Some(FileFormat::JsonLines)),
837 ("dir/b.NDJSON", Some(FileFormat::JsonLines)),
838 ("c.json.gz", Some(FileFormat::JsonArray)),
839 ("d.csv.gz", Some(FileFormat::Csv)),
840 ("e.xml.zst", Some(FileFormat::Xml)),
841 ("f.xlsx", Some(FileFormat::Xlsx)),
842 ("g.parquet", Some(FileFormat::Parquet)),
843 ("h.txt", Some(FileFormat::RawText)),
844 ("i.avro", Some(FileFormat::Avro)),
845 ("j.orc", Some(FileFormat::Orc)),
846 ("k.csv.gzip", Some(FileFormat::Csv)),
847 ("l.jsonl.zstd", Some(FileFormat::JsonLines)),
848 ("m.tsv", None),
849 ("noext", None),
850 ("n.gz", None),
851 ];
852 for (path, want) in cases {
853 assert_eq!(FileFormat::from_path(path), want, "{path}");
854 }
855 }
856
857 #[test]
858 fn container_and_writable_classification() {
859 assert!(FileFormat::Avro.is_container() && FileFormat::Orc.is_container());
860 assert!(!FileFormat::Csv.is_container());
861 assert!(FileFormat::Avro.is_writable());
862 assert!(!FileFormat::Orc.is_writable() && !FileFormat::Parquet.is_writable());
863 }
864
865 #[tokio::test]
866 async fn orc_is_refused_for_writing() {
867 let err = encode(&[], FileFormat::Orc, &FormatOptions::default()).expect_err("orc");
868 assert!(err.to_string().contains("read-only"), "{err}");
869 }
870
871 #[tokio::test]
872 async fn parquet_is_refused_on_both_directions() {
873 let err = decode(b"", FileFormat::Parquet, &FormatOptions::default())
876 .await
877 .expect_err("decode refuses parquet");
878 assert!(err.to_string().contains("columnar"), "{err}");
879 let err = encode(&[], FileFormat::Parquet, &FormatOptions::default())
880 .expect_err("encode refuses parquet");
881 assert!(err.to_string().contains("columnar"), "{err}");
882 }
883
884 #[test]
885 fn delimiter_must_be_one_byte() {
886 let mut o = CsvOptions::default();
887 assert_eq!(o.delimiter_byte().expect("default"), b',');
888 o.delimiter = "\\t".into();
889 assert_eq!(o.delimiter_byte().expect("tab"), b'\t');
890 o.delimiter = "||".into();
891 let err = o.delimiter_byte().expect_err("multi-byte");
892 assert!(err.to_string().contains("exactly one byte"), "{err}");
893 o.delimiter = "§".into();
896 assert!(o.delimiter_byte().is_err());
897 }
898
899 #[test]
900 fn header_union_widens_across_records_and_ignores_non_objects() {
901 let recs = vec![json!({"a": 1}), json!({"b": 2}), json!("not an object")];
905 assert_eq!(header_union(&recs), vec!["a", "b"]);
906 assert_eq!(header_union(&[json!({"a": 1}), json!({"a": 2})]), vec!["a"]);
908 assert!(header_union(&[json!([1, 2])]).is_empty());
909 }
910
911 #[test]
912 fn cells_render_scalars_plainly_and_structure_as_json() {
913 assert_eq!(cell_text(&Value::Null), "");
914 assert_eq!(cell_text(&json!("x")), "x");
915 assert_eq!(cell_text(&json!(3)), "3");
916 assert_eq!(cell_text(&json!(true)), "true");
917 assert_eq!(cell_text(&json!({"k": 1})), r#"{"k":1}"#);
918 }
919}