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
417            .headers
418            .as_ref()
419            .ok_or_else(|| invalid_data("CSV header missing after header parse"))?;
420        if cells.len() != headers.len() {
421            return Err(invalid_data(format!(
422                "row has {} cells; expected {} (one per header)",
423                cells.len(),
424                headers.len()
425            )));
426        }
427        let mut out = Vec::with_capacity(cells.len());
428        for (idx, cell) in cells.into_iter().enumerate() {
429            let h = &headers[idx];
430            let value = parse_cell(&cell, &h.ty).map_err(invalid_data)?;
431            out.push((self.column_names[idx].clone(), value));
432        }
433        Ok(Some(out))
434    }
435}
436
437pub(crate) fn parse_cell(raw: &str, ty: &CsvType) -> Result<LoraValue, String> {
438    if raw.is_empty() {
439        return Ok(LoraValue::Null);
440    }
441    match ty {
442        CsvType::Auto | CsvType::String | CsvType::SchemaLabel | CsvType::SchemaType => {
443            Ok(LoraValue::String(raw.to_string()))
444        }
445        CsvType::Int
446        | CsvType::Long
447        | CsvType::SchemaId
448        | CsvType::SchemaStartId
449        | CsvType::SchemaEndId => raw
450            .parse::<i64>()
451            .map(LoraValue::Int)
452            .map_err(|e| format!("invalid integer `{raw}`: {e}")),
453        CsvType::Float | CsvType::Double => raw
454            .parse::<f64>()
455            .map(LoraValue::Float)
456            .map_err(|e| format!("invalid float `{raw}`: {e}")),
457        CsvType::Bool => match raw.to_ascii_lowercase().as_str() {
458            "true" | "t" | "1" | "yes" => Ok(LoraValue::Bool(true)),
459            "false" | "f" | "0" | "no" => Ok(LoraValue::Bool(false)),
460            other => Err(format!("invalid boolean `{other}`")),
461        },
462        CsvType::Date
463        | CsvType::DateTime
464        | CsvType::LocalDateTime
465        | CsvType::Time
466        | CsvType::LocalTime
467        | CsvType::Duration
468        | CsvType::Point => {
469            // Accept either tagged JSON or an ISO string. Try tagged
470            // first by sniffing for `{`; fall back to the typed string.
471            if raw.trim_start().starts_with('{') {
472                let v: J = serde_json::from_str(raw).map_err(|e| e.to_string())?;
473                lora_value_from_json(v)
474            } else {
475                let tag = match ty {
476                    CsvType::Date => "date",
477                    CsvType::DateTime => "datetime",
478                    CsvType::LocalDateTime => "localdatetime",
479                    CsvType::Time => "time",
480                    CsvType::LocalTime => "localtime",
481                    CsvType::Duration => "duration",
482                    CsvType::Point => {
483                        return Err("point cells must be JSON-encoded".into());
484                    }
485                    // Defensive: the outer match restricts `ty` to the
486                    // typed-string branches above, but if a new
487                    // `CsvType` variant is added without extending this
488                    // arm we want untrusted CSV to surface a parse
489                    // error, not panic the parser.
490                    other => return Err(format!("unsupported csv type `{other:?}`")),
491                };
492                let json = serde_json::json!({ "kind": tag, "iso": raw });
493                lora_value_from_json(json)
494            }
495        }
496        CsvType::Json => {
497            let v: J = serde_json::from_str(raw).map_err(|e| e.to_string())?;
498            lora_value_from_json(v)
499        }
500        CsvType::Array(inner) => {
501            // ';' separator; an explicit `[...]` JSON cell also works.
502            if raw.trim_start().starts_with('[') {
503                let v: J = serde_json::from_str(raw).map_err(|e| e.to_string())?;
504                lora_value_from_json(v)
505            } else {
506                let mut items = Vec::new();
507                for part in raw.split(';') {
508                    items.push(parse_cell(part, inner)?);
509                }
510                Ok(LoraValue::List(items))
511            }
512        }
513    }
514}
515
516/// Read one CSV record. Returns `Ok(None)` at EOF.
517fn read_record<R: BufRead>(reader: &mut R) -> std::io::Result<Option<Vec<String>>> {
518    let mut cells: Vec<Vec<u8>> = Vec::new();
519    let mut current: Vec<u8> = Vec::new();
520    let mut in_quotes = false;
521    let mut started = false;
522
523    loop {
524        let buf = reader.fill_buf()?;
525        if buf.is_empty() {
526            if !started && current.is_empty() && cells.is_empty() {
527                return Ok(None);
528            }
529            cells.push(std::mem::take(&mut current));
530            return decode_record(cells).map(Some);
531        }
532        let consumed = process_buf(buf, &mut cells, &mut current, &mut in_quotes, &mut started);
533        let (n, end_of_record) = consumed;
534        reader.consume(n);
535        if end_of_record {
536            cells.push(std::mem::take(&mut current));
537            return decode_record(cells).map(Some);
538        }
539    }
540}
541
542fn decode_record(cells: Vec<Vec<u8>>) -> std::io::Result<Vec<String>> {
543    cells
544        .into_iter()
545        .map(|cell| String::from_utf8(cell).map_err(invalid_data))
546        .collect()
547}
548
549/// Inner record-parsing state machine. Mutates `cells`/`current` as it
550/// consumes bytes from `buf`. Returns `(bytes_consumed, end_of_record)`.
551///
552/// Re-used by [`StreamingCsvDecoder`] to drive a push-based parser:
553/// the state lives on the decoder struct rather than the stack, and
554/// each `feed(chunk)` call advances it one chunk at a time.
555pub(crate) fn process_buf(
556    buf: &[u8],
557    cells: &mut Vec<Vec<u8>>,
558    current: &mut Vec<u8>,
559    in_quotes: &mut bool,
560    started: &mut bool,
561) -> (usize, bool) {
562    let mut i = 0;
563    while i < buf.len() {
564        let b = buf[i];
565        *started = true;
566        if *in_quotes {
567            match b {
568                b'"' => {
569                    if i + 1 < buf.len() && buf[i + 1] == b'"' {
570                        current.push(b'"');
571                        i += 2;
572                    } else {
573                        *in_quotes = false;
574                        i += 1;
575                    }
576                }
577                _ => {
578                    current.push(b);
579                    i += 1;
580                }
581            }
582        } else {
583            match b {
584                b'"' => {
585                    *in_quotes = true;
586                    i += 1;
587                }
588                b',' => {
589                    cells.push(std::mem::take(current));
590                    i += 1;
591                }
592                b'\n' => {
593                    return (i + 1, true);
594                }
595                b'\r' => {
596                    let consumed = if i + 1 < buf.len() && buf[i + 1] == b'\n' {
597                        i + 2
598                    } else {
599                        i + 1
600                    };
601                    return (consumed, true);
602                }
603                _ => {
604                    current.push(b);
605                    i += 1;
606                }
607            }
608        }
609    }
610    (i, false)
611}
612
613/// Push-based CSV decoder. Layers a streaming feed loop on top of
614/// the same `process_buf` byte-level state machine used by the
615/// pull-based [`CsvDecoder`]. The cell/record state lives on the
616/// struct so it persists across `feed` calls — partial records
617/// stay parked until enough bytes arrive to complete them.
618pub struct StreamingCsvDecoder {
619    /// Bytes received but not yet consumed by the state machine.
620    /// `process_buf` reports how many bytes it consumed per call;
621    /// anything left over (inside a quoted cell, mid-cell, etc.)
622    /// stays here for the next feed.
623    chunk_buffer: Vec<u8>,
624    /// Read offset into `chunk_buffer`. `process_buf` is fed
625    /// `&chunk_buffer[head..]` each iteration, and `head` is advanced
626    /// by however many bytes the state machine consumed. The buffer
627    /// is only physically drained (one memmove of the unread tail)
628    /// when `head` exceeds a threshold — without this, draining inside
629    /// the per-record loop turns one chunk's worth of N records into
630    /// O(N²) memmove work, which dominates throughput on imports
631    /// with millions of small rows.
632    head: usize,
633    /// In-flight record cells.
634    cells: Vec<Vec<u8>>,
635    /// In-flight cell.
636    current: Vec<u8>,
637    in_quotes: bool,
638    started: bool,
639    /// Parsed header row, populated after the first record completes.
640    /// `None` before the header is seen.
641    header: Option<Vec<CsvHeader>>,
642    /// Synthesised column names (header names canonicalised so
643    /// `:ID` becomes `_id`, etc.).
644    column_names: Vec<String>,
645    /// Completed data records waiting for `drain`.
646    completed: Vec<Vec<(String, LoraValue)>>,
647    bytes_fed: u64,
648    rows_emitted: u64,
649    /// 1-indexed counter of data rows seen (excluding the header).
650    /// Advances even when a row fails or is skipped in permissive
651    /// mode, so error attribution always reports the user-visible
652    /// row number.
653    record_index: u64,
654    permissive: bool,
655    errors: Vec<RowParseError>,
656}
657
658impl Default for StreamingCsvDecoder {
659    fn default() -> Self {
660        Self::new()
661    }
662}
663
664impl StreamingCsvDecoder {
665    pub fn new() -> Self {
666        Self {
667            chunk_buffer: Vec::with_capacity(64 * 1024),
668            head: 0,
669            cells: Vec::new(),
670            current: Vec::new(),
671            in_quotes: false,
672            started: false,
673            header: None,
674            column_names: Vec::new(),
675            completed: Vec::new(),
676            bytes_fed: 0,
677            rows_emitted: 0,
678            record_index: 0,
679            permissive: false,
680            errors: Vec::new(),
681        }
682    }
683
684    /// Parsed typed headers, surfaced once the first record has
685    /// been consumed. Useful for the playground's mapping preview.
686    pub fn parsed_headers(&self) -> Option<&[CsvHeader]> {
687        self.header.as_deref()
688    }
689
690    /// Drain as many complete records as the current buffer holds.
691    fn advance(&mut self) -> std::io::Result<()> {
692        // Repeatedly call process_buf until it stops finding record
693        // terminators. Each call returns how many bytes of the
694        // unread tail it consumed; we advance `head` instead of
695        // physically draining, so a chunk containing N records pays
696        // one O(tail) memmove at the end of feed() rather than N of
697        // them inside the loop.
698        loop {
699            let (consumed, end_of_record) = process_buf(
700                &self.chunk_buffer[self.head..],
701                &mut self.cells,
702                &mut self.current,
703                &mut self.in_quotes,
704                &mut self.started,
705            );
706            self.head += consumed;
707            if !end_of_record {
708                break;
709            }
710            // Record complete: capture the current cell and route
711            // the record (header → metadata, body → completed list).
712            self.cells.push(std::mem::take(&mut self.current));
713            let record = decode_record(std::mem::take(&mut self.cells))?;
714            self.handle_record(record)?;
715        }
716        // Compact the buffer once the read offset has moved past
717        // half its length, or when the buffer is fully consumed.
718        // Doing the memmove here (amortized) keeps the total drain
719        // work O(file_size) instead of O(file_size × rows_per_chunk).
720        if self.head >= self.chunk_buffer.len() {
721            self.chunk_buffer.clear();
722            self.head = 0;
723        } else if self.head * 2 >= self.chunk_buffer.len() {
724            self.chunk_buffer.drain(..self.head);
725            self.head = 0;
726        }
727        Ok(())
728    }
729
730    fn handle_record(&mut self, mut cells: Vec<String>) -> std::io::Result<()> {
731        if self.header.is_none() {
732            // Header errors are fatal: without a valid header the
733            // body can't be parsed at all, so permissive mode does
734            // not apply.
735            if let Some(first) = cells.first_mut() {
736                strip_utf8_bom(first);
737            }
738            let mut headers = Vec::with_capacity(cells.len());
739            let mut column_names = Vec::with_capacity(cells.len());
740            for (idx, raw) in cells.iter().enumerate() {
741                let h = CsvHeader::parse(raw).map_err(invalid_data)?;
742                column_names.push(synthetic_name(&h, idx));
743                headers.push(h);
744            }
745            self.header = Some(headers);
746            self.column_names = column_names;
747            return Ok(());
748        }
749        self.record_index += 1;
750        let header = self
751            .header
752            .as_ref()
753            .ok_or_else(|| invalid_data("CSV header missing after header parse"))?;
754        if cells.len() != header.len() {
755            let message = format!(
756                "row has {} cells; expected {} (one per header)",
757                cells.len(),
758                header.len()
759            );
760            return self.report_record_error(None, message, &cells);
761        }
762        let mut out = Vec::with_capacity(cells.len());
763        for (idx, cell) in cells.iter().enumerate() {
764            let h = &header[idx];
765            match parse_cell(cell, &h.ty) {
766                Ok(value) => out.push((self.column_names[idx].clone(), value)),
767                Err(message) => {
768                    let column = Some(self.column_names[idx].clone());
769                    return self.report_record_error(column, message, &cells);
770                }
771            }
772        }
773        self.completed.push(out);
774        self.rows_emitted += 1;
775        Ok(())
776    }
777
778    fn report_record_error(
779        &mut self,
780        column: Option<String>,
781        message: String,
782        cells: &[String],
783    ) -> std::io::Result<()> {
784        let raw = cells.join(",");
785        let err = RowParseError {
786            row: self.record_index,
787            column,
788            raw_sample: RowParseError::make_sample(&raw),
789            message,
790        };
791        if self.permissive {
792            self.errors.push(err);
793            Ok(())
794        } else {
795            Err(row_parse_io_error(err))
796        }
797    }
798}
799
800impl StreamingRowDecoder for StreamingCsvDecoder {
801    fn feed(&mut self, chunk: &[u8]) -> std::io::Result<()> {
802        if chunk.is_empty() {
803            return Ok(());
804        }
805        self.bytes_fed += chunk.len() as u64;
806        self.chunk_buffer.extend_from_slice(chunk);
807        self.advance()
808    }
809
810    fn drain(&mut self) -> std::io::Result<Vec<Vec<(String, LoraValue)>>> {
811        Ok(std::mem::take(&mut self.completed))
812    }
813
814    fn finish(&mut self) -> std::io::Result<Vec<Vec<(String, LoraValue)>>> {
815        // Flush anything still in the byte buffer.
816        self.advance()?;
817        // Files often end without a trailing newline; if a record
818        // was building when input ran out, close it now.
819        if self.started && (!self.cells.is_empty() || !self.current.is_empty() || self.in_quotes) {
820            self.cells.push(std::mem::take(&mut self.current));
821            let record = decode_record(std::mem::take(&mut self.cells))?;
822            self.started = false;
823            self.in_quotes = false;
824            self.handle_record(record)?;
825        }
826        Ok(std::mem::take(&mut self.completed))
827    }
828
829    fn header(&self) -> Option<&[String]> {
830        if self.column_names.is_empty() {
831            None
832        } else {
833            Some(&self.column_names)
834        }
835    }
836
837    fn bytes_fed(&self) -> u64 {
838        self.bytes_fed
839    }
840
841    fn rows_emitted(&self) -> u64 {
842        self.rows_emitted
843    }
844
845    fn set_permissive(&mut self, on: bool) {
846        self.permissive = on;
847    }
848
849    fn take_errors(&mut self) -> Vec<RowParseError> {
850        std::mem::take(&mut self.errors)
851    }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use std::io::Cursor;
858
859    #[test]
860    fn round_trip_simple() {
861        let mut buf = Vec::new();
862        {
863            let mut enc = CsvEncoder::new(&mut buf);
864            enc.begin(&["name".into(), "age".into()]).unwrap();
865            enc.write_named_row(&[
866                ("name".into(), LoraValue::String("alice".into())),
867                ("age".into(), LoraValue::Int(30)),
868            ])
869            .unwrap();
870            enc.write_named_row(&[
871                ("name".into(), LoraValue::String("bob".into())),
872                ("age".into(), LoraValue::Int(25)),
873            ])
874            .unwrap();
875            enc.finish().unwrap();
876        }
877        let text = std::str::from_utf8(&buf).unwrap();
878        assert_eq!(text, "name,age\nalice,30\nbob,25\n");
879
880        let mut dec = CsvDecoder::new(Cursor::new(buf));
881        let h = dec.header().unwrap().unwrap();
882        assert_eq!(h, vec!["name".to_string(), "age".to_string()]);
883
884        let r1 = dec.next_row().unwrap().unwrap();
885        assert_eq!(r1[0].1, LoraValue::String("alice".into()));
886        // Auto type -> string. Caller would re-tag via column type.
887        assert_eq!(r1[1].1, LoraValue::String("30".into()));
888    }
889
890    #[test]
891    fn typed_headers_parse_numeric_cells() {
892        let csv = "name:string,age:int\nalice,30\nbob,25\n";
893        let mut dec = CsvDecoder::new(Cursor::new(csv));
894        let h = dec.header().unwrap().unwrap();
895        assert_eq!(h, vec!["name".to_string(), "age".to_string()]);
896        let r = dec.next_row().unwrap().unwrap();
897        assert_eq!(r[0].1, LoraValue::String("alice".into()));
898        assert_eq!(r[1].1, LoraValue::Int(30));
899    }
900
901    #[test]
902    fn schema_markers_parse() {
903        let csv = ":ID,:LABEL,name:string\n1,User,alice\n2,User,bob\n";
904        let mut dec = CsvDecoder::new(Cursor::new(csv));
905        let h = dec.header().unwrap().unwrap();
906        assert_eq!(
907            h,
908            vec!["_id".to_string(), "_label".to_string(), "name".to_string()]
909        );
910        let r = dec.next_row().unwrap().unwrap();
911        assert_eq!(r[0].1, LoraValue::Int(1));
912        assert_eq!(r[1].1, LoraValue::String("User".into()));
913    }
914
915    #[test]
916    fn array_cells_split_on_semicolon() {
917        let csv = "tags:string[]\nfoo;bar;baz\n";
918        let mut dec = CsvDecoder::new(Cursor::new(csv));
919        let _ = dec.header().unwrap();
920        let r = dec.next_row().unwrap().unwrap();
921        let LoraValue::List(items) = &r[0].1 else {
922            panic!("expected list");
923        };
924        assert_eq!(items.len(), 3);
925        assert_eq!(items[0], LoraValue::String("foo".into()));
926    }
927
928    #[test]
929    fn quoted_cells_preserve_commas_and_newlines() {
930        let csv = "name:string,note:string\n\"hi, there\",\"line1\nline2\"\n";
931        let mut dec = CsvDecoder::new(Cursor::new(csv));
932        let _ = dec.header().unwrap();
933        let r = dec.next_row().unwrap().unwrap();
934        assert_eq!(r[0].1, LoraValue::String("hi, there".into()));
935        assert_eq!(r[1].1, LoraValue::String("line1\nline2".into()));
936    }
937
938    #[test]
939    fn quotes_escape_by_doubling() {
940        let csv = "v:string\n\"she said \"\"hi\"\"\"\n";
941        let mut dec = CsvDecoder::new(Cursor::new(csv));
942        let _ = dec.header().unwrap();
943        let r = dec.next_row().unwrap().unwrap();
944        assert_eq!(r[0].1, LoraValue::String("she said \"hi\"".into()));
945    }
946
947    #[test]
948    fn encoder_quotes_when_needed() {
949        let mut buf = Vec::new();
950        {
951            let mut enc = CsvEncoder::new(&mut buf);
952            enc.begin(&["v".into()]).unwrap();
953            enc.write_named_row(&[("v".into(), LoraValue::String("hi, \"there\"\n".into()))])
954                .unwrap();
955            enc.finish().unwrap();
956        }
957        let text = std::str::from_utf8(&buf).unwrap();
958        assert_eq!(text, "v\n\"hi, \"\"there\"\"\n\"\n");
959    }
960
961    #[test]
962    fn json_typed_cells_round_trip_temporal() {
963        let csv = "ts:datetime\n2024-01-15T10:30:00Z\n";
964        let mut dec = CsvDecoder::new(Cursor::new(csv));
965        let _ = dec.header().unwrap();
966        let r = dec.next_row().unwrap().unwrap();
967        match &r[0].1 {
968            LoraValue::DateTime(_) => {}
969            other => panic!("expected DateTime, got {other:?}"),
970        }
971    }
972
973    #[test]
974    fn streaming_csv_split_across_chunks() {
975        // Header split mid-cell, multiple rows split arbitrarily.
976        let mut dec = StreamingCsvDecoder::new();
977        dec.feed(b"name:s").unwrap();
978        assert!(dec.header().is_none());
979        dec.feed(b"tring,age:int\nalice,30\nbo").unwrap();
980        let rows = dec.drain().unwrap();
981        assert_eq!(rows.len(), 1);
982        assert_eq!(
983            rows[0][0],
984            ("name".into(), LoraValue::String("alice".into()))
985        );
986        assert_eq!(rows[0][1], ("age".into(), LoraValue::Int(30)));
987        dec.feed(b"b,25\n").unwrap();
988        let rows2 = dec.drain().unwrap();
989        assert_eq!(rows2.len(), 1);
990        assert_eq!(
991            rows2[0][0],
992            ("name".into(), LoraValue::String("bob".into()))
993        );
994        // Header should be populated now.
995        assert_eq!(
996            dec.header().unwrap(),
997            &["name".to_string(), "age".to_string()]
998        );
999        // No trailing newline on final record: dripped through finish().
1000        dec.feed(b"carol,40").unwrap();
1001        assert!(dec.drain().unwrap().is_empty());
1002        let final_rows = dec.finish().unwrap();
1003        assert_eq!(final_rows.len(), 1);
1004        assert_eq!(
1005            final_rows[0][0],
1006            ("name".into(), LoraValue::String("carol".into()))
1007        );
1008        assert_eq!(dec.rows_emitted(), 3);
1009    }
1010
1011    #[test]
1012    fn streaming_csv_quoted_newline_across_chunks() {
1013        // A quoted cell with an embedded \n that gets split mid-cell
1014        // across feeds. The state machine must keep `in_quotes` set
1015        // across calls so the newline doesn't terminate the record
1016        // prematurely.
1017        let mut dec = StreamingCsvDecoder::new();
1018        dec.feed(b"v:string\n\"line").unwrap();
1019        assert!(dec.drain().unwrap().is_empty());
1020        dec.feed(b"1\nline2\"\n").unwrap();
1021        let rows = dec.drain().unwrap();
1022        assert_eq!(rows.len(), 1);
1023        assert_eq!(
1024            rows[0][0],
1025            ("v".into(), LoraValue::String("line1\nline2".into()))
1026        );
1027    }
1028
1029    #[test]
1030    fn streaming_csv_utf8_split_across_chunks() {
1031        let mut dec = StreamingCsvDecoder::new();
1032        dec.feed(b"name:string\nal").unwrap();
1033        dec.feed(&[0xc3]).unwrap();
1034        dec.feed(&[0xa9, b'\n']).unwrap();
1035        let rows = dec.drain().unwrap();
1036        assert_eq!(rows.len(), 1);
1037        assert_eq!(rows[0][0], ("name".into(), LoraValue::String("alé".into())));
1038    }
1039
1040    #[test]
1041    fn streaming_csv_strict_attributes_failing_cell() {
1042        let mut dec = StreamingCsvDecoder::new();
1043        let err = dec
1044            .feed(b"name:string,age:int\nalice,30\nbob,not-a-number\n")
1045            .unwrap_err();
1046        let parse = super::super::format::downcast_row_parse_error(&err)
1047            .expect("error should carry RowParseError");
1048        assert_eq!(parse.row, 2);
1049        assert_eq!(parse.column.as_deref(), Some("age"));
1050        assert!(parse.message.contains("not-a-number"));
1051        assert!(parse.raw_sample.contains("bob"));
1052    }
1053
1054    #[test]
1055    fn streaming_csv_permissive_skips_bad_rows() {
1056        let mut dec = StreamingCsvDecoder::new();
1057        dec.set_permissive(true);
1058        dec.feed(b"name:string,age:int\nalice,30\nbob,oops\ncarol,40\n")
1059            .unwrap();
1060        let rows = dec.drain().unwrap();
1061        assert_eq!(rows.len(), 2);
1062        assert_eq!(
1063            rows[0][0],
1064            ("name".into(), LoraValue::String("alice".into()))
1065        );
1066        assert_eq!(
1067            rows[1][0],
1068            ("name".into(), LoraValue::String("carol".into()))
1069        );
1070        let errors = dec.take_errors();
1071        assert_eq!(errors.len(), 1);
1072        assert_eq!(errors[0].row, 2);
1073        assert_eq!(errors[0].column.as_deref(), Some("age"));
1074    }
1075
1076    #[test]
1077    fn pull_decoder_strips_utf8_bom_from_first_header() {
1078        // Mimics what Excel / Google Sheets writes on "Save as CSV
1079        // (UTF-8)": the file starts with `EF BB BF`. Without the
1080        // strip, the first column name parses as `\u{feff}name` and
1081        // every downstream lookup misses.
1082        let bytes = [0xEF, 0xBB, 0xBF];
1083        let mut csv = String::from_utf8(bytes.to_vec()).unwrap();
1084        csv.push_str("name,age\nalice,30\n");
1085        let mut dec = CsvDecoder::new(Cursor::new(csv));
1086        let h = dec.header().unwrap().unwrap();
1087        assert_eq!(h, vec!["name".to_string(), "age".to_string()]);
1088        let r = dec.next_row().unwrap().unwrap();
1089        assert_eq!(r[0].0, "name");
1090    }
1091
1092    #[test]
1093    fn streaming_csv_strips_utf8_bom_from_first_header() {
1094        let mut dec = StreamingCsvDecoder::new();
1095        // BOM split across the chunk boundary on purpose — the strip
1096        // happens after the whole first record is assembled, so the
1097        // exact chunking of the BOM bytes doesn't matter.
1098        dec.feed(&[0xEF, 0xBB]).unwrap();
1099        dec.feed(&[0xBF]).unwrap();
1100        dec.feed(b"name,age\nalice,30\n").unwrap();
1101        let rows = dec.drain().unwrap();
1102        assert_eq!(rows.len(), 1);
1103        assert_eq!(rows[0][0].0, "name");
1104        assert_eq!(
1105            dec.header().unwrap(),
1106            &["name".to_string(), "age".to_string()]
1107        );
1108    }
1109
1110    #[test]
1111    fn encoder_falls_back_to_json_when_list_element_contains_separator() {
1112        // `["a;b", "c"]` would round-trip as `["a", "b", "c"]` if
1113        // we picked the `;`-join encoding — the decoder splits on
1114        // `;`. Force the JSON path instead so the data survives.
1115        let mut buf = Vec::new();
1116        {
1117            let mut enc = CsvEncoder::new(&mut buf);
1118            enc.begin(&["tags".into()]).unwrap();
1119            enc.write_named_row(&[(
1120                "tags".into(),
1121                LoraValue::List(vec![
1122                    LoraValue::String("a;b".into()),
1123                    LoraValue::String("c".into()),
1124                ]),
1125            )])
1126            .unwrap();
1127            enc.finish().unwrap();
1128        }
1129        let text = std::str::from_utf8(&buf).unwrap();
1130        // Cell carries the JSON encoding, comma-quoted because of the
1131        // embedded `"`.
1132        assert!(
1133            text.contains(r#""[""a;b"",""c""]""#),
1134            "expected JSON-encoded list, got: {text}"
1135        );
1136    }
1137
1138    #[test]
1139    fn encoder_uses_semicolon_join_for_safe_list_elements() {
1140        let mut buf = Vec::new();
1141        {
1142            let mut enc = CsvEncoder::new(&mut buf);
1143            enc.begin(&["tags".into()]).unwrap();
1144            enc.write_named_row(&[(
1145                "tags".into(),
1146                LoraValue::List(vec![
1147                    LoraValue::String("a".into()),
1148                    LoraValue::String("b".into()),
1149                ]),
1150            )])
1151            .unwrap();
1152            enc.finish().unwrap();
1153        }
1154        let text = std::str::from_utf8(&buf).unwrap();
1155        assert_eq!(text, "tags\na;b\n");
1156    }
1157
1158    #[test]
1159    fn streaming_csv_permissive_handles_cell_count_mismatch() {
1160        let mut dec = StreamingCsvDecoder::new();
1161        dec.set_permissive(true);
1162        dec.feed(b"a:string,b:string\nx,y\nz\nq,r\n").unwrap();
1163        let rows = dec.drain().unwrap();
1164        assert_eq!(rows.len(), 2);
1165        let errors = dec.take_errors();
1166        assert_eq!(errors.len(), 1);
1167        assert_eq!(errors[0].row, 2);
1168        assert!(errors[0].column.is_none());
1169        assert!(errors[0].message.contains("cells"));
1170    }
1171}