1use 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#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum CsvType {
27 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 Array(Box<CsvType>),
47 SchemaLabel,
53 SchemaId,
54 SchemaStartId,
55 SchemaEndId,
56 SchemaType,
57}
58
59#[derive(Debug, Clone)]
60pub struct CsvHeader {
61 pub name: String,
63 pub ty: CsvType,
65}
66
67impl CsvHeader {
68 pub fn parse(raw: &str) -> Result<Self, String> {
70 let trimmed = raw.trim();
71 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 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 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 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 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 _ => 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
272fn 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 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 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
380pub(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 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 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 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
516fn 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
549pub(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
613pub struct StreamingCsvDecoder {
619 chunk_buffer: Vec<u8>,
624 head: usize,
633 cells: Vec<Vec<u8>>,
635 current: Vec<u8>,
637 in_quotes: bool,
638 started: bool,
639 header: Option<Vec<CsvHeader>>,
642 column_names: Vec<String>,
645 completed: Vec<Vec<(String, LoraValue)>>,
647 bytes_fed: u64,
648 rows_emitted: u64,
649 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 pub fn parsed_headers(&self) -> Option<&[CsvHeader]> {
687 self.header.as_deref()
688 }
689
690 fn advance(&mut self) -> std::io::Result<()> {
692 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 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 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 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 self.advance()?;
817 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 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 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 assert_eq!(
996 dec.header().unwrap(),
997 &["name".to_string(), "age".to_string()]
998 );
999 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 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 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 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 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 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}