Skip to main content

lora_io/
csv.rs

1//! CSV codec with typed-header convention.
2//!
3//! Headers can be plain (`name`) or typed (`name:int`, `tags:string[]`,
4//! `metadata:json`, `:LABEL`, `:ID`, `:START_ID`, `:END_ID`, `:TYPE`).
5//! Cells parse according to the header type; cells whose type is
6//! `json` are parsed as canonical tagged JSON (so vectors, points,
7//! and temporal values can round-trip through CSV).
8//!
9//! Quoting follows RFC 4180: fields containing `,`, `"`, `\r`, or
10//! `\n` are wrapped in double quotes; embedded `"` is escaped by
11//! doubling it. Records may use `\n` or `\r\n` separators.
12
13use std::collections::BTreeMap;
14use std::io::{BufRead, Write};
15
16use lora_executor::{LoraValue, Row};
17use serde_json::Value as J;
18
19use super::format::{
20    invalid_data, row_parse_io_error, RowDecoder, RowEncoder, RowParseError, StreamingRowDecoder,
21};
22use super::value_json::{lora_value_from_json, lora_value_to_json};
23
24/// Logical column type declared in a typed header (`name:type`).
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum CsvType {
27    /// No type annotation; treat the cell as a string at decode time
28    /// (encoder picks a sensible cell representation).
29    Auto,
30    String,
31    Int,
32    Long,
33    Float,
34    Double,
35    Bool,
36    Date,
37    DateTime,
38    LocalDateTime,
39    Time,
40    LocalTime,
41    Duration,
42    Point,
43    Json,
44    /// Element type for `name:T[]` headers. Cells are split on `;`
45    /// and each element is parsed as `T`.
46    Array(Box<CsvType>),
47    /// Schema markers — used by the import driver to route rows into
48    /// node vs. relationship targets when no [`super::RowMapping`]
49    /// is supplied. The decoder still emits these as plain values
50    /// (string for `:LABEL` and `:TYPE`, integer for `:ID` /
51    /// `:START_ID` / `:END_ID`).
52    SchemaLabel,
53    SchemaId,
54    SchemaStartId,
55    SchemaEndId,
56    SchemaType,
57}
58
59#[derive(Debug, Clone)]
60pub struct CsvHeader {
61    /// External column name (no type suffix).
62    pub name: String,
63    /// Parsed type tag.
64    pub ty: CsvType,
65}
66
67impl CsvHeader {
68    /// Parse one header cell. Returns `Err` for malformed type tags.
69    pub fn parse(raw: &str) -> Result<Self, String> {
70        let trimmed = raw.trim();
71        // Schema markers always start with ':' and have no name.
72        if let Some(rest) = trimmed.strip_prefix(':') {
73            let ty = match rest.to_ascii_uppercase().as_str() {
74                "LABEL" => CsvType::SchemaLabel,
75                "ID" => CsvType::SchemaId,
76                "START_ID" => CsvType::SchemaStartId,
77                "END_ID" => CsvType::SchemaEndId,
78                "TYPE" => CsvType::SchemaType,
79                other => return Err(format!("unknown schema marker `:{other}`")),
80            };
81            return Ok(Self {
82                name: String::new(),
83                ty,
84            });
85        }
86
87        match trimmed.split_once(':') {
88            None => Ok(Self {
89                name: trimmed.to_string(),
90                ty: CsvType::Auto,
91            }),
92            Some((name, ty_part)) => Ok(Self {
93                name: name.trim().to_string(),
94                ty: parse_type(ty_part.trim())?,
95            }),
96        }
97    }
98}
99
100fn parse_type(tag: &str) -> Result<CsvType, String> {
101    let (base, is_array) = match tag.strip_suffix("[]") {
102        Some(b) => (b.trim(), true),
103        None => (tag, false),
104    };
105    let base_ty = match base.to_ascii_lowercase().as_str() {
106        "string" | "text" => CsvType::String,
107        "int" | "integer" => CsvType::Int,
108        "long" => CsvType::Long,
109        "float" => CsvType::Float,
110        "double" => CsvType::Double,
111        "bool" | "boolean" => CsvType::Bool,
112        "date" => CsvType::Date,
113        "datetime" => CsvType::DateTime,
114        "localdatetime" => CsvType::LocalDateTime,
115        "time" => CsvType::Time,
116        "localtime" => CsvType::LocalTime,
117        "duration" => CsvType::Duration,
118        "point" => CsvType::Point,
119        "json" => CsvType::Json,
120        other => return Err(format!("unknown column type `{other}`")),
121    };
122    Ok(if is_array {
123        CsvType::Array(Box::new(base_ty))
124    } else {
125        base_ty
126    })
127}
128
129pub struct CsvEncoder<W: Write> {
130    writer: W,
131    header_written: bool,
132    columns: Vec<String>,
133    /// Pre-computed type-suffixed header cells (used when caller
134    /// supplies explicit types via [`Self::with_types`]).
135    typed_headers: Option<Vec<String>>,
136}
137
138impl<W: Write> CsvEncoder<W> {
139    pub fn new(writer: W) -> Self {
140        Self {
141            writer,
142            header_written: false,
143            columns: Vec::new(),
144            typed_headers: None,
145        }
146    }
147
148    /// Pre-declare typed headers (one per column, in order). When set,
149    /// [`Self::begin`] uses these directly and ignores `columns`.
150    pub fn with_typed_headers(mut self, headers: Vec<String>) -> Self {
151        self.typed_headers = Some(headers);
152        self
153    }
154
155    pub fn into_inner(self) -> W {
156        self.writer
157    }
158}
159
160impl<W: Write> RowEncoder for CsvEncoder<W> {
161    fn begin(&mut self, columns: &[String]) -> std::io::Result<()> {
162        if self.header_written {
163            return Ok(());
164        }
165        let header_cells: Vec<&str> = if let Some(typed) = &self.typed_headers {
166            typed.iter().map(|s| s.as_str()).collect()
167        } else {
168            columns.iter().map(|s| s.as_str()).collect()
169        };
170        write_record(&mut self.writer, header_cells.iter().copied())?;
171        self.columns = columns.to_vec();
172        self.header_written = true;
173        Ok(())
174    }
175
176    fn write_row(&mut self, row: &Row) -> std::io::Result<()> {
177        let mut indexed: BTreeMap<String, &LoraValue> = BTreeMap::new();
178        for (_, name, value) in row.iter_named() {
179            indexed.insert(name.into_owned(), value);
180        }
181        let mut cells = Vec::with_capacity(self.columns.len());
182        for col in &self.columns {
183            let cell = match indexed.get(col.as_str()) {
184                Some(v) => encode_cell(v),
185                None => encode_cell(&LoraValue::Null),
186            };
187            cells.push(cell);
188        }
189        write_record(&mut self.writer, cells.iter().map(|s| s.as_str()))
190    }
191
192    fn write_named_row(&mut self, columns: &[(String, LoraValue)]) -> std::io::Result<()> {
193        if !self.header_written {
194            let header_columns: Vec<String> =
195                columns.iter().map(|(name, _)| name.clone()).collect();
196            self.begin(&header_columns)?;
197        }
198        // Contract: every key in `columns` must appear in the header.
199        // CSV is positional — once the header is locked, a row can
200        // only fill those slots. Unknown keys would otherwise be
201        // silently dropped, which is a common source of "where did
202        // my column go?" bugs when query shapes drift between calls.
203        // The check is debug-only so release builds keep the cheap
204        // BTreeMap path; in tests + dev, the panic flags the bug at
205        // the call-site that introduced it.
206        debug_assert!(
207            columns
208                .iter()
209                .all(|(k, _)| self.columns.iter().any(|c| c == k)),
210            "row has keys not in the encoder's header: {:?} (header: {:?})",
211            columns.iter().map(|(k, _)| k).collect::<Vec<_>>(),
212            self.columns,
213        );
214        let lookup: BTreeMap<&str, &LoraValue> =
215            columns.iter().map(|(k, v)| (k.as_str(), v)).collect();
216        let mut cells = Vec::with_capacity(self.columns.len());
217        for col in &self.columns {
218            let v = lookup
219                .get(col.as_str())
220                .copied()
221                .cloned()
222                .unwrap_or(LoraValue::Null);
223            cells.push(encode_cell(&v));
224        }
225        write_record(&mut self.writer, cells.iter().map(|s| s.as_str()))
226    }
227
228    fn finish(&mut self) -> std::io::Result<()> {
229        self.writer.flush()
230    }
231}
232
233fn encode_cell(value: &LoraValue) -> String {
234    match value {
235        LoraValue::Null => String::new(),
236        LoraValue::Bool(b) => b.to_string(),
237        LoraValue::Int(i) => i.to_string(),
238        LoraValue::Float(f) => f.to_string(),
239        LoraValue::String(s) => s.clone(),
240        LoraValue::List(items) => {
241            // Scalar lists are joined with `;`, but only when no
242            // element carries a char that the `;`-split decoder would
243            // misinterpret — otherwise the round-trip silently loses
244            // data (e.g. `["a;b"]` decoding back as two elements).
245            // Fall back to JSON for those cases.
246            if items.iter().all(is_scalar) && items.iter().all(list_element_safe_for_semicolon) {
247                items
248                    .iter()
249                    .map(encode_cell_scalar_only)
250                    .collect::<Vec<_>>()
251                    .join(";")
252            } else {
253                serde_json::to_string(&lora_value_to_json(value)).unwrap_or_default()
254            }
255        }
256        // Temporals / point / vector / binary / map -> tagged JSON.
257        _ => serde_json::to_string(&lora_value_to_json(value)).unwrap_or_default(),
258    }
259}
260
261fn is_scalar(v: &LoraValue) -> bool {
262    matches!(
263        v,
264        LoraValue::Null
265            | LoraValue::Bool(_)
266            | LoraValue::Int(_)
267            | LoraValue::Float(_)
268            | LoraValue::String(_)
269    )
270}
271
272/// String elements containing the `;` separator, embedded quotes, or
273/// line terminators can't be encoded safely via `;`-join — the decoder
274/// (or downstream CSV consumers) would re-split or mis-quote them. The
275/// caller falls back to JSON encoding when this returns `false`.
276fn list_element_safe_for_semicolon(v: &LoraValue) -> bool {
277    match v {
278        LoraValue::String(s) => !s.contains([';', '"', '\n', '\r']),
279        _ => true,
280    }
281}
282
283fn encode_cell_scalar_only(v: &LoraValue) -> String {
284    match v {
285        LoraValue::Null => String::new(),
286        LoraValue::Bool(b) => b.to_string(),
287        LoraValue::Int(i) => i.to_string(),
288        LoraValue::Float(f) => f.to_string(),
289        LoraValue::String(s) => s.clone(),
290        _ => String::new(),
291    }
292}
293
294fn write_record<'a, I, W>(writer: &mut W, cells: I) -> std::io::Result<()>
295where
296    I: IntoIterator<Item = &'a str>,
297    W: Write,
298{
299    let mut first = true;
300    for cell in cells {
301        if !first {
302            writer.write_all(b",")?;
303        }
304        first = false;
305        write_cell(writer, cell)?;
306    }
307    writer.write_all(b"\n")
308}
309
310fn write_cell<W: Write>(writer: &mut W, value: &str) -> std::io::Result<()> {
311    let needs_quote = value
312        .chars()
313        .any(|c| c == ',' || c == '"' || c == '\n' || c == '\r');
314    if !needs_quote {
315        writer.write_all(value.as_bytes())
316    } else {
317        writer.write_all(b"\"")?;
318        for ch in value.chars() {
319            if ch == '"' {
320                writer.write_all(b"\"\"")?;
321            } else {
322                let mut buf = [0u8; 4];
323                writer.write_all(ch.encode_utf8(&mut buf).as_bytes())?;
324            }
325        }
326        writer.write_all(b"\"")
327    }
328}
329
330pub struct CsvDecoder<R: BufRead> {
331    reader: R,
332    headers: Option<Vec<CsvHeader>>,
333    /// Synthesised column names (with schema markers expanded to a
334    /// concrete identifier so the row map has a stable key).
335    column_names: Vec<String>,
336}
337
338impl<R: BufRead> CsvDecoder<R> {
339    pub fn new(reader: R) -> Self {
340        Self {
341            reader,
342            headers: None,
343            column_names: Vec::new(),
344        }
345    }
346
347    /// Expose the parsed typed headers — surfaced by the playground's
348    /// mapping editor when previewing a file.
349    pub fn parsed_headers(&self) -> Option<&[CsvHeader]> {
350        self.headers.as_deref()
351    }
352
353    fn ensure_header(&mut self) -> std::io::Result<()> {
354        if self.headers.is_some() {
355            return Ok(());
356        }
357        let mut cells = match read_record(&mut self.reader)? {
358            Some(c) => c,
359            None => {
360                self.headers = Some(Vec::new());
361                return Ok(());
362            }
363        };
364        if let Some(first) = cells.first_mut() {
365            strip_utf8_bom(first);
366        }
367        let mut headers = Vec::with_capacity(cells.len());
368        let mut column_names = Vec::with_capacity(cells.len());
369        for (idx, raw) in cells.iter().enumerate() {
370            let h = CsvHeader::parse(raw).map_err(invalid_data)?;
371            column_names.push(synthetic_name(&h, idx));
372            headers.push(h);
373        }
374        self.headers = Some(headers);
375        self.column_names = column_names;
376        Ok(())
377    }
378}
379
380/// Remove the UTF-8 byte-order mark (`\u{feff}`) from the start of a
381/// header cell, if present. Excel, Google Sheets, and most native
382/// "Save as CSV (UTF-8)" exports prepend the BOM; without this strip
383/// the first column name silently includes the BOM character and all
384/// downstream `r.name` lookups against the generated Cypher miss.
385pub(crate) fn strip_utf8_bom(s: &mut String) {
386    if let Some(rest) = s.strip_prefix('\u{feff}') {
387        *s = rest.to_string();
388    }
389}
390
391pub(crate) fn synthetic_name(h: &CsvHeader, idx: usize) -> String {
392    if !h.name.is_empty() {
393        return h.name.clone();
394    }
395    match h.ty {
396        CsvType::SchemaLabel => "_label".to_string(),
397        CsvType::SchemaId => "_id".to_string(),
398        CsvType::SchemaStartId => "_start_id".to_string(),
399        CsvType::SchemaEndId => "_end_id".to_string(),
400        CsvType::SchemaType => "_type".to_string(),
401        _ => format!("col_{idx}"),
402    }
403}
404
405impl<R: BufRead> RowDecoder for CsvDecoder<R> {
406    fn header(&mut self) -> std::io::Result<Option<Vec<String>>> {
407        self.ensure_header()?;
408        Ok(Some(self.column_names.clone()))
409    }
410
411    fn next_row(&mut self) -> std::io::Result<Option<Vec<(String, LoraValue)>>> {
412        self.ensure_header()?;
413        let Some(cells) = read_record(&mut self.reader)? else {
414            return Ok(None);
415        };
416        let headers = self.headers.as_ref().unwrap();
417        if cells.len() != headers.len() {
418            return Err(invalid_data(format!(
419                "row has {} cells; expected {} (one per header)",
420                cells.len(),
421                headers.len()
422            )));
423        }
424        let mut out = Vec::with_capacity(cells.len());
425        for (idx, cell) in cells.into_iter().enumerate() {
426            let h = &headers[idx];
427            let value = parse_cell(&cell, &h.ty).map_err(invalid_data)?;
428            out.push((self.column_names[idx].clone(), value));
429        }
430        Ok(Some(out))
431    }
432}
433
434pub(crate) fn parse_cell(raw: &str, ty: &CsvType) -> Result<LoraValue, String> {
435    if raw.is_empty() {
436        return Ok(LoraValue::Null);
437    }
438    match ty {
439        CsvType::Auto | CsvType::String | CsvType::SchemaLabel | CsvType::SchemaType => {
440            Ok(LoraValue::String(raw.to_string()))
441        }
442        CsvType::Int
443        | CsvType::Long
444        | CsvType::SchemaId
445        | CsvType::SchemaStartId
446        | CsvType::SchemaEndId => raw
447            .parse::<i64>()
448            .map(LoraValue::Int)
449            .map_err(|e| format!("invalid integer `{raw}`: {e}")),
450        CsvType::Float | CsvType::Double => raw
451            .parse::<f64>()
452            .map(LoraValue::Float)
453            .map_err(|e| format!("invalid float `{raw}`: {e}")),
454        CsvType::Bool => match raw.to_ascii_lowercase().as_str() {
455            "true" | "t" | "1" | "yes" => Ok(LoraValue::Bool(true)),
456            "false" | "f" | "0" | "no" => Ok(LoraValue::Bool(false)),
457            other => Err(format!("invalid boolean `{other}`")),
458        },
459        CsvType::Date
460        | CsvType::DateTime
461        | CsvType::LocalDateTime
462        | CsvType::Time
463        | CsvType::LocalTime
464        | CsvType::Duration
465        | CsvType::Point => {
466            // Accept either tagged JSON or an ISO string. Try tagged
467            // first by sniffing for `{`; fall back to the typed string.
468            if raw.trim_start().starts_with('{') {
469                let v: J = serde_json::from_str(raw).map_err(|e| e.to_string())?;
470                lora_value_from_json(v)
471            } else {
472                let tag = match ty {
473                    CsvType::Date => "date",
474                    CsvType::DateTime => "datetime",
475                    CsvType::LocalDateTime => "localdatetime",
476                    CsvType::Time => "time",
477                    CsvType::LocalTime => "localtime",
478                    CsvType::Duration => "duration",
479                    CsvType::Point => {
480                        return Err("point cells must be JSON-encoded".into());
481                    }
482                    _ => unreachable!(),
483                };
484                let json = serde_json::json!({ "kind": tag, "iso": raw });
485                lora_value_from_json(json)
486            }
487        }
488        CsvType::Json => {
489            let v: J = serde_json::from_str(raw).map_err(|e| e.to_string())?;
490            lora_value_from_json(v)
491        }
492        CsvType::Array(inner) => {
493            // ';' separator; an explicit `[...]` JSON cell also works.
494            if raw.trim_start().starts_with('[') {
495                let v: J = serde_json::from_str(raw).map_err(|e| e.to_string())?;
496                lora_value_from_json(v)
497            } else {
498                let mut items = Vec::new();
499                for part in raw.split(';') {
500                    items.push(parse_cell(part, inner)?);
501                }
502                Ok(LoraValue::List(items))
503            }
504        }
505    }
506}
507
508/// Read one CSV record. Returns `Ok(None)` at EOF.
509fn read_record<R: BufRead>(reader: &mut R) -> std::io::Result<Option<Vec<String>>> {
510    let mut cells: Vec<Vec<u8>> = Vec::new();
511    let mut current: Vec<u8> = Vec::new();
512    let mut in_quotes = false;
513    let mut started = false;
514
515    loop {
516        let buf = reader.fill_buf()?;
517        if buf.is_empty() {
518            if !started && current.is_empty() && cells.is_empty() {
519                return Ok(None);
520            }
521            cells.push(std::mem::take(&mut current));
522            return decode_record(cells).map(Some);
523        }
524        let consumed = process_buf(buf, &mut cells, &mut current, &mut in_quotes, &mut started);
525        let (n, end_of_record) = consumed;
526        reader.consume(n);
527        if end_of_record {
528            cells.push(std::mem::take(&mut current));
529            return decode_record(cells).map(Some);
530        }
531    }
532}
533
534fn decode_record(cells: Vec<Vec<u8>>) -> std::io::Result<Vec<String>> {
535    cells
536        .into_iter()
537        .map(|cell| String::from_utf8(cell).map_err(invalid_data))
538        .collect()
539}
540
541/// Inner record-parsing state machine. Mutates `cells`/`current` as it
542/// consumes bytes from `buf`. Returns `(bytes_consumed, end_of_record)`.
543///
544/// Re-used by [`StreamingCsvDecoder`] to drive a push-based parser:
545/// the state lives on the decoder struct rather than the stack, and
546/// each `feed(chunk)` call advances it one chunk at a time.
547pub(crate) fn process_buf(
548    buf: &[u8],
549    cells: &mut Vec<Vec<u8>>,
550    current: &mut Vec<u8>,
551    in_quotes: &mut bool,
552    started: &mut bool,
553) -> (usize, bool) {
554    let mut i = 0;
555    while i < buf.len() {
556        let b = buf[i];
557        *started = true;
558        if *in_quotes {
559            match b {
560                b'"' => {
561                    if i + 1 < buf.len() && buf[i + 1] == b'"' {
562                        current.push(b'"');
563                        i += 2;
564                    } else {
565                        *in_quotes = false;
566                        i += 1;
567                    }
568                }
569                _ => {
570                    current.push(b);
571                    i += 1;
572                }
573            }
574        } else {
575            match b {
576                b'"' => {
577                    *in_quotes = true;
578                    i += 1;
579                }
580                b',' => {
581                    cells.push(std::mem::take(current));
582                    i += 1;
583                }
584                b'\n' => {
585                    return (i + 1, true);
586                }
587                b'\r' => {
588                    let consumed = if i + 1 < buf.len() && buf[i + 1] == b'\n' {
589                        i + 2
590                    } else {
591                        i + 1
592                    };
593                    return (consumed, true);
594                }
595                _ => {
596                    current.push(b);
597                    i += 1;
598                }
599            }
600        }
601    }
602    (i, false)
603}
604
605/// Push-based CSV decoder. Layers a streaming feed loop on top of
606/// the same `process_buf` byte-level state machine used by the
607/// pull-based [`CsvDecoder`]. The cell/record state lives on the
608/// struct so it persists across `feed` calls — partial records
609/// stay parked until enough bytes arrive to complete them.
610pub struct StreamingCsvDecoder {
611    /// Bytes received but not yet consumed by the state machine.
612    /// `process_buf` reports how many bytes it consumed per call;
613    /// anything left over (inside a quoted cell, mid-cell, etc.)
614    /// stays here for the next feed.
615    chunk_buffer: Vec<u8>,
616    /// In-flight record cells.
617    cells: Vec<Vec<u8>>,
618    /// In-flight cell.
619    current: Vec<u8>,
620    in_quotes: bool,
621    started: bool,
622    /// Parsed header row, populated after the first record completes.
623    /// `None` before the header is seen.
624    header: Option<Vec<CsvHeader>>,
625    /// Synthesised column names (header names canonicalised so
626    /// `:ID` becomes `_id`, etc.).
627    column_names: Vec<String>,
628    /// Completed data records waiting for `drain`.
629    completed: Vec<Vec<(String, LoraValue)>>,
630    bytes_fed: u64,
631    rows_emitted: u64,
632    /// 1-indexed counter of data rows seen (excluding the header).
633    /// Advances even when a row fails or is skipped in permissive
634    /// mode, so error attribution always reports the user-visible
635    /// row number.
636    record_index: u64,
637    permissive: bool,
638    errors: Vec<RowParseError>,
639}
640
641impl Default for StreamingCsvDecoder {
642    fn default() -> Self {
643        Self::new()
644    }
645}
646
647impl StreamingCsvDecoder {
648    pub fn new() -> Self {
649        Self {
650            chunk_buffer: Vec::with_capacity(64 * 1024),
651            cells: Vec::new(),
652            current: Vec::new(),
653            in_quotes: false,
654            started: false,
655            header: None,
656            column_names: Vec::new(),
657            completed: Vec::new(),
658            bytes_fed: 0,
659            rows_emitted: 0,
660            record_index: 0,
661            permissive: false,
662            errors: Vec::new(),
663        }
664    }
665
666    /// Parsed typed headers, surfaced once the first record has
667    /// been consumed. Useful for the playground's mapping preview.
668    pub fn parsed_headers(&self) -> Option<&[CsvHeader]> {
669        self.header.as_deref()
670    }
671
672    /// Drain as many complete records as the current buffer holds.
673    fn advance(&mut self) -> std::io::Result<()> {
674        // Repeatedly call process_buf until it stops finding record
675        // terminators. Each call advances the byte cursor by
676        // `consumed` bytes; we drain that prefix and loop.
677        loop {
678            let (consumed, end_of_record) = process_buf(
679                &self.chunk_buffer,
680                &mut self.cells,
681                &mut self.current,
682                &mut self.in_quotes,
683                &mut self.started,
684            );
685            if consumed > 0 {
686                self.chunk_buffer.drain(..consumed);
687            }
688            if !end_of_record {
689                break;
690            }
691            // Record complete: capture the current cell and route
692            // the record (header → metadata, body → completed list).
693            self.cells.push(std::mem::take(&mut self.current));
694            let record = decode_record(std::mem::take(&mut self.cells))?;
695            self.handle_record(record)?;
696        }
697        Ok(())
698    }
699
700    fn handle_record(&mut self, mut cells: Vec<String>) -> std::io::Result<()> {
701        if self.header.is_none() {
702            // Header errors are fatal: without a valid header the
703            // body can't be parsed at all, so permissive mode does
704            // not apply.
705            if let Some(first) = cells.first_mut() {
706                strip_utf8_bom(first);
707            }
708            let mut headers = Vec::with_capacity(cells.len());
709            let mut column_names = Vec::with_capacity(cells.len());
710            for (idx, raw) in cells.iter().enumerate() {
711                let h = CsvHeader::parse(raw).map_err(invalid_data)?;
712                column_names.push(synthetic_name(&h, idx));
713                headers.push(h);
714            }
715            self.header = Some(headers);
716            self.column_names = column_names;
717            return Ok(());
718        }
719        self.record_index += 1;
720        let header = self.header.as_ref().unwrap();
721        if cells.len() != header.len() {
722            let message = format!(
723                "row has {} cells; expected {} (one per header)",
724                cells.len(),
725                header.len()
726            );
727            return self.report_record_error(None, message, &cells);
728        }
729        let mut out = Vec::with_capacity(cells.len());
730        for (idx, cell) in cells.iter().enumerate() {
731            let h = &header[idx];
732            match parse_cell(cell, &h.ty) {
733                Ok(value) => out.push((self.column_names[idx].clone(), value)),
734                Err(message) => {
735                    let column = Some(self.column_names[idx].clone());
736                    return self.report_record_error(column, message, &cells);
737                }
738            }
739        }
740        self.completed.push(out);
741        self.rows_emitted += 1;
742        Ok(())
743    }
744
745    fn report_record_error(
746        &mut self,
747        column: Option<String>,
748        message: String,
749        cells: &[String],
750    ) -> std::io::Result<()> {
751        let raw = cells.join(",");
752        let err = RowParseError {
753            row: self.record_index,
754            column,
755            raw_sample: RowParseError::make_sample(&raw),
756            message,
757        };
758        if self.permissive {
759            self.errors.push(err);
760            Ok(())
761        } else {
762            Err(row_parse_io_error(err))
763        }
764    }
765}
766
767impl StreamingRowDecoder for StreamingCsvDecoder {
768    fn feed(&mut self, chunk: &[u8]) -> std::io::Result<()> {
769        if chunk.is_empty() {
770            return Ok(());
771        }
772        self.bytes_fed += chunk.len() as u64;
773        self.chunk_buffer.extend_from_slice(chunk);
774        self.advance()
775    }
776
777    fn drain(&mut self) -> std::io::Result<Vec<Vec<(String, LoraValue)>>> {
778        Ok(std::mem::take(&mut self.completed))
779    }
780
781    fn finish(&mut self) -> std::io::Result<Vec<Vec<(String, LoraValue)>>> {
782        // Flush anything still in the byte buffer.
783        self.advance()?;
784        // Files often end without a trailing newline; if a record
785        // was building when input ran out, close it now.
786        if self.started && (!self.cells.is_empty() || !self.current.is_empty() || self.in_quotes) {
787            self.cells.push(std::mem::take(&mut self.current));
788            let record = decode_record(std::mem::take(&mut self.cells))?;
789            self.started = false;
790            self.in_quotes = false;
791            self.handle_record(record)?;
792        }
793        Ok(std::mem::take(&mut self.completed))
794    }
795
796    fn header(&self) -> Option<&[String]> {
797        if self.column_names.is_empty() {
798            None
799        } else {
800            Some(&self.column_names)
801        }
802    }
803
804    fn bytes_fed(&self) -> u64 {
805        self.bytes_fed
806    }
807
808    fn rows_emitted(&self) -> u64 {
809        self.rows_emitted
810    }
811
812    fn set_permissive(&mut self, on: bool) {
813        self.permissive = on;
814    }
815
816    fn take_errors(&mut self) -> Vec<RowParseError> {
817        std::mem::take(&mut self.errors)
818    }
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824    use std::io::Cursor;
825
826    #[test]
827    fn round_trip_simple() {
828        let mut buf = Vec::new();
829        {
830            let mut enc = CsvEncoder::new(&mut buf);
831            enc.begin(&["name".into(), "age".into()]).unwrap();
832            enc.write_named_row(&[
833                ("name".into(), LoraValue::String("alice".into())),
834                ("age".into(), LoraValue::Int(30)),
835            ])
836            .unwrap();
837            enc.write_named_row(&[
838                ("name".into(), LoraValue::String("bob".into())),
839                ("age".into(), LoraValue::Int(25)),
840            ])
841            .unwrap();
842            enc.finish().unwrap();
843        }
844        let text = std::str::from_utf8(&buf).unwrap();
845        assert_eq!(text, "name,age\nalice,30\nbob,25\n");
846
847        let mut dec = CsvDecoder::new(Cursor::new(buf));
848        let h = dec.header().unwrap().unwrap();
849        assert_eq!(h, vec!["name".to_string(), "age".to_string()]);
850
851        let r1 = dec.next_row().unwrap().unwrap();
852        assert_eq!(r1[0].1, LoraValue::String("alice".into()));
853        // Auto type -> string. Caller would re-tag via column type.
854        assert_eq!(r1[1].1, LoraValue::String("30".into()));
855    }
856
857    #[test]
858    fn typed_headers_parse_numeric_cells() {
859        let csv = "name:string,age:int\nalice,30\nbob,25\n";
860        let mut dec = CsvDecoder::new(Cursor::new(csv));
861        let h = dec.header().unwrap().unwrap();
862        assert_eq!(h, vec!["name".to_string(), "age".to_string()]);
863        let r = dec.next_row().unwrap().unwrap();
864        assert_eq!(r[0].1, LoraValue::String("alice".into()));
865        assert_eq!(r[1].1, LoraValue::Int(30));
866    }
867
868    #[test]
869    fn schema_markers_parse() {
870        let csv = ":ID,:LABEL,name:string\n1,User,alice\n2,User,bob\n";
871        let mut dec = CsvDecoder::new(Cursor::new(csv));
872        let h = dec.header().unwrap().unwrap();
873        assert_eq!(
874            h,
875            vec!["_id".to_string(), "_label".to_string(), "name".to_string()]
876        );
877        let r = dec.next_row().unwrap().unwrap();
878        assert_eq!(r[0].1, LoraValue::Int(1));
879        assert_eq!(r[1].1, LoraValue::String("User".into()));
880    }
881
882    #[test]
883    fn array_cells_split_on_semicolon() {
884        let csv = "tags:string[]\nfoo;bar;baz\n";
885        let mut dec = CsvDecoder::new(Cursor::new(csv));
886        let _ = dec.header().unwrap();
887        let r = dec.next_row().unwrap().unwrap();
888        let LoraValue::List(items) = &r[0].1 else {
889            panic!("expected list");
890        };
891        assert_eq!(items.len(), 3);
892        assert_eq!(items[0], LoraValue::String("foo".into()));
893    }
894
895    #[test]
896    fn quoted_cells_preserve_commas_and_newlines() {
897        let csv = "name:string,note:string\n\"hi, there\",\"line1\nline2\"\n";
898        let mut dec = CsvDecoder::new(Cursor::new(csv));
899        let _ = dec.header().unwrap();
900        let r = dec.next_row().unwrap().unwrap();
901        assert_eq!(r[0].1, LoraValue::String("hi, there".into()));
902        assert_eq!(r[1].1, LoraValue::String("line1\nline2".into()));
903    }
904
905    #[test]
906    fn quotes_escape_by_doubling() {
907        let csv = "v:string\n\"she said \"\"hi\"\"\"\n";
908        let mut dec = CsvDecoder::new(Cursor::new(csv));
909        let _ = dec.header().unwrap();
910        let r = dec.next_row().unwrap().unwrap();
911        assert_eq!(r[0].1, LoraValue::String("she said \"hi\"".into()));
912    }
913
914    #[test]
915    fn encoder_quotes_when_needed() {
916        let mut buf = Vec::new();
917        {
918            let mut enc = CsvEncoder::new(&mut buf);
919            enc.begin(&["v".into()]).unwrap();
920            enc.write_named_row(&[("v".into(), LoraValue::String("hi, \"there\"\n".into()))])
921                .unwrap();
922            enc.finish().unwrap();
923        }
924        let text = std::str::from_utf8(&buf).unwrap();
925        assert_eq!(text, "v\n\"hi, \"\"there\"\"\n\"\n");
926    }
927
928    #[test]
929    fn json_typed_cells_round_trip_temporal() {
930        let csv = "ts:datetime\n2024-01-15T10:30:00Z\n";
931        let mut dec = CsvDecoder::new(Cursor::new(csv));
932        let _ = dec.header().unwrap();
933        let r = dec.next_row().unwrap().unwrap();
934        match &r[0].1 {
935            LoraValue::DateTime(_) => {}
936            other => panic!("expected DateTime, got {other:?}"),
937        }
938    }
939
940    #[test]
941    fn streaming_csv_split_across_chunks() {
942        // Header split mid-cell, multiple rows split arbitrarily.
943        let mut dec = StreamingCsvDecoder::new();
944        dec.feed(b"name:s").unwrap();
945        assert!(dec.header().is_none());
946        dec.feed(b"tring,age:int\nalice,30\nbo").unwrap();
947        let rows = dec.drain().unwrap();
948        assert_eq!(rows.len(), 1);
949        assert_eq!(
950            rows[0][0],
951            ("name".into(), LoraValue::String("alice".into()))
952        );
953        assert_eq!(rows[0][1], ("age".into(), LoraValue::Int(30)));
954        dec.feed(b"b,25\n").unwrap();
955        let rows2 = dec.drain().unwrap();
956        assert_eq!(rows2.len(), 1);
957        assert_eq!(
958            rows2[0][0],
959            ("name".into(), LoraValue::String("bob".into()))
960        );
961        // Header should be populated now.
962        assert_eq!(
963            dec.header().unwrap(),
964            &["name".to_string(), "age".to_string()]
965        );
966        // No trailing newline on final record: dripped through finish().
967        dec.feed(b"carol,40").unwrap();
968        assert!(dec.drain().unwrap().is_empty());
969        let final_rows = dec.finish().unwrap();
970        assert_eq!(final_rows.len(), 1);
971        assert_eq!(
972            final_rows[0][0],
973            ("name".into(), LoraValue::String("carol".into()))
974        );
975        assert_eq!(dec.rows_emitted(), 3);
976    }
977
978    #[test]
979    fn streaming_csv_quoted_newline_across_chunks() {
980        // A quoted cell with an embedded \n that gets split mid-cell
981        // across feeds. The state machine must keep `in_quotes` set
982        // across calls so the newline doesn't terminate the record
983        // prematurely.
984        let mut dec = StreamingCsvDecoder::new();
985        dec.feed(b"v:string\n\"line").unwrap();
986        assert!(dec.drain().unwrap().is_empty());
987        dec.feed(b"1\nline2\"\n").unwrap();
988        let rows = dec.drain().unwrap();
989        assert_eq!(rows.len(), 1);
990        assert_eq!(
991            rows[0][0],
992            ("v".into(), LoraValue::String("line1\nline2".into()))
993        );
994    }
995
996    #[test]
997    fn streaming_csv_utf8_split_across_chunks() {
998        let mut dec = StreamingCsvDecoder::new();
999        dec.feed(b"name:string\nal").unwrap();
1000        dec.feed(&[0xc3]).unwrap();
1001        dec.feed(&[0xa9, b'\n']).unwrap();
1002        let rows = dec.drain().unwrap();
1003        assert_eq!(rows.len(), 1);
1004        assert_eq!(rows[0][0], ("name".into(), LoraValue::String("alé".into())));
1005    }
1006
1007    #[test]
1008    fn streaming_csv_strict_attributes_failing_cell() {
1009        let mut dec = StreamingCsvDecoder::new();
1010        let err = dec
1011            .feed(b"name:string,age:int\nalice,30\nbob,not-a-number\n")
1012            .unwrap_err();
1013        let parse = super::super::format::downcast_row_parse_error(&err)
1014            .expect("error should carry RowParseError");
1015        assert_eq!(parse.row, 2);
1016        assert_eq!(parse.column.as_deref(), Some("age"));
1017        assert!(parse.message.contains("not-a-number"));
1018        assert!(parse.raw_sample.contains("bob"));
1019    }
1020
1021    #[test]
1022    fn streaming_csv_permissive_skips_bad_rows() {
1023        let mut dec = StreamingCsvDecoder::new();
1024        dec.set_permissive(true);
1025        dec.feed(b"name:string,age:int\nalice,30\nbob,oops\ncarol,40\n")
1026            .unwrap();
1027        let rows = dec.drain().unwrap();
1028        assert_eq!(rows.len(), 2);
1029        assert_eq!(
1030            rows[0][0],
1031            ("name".into(), LoraValue::String("alice".into()))
1032        );
1033        assert_eq!(
1034            rows[1][0],
1035            ("name".into(), LoraValue::String("carol".into()))
1036        );
1037        let errors = dec.take_errors();
1038        assert_eq!(errors.len(), 1);
1039        assert_eq!(errors[0].row, 2);
1040        assert_eq!(errors[0].column.as_deref(), Some("age"));
1041    }
1042
1043    #[test]
1044    fn pull_decoder_strips_utf8_bom_from_first_header() {
1045        // Mimics what Excel / Google Sheets writes on "Save as CSV
1046        // (UTF-8)": the file starts with `EF BB BF`. Without the
1047        // strip, the first column name parses as `\u{feff}name` and
1048        // every downstream lookup misses.
1049        let bytes = [0xEF, 0xBB, 0xBF];
1050        let mut csv = String::from_utf8(bytes.to_vec()).unwrap();
1051        csv.push_str("name,age\nalice,30\n");
1052        let mut dec = CsvDecoder::new(Cursor::new(csv));
1053        let h = dec.header().unwrap().unwrap();
1054        assert_eq!(h, vec!["name".to_string(), "age".to_string()]);
1055        let r = dec.next_row().unwrap().unwrap();
1056        assert_eq!(r[0].0, "name");
1057    }
1058
1059    #[test]
1060    fn streaming_csv_strips_utf8_bom_from_first_header() {
1061        let mut dec = StreamingCsvDecoder::new();
1062        // BOM split across the chunk boundary on purpose — the strip
1063        // happens after the whole first record is assembled, so the
1064        // exact chunking of the BOM bytes doesn't matter.
1065        dec.feed(&[0xEF, 0xBB]).unwrap();
1066        dec.feed(&[0xBF]).unwrap();
1067        dec.feed(b"name,age\nalice,30\n").unwrap();
1068        let rows = dec.drain().unwrap();
1069        assert_eq!(rows.len(), 1);
1070        assert_eq!(rows[0][0].0, "name");
1071        assert_eq!(
1072            dec.header().unwrap(),
1073            &["name".to_string(), "age".to_string()]
1074        );
1075    }
1076
1077    #[test]
1078    fn encoder_falls_back_to_json_when_list_element_contains_separator() {
1079        // `["a;b", "c"]` would round-trip as `["a", "b", "c"]` if
1080        // we picked the `;`-join encoding — the decoder splits on
1081        // `;`. Force the JSON path instead so the data survives.
1082        let mut buf = Vec::new();
1083        {
1084            let mut enc = CsvEncoder::new(&mut buf);
1085            enc.begin(&["tags".into()]).unwrap();
1086            enc.write_named_row(&[(
1087                "tags".into(),
1088                LoraValue::List(vec![
1089                    LoraValue::String("a;b".into()),
1090                    LoraValue::String("c".into()),
1091                ]),
1092            )])
1093            .unwrap();
1094            enc.finish().unwrap();
1095        }
1096        let text = std::str::from_utf8(&buf).unwrap();
1097        // Cell carries the JSON encoding, comma-quoted because of the
1098        // embedded `"`.
1099        assert!(
1100            text.contains(r#""[""a;b"",""c""]""#),
1101            "expected JSON-encoded list, got: {text}"
1102        );
1103    }
1104
1105    #[test]
1106    fn encoder_uses_semicolon_join_for_safe_list_elements() {
1107        let mut buf = Vec::new();
1108        {
1109            let mut enc = CsvEncoder::new(&mut buf);
1110            enc.begin(&["tags".into()]).unwrap();
1111            enc.write_named_row(&[(
1112                "tags".into(),
1113                LoraValue::List(vec![
1114                    LoraValue::String("a".into()),
1115                    LoraValue::String("b".into()),
1116                ]),
1117            )])
1118            .unwrap();
1119            enc.finish().unwrap();
1120        }
1121        let text = std::str::from_utf8(&buf).unwrap();
1122        assert_eq!(text, "tags\na;b\n");
1123    }
1124
1125    #[test]
1126    fn streaming_csv_permissive_handles_cell_count_mismatch() {
1127        let mut dec = StreamingCsvDecoder::new();
1128        dec.set_permissive(true);
1129        dec.feed(b"a:string,b:string\nx,y\nz\nq,r\n").unwrap();
1130        let rows = dec.drain().unwrap();
1131        assert_eq!(rows.len(), 2);
1132        let errors = dec.take_errors();
1133        assert_eq!(errors.len(), 1);
1134        assert_eq!(errors[0].row, 2);
1135        assert!(errors[0].column.is_none());
1136        assert!(errors[0].message.contains("cells"));
1137    }
1138}