1use super::CsvOptions;
11use crate::error::FaucetError;
12use serde_json::{Map, Value};
13
14pub 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
33pub 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
48pub 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 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 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 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
181fn 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
208pub fn encode(records: &[Value], delimiter: u8, has_headers: bool) -> Result<Vec<u8>, FaucetError> {
215 encode_rows(records, delimiter, b'"', has_headers)
216}
217
218pub 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 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 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}