Skip to main content

faucet_core/file_format/
csv.rs

1//! CSV read/write for the file connectors (#604).
2//!
3//! The reader is the one the REST source has used since #497 — streaming
4//! RFC-4180 via `csv-async`, `flexible(true)`, header-derived keys with a
5//! `column_<i>` fallback, all-`String` values. Keeping those semantics exactly
6//! is the point of moving it here rather than writing a second one: a pipeline
7//! that reads a CSV through the REST source and one that reads the same file
8//! from S3 must produce the same records.
9
10use super::CsvOptions;
11use crate::error::FaucetError;
12use serde_json::{Map, Value};
13
14/// Parse CSV bytes into records (lenient about ragged rows).
15pub async fn decode(
16    bytes: &[u8],
17    delimiter: u8,
18    has_headers: bool,
19) -> Result<Vec<Value>, FaucetError> {
20    let opts = CsvOptions {
21        delimiter: (delimiter as char).to_string(),
22        has_headers,
23        ..CsvOptions::default()
24    };
25    let mut rdr = CsvRowReader::with_bytes(bytes, delimiter, &opts, true)?;
26    let mut out = Vec::new();
27    while let Some(r) = rdr.next_record().await? {
28        out.push(r);
29    }
30    Ok(out)
31}
32
33/// Parse CSV bytes into records with the full dialect in `opts`;
34/// `default_flexible` applies when `opts.flexible` is unset.
35pub async fn decode_with(
36    bytes: &[u8],
37    opts: &CsvOptions,
38    default_flexible: bool,
39) -> Result<Vec<Value>, FaucetError> {
40    let mut rdr = CsvRowReader::new(bytes, opts, default_flexible)?;
41    let mut out = Vec::new();
42    while let Some(r) = rdr.next_record().await? {
43        out.push(r);
44    }
45    Ok(out)
46}
47
48/// A streaming CSV reader yielding one JSON object per row.
49///
50/// Header names must be unique: rows are keyed by header, so a repeated name
51/// would silently drop a column. A ragged row fails naming its line unless
52/// the dialect is flexible.
53pub struct CsvRowReader<R> {
54    inner: csv_async::AsyncReader<R>,
55    record: csv_async::StringRecord,
56    headers: Option<Vec<String>>,
57    has_headers: bool,
58    flexible: bool,
59    null_values: Vec<String>,
60    records: usize,
61    width: Option<usize>,
62    ragged: usize,
63}
64
65impl<R: tokio::io::AsyncRead + Unpin + Send> CsvRowReader<R> {
66    /// Build a reader over `reader` with the dialect in `opts`.
67    pub fn new(reader: R, opts: &CsvOptions, default_flexible: bool) -> Result<Self, FaucetError> {
68        Self::with_bytes(reader, opts.delimiter_byte()?, opts, default_flexible)
69    }
70
71    fn with_bytes(
72        reader: R,
73        delimiter: u8,
74        opts: &CsvOptions,
75        default_flexible: bool,
76    ) -> Result<Self, FaucetError> {
77        let flexible = opts.flexible_or(default_flexible);
78        let inner = csv_async::AsyncReaderBuilder::new()
79            .has_headers(false)
80            .delimiter(delimiter)
81            .quote(opts.quote_byte()?)
82            .flexible(flexible)
83            .create_reader(reader);
84        Ok(Self {
85            inner,
86            record: csv_async::StringRecord::new(),
87            headers: None,
88            has_headers: opts.has_headers,
89            flexible,
90            null_values: opts.null_values.clone(),
91            records: 0,
92            width: None,
93            ragged: 0,
94        })
95    }
96
97    /// The next row, or `None` at the end of the input.
98    pub async fn next_record(&mut self) -> Result<Option<Value>, FaucetError> {
99        loop {
100            self.records += 1;
101            let more = self
102                .inner
103                .read_record(&mut self.record)
104                .await
105                .map_err(|e| self.parse_error(e))?;
106            if !more {
107                if self.ragged > 0 {
108                    tracing::warn!(
109                        ragged_rows = self.ragged,
110                        "csv: accepted {} ragged row(s) because the dialect is flexible — short \
111                         rows lose trailing fields and long rows gain `column_<i>` keys; set \
112                         `csv.flexible: false` to refuse them",
113                        self.ragged
114                    );
115                    self.ragged = 0;
116                }
117                return Ok(None);
118            }
119            let width = self.record.len();
120            match self.width {
121                None => self.width = Some(width),
122                Some(w) if w != width => self.ragged += 1,
123                Some(_) => {}
124            }
125            if self.has_headers && self.headers.is_none() {
126                self.headers = Some(unique_headers(&self.record)?);
127                continue;
128            }
129            let at = self.records;
130            return record_to_object(&self.record, self.headers.as_deref(), &self.null_values)
131                .map(|o| Some(Value::Object(o)))
132                .map_err(|key| {
133                    FaucetError::Source(format!(
134                        "csv: record {at} is longer than the header and its extra field \
135                         `{key}` would overwrite the column of that name"
136                    ))
137                });
138        }
139    }
140
141    /// Where a failed read was: the physical line when the parser knows it
142    /// (a quoted field can span lines, so records are not lines), else the
143    /// record number.
144    fn location(&self, e: &csv_async::Error) -> String {
145        match e.position() {
146            Some(p) => format!("line {}", p.line()),
147            None => format!("record {}", self.records),
148        }
149    }
150
151    fn parse_error(&self, e: csv_async::Error) -> FaucetError {
152        let at = self.location(&e);
153        if !self.flexible && matches!(e.kind(), csv_async::ErrorKind::UnequalLengths { .. }) {
154            FaucetError::Source(format!(
155                "csv: ragged row at {at}: {e} — a short or long row is a structural defect \
156                 that would silently misalign fields; fix the file or set `csv.flexible: true` \
157                 to accept uneven rows"
158            ))
159        } else {
160            FaucetError::Source(format!("csv: parse error at {at}: {e}"))
161        }
162    }
163}
164
165fn unique_headers(rec: &csv_async::StringRecord) -> Result<Vec<String>, FaucetError> {
166    let headers: Vec<String> = rec.iter().map(str::to_string).collect();
167    let mut seen = std::collections::HashMap::with_capacity(headers.len());
168    for (i, name) in headers.iter().enumerate() {
169        if let Some(first) = seen.insert(name.as_str(), i) {
170            let shown = if name.is_empty() { "(empty)" } else { name };
171            return Err(FaucetError::Source(format!(
172                "csv: duplicate header {shown} at columns {first} and {i}; rows are keyed by \
173                 header name, so a repeated header would silently drop a column — rename it \
174                 or set `csv.has_headers: false`"
175            )));
176        }
177    }
178    Ok(headers)
179}
180
181/// How one CSV record becomes a JSON object — the single definition, so the
182/// `Value` path and any byte path can never drift apart.
183///
184/// `Err(key)` when a field past the header would overwrite a header column
185/// literally named `column_<i>` (CORE-60).
186fn record_to_object(
187    rec: &csv_async::StringRecord,
188    headers: Option<&[String]>,
189    null_values: &[String],
190) -> Result<Map<String, Value>, String> {
191    let mut obj = Map::new();
192    for (i, field) in rec.iter().enumerate() {
193        let key = headers
194            .and_then(|h| h.get(i).cloned())
195            .unwrap_or_else(|| format!("column_{i}"));
196        let value = if null_values.iter().any(|n| n == field) {
197            Value::Null
198        } else {
199            Value::String(field.to_string())
200        };
201        if obj.insert(key.clone(), value).is_some() {
202            return Err(key);
203        }
204    }
205    Ok(obj)
206}
207
208/// Write records as CSV.
209///
210/// Columns are the first-seen union of every record's keys
211/// ([`header_union`](super::header_union)), so a record that gains a field
212/// mid-page widens the file instead of losing the field. A record missing a
213/// column writes an empty cell.
214pub fn encode(records: &[Value], delimiter: u8, has_headers: bool) -> Result<Vec<u8>, FaucetError> {
215    encode_rows(records, delimiter, b'"', has_headers)
216}
217
218/// [`encode`] with the full dialect in `opts`.
219pub fn encode_with(records: &[Value], opts: &CsvOptions) -> Result<Vec<u8>, FaucetError> {
220    encode_rows(
221        records,
222        opts.delimiter_byte()?,
223        opts.quote_byte()?,
224        opts.has_headers,
225    )
226}
227
228fn encode_rows(
229    records: &[Value],
230    delimiter: u8,
231    quote: u8,
232    has_headers: bool,
233) -> Result<Vec<u8>, FaucetError> {
234    let headers = super::header_union(records);
235    let mut wtr = csv::WriterBuilder::new()
236        .delimiter(delimiter)
237        .quote(quote)
238        .from_writer(Vec::new());
239    if has_headers && !headers.is_empty() {
240        wtr.write_record(&headers)
241            .map_err(|e| FaucetError::Sink(format!("csv: writing header: {e}")))?;
242    }
243    for r in records {
244        let row: Vec<String> = headers
245            .iter()
246            .map(|h| r.get(h).map(super::cell_text).unwrap_or_default())
247            .collect();
248        wtr.write_record(&row)
249            .map_err(|e| FaucetError::Sink(format!("csv: writing row: {e}")))?;
250    }
251    wtr.into_inner()
252        .map_err(|e| FaucetError::Sink(format!("csv: finishing: {e}")))
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use serde_json::json;
259
260    #[tokio::test]
261    async fn headers_name_the_fields() {
262        let recs = decode(b"a,b\n1,2\n3,4\n", b',', true)
263            .await
264            .expect("decode");
265        assert_eq!(
266            recs,
267            vec![json!({"a": "1", "b": "2"}), json!({"a": "3", "b": "4"})]
268        );
269    }
270
271    #[tokio::test]
272    async fn without_headers_fields_fall_back_to_column_index() {
273        let recs = decode(b"1,2\n", b',', false).await.expect("decode");
274        assert_eq!(recs, vec![json!({"column_0": "1", "column_1": "2"})]);
275    }
276
277    #[tokio::test]
278    async fn a_quoted_embedded_newline_stays_one_record() {
279        let recs = decode(b"a\n\"x\ny\"\n", b',', true).await.expect("decode");
280        assert_eq!(recs, vec![json!({"a": "x\ny"})]);
281    }
282
283    #[tokio::test]
284    async fn a_ragged_row_is_kept_not_rejected() {
285        // `flexible(true)`: a short row yields the fields it has rather than
286        // failing the whole object, which is what the REST source does today.
287        let recs = decode(b"a,b\n1\n", b',', true).await.expect("decode");
288        assert_eq!(recs, vec![json!({"a": "1"})]);
289    }
290
291    #[tokio::test]
292    async fn header_only_and_empty_bodies_yield_nothing() {
293        assert!(
294            decode(b"a,b\n", b',', true)
295                .await
296                .expect("header")
297                .is_empty()
298        );
299        assert!(decode(b"", b',', true).await.expect("empty").is_empty());
300    }
301
302    #[test]
303    fn encode_uses_the_union_of_keys_and_blanks_the_missing_ones() {
304        let out = encode(
305            &[json!({"a": 1, "b": 2}), json!({"a": 3, "c": 4})],
306            b',',
307            true,
308        )
309        .expect("encode");
310        assert_eq!(String::from_utf8(out).unwrap(), "a,b,c\n1,2,\n3,,4\n");
311    }
312
313    #[test]
314    fn encode_can_omit_the_header() {
315        let out = encode(&[json!({"a": 1})], b',', false).expect("encode");
316        assert_eq!(String::from_utf8(out).unwrap(), "1\n");
317    }
318
319    #[tokio::test]
320    async fn a_tab_delimiter_round_trips() {
321        let out = encode(&[json!({"a": "x", "b": "y"})], b'\t', true).expect("encode");
322        assert_eq!(String::from_utf8(out.clone()).unwrap(), "a\tb\nx\ty\n");
323        let back = decode(&out, b'\t', true).await.expect("decode");
324        assert_eq!(back, vec![json!({"a": "x", "b": "y"})]);
325    }
326
327    #[test]
328    fn nested_structure_survives_as_json_rather_than_being_dropped() {
329        let out = encode(&[json!({"a": {"k": 1}})], b',', true).expect("encode");
330        assert_eq!(String::from_utf8(out).unwrap(), "a\n\"{\"\"k\"\":1}\"\n");
331    }
332    fn opts(f: impl FnOnce(&mut CsvOptions)) -> CsvOptions {
333        let mut o = CsvOptions::default();
334        f(&mut o);
335        o
336    }
337
338    #[tokio::test]
339    async fn a_custom_quote_character_is_honoured_on_read_and_write() {
340        let o = opts(|o| o.quote = "'".into());
341        let recs = decode_with(b"a,b\n'x,y',2\n", &o, false)
342            .await
343            .expect("decode");
344        assert_eq!(recs, vec![json!({"a": "x,y", "b": "2"})]);
345        let out = encode_with(&recs, &o).expect("encode");
346        assert_eq!(String::from_utf8(out).unwrap(), "a,b\n'x,y',2\n");
347    }
348
349    #[tokio::test]
350    async fn a_long_row_cannot_overwrite_a_column_named_like_its_fallback_key() {
351        let err = decode_with(b"a,column_2\n1,2\n3,4,5\n", &CsvOptions::default(), true)
352            .await
353            .unwrap_err()
354            .to_string();
355        assert!(
356            err.contains("`column_2`") && err.contains("record 3"),
357            "{err}"
358        );
359        // Ragged rows that collide with nothing are still accepted (and counted).
360        let recs = decode_with(b"a,b\n1\n2,3,4\n", &CsvOptions::default(), true)
361            .await
362            .unwrap();
363        assert_eq!(
364            recs,
365            vec![
366                json!({"a": "1"}),
367                json!({"a": "2", "b": "3", "column_2": "4"})
368            ]
369        );
370    }
371
372    #[tokio::test]
373    async fn a_strict_dialect_rejects_a_ragged_row_naming_its_line() {
374        let o = opts(|o| o.flexible = Some(false));
375        let err = decode_with(b"a,b\n1,2\n3\n", &o, true).await.unwrap_err();
376        let msg = err.to_string();
377        assert!(msg.contains("ragged row at line 3"), "{msg}");
378        let multi = decode_with(b"a,b\n\"x\ny\",2\n3\n", &o, true)
379            .await
380            .unwrap_err()
381            .to_string();
382        assert!(
383            multi.contains("ragged row at line 4"),
384            "the physical line: {multi}"
385        );
386        assert!(msg.contains("csv.flexible"), "{msg}");
387        let lenient = decode_with(b"a,b\n1,2\n3\n", &CsvOptions::default(), false)
388            .await
389            .unwrap_err();
390        assert!(lenient.to_string().contains("ragged"));
391        let ok = decode_with(b"a,b\n3\n", &CsvOptions::default(), true)
392            .await
393            .expect("default flexible");
394        assert_eq!(ok, vec![json!({"a": "3"})]);
395    }
396
397    #[tokio::test]
398    async fn null_values_read_as_null() {
399        let o = opts(|o| o.null_values = vec!["".into(), "NULL".into()]);
400        let recs = decode_with(b"a,b,c\n,NULL,x\n", &o, false)
401            .await
402            .expect("decode");
403        assert_eq!(recs, vec![json!({"a": null, "b": null, "c": "x"})]);
404    }
405
406    #[tokio::test]
407    async fn a_duplicate_header_is_refused_rather_than_dropping_a_column() {
408        let err = decode(b"a,a\n1,2\n", b',', true).await.unwrap_err();
409        let msg = err.to_string();
410        assert!(
411            msg.contains("duplicate header a at columns 0 and 1"),
412            "{msg}"
413        );
414        let err = decode(b",\n1,2\n", b',', true).await.unwrap_err();
415        assert!(err.to_string().contains("(empty)"));
416        let ok = decode(b"a,a\n1,2\n", b',', false)
417            .await
418            .expect("no headers");
419        assert_eq!(
420            ok,
421            vec![
422                json!({"column_0": "a", "column_1": "a"}),
423                json!({"column_0": "1", "column_1": "2"})
424            ]
425        );
426    }
427
428    #[tokio::test]
429    async fn a_malformed_quote_is_a_typed_parse_error() {
430        let o = opts(|o| o.flexible = Some(false));
431        let bad: &[u8] = b"a\n\xff\xfe\n";
432        let err = decode_with(bad, &o, false).await.unwrap_err();
433        assert!(err.to_string().contains("parse error at line"), "{err}");
434    }
435
436    #[test]
437    fn dialect_bytes_are_validated() {
438        assert!(opts(|o| o.quote = "''".into()).validate().is_err());
439        assert!(opts(|o| o.delimiter = ";;".into()).validate().is_err());
440        assert!(opts(|o| o.delimiter = "\\t".into()).validate().is_ok());
441        assert!(encode_with(&[json!({"a": 1})], &opts(|o| o.quote = "".into())).is_err());
442    }
443
444    #[test]
445    fn has_headers_has_one_name() {
446        let o: CsvOptions = serde_json::from_value(json!({"has_headers": false})).unwrap();
447        assert!(!o.has_headers);
448        assert_eq!(o.on_unknown_field, super::super::CsvUnknownField::Widen);
449        assert!(serde_json::from_value::<CsvOptions>(json!({"write_headers": false})).is_err());
450    }
451}