Skip to main content

faucet_core/file_format/
mod.rs

1//! One file-format vocabulary for every file and object-store connector (#604).
2//!
3//! S3, GCS, Azure Blob and SFTP all move *files*, and the files they move are
4//! CSV exports, JSON dumps, XML feeds and Excel workbooks as often as they are
5//! JSON Lines. Before this module each connector carried its own small format
6//! enum — `JsonLines | JsonArray | RawText | Parquet` on the source side, and
7//! only `JsonLines | Parquet` on the sink side — so what you could read depended
8//! on which bucket it was in, and what you could write was a strict subset of
9//! what you could read. The gap was filled by pre- and post-processing outside
10//! the pipeline, which is exactly the work a movement engine exists to absorb.
11//!
12//! The parsers themselves are not new: the REST source has decoded
13//! `json | csv | xlsx | xml` since #497/#515. This module is where they move so
14//! that every connector — including a third-party one, which depends only on
15//! `faucet-core` — gets the same set, with the same option names and the same
16//! edge-case behaviour.
17//!
18//! # Shape
19//!
20//! ```yaml
21//! source:
22//!   type: s3
23//!   config:
24//!     bucket: exports
25//!     prefix: daily/
26//!     format: csv                  # FileFormat
27//!     compression: auto            # faucet_core::compression
28//!     csv: { has_headers: true, delimiter: "," }
29//! sink:
30//!   type: s3
31//!   config:
32//!     bucket: reports
33//!     format: xlsx
34//!     compression: gzip
35//! ```
36//!
37//! # What is deliberately not here
38//!
39//! **Parquet.** It is columnar, self-describing, and already has a dedicated
40//! Arrow read/write path per connector that must not be routed through a
41//! `Vec<Value>`. [`FileFormat::Parquet`] therefore names the format for config
42//! purposes but [`decode`]/[`encode`] refuse it, so a caller cannot silently
43//! lose the columnar fast path by going through the generic helper.
44//!
45//! **Compression.** [`crate::compression`] already owns codec selection,
46//! extension-based `auto` resolution, and the mismatch warning. Format and
47//! codec compose; neither needs to know about the other.
48
49use 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/// How the bytes of one object are laid out.
71///
72/// The variants are the union of what the file connectors could previously read
73/// *or* write, so adopting this enum never removes a format from a connector
74/// that had it.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
76#[serde(rename_all = "snake_case")]
77pub enum FileFormat {
78    /// One JSON value per line (NDJSON). The default everywhere, because it is
79    /// the only format that both streams and round-trips arbitrary JSON.
80    #[default]
81    JsonLines,
82    /// A single JSON array holding every record.
83    JsonArray,
84    /// Delimited text. See [`CsvOptions`].
85    Csv,
86    /// XML. See [`XmlOptions`].
87    Xml,
88    /// An Excel workbook (`.xlsx`). See [`ExcelOptions`].
89    Xlsx,
90    /// Apache Parquet — named for config, handled by each connector's own
91    /// Arrow path, never by [`decode`]/[`encode`].
92    Parquet,
93    /// Unparsed text: one record per object, `{"text": "<whole body>"}`.
94    RawText,
95    /// Apache Avro Object Container File. See [`AvroOptions`]. Read and
96    /// write; decodes to records or, with `arrow`, straight to Arrow batches.
97    Avro,
98    /// Apache ORC. See [`OrcOptions`]. **Read-only** — see the `orc` module
99    /// for why there is no writer.
100    Orc,
101}
102
103impl FileFormat {
104    /// The conventional file extension, used to name objects a sink creates
105    /// when the user did not pick one.
106    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    /// Whether the whole object must be in memory to produce the first record.
121    ///
122    /// Callers use this to decide between a streaming read and a buffered one.
123    /// It is a property of the format, not of the source: a JSON array has no
124    /// record boundary until it is parsed, a zip-container workbook has its
125    /// directory at the end, and an XML document is a tree.
126    pub fn requires_whole_object(self) -> bool {
127        matches!(self, Self::JsonArray | Self::Xml | Self::Xlsx | Self::Orc)
128    }
129
130    /// Whether the format is a self-describing binary container decoded by
131    /// [`ContainerDecoder`] (Avro, ORC) rather than by [`decode`] per object.
132    pub fn is_container(self) -> bool {
133        matches!(self, Self::Avro | Self::Orc)
134    }
135
136    /// Whether a sink can write this format through [`encode`].
137    pub fn is_writable(self) -> bool {
138        !matches!(self, Self::Orc | Self::Parquet)
139    }
140
141    /// The format a file name's extension names, looking through a trailing
142    /// compression suffix (`.gz`, `.gzip`, `.zst`, `.zstd`), so
143    /// `export.csv.gz` is CSV. `None` for an extension no format claims.
144    ///
145    /// `.json` is a JSON array (a lone object is one record); `.jsonl` and
146    /// `.ndjson` are JSON Lines; `.tsv` is not claimed, because its delimiter
147    /// is an option rather than a format.
148    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    /// The name used in config and error messages.
174    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/// CSV dialect.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
199#[serde(deny_unknown_fields)]
200pub struct CsvOptions {
201    /// Field separator. A single character; `"\t"` is accepted for tabs.
202    #[serde(default = "default_delimiter")]
203    pub delimiter: String,
204    /// Whether the first row names the fields. When false, fields are named
205    /// `column_0`, `column_1`, … — the same fallback the REST source and the
206    /// `csv` connector already use. On write it decides whether a header row
207    /// is written.
208    #[serde(default = "default_true")]
209    pub has_headers: bool,
210    /// Quote character. A single byte; default `"`.
211    #[serde(default = "default_quote")]
212    pub quote: String,
213    /// Whether rows may have a different number of fields than the header.
214    /// `false` fails the read on the first ragged row, naming its line; `true`
215    /// keeps the fields the row has. Unset, each connector picks: the `file`
216    /// source is strict, the object-store sources and the REST source are
217    /// lenient.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub flexible: Option<bool>,
220    /// Cell values read as `null` instead of a string (e.g. `["", "NULL"]`).
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub null_values: Vec<String>,
223    /// What a streaming CSV writer does with a field the header does not
224    /// have. See [`CsvUnknownField`]. Whole-object encoders always write the
225    /// union of every record's fields, so it only affects the `file` sink.
226    #[serde(default)]
227    pub on_unknown_field: CsvUnknownField,
228}
229
230/// What a CSV writer does with a field that is not in the header.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
232#[serde(rename_all = "snake_case")]
233pub enum CsvUnknownField {
234    /// Add it as a new column; earlier rows get an empty cell for it.
235    #[default]
236    Widen,
237    /// Fix the header from the first page and drop the field, logging a
238    /// warning once per field.
239    Warn,
240    /// Fix the header from the first page and fail the write naming the field.
241    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    /// The delimiter as the single byte the CSV readers want.
263    ///
264    /// Rejected rather than truncated: a multi-byte delimiter silently becoming
265    /// its first byte would split every row in the wrong place and produce
266    /// plausible-looking garbage.
267    pub fn delimiter_byte(&self) -> Result<u8, FaucetError> {
268        single_byte("delimiter", &self.delimiter)
269    }
270
271    /// The quote character as a single byte.
272    pub fn quote_byte(&self) -> Result<u8, FaucetError> {
273        single_byte("quote", &self.quote)
274    }
275
276    /// Whether ragged rows are accepted, with `default` when unset.
277    pub fn flexible_or(&self, default: bool) -> bool {
278        self.flexible.unwrap_or(default)
279    }
280
281    /// Check the single-byte fields, so a bad dialect fails at load time.
282    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/// Excel worksheet selection.
303#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
304#[serde(deny_unknown_fields)]
305pub struct ExcelOptions {
306    /// Worksheet name, or an index as a string. Default: the first sheet.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub sheet: Option<String>,
309    /// 0-based row supplying the field names.
310    #[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/// XML record framing.
323///
324/// XML has no canonical record boundary, so one must be declared. On read,
325/// `record_element` names the repeated element; without it the decoder falls
326/// back to "the document's root children", which is right for the common
327/// `<rows><row/>…</rows>` shape and wrong often enough that naming the element
328/// is recommended in the docs.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
330#[serde(deny_unknown_fields)]
331pub struct XmlOptions {
332    /// Element that delimits one record. Read: select these. Write: wrap each
333    /// record in one.
334    #[serde(default = "default_record_element")]
335    pub record_element: String,
336    /// Document element wrapping the records. Write only.
337    #[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/// Block compression for written files.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
352#[serde(rename_all = "snake_case")]
353pub enum AvroCodec {
354    /// Uncompressed blocks — the Avro default and the most portable.
355    #[default]
356    Null,
357    /// Raw deflate (RFC 1951), readable by every Avro implementation.
358    Deflate,
359    /// Snappy with the per-block CRC-32 the spec requires.
360    Snappy,
361    /// Zstandard.
362    Zstd,
363}
364
365/// Avro read/write options.
366#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
367#[serde(deny_unknown_fields)]
368pub struct AvroOptions {
369    /// An Avro schema (the JSON form: an object, an array for a union, or a
370    /// primitive name). On a **source** it is the *reader* schema every file
371    /// is resolved against — projection, aliases, defaults for added fields.
372    /// On a **sink** it is the *writer* schema; without it the schema is
373    /// inferred from the records.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub schema: Option<Value>,
376    /// Block codec for written files: `null` (default), `deflate`, `snappy`,
377    /// `zstd`. Reading detects the codec from the file header.
378    #[serde(default)]
379    pub codec: AvroCodec,
380}
381
382/// ORC read options.
383#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
384#[serde(deny_unknown_fields)]
385pub struct OrcOptions {
386    /// Top-level columns to read, in file order. Default: every column. A
387    /// name the file does not have is an error, not an empty column.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub columns: Option<Vec<String>>,
390}
391
392/// The per-format option blocks, flattened into a connector's config so they
393/// appear at its top level (`csv: {...}`, `excel: {...}`, `xml: {...}`).
394///
395/// Every block is defaulted, so a connector that adopts this struct adds no
396/// required field and the change stays a minor bump under the project's
397/// [API-evolution policy](https://github.com/faucet-hq/faucet-stream).
398#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
399pub struct FormatOptions {
400    /// CSV dialect, used when `format: csv`.
401    #[serde(default)]
402    pub csv: CsvOptions,
403    /// Worksheet selection, used when `format: xlsx`.
404    #[serde(default)]
405    pub excel: ExcelOptions,
406    /// Record framing, used when `format: xml`.
407    #[serde(default)]
408    pub xml: XmlOptions,
409    /// Reader / writer schema and codec, used when `format: avro`.
410    #[serde(default)]
411    pub avro: AvroOptions,
412    /// Column projection, used when `format: orc`.
413    #[serde(default)]
414    pub orc: OrcOptions,
415}
416
417/// Decode one object's bytes into records.
418///
419/// Async because the CSV reader is (`csv-async`), and because keeping one
420/// signature for every format is worth more than saving an `.await` on the
421/// three that are synchronous.
422pub 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
440/// [`decode`] for an owned body, run on a blocking thread.
441///
442/// The XML / Excel / Avro / ORC / JSON-array / CSV decoders are synchronous and
443/// CPU-bound; inline on an async worker a large object stalls heartbeats,
444/// cancellation and every other task on that worker (CORE-34). The body is
445/// moved rather than copied (ORC reads it in place).
446pub 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
461/// Encode records into one object's bytes.
462pub 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
484/// Parquet never goes through the generic helper.
485///
486/// Routing it here would work and would silently cost the Arrow fast path and
487/// the columnar memory profile, so it is refused rather than accommodated.
488fn 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
495/// Default ceiling on one file's size once decompressed, for formats read
496/// whole into memory (2 GiB).
497pub const DEFAULT_MAX_OBJECT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
498
499/// Read `reader` to the end, failing once it yields more than `max` bytes — so
500/// a decompression bomb fails the file instead of exhausting memory. `what`
501/// names the file in the error.
502pub 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
517/// [`read_to_end_capped`] for a UTF-8 text body.
518pub 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
531/// The error for a file past `max_object_bytes`.
532pub 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/// The error a build without the feature reports.
543///
544/// Named rather than mis-parsed: decoding an Excel workbook as CSV produces
545/// records, which is the worst possible outcome.
546#[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
654/// Field names across `records`, in first-seen order.
655///
656/// A union rather than the first record's keys, so a later record carrying an
657/// extra field widens the file instead of losing the field silently.
658///
659/// "First-seen" orders *records* deterministically; within one record the order
660/// is whatever `serde_json::Map` iterates, which is insertion order only when
661/// the `preserve_order` feature is unified into the build and sorted otherwise.
662/// Column order is therefore a build-time property, not a guarantee — do not
663/// write a test that pins it.
664pub 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
679/// One cell's text for the tabular formats.
680///
681/// A nested object or array is re-serialized as JSON rather than dropped or
682/// `Debug`-printed: a spreadsheet cell cannot hold structure, and the round
683/// trip back through `json_parse` is at least lossless.
684pub 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    /// Every variant's extension and wire name, so adding a format without
757    /// giving it both is a test failure rather than a `.txt` file called
758    /// "raw_text".
759    #[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    /// `raw_text` is source-only in the connectors, but the shared helper
779    /// still round-trips it — the sink side is what `encode_raw_text` exists
780    /// for, and routing it through the top-level dispatch is how a connector
781    /// reaches it.
782    #[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    /// The refusal a build without the feature emits — it must name the
794    /// format and the feature, because mis-parsing an Excel blob as CSV is
795    /// the outcome this exists to prevent.
796    #[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        // Going through the generic helper would work and would silently cost
873        // the Arrow path, so it must be an error, not a fallback.
874        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        // A multi-byte character is rejected too — truncating it to its first
893        // UTF-8 byte would split rows at a byte that never appears alone.
894        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        // Records are visited in order, so a key only this record has lands
901        // after the earlier ones. Intra-record order is a build-time property
902        // of `serde_json::Map`, so it is deliberately not asserted.
903        let recs = vec![json!({"a": 1}), json!({"b": 2}), json!("not an object")];
904        assert_eq!(header_union(&recs), vec!["a", "b"]);
905        // A key seen twice appears once.
906        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}