1use std::fs::File;
14use std::io::{BufRead, BufReader};
15use std::path::Path;
16
17use datarust::StrMatrix;
18
19use crate::error::{ProfileError, Result};
20
21#[derive(Debug, Clone)]
23pub struct CsvReadOptions {
24 pub delimiter: u8,
26 pub quote: Option<u8>,
29 pub has_header: bool,
31 pub trim: bool,
33}
34
35impl Default for CsvReadOptions {
36 fn default() -> Self {
37 CsvReadOptions {
38 delimiter: b',',
39 quote: Some(b'"'),
40 has_header: true,
41 trim: false,
42 }
43 }
44}
45
46pub(crate) type Record = (Vec<String>, usize);
49
50pub(crate) type Records = Vec<Record>;
52
53#[derive(Debug, Clone)]
55pub struct CsvTable {
56 pub header: Option<Vec<String>>,
58 pub rows: StrMatrix,
60}
61
62impl CsvTable {
63 pub fn nrows(&self) -> usize {
65 self.rows.nrows()
66 }
67
68 pub fn ncols(&self) -> usize {
70 self.rows.ncols()
71 }
72
73 pub fn column_names(&self) -> Vec<String> {
76 match &self.header {
77 Some(h) if h.len() == self.ncols() => h.clone(),
78 _ => (0..self.ncols()).map(|j| format!("x{j}")).collect(),
79 }
80 }
81}
82
83pub fn read_csv(path: &Path) -> Result<CsvTable> {
85 read_csv_with(path, &CsvReadOptions::default())
86}
87
88pub fn read_csv_with(path: &Path, opts: &CsvReadOptions) -> Result<CsvTable> {
90 let file = File::open(path)?;
91 read_csv_reader(BufReader::new(file), opts)
92}
93
94pub fn read_csv_header(path: &Path, opts: &CsvReadOptions) -> Result<Vec<String>> {
99 let file = File::open(path)?;
100 read_csv_header_reader(BufReader::new(file), opts)
101}
102
103pub fn read_csv_header_reader<R: BufRead>(
105 mut reader: R,
106 opts: &CsvReadOptions,
107) -> Result<Vec<String>> {
108 validate_options(opts)?;
109 let mut parser = RecordParser::new(opts);
110 let mut records: Vec<(Vec<String>, usize)> = Vec::new();
111 let mut buf = String::new();
112 loop {
113 buf.clear();
114 let n = reader.read_line(&mut buf)?;
115 if n == 0 {
116 break;
117 }
118 parser.feed(&buf, &mut records)?;
119 if let Some((row, _)) = records.first() {
122 let mut row = row.clone();
123 if let Some(cell) = row.first_mut() {
124 if let Some(stripped) = cell.strip_prefix('\u{feff}') {
125 *cell = stripped.to_string();
126 }
127 }
128 return Ok(row);
129 }
130 }
131 Err(ProfileError::EmptyInput("CSV has no records".into()))
132}
133
134fn validate_options(opts: &CsvReadOptions) -> Result<()> {
136 if !opts.delimiter.is_ascii() {
137 return Err(ProfileError::InvalidInput(
138 "CSV delimiter must be an ASCII byte".into(),
139 ));
140 }
141 if matches!(opts.delimiter, b'\r' | b'\n') {
144 return Err(ProfileError::InvalidInput(
145 "CSV delimiter must not be CR or LF".into(),
146 ));
147 }
148 if let Some(q) = opts.quote {
149 if !q.is_ascii() {
150 return Err(ProfileError::InvalidInput(
151 "CSV quote must be an ASCII byte".into(),
152 ));
153 }
154 if matches!(q, b'\r' | b'\n') {
155 return Err(ProfileError::InvalidInput(
156 "CSV quote must not be CR or LF".into(),
157 ));
158 }
159 if q == opts.delimiter {
160 return Err(ProfileError::InvalidInput(
161 "CSV delimiter and quote byte must differ".into(),
162 ));
163 }
164 }
165 Ok(())
166}
167
168pub fn read_csv_reader<R: BufRead>(mut reader: R, opts: &CsvReadOptions) -> Result<CsvTable> {
175 validate_options(opts)?;
176
177 let mut parser = RecordParser::new(opts);
178 let mut records: Vec<(Vec<String>, usize)> = Vec::new();
179 let mut buf = String::new();
180 loop {
181 buf.clear();
182 let n = reader.read_line(&mut buf)?;
183 if n == 0 {
184 break;
185 }
186 parser.feed(&buf, &mut records)?;
187 }
188 parser.finish(&mut records)?;
189
190 if let Some((row, _)) = records.first_mut() {
192 if let Some(cell) = row.first_mut() {
193 if let Some(stripped) = cell.strip_prefix('\u{feff}') {
194 *cell = stripped.to_string();
195 }
196 }
197 }
198
199 if records.is_empty() {
200 return Err(ProfileError::EmptyInput("CSV has no records".into()));
201 }
202
203 let (header, data): (Option<Vec<String>>, Records) = if opts.has_header {
204 (Some(records.remove(0).0), records)
205 } else {
206 (None, records)
207 };
208 if data.is_empty() {
209 return Err(ProfileError::EmptyInput("CSV has no data rows".into()));
210 }
211 let cols = header.as_ref().map_or(data[0].0.len(), |h| h.len());
214 if cols == 0 {
215 return Err(ProfileError::EmptyInput("CSV has no columns".into()));
216 }
217 if let Some((len, line)) = data
218 .iter()
219 .find(|(r, _)| r.len() != cols)
220 .map(|(r, l)| (r.len(), *l))
221 {
222 return Err(ProfileError::InvalidInput(format!(
225 "CSV line {line} has {len} fields, expected {cols}"
226 )));
227 }
228 let rows = StrMatrix::from_strings(data.into_iter().map(|(row, _)| row))?;
229 Ok(CsvTable { header, rows })
230}
231
232pub(crate) struct RecordParser {
238 delimiter: u8,
239 quote: Option<u8>,
240 trim: bool,
241 line: usize,
243 record_line: usize,
245 record: Vec<String>,
246 field: String,
247 field_quoted: bool,
249 record_has_quote: bool,
251 in_quotes: bool,
253}
254
255impl RecordParser {
256 pub(crate) fn new(opts: &CsvReadOptions) -> Self {
257 RecordParser {
258 delimiter: opts.delimiter,
259 quote: opts.quote,
260 trim: opts.trim,
261 line: 0,
262 record_line: 0,
263 record: Vec::new(),
264 field: String::new(),
265 field_quoted: false,
266 record_has_quote: false,
267 in_quotes: false,
268 }
269 }
270
271 pub(crate) fn feed(&mut self, line: &str, out: &mut Vec<(Vec<String>, usize)>) -> Result<()> {
275 self.line += 1;
276 let mut chars = line.chars().peekable();
277 while let Some(c) = chars.next() {
278 if self.in_quotes {
279 if self.quote.is_some_and(|q| c == q as char) {
280 let quote = self.quote.unwrap() as char;
281 if chars.peek() == Some("e) {
282 chars.next();
284 self.field.push(c);
285 } else {
286 self.in_quotes = false;
287 }
288 } else {
289 self.field.push(c);
290 }
291 continue;
292 }
293 if self.record_line == 0 {
294 self.record_line = self.line;
295 }
296 if self.quote.is_some_and(|q| c == q as char) {
297 if !self.field.is_empty() {
298 return Err(ProfileError::InvalidInput(
299 "CSV quote appears inside an unquoted field".into(),
300 ));
301 }
302 self.in_quotes = true;
303 self.field_quoted = true;
304 self.record_has_quote = true;
305 continue;
306 }
307 if c == self.delimiter as char {
308 self.end_field();
309 continue;
310 }
311 if c == '\r' && chars.peek() == Some(&'\n') {
312 chars.next();
313 self.end_field();
314 self.end_record(out);
315 continue;
316 }
317 if c == '\n' {
318 self.end_field();
319 self.end_record(out);
320 continue;
321 }
322 self.field.push(c);
323 }
324 Ok(())
325 }
326
327 pub(crate) fn finish(&mut self, out: &mut Vec<(Vec<String>, usize)>) -> Result<()> {
330 if self.in_quotes {
331 return Err(ProfileError::InvalidInput(
332 "CSV input ended inside a quoted field".into(),
333 ));
334 }
335 if !self.field.is_empty() || !self.record.is_empty() || self.field_quoted {
338 self.end_field();
339 self.end_record(out);
340 }
341 Ok(())
342 }
343
344 fn end_field(&mut self) {
345 let mut value = std::mem::take(&mut self.field);
346 if self.trim && !self.field_quoted {
347 value = value.trim().to_string();
348 }
349 self.record.push(value);
350 self.field_quoted = false;
351 }
352
353 fn end_record(&mut self, out: &mut Vec<(Vec<String>, usize)>) {
354 if self.record.len() == 1 && self.record[0].is_empty() && !self.record_has_quote {
356 self.record.clear();
357 self.record_line = 0;
358 return;
359 }
360 out.push((std::mem::take(&mut self.record), self.record_line.max(1)));
361 self.record_line = 0;
362 self.record_has_quote = false;
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use std::io::Cursor;
370
371 fn parse(input: &str) -> Result<CsvTable> {
372 read_csv_reader(Cursor::new(input.to_string()), &CsvReadOptions::default())
373 }
374
375 #[test]
376 fn parses_plain_rows_with_header() {
377 let t = parse("a,b,c\n1,2,3\n4,5,6\n").unwrap();
378 assert_eq!(t.nrows(), 2);
379 assert_eq!(t.ncols(), 3);
380 assert_eq!(
381 t.header.as_deref().unwrap(),
382 &["a".to_string(), "b".to_string(), "c".to_string()]
383 );
384 assert_eq!(t.rows.get(0, 0), "1");
385 assert_eq!(t.rows.get(1, 2), "6");
386 }
387
388 #[test]
389 fn parses_quoted_fields_and_escapes() {
390 let t = parse("name,note\n\"Doe, John\",\"said \"\"hi\"\"\"\n").unwrap();
391 assert_eq!(t.rows.get(0, 0), "Doe, John");
392 assert_eq!(t.rows.get(0, 1), "said \"hi\"");
393 }
394
395 #[test]
396 fn quoted_multiline_field() {
397 let t = parse("a,b\n\"line1\nline2\",x\n").unwrap();
398 assert_eq!(t.nrows(), 1);
399 assert_eq!(t.rows.get(0, 0), "line1\nline2");
400 }
401
402 #[test]
403 fn crlf_and_lf_identical() {
404 let lf = parse("a,b\n1,2\n").unwrap();
405 let crlf = parse("a,b\r\n1,2\r\n").unwrap();
406 assert_eq!(lf.rows.get(0, 0), crlf.rows.get(0, 0));
407 assert_eq!(lf.rows.get(0, 1), crlf.rows.get(0, 1));
408 }
409
410 #[test]
411 fn bom_is_stripped() {
412 let t = parse("\u{feff}a,b\n1,2\n").unwrap();
413 assert_eq!(t.header.as_deref().unwrap()[0], "a");
414 }
415
416 #[test]
417 fn no_header_option() {
418 let opts = CsvReadOptions {
419 has_header: false,
420 ..Default::default()
421 };
422 let t = read_csv_reader(Cursor::new("1,2\n3,4\n".to_string()), &opts).unwrap();
423 assert!(t.header.is_none());
424 assert_eq!(t.nrows(), 2);
425 assert_eq!(t.column_names(), vec!["x0".to_string(), "x1".to_string()]);
426 }
427
428 #[test]
429 fn blank_lines_skipped() {
430 let t = parse("a,b\n\n1,2\n\n\n3,4\n").unwrap();
431 assert_eq!(t.nrows(), 2);
432 }
433
434 #[test]
435 fn trailing_newline_optional() {
436 let t = parse("a,b\n1,2").unwrap();
437 assert_eq!(t.nrows(), 1);
438 }
439
440 #[test]
441 fn ragged_row_is_rejected_with_line_number() {
442 let err = parse("a,b\n1,2,3\n").unwrap_err();
443 assert!(err.to_string().contains("line 2 has 3 fields, expected 2"));
444 }
445
446 #[test]
447 fn non_ascii_chars_matching_ascii_low_byte_stay_intact() {
448 let t = parse("a,b\nĬ,x\n").unwrap();
451 assert_eq!(t.nrows(), 1);
452 assert_eq!(t.rows.get(0, 0), "Ĭ");
453 assert_eq!(t.rows.get(0, 1), "x");
454
455 let t = parse("a\nĢ\n").unwrap();
456 assert_eq!(t.rows.get(0, 0), "Ģ");
457 }
458
459 #[test]
460 fn cr_or_lf_delimiter_and_quote_are_rejected() {
461 for bad in [b'\r', b'\n'] {
462 let opts = CsvReadOptions {
463 delimiter: bad,
464 ..Default::default()
465 };
466 let err = read_csv_reader(Cursor::new(String::new()), &opts).unwrap_err();
467 assert!(err.to_string().contains("CR or LF"), "{err}");
468
469 let opts = CsvReadOptions {
470 quote: Some(bad),
471 ..Default::default()
472 };
473 let err = read_csv_reader(Cursor::new(String::new()), &opts).unwrap_err();
474 assert!(err.to_string().contains("CR or LF"), "{err}");
475 }
476 }
477
478 #[test]
479 fn ragged_line_number_accounts_for_blank_lines() {
480 let err = parse("a,b\n\n1,2,3\n").unwrap_err();
482 assert!(err.to_string().contains("line 3"), "{err}");
483 }
484
485 #[test]
486 fn quoted_empty_field_without_trailing_newline() {
487 let opts = CsvReadOptions {
489 has_header: false,
490 ..Default::default()
491 };
492 let t = read_csv_reader(Cursor::new("\"\"".to_string()), &opts).unwrap();
493 assert_eq!(t.nrows(), 1);
494 assert_eq!(t.ncols(), 1);
495 assert_eq!(t.rows.get(0, 0), "");
496 }
497
498 #[test]
499 fn non_ascii_delimiter_is_rejected() {
500 let opts = CsvReadOptions {
501 delimiter: 0xFF,
502 ..Default::default()
503 };
504 let err = read_csv_reader(Cursor::new("a\n".to_string()), &opts).unwrap_err();
505 assert!(matches!(err, ProfileError::InvalidInput(_)));
506 }
507
508 #[test]
509 fn delimiter_and_quote_configurable() {
510 let opts = CsvReadOptions {
511 delimiter: b';',
512 ..Default::default()
513 };
514 let t = read_csv_reader(Cursor::new("a;b\n1;2\n".to_string()), &opts).unwrap();
515 assert_eq!(t.rows.get(0, 1), "2");
516 }
517
518 #[test]
519 fn quoted_delimiter_stays_in_field() {
520 let opts = CsvReadOptions {
521 delimiter: b';',
522 ..Default::default()
523 };
524 let t = read_csv_reader(Cursor::new("a;b\n\"1;2\";3\n".to_string()), &opts).unwrap();
525 assert_eq!(t.rows.get(0, 0), "1;2");
526 assert_eq!(t.rows.get(0, 1), "3");
527 }
528
529 #[test]
530 fn empty_input_is_error() {
531 let err = parse("").unwrap_err();
532 assert!(matches!(err, ProfileError::EmptyInput(_)));
533 }
534
535 #[test]
536 fn header_only_is_empty_input() {
537 let err = parse("a,b\n").unwrap_err();
538 assert!(matches!(err, ProfileError::EmptyInput(_)));
539 }
540
541 #[test]
542 fn trim_option_applies_to_unquoted_fields() {
543 let opts = CsvReadOptions {
544 trim: true,
545 ..Default::default()
546 };
547 let t = read_csv_reader(Cursor::new("a , b\n x ,\" y \"\n".to_string()), &opts).unwrap();
548 assert_eq!(t.rows.get(0, 0), "x");
549 assert_eq!(t.rows.get(0, 1), " y ");
551 }
552
553 #[test]
554 fn quote_none_keeps_quote_characters() {
555 let opts = CsvReadOptions {
556 quote: None,
557 ..Default::default()
558 };
559 let t = read_csv_reader(Cursor::new("a,b\n\"x\",2\n".to_string()), &opts).unwrap();
560 assert_eq!(t.rows.get(0, 0), "\"x\"");
561 assert_eq!(t.rows.get(0, 1), "2");
562 }
563
564 #[test]
565 fn same_delimiter_and_quote_is_rejected() {
566 let opts = CsvReadOptions {
567 delimiter: b',',
568 quote: Some(b','),
569 ..Default::default()
570 };
571 let err = read_csv_reader(Cursor::new("a,b\n".to_string()), &opts).unwrap_err();
572 assert!(matches!(err, ProfileError::InvalidInput(_)));
573 }
574
575 #[test]
576 fn unterminated_quote_is_rejected() {
577 let err = parse("a,b\n\"open,2\n").unwrap_err();
578 assert!(err.to_string().contains("ended inside a quoted field"));
579 }
580
581 #[test]
582 fn quoted_empty_field_is_kept() {
583 let t = parse("a,b\n\"\",x\n").unwrap();
584 assert_eq!(t.rows.get(0, 0), "");
585 assert_eq!(t.rows.get(0, 1), "x");
586 }
587
588 #[test]
589 fn read_csv_file_round_trip() {
590 let dir = tempfile::tempdir().unwrap();
591 let path = dir.path().join("t.csv");
592 std::fs::write(&path, "a,b\n1,2\n").unwrap();
593 let t = read_csv(&path).unwrap();
594 assert_eq!(t.nrows(), 1);
595 assert_eq!(t.rows.get(0, 0), "1");
596 }
597}