Skip to main content

datarust_profile/
csv.rs

1//! Pure-Rust CSV reading for the `csv` feature.
2//!
3//! A small RFC-4180-subset reader with zero external dependencies: quoted
4//! fields, embedded delimiters/newlines/quotes (`""` escapes), LF and CRLF
5//! line endings, and UTF-8 BOM stripping. Parsing is intentionally strict: a
6//! row with a different field count than the first data row is an error
7//! rather than a silent misalignment.
8//!
9//! The reader feeds exactly the same [`StrMatrix`] path as
10//! [`crate::profile_str_matrix`], so `profile_csv(path)` and
11//! `profile_str_matrix` produce bit-identical profiles on the same data.
12
13use std::fs::File;
14use std::io::{BufRead, BufReader};
15use std::path::Path;
16
17use datarust::StrMatrix;
18
19use crate::error::{ProfileError, Result};
20
21/// Options controlling CSV parsing.
22#[derive(Debug, Clone)]
23pub struct CsvReadOptions {
24    /// Field delimiter byte (default `b','`). Must be ASCII.
25    pub delimiter: u8,
26    /// Quote byte (default `Some(b'"')`). `None` disables quoting entirely.
27    /// Must be ASCII when present.
28    pub quote: Option<u8>,
29    /// Whether the first physical record is a header (default `true`).
30    pub has_header: bool,
31    /// Trim ASCII whitespace from every unquoted field (default `false`).
32    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
46/// One parsed record: its fields plus the 1-based physical file line where
47/// the record started (blank lines and quoted newlines are counted).
48pub(crate) type Record = (Vec<String>, usize);
49
50/// A list of parsed records.
51pub(crate) type Records = Vec<Record>;
52
53/// A parsed CSV document: optional header plus a rectangular data block.
54#[derive(Debug, Clone)]
55pub struct CsvTable {
56    /// Header names when the source had a header row, `None` otherwise.
57    pub header: Option<Vec<String>>,
58    /// Data rows (excluding the header), as a [`StrMatrix`].
59    pub rows: StrMatrix,
60}
61
62impl CsvTable {
63    /// Number of data rows.
64    pub fn nrows(&self) -> usize {
65        self.rows.nrows()
66    }
67
68    /// Number of columns.
69    pub fn ncols(&self) -> usize {
70        self.rows.ncols()
71    }
72
73    /// Column names: the header when present (and of matching length),
74    /// `x0..x{n-1}` otherwise.
75    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
83/// Reads and parses a CSV file with [`CsvReadOptions::default`].
84pub fn read_csv(path: &Path) -> Result<CsvTable> {
85    read_csv_with(path, &CsvReadOptions::default())
86}
87
88/// Reads and parses a CSV file with explicit options.
89pub 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
94/// Reads only the first CSV record (the header when `has_header`) without
95/// parsing the rest of the file — cheap up-front validation of arguments
96/// such as a target column name. The UTF-8 BOM is stripped like
97/// [`read_csv_reader`].
98pub 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
103/// Reader-based counterpart of [`read_csv_header`].
104pub 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        // One physical line ends at most one record, so the first record
120        // is complete as soon as it appears.
121        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
134/// Rejects option combinations the record parser cannot represent.
135fn 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    // CR/LF terminate records; accepting them as delimiter or quote would
142    // make record termination impossible.
143    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
168/// Parses CSV records from any [`BufRead`] with explicit options.
169///
170/// The delimiter and quote bytes must be ASCII and differ. A trailing newline
171/// is optional; fully blank records are dropped, and a header-only file is an
172/// `EmptyInput` error. Each parsed record also carries the 1-based physical
173/// file line where it started (blank lines and quoted newlines are counted).
174pub 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    // Strip the UTF-8 BOM from the very first cell.
191    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    // The expected width is the header's when present, else the first data
212    // row's (which then defines the table width).
213    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        // `line` is the physical file line where the record started, tracked
223        // by the parser (blank lines never shift it).
224        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
232/// Incremental RFC-4180 record parser used by [`read_csv_reader`] and the
233/// streaming profiler.
234///
235/// Feeds line-shaped chunks and emits complete records into `out`. Quoted
236/// fields may span lines; quote state is tracked across `feed` calls.
237pub(crate) struct RecordParser {
238    delimiter: u8,
239    quote: Option<u8>,
240    trim: bool,
241    /// Current physical line, 1-based; each `feed` call is one line.
242    line: usize,
243    /// Physical line where the current record started (`0` = not started).
244    record_line: usize,
245    record: Vec<String>,
246    field: String,
247    /// Whether `field` was quoted (disables `trim` and blank-record dropping).
248    field_quoted: bool,
249    /// Whether the current record contained at least one quoted field.
250    record_has_quote: bool,
251    /// Whether we are inside a quoted field (possibly spanning lines).
252    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    /// Feeds one physical line (including its terminator). Quoted fields
272    /// spanning lines keep their newline as field data. Callers must feed
273    /// exactly one line per call so that record line numbers stay accurate.
274    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(&quote) {
282                        // Escaped quote (""), part of the field value.
283                        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    /// Flushes the final record when the input ends without a trailing
328    /// newline. Errors when the input ended inside a quoted field.
329    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        // `field_quoted` covers a final *empty* quoted field (`""` with no
336        // trailing newline), which must still complete its record.
337        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        // A single empty unquoted field is a blank line, not a record.
355        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        // `Ĭ` (U+012C) and `Ģ` (U+0122) have ',' and '"' as their low bytes;
449        // `c as u8` comparisons would truncate them into delimiter/quote.
450        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        // The blank line is skipped, but physical line numbers do not shift.
481        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        // Headerless: the single record is data, not a header.
488        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        // Quoted fields are never trimmed.
550        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}