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)]
200pub struct CsvOptions {
201 #[serde(default = "default_delimiter")]
203 pub delimiter: String,
204 #[serde(default = "default_true")]
209 pub has_headers: bool,
210 #[serde(default = "default_quote")]
212 pub quote: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub flexible: Option<bool>,
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub null_values: Vec<String>,
223 #[serde(default)]
227 pub on_unknown_field: CsvUnknownField,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
232#[serde(rename_all = "snake_case")]
233pub enum CsvUnknownField {
234 #[default]
236 Widen,
237 Warn,
240 Error,
242}
243
244fn default_quote() -> String {
245 "\"".into()
246}
247
248impl Default for CsvOptions {
249 fn default() -> Self {
250 Self {
251 delimiter: default_delimiter(),
252 has_headers: true,
253 quote: default_quote(),
254 flexible: None,
255 null_values: Vec::new(),
256 on_unknown_field: CsvUnknownField::Widen,
257 }
258 }
259}
260
261impl CsvOptions {
262 pub fn delimiter_byte(&self) -> Result<u8, FaucetError> {
268 single_byte("delimiter", &self.delimiter)
269 }
270
271 pub fn quote_byte(&self) -> Result<u8, FaucetError> {
273 single_byte("quote", &self.quote)
274 }
275
276 pub fn flexible_or(&self, default: bool) -> bool {
278 self.flexible.unwrap_or(default)
279 }
280
281 pub fn validate(&self) -> Result<(), FaucetError> {
283 self.delimiter_byte()?;
284 self.quote_byte()?;
285 Ok(())
286 }
287}
288
289fn single_byte(name: &str, value: &str) -> Result<u8, FaucetError> {
290 let d = match value {
291 "\\t" => "\t",
292 other => other,
293 };
294 match d.as_bytes() {
295 [b] => Ok(*b),
296 _ => Err(FaucetError::Config(format!(
297 "csv.{name} must be exactly one byte, got {value:?}"
298 ))),
299 }
300}
301
302#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
304#[serde(deny_unknown_fields)]
305pub struct ExcelOptions {
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub sheet: Option<String>,
309 #[serde(default)]
311 pub header_row: usize,
312}
313
314fn default_record_element() -> String {
315 "record".into()
316}
317
318fn default_root_element() -> String {
319 "records".into()
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
330#[serde(deny_unknown_fields)]
331pub struct XmlOptions {
332 #[serde(default = "default_record_element")]
335 pub record_element: String,
336 #[serde(default = "default_root_element")]
338 pub root_element: String,
339}
340
341impl Default for XmlOptions {
342 fn default() -> Self {
343 Self {
344 record_element: default_record_element(),
345 root_element: default_root_element(),
346 }
347 }
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
352#[serde(rename_all = "snake_case")]
353pub enum AvroCodec {
354 #[default]
356 Null,
357 Deflate,
359 Snappy,
361 Zstd,
363}
364
365#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
367#[serde(deny_unknown_fields)]
368pub struct AvroOptions {
369 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub schema: Option<Value>,
376 #[serde(default)]
379 pub codec: AvroCodec,
380}
381
382#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
384#[serde(deny_unknown_fields)]
385pub struct OrcOptions {
386 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub columns: Option<Vec<String>>,
390}
391
392#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
399pub struct FormatOptions {
400 #[serde(default)]
402 pub csv: CsvOptions,
403 #[serde(default)]
405 pub excel: ExcelOptions,
406 #[serde(default)]
408 pub xml: XmlOptions,
409 #[serde(default)]
411 pub avro: AvroOptions,
412 #[serde(default)]
414 pub orc: OrcOptions,
415}
416
417pub async fn decode(
423 bytes: &[u8],
424 format: FileFormat,
425 opts: &FormatOptions,
426) -> Result<Vec<Value>, FaucetError> {
427 match format {
428 FileFormat::JsonLines => json::decode_lines(bytes),
429 FileFormat::JsonArray => json::decode_array(bytes),
430 FileFormat::RawText => json::decode_raw_text(bytes),
431 FileFormat::Csv => decode_csv(bytes, opts).await,
432 FileFormat::Xml => decode_xml(bytes, opts),
433 FileFormat::Xlsx => decode_xlsx(bytes, opts),
434 FileFormat::Parquet => Err(columnar_refusal("decode")),
435 FileFormat::Avro => decode_avro(bytes, opts),
436 FileFormat::Orc => decode_orc(bytes, opts),
437 }
438}
439
440pub async fn decode_owned(
447 bytes: Vec<u8>,
448 format: FileFormat,
449 opts: &FormatOptions,
450) -> Result<Vec<Value>, FaucetError> {
451 let opts = opts.clone();
452 tokio::task::spawn_blocking(move || match format {
453 #[cfg(feature = "file-format-orc")]
454 FileFormat::Orc => orc::decode_owned(bytes, &opts.orc),
455 other => futures::executor::block_on(decode(&bytes, other, &opts)),
456 })
457 .await
458 .map_err(|e| FaucetError::Source(format!("file_format: decode task failed: {e}")))?
459}
460
461pub fn encode(
463 records: &[Value],
464 format: FileFormat,
465 opts: &FormatOptions,
466) -> Result<Vec<u8>, FaucetError> {
467 match format {
468 FileFormat::JsonLines => json::encode_lines(records),
469 FileFormat::JsonArray => json::encode_array(records),
470 FileFormat::RawText => json::encode_raw_text(records),
471 FileFormat::Csv => encode_csv(records, opts),
472 FileFormat::Xml => encode_xml(records, opts),
473 FileFormat::Xlsx => encode_xlsx(records, opts),
474 FileFormat::Parquet => Err(columnar_refusal("encode")),
475 FileFormat::Avro => encode_avro(records, opts),
476 FileFormat::Orc => Err(FaucetError::Config(
477 "`format: orc` is read-only — there is no ORC writer; write Parquet for a columnar \
478 output"
479 .into(),
480 )),
481 }
482}
483
484fn columnar_refusal(verb: &str) -> FaucetError {
489 FaucetError::Config(format!(
490 "file_format::{verb}: `parquet` is columnar and is handled by each connector's Arrow \
491 path, not the generic record helper"
492 ))
493}
494
495pub const DEFAULT_MAX_OBJECT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
498
499pub async fn read_to_end_capped<R>(reader: R, max: u64, what: &str) -> Result<Vec<u8>, FaucetError>
503where
504 R: tokio::io::AsyncRead + Unpin,
505{
506 use tokio::io::AsyncReadExt as _;
507 let mut buf = Vec::new();
508 reader
509 .take(max.saturating_add(1))
510 .read_to_end(&mut buf)
511 .await
512 .map_err(|e| FaucetError::Source(format!("read error for '{what}': {e}")))?;
513 check_object_size(buf.len() as u64, max, what)?;
514 Ok(buf)
515}
516
517pub async fn read_to_string_capped<R>(
519 reader: R,
520 max: u64,
521 what: &str,
522) -> Result<String, FaucetError>
523where
524 R: tokio::io::AsyncRead + Unpin,
525{
526 let bytes = read_to_end_capped(reader, max, what).await?;
527 String::from_utf8(bytes)
528 .map_err(|e| FaucetError::Source(format!("'{what}' is not valid UTF-8: {e}")))
529}
530
531pub fn check_object_size(len: u64, max: u64, what: &str) -> Result<(), FaucetError> {
533 if len > max {
534 return Err(FaucetError::Source(format!(
535 "'{what}' is larger than `max_object_bytes` ({max} bytes) once decompressed; \
536 raise `max_object_bytes` to read it"
537 )));
538 }
539 Ok(())
540}
541
542#[cfg_attr(
547 all(
548 feature = "file-format-csv",
549 feature = "file-format-xml",
550 feature = "file-format-excel",
551 feature = "file-format-avro",
552 feature = "file-format-orc"
553 ),
554 allow(dead_code)
555)]
556fn missing_feature(format: FileFormat, feature: &str) -> FaucetError {
557 FaucetError::Config(format!(
558 "`format: {}` requires the `{feature}` build feature — rebuild with \
559 `--features {feature}`",
560 format.as_str()
561 ))
562}
563
564#[cfg(feature = "file-format-csv")]
565async fn decode_csv(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
566 csv::decode_with(bytes, &opts.csv, true).await
567}
568
569#[cfg(not(feature = "file-format-csv"))]
570async fn decode_csv(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
571 Err(missing_feature(FileFormat::Csv, "file-format-csv"))
572}
573
574#[cfg(feature = "file-format-csv")]
575fn encode_csv(records: &[Value], opts: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
576 csv::encode_with(records, &opts.csv)
577}
578
579#[cfg(not(feature = "file-format-csv"))]
580fn encode_csv(_: &[Value], _: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
581 Err(missing_feature(FileFormat::Csv, "file-format-csv"))
582}
583
584#[cfg(feature = "file-format-xml")]
585fn decode_xml(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
586 xml::decode(bytes, &opts.xml.record_element)
587}
588
589#[cfg(not(feature = "file-format-xml"))]
590fn decode_xml(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
591 Err(missing_feature(FileFormat::Xml, "file-format-xml"))
592}
593
594#[cfg(feature = "file-format-xml")]
595fn encode_xml(records: &[Value], opts: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
596 xml::encode(records, &opts.xml.root_element, &opts.xml.record_element)
597}
598
599#[cfg(not(feature = "file-format-xml"))]
600fn encode_xml(_: &[Value], _: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
601 Err(missing_feature(FileFormat::Xml, "file-format-xml"))
602}
603
604#[cfg(feature = "file-format-excel")]
605fn decode_xlsx(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
606 excel::decode(bytes, opts.excel.sheet.as_deref(), opts.excel.header_row)
607}
608
609#[cfg(not(feature = "file-format-excel"))]
610fn decode_xlsx(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
611 Err(missing_feature(FileFormat::Xlsx, "file-format-excel"))
612}
613
614#[cfg(feature = "file-format-excel")]
615fn encode_xlsx(records: &[Value], opts: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
616 excel::encode(records, opts.excel.sheet.as_deref())
617}
618
619#[cfg(not(feature = "file-format-excel"))]
620fn encode_xlsx(_: &[Value], _: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
621 Err(missing_feature(FileFormat::Xlsx, "file-format-excel"))
622}
623
624#[cfg(feature = "file-format-avro")]
625fn decode_avro(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
626 avro::decode(bytes, &opts.avro)
627}
628
629#[cfg(not(feature = "file-format-avro"))]
630fn decode_avro(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
631 Err(missing_feature(FileFormat::Avro, "file-format-avro"))
632}
633
634#[cfg(feature = "file-format-avro")]
635fn encode_avro(records: &[Value], opts: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
636 avro::encode(records, &opts.avro)
637}
638
639#[cfg(not(feature = "file-format-avro"))]
640fn encode_avro(_: &[Value], _: &FormatOptions) -> Result<Vec<u8>, FaucetError> {
641 Err(missing_feature(FileFormat::Avro, "file-format-avro"))
642}
643
644#[cfg(feature = "file-format-orc")]
645fn decode_orc(bytes: &[u8], opts: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
646 orc::decode(bytes, &opts.orc)
647}
648
649#[cfg(not(feature = "file-format-orc"))]
650fn decode_orc(_: &[u8], _: &FormatOptions) -> Result<Vec<Value>, FaucetError> {
651 Err(missing_feature(FileFormat::Orc, "file-format-orc"))
652}
653
654pub fn header_union(records: &[Value]) -> Vec<String> {
665 let mut seen = std::collections::HashSet::new();
666 let mut out = Vec::new();
667 for r in records {
668 if let Value::Object(map) = r {
669 for k in map.keys() {
670 if seen.insert(k.clone()) {
671 out.push(k.clone());
672 }
673 }
674 }
675 }
676 out
677}
678
679pub fn cell_text(v: &Value) -> String {
685 match v {
686 Value::Null => String::new(),
687 Value::String(s) => s.clone(),
688 Value::Bool(b) => b.to_string(),
689 Value::Number(n) => n.to_string(),
690 other => crate::util::canonical_json(other),
691 }
692}
693
694#[cfg(test)]
695mod capped_read_tests {
696 use super::*;
697
698 #[tokio::test]
699 async fn a_body_past_the_cap_fails_and_one_at_the_cap_reads() {
700 let body = [b'x'; 64];
701 let ok = read_to_end_capped(&body[..], 64, "f").await.unwrap();
702 assert_eq!(ok.len(), 64);
703 let err = read_to_end_capped(&body[..], 63, "big.json.gz")
704 .await
705 .unwrap_err();
706 assert!(err.to_string().contains("big.json.gz"), "{err}");
707 assert!(err.to_string().contains("max_object_bytes"), "{err}");
708 assert_eq!(
709 read_to_string_capped(&b"hi"[..], 8, "t").await.unwrap(),
710 "hi"
711 );
712 assert!(
713 read_to_string_capped(&[0xff, 0xfe][..], 8, "t")
714 .await
715 .is_err()
716 );
717 assert!(check_object_size(5, 4, "x").is_err());
718 }
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use serde_json::json;
725
726 #[tokio::test]
727 async fn decode_owned_matches_decode_and_runs_off_the_runtime() {
728 let body = b"[{\"a\":1},{\"a\":2}]".to_vec();
729 let opts = FormatOptions::default();
730 assert_eq!(
731 decode_owned(body.clone(), FileFormat::JsonArray, &opts)
732 .await
733 .unwrap(),
734 decode(&body, FileFormat::JsonArray, &opts).await.unwrap()
735 );
736 assert!(
737 decode_owned(Vec::new(), FileFormat::Parquet, &opts)
738 .await
739 .is_err()
740 );
741 }
742
743 #[cfg(feature = "file-format-orc")]
744 #[tokio::test]
745 async fn decode_owned_reads_orc_in_place() {
746 const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/orc/people.orc");
747 let opts = FormatOptions::default();
748 assert_eq!(
749 decode_owned(FIXTURE.to_vec(), FileFormat::Orc, &opts)
750 .await
751 .unwrap(),
752 decode(FIXTURE, FileFormat::Orc, &opts).await.unwrap()
753 );
754 }
755
756 #[test]
760 fn every_variant_has_an_extension_and_a_wire_name() {
761 let all = [
762 (FileFormat::JsonLines, ".jsonl", "json_lines"),
763 (FileFormat::JsonArray, ".json", "json_array"),
764 (FileFormat::Csv, ".csv", "csv"),
765 (FileFormat::Xml, ".xml", "xml"),
766 (FileFormat::Xlsx, ".xlsx", "xlsx"),
767 (FileFormat::Parquet, ".parquet", "parquet"),
768 (FileFormat::RawText, ".txt", "raw_text"),
769 (FileFormat::Avro, ".avro", "avro"),
770 (FileFormat::Orc, ".orc", "orc"),
771 ];
772 for (f, ext, name) in all {
773 assert_eq!(f.extension(), ext, "{f:?}");
774 assert_eq!(f.as_str(), name, "{f:?}");
775 }
776 }
777
778 #[tokio::test]
783 async fn raw_text_round_trips_through_the_top_level_dispatch() {
784 let opts = FormatOptions::default();
785 let recs = decode(b"hello", FileFormat::RawText, &opts)
786 .await
787 .expect("decode");
788 assert_eq!(recs, vec![json!({"text": "hello"})]);
789 let bytes = encode(&recs, FileFormat::RawText, &opts).expect("encode");
790 assert_eq!(bytes, b"hello\n");
791 }
792
793 #[test]
797 fn a_missing_feature_is_named_not_guessed() {
798 let err = missing_feature(FileFormat::Xlsx, "file-format-excel");
799 let msg = err.to_string();
800 assert!(msg.contains("xlsx"), "{msg}");
801 assert!(msg.contains("file-format-excel"), "{msg}");
802 }
803
804 #[test]
805 fn extensions_and_names_are_stable() {
806 assert_eq!(FileFormat::default(), FileFormat::JsonLines);
807 assert_eq!(FileFormat::Csv.extension(), ".csv");
808 assert_eq!(FileFormat::Xlsx.as_str(), "xlsx");
809 }
810
811 #[test]
812 fn whole_object_formats_are_the_ones_without_a_record_boundary() {
813 for f in [
814 FileFormat::JsonArray,
815 FileFormat::Xml,
816 FileFormat::Xlsx,
817 FileFormat::Orc,
818 ] {
819 assert!(f.requires_whole_object(), "{f:?}");
820 }
821 for f in [
822 FileFormat::JsonLines,
823 FileFormat::Csv,
824 FileFormat::RawText,
825 FileFormat::Parquet,
826 FileFormat::Avro,
827 ] {
828 assert!(!f.requires_whole_object(), "{f:?}");
829 }
830 }
831
832 #[test]
833 fn extensions_resolve_through_compression_suffixes() {
834 let cases = [
835 ("a.jsonl", Some(FileFormat::JsonLines)),
836 ("dir/b.NDJSON", Some(FileFormat::JsonLines)),
837 ("c.json.gz", Some(FileFormat::JsonArray)),
838 ("d.csv.gz", Some(FileFormat::Csv)),
839 ("e.xml.zst", Some(FileFormat::Xml)),
840 ("f.xlsx", Some(FileFormat::Xlsx)),
841 ("g.parquet", Some(FileFormat::Parquet)),
842 ("h.txt", Some(FileFormat::RawText)),
843 ("i.avro", Some(FileFormat::Avro)),
844 ("j.orc", Some(FileFormat::Orc)),
845 ("k.csv.gzip", Some(FileFormat::Csv)),
846 ("l.jsonl.zstd", Some(FileFormat::JsonLines)),
847 ("m.tsv", None),
848 ("noext", None),
849 ("n.gz", None),
850 ];
851 for (path, want) in cases {
852 assert_eq!(FileFormat::from_path(path), want, "{path}");
853 }
854 }
855
856 #[test]
857 fn container_and_writable_classification() {
858 assert!(FileFormat::Avro.is_container() && FileFormat::Orc.is_container());
859 assert!(!FileFormat::Csv.is_container());
860 assert!(FileFormat::Avro.is_writable());
861 assert!(!FileFormat::Orc.is_writable() && !FileFormat::Parquet.is_writable());
862 }
863
864 #[tokio::test]
865 async fn orc_is_refused_for_writing() {
866 let err = encode(&[], FileFormat::Orc, &FormatOptions::default()).expect_err("orc");
867 assert!(err.to_string().contains("read-only"), "{err}");
868 }
869
870 #[tokio::test]
871 async fn parquet_is_refused_on_both_directions() {
872 let err = decode(b"", FileFormat::Parquet, &FormatOptions::default())
875 .await
876 .expect_err("decode refuses parquet");
877 assert!(err.to_string().contains("columnar"), "{err}");
878 let err = encode(&[], FileFormat::Parquet, &FormatOptions::default())
879 .expect_err("encode refuses parquet");
880 assert!(err.to_string().contains("columnar"), "{err}");
881 }
882
883 #[test]
884 fn delimiter_must_be_one_byte() {
885 let mut o = CsvOptions::default();
886 assert_eq!(o.delimiter_byte().expect("default"), b',');
887 o.delimiter = "\\t".into();
888 assert_eq!(o.delimiter_byte().expect("tab"), b'\t');
889 o.delimiter = "||".into();
890 let err = o.delimiter_byte().expect_err("multi-byte");
891 assert!(err.to_string().contains("exactly one byte"), "{err}");
892 o.delimiter = "§".into();
895 assert!(o.delimiter_byte().is_err());
896 }
897
898 #[test]
899 fn header_union_widens_across_records_and_ignores_non_objects() {
900 let recs = vec![json!({"a": 1}), json!({"b": 2}), json!("not an object")];
904 assert_eq!(header_union(&recs), vec!["a", "b"]);
905 assert_eq!(header_union(&[json!({"a": 1}), json!({"a": 2})]), vec!["a"]);
907 assert!(header_union(&[json!([1, 2])]).is_empty());
908 }
909
910 #[test]
911 fn cells_render_scalars_plainly_and_structure_as_json() {
912 assert_eq!(cell_text(&Value::Null), "");
913 assert_eq!(cell_text(&json!("x")), "x");
914 assert_eq!(cell_text(&json!(3)), "3");
915 assert_eq!(cell_text(&json!(true)), "true");
916 assert_eq!(cell_text(&json!({"k": 1})), r#"{"k":1}"#);
917 }
918}