1use std::fmt;
4
5pub 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
19pub 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum CsvError {
31 UnterminatedQuote {
33 line: usize,
35 },
36 MisplacedQuote {
38 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#[derive(Clone, Copy)]
56enum FieldState {
57 Start,
58 Unquoted,
59 Quoted,
60 QuoteClosed,
61}
62
63pub 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 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}