Skip to main content

henad_core/export/
csv.rs

1//! Comma-separated values (CSV) fields, written and read as RFC 4180 describes them.
2
3use std::fmt;
4
5/// Returns `value` as a CSV field, with full round-trip precision and no trailing `.0` on a whole number.
6///
7/// A value that is not finite becomes an empty field, the usual mark of a missing value. Most readers do not accept
8/// `NaN` or `inf`.
9pub fn fmt_f64(value: f64) -> String {
10    if !value.is_finite() {
11        String::new()
12    } else if value.fract() == 0.0 && value.abs() < 1e15 {
13        format!("{value:.0}")
14    } else {
15        format!("{value}")
16    }
17}
18
19/// Returns `field` quoted when it holds a comma, a quote or a line break, with each inner quote doubled.
20pub fn escape_field(field: &str) -> String {
21    if field.contains([',', '"', '\n', '\r']) {
22        format!("\"{}\"", field.replace('"', "\"\""))
23    } else {
24        field.to_owned()
25    }
26}
27
28/// Text that is not valid CSV. Each line number counts from 1.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum CsvError {
31    /// A quoted field opened on `line` is still open at the end of the text.
32    UnterminatedQuote {
33        /// Line the quoted field opens on.
34        line: usize,
35    },
36    /// A quote inside an unquoted field, or text after a closing quote.
37    MisplacedQuote {
38        /// Line of the misplaced quote or text.
39        line: usize,
40    },
41}
42
43impl fmt::Display for CsvError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::UnterminatedQuote { line } => write!(f, "the quoted field opened on line {line} is never closed"),
47            Self::MisplacedQuote { line } => write!(f, "misplaced quote on line {line}"),
48        }
49    }
50}
51
52impl std::error::Error for CsvError {}
53
54/// Position of the reader within the current field.
55#[derive(Clone, Copy)]
56enum FieldState {
57    Start,
58    Unquoted,
59    Quoted,
60    QuoteClosed,
61}
62
63/// Splits `text` into records of fields, undoing [`escape_field`].
64///
65/// A record ends at a CRLF or an LF outside quotes, and the final line ending is optional. A
66/// quoted field keeps its commas and line endings, and a doubled quote inside it is read as one quote.
67/// Note that a blank line is a record holding one empty field.
68///
69/// # Errors
70///
71/// Returns [`CsvError`] when a quote is never closed or sits anywhere but around a whole field.
72pub fn parse_records(text: &str) -> Result<Vec<Vec<String>>, CsvError> {
73    let mut records = Vec::new();
74    let mut record = Vec::new();
75    let mut field = String::new();
76    let mut state = FieldState::Start;
77    let mut line = 1;
78    let mut quote_line = 1;
79    let mut chars = text.chars().peekable();
80
81    while let Some(character) = chars.next() {
82        match (state, character) {
83            (FieldState::Quoted, '"') if chars.peek() == Some(&'"') => {
84                chars.next();
85                field.push('"');
86            }
87            (FieldState::Quoted, '"') => state = FieldState::QuoteClosed,
88            (FieldState::Quoted, _) => {
89                if character == '\n' {
90                    line += 1;
91                }
92                field.push(character);
93            }
94            (FieldState::Start, '"') => {
95                state = FieldState::Quoted;
96                quote_line = line;
97            }
98            (_, ',') => {
99                record.push(std::mem::take(&mut field));
100                state = FieldState::Start;
101            }
102            (_, '\n') => {
103                record.push(std::mem::take(&mut field));
104                records.push(std::mem::take(&mut record));
105                state = FieldState::Start;
106                line += 1;
107            }
108            (_, '\r') if chars.peek() == Some(&'\n') => {
109                chars.next();
110                record.push(std::mem::take(&mut field));
111                records.push(std::mem::take(&mut record));
112                state = FieldState::Start;
113                line += 1;
114            }
115            (FieldState::Unquoted | FieldState::QuoteClosed, '"') | (FieldState::QuoteClosed, _) => {
116                return Err(CsvError::MisplacedQuote { line });
117            }
118            (FieldState::Start | FieldState::Unquoted, _) => {
119                field.push(character);
120                state = FieldState::Unquoted;
121            }
122        }
123    }
124
125    match state {
126        FieldState::Quoted => return Err(CsvError::UnterminatedQuote { line: quote_line }),
127        // The text is empty or ends on a line ending, so no record is open.
128        FieldState::Start if record.is_empty() => {}
129        FieldState::Start | FieldState::Unquoted | FieldState::QuoteClosed => {
130            record.push(field);
131            records.push(record);
132        }
133    }
134    Ok(records)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::{CsvError, escape_field, fmt_f64, parse_records};
140
141    fn records(rows: &[&[&str]]) -> Vec<Vec<String>> {
142        rows.iter()
143            .map(|row| row.iter().map(|&field| field.to_owned()).collect())
144            .collect()
145    }
146
147    #[test]
148    fn plain_fields_split_at_commas_and_line_endings() {
149        let expected = records(&[&["tick", "A", "B"], &["0", "1", "2"]]);
150        assert_eq!(parse_records("tick,A,B\n0,1,2\n"), Ok(expected.clone()));
151        assert_eq!(parse_records("tick,A,B\r\n0,1,2\r\n"), Ok(expected.clone()), "CRLF");
152        assert_eq!(parse_records("tick,A,B\r\n0,1,2\n"), Ok(expected), "mixed endings");
153    }
154
155    #[test]
156    fn the_final_line_ending_is_optional() {
157        let expected = records(&[&["a", "b"], &["1", "2"]]);
158        assert_eq!(parse_records("a,b\n1,2"), Ok(expected.clone()));
159        assert_eq!(parse_records("a,b\n1,2\n"), Ok(expected.clone()));
160        assert_eq!(parse_records("a,b\r\n1,2\r\n"), Ok(expected));
161        assert_eq!(parse_records(""), Ok(Vec::new()), "no text, no records");
162    }
163
164    #[test]
165    fn empty_fields_and_blank_lines_are_kept() {
166        assert_eq!(parse_records("a,,c\n"), Ok(records(&[&["a", "", "c"]])));
167        assert_eq!(parse_records("a,\n"), Ok(records(&[&["a", ""]])), "a trailing comma");
168        assert_eq!(parse_records("a\n\nb\n"), Ok(records(&[&["a"], &[""], &["b"]])));
169        assert_eq!(parse_records("\"\"\n"), Ok(records(&[&[""]])), "a quoted empty field");
170    }
171
172    #[test]
173    fn quoted_fields_keep_commas_quotes_and_line_endings() {
174        let text = "\"Susceptible, count\",\"say \"\"hi\"\"\",\"two\nlines\",\"crlf\r\nkept\"\n1,2,3,4\n";
175        let expected = records(&[
176            &["Susceptible, count", "say \"hi\"", "two\nlines", "crlf\r\nkept"],
177            &["1", "2", "3", "4"],
178        ]);
179        assert_eq!(parse_records(text), Ok(expected));
180    }
181
182    #[test]
183    fn an_escaped_field_reads_back_as_itself() {
184        let fields = [
185            "plain",
186            "a,b",
187            "say \"hi\"",
188            "two\nlines",
189            "cr\ronly",
190            "",
191            "\"",
192            "Speed.[0, 1)",
193        ];
194        let line: Vec<String> = fields.iter().map(|&field| escape_field(field)).collect();
195        let text = format!("{}\n", line.join(","));
196        assert_eq!(parse_records(&text), Ok(records(&[&fields])));
197    }
198
199    #[test]
200    fn an_unterminated_quote_is_refused() {
201        assert_eq!(
202            parse_records("a,b\n1,\"open\n2,3\n"),
203            Err(CsvError::UnterminatedQuote { line: 2 })
204        );
205    }
206
207    #[test]
208    fn a_misplaced_quote_is_refused() {
209        assert_eq!(parse_records("a\"b\n"), Err(CsvError::MisplacedQuote { line: 1 }));
210        assert_eq!(
211            parse_records("x\n\"closed\"early\n"),
212            Err(CsvError::MisplacedQuote { line: 2 })
213        );
214    }
215
216    #[test]
217    fn numbers_format_as_the_stats_file_writes_them() {
218        assert_eq!(fmt_f64(3.0), "3");
219        assert_eq!(fmt_f64(-0.5), "-0.5");
220        assert_eq!(fmt_f64(0.1 + 0.2), "0.30000000000000004");
221        assert_eq!(fmt_f64(1e15), "1000000000000000");
222        assert_eq!(fmt_f64(f64::NAN), "");
223        assert_eq!(fmt_f64(f64::NEG_INFINITY), "");
224    }
225}