use anyhow::{anyhow, Result};
#[derive(Debug, PartialEq)]
pub struct Csv {
pub header: Vec<String>,
pub rows: Vec<Vec<String>>,
}
pub fn parse(text: &str, sep: char) -> Result<Csv> {
let mut records: Vec<Vec<String>> = Vec::new();
let mut field = String::new();
let mut row: Vec<String> = Vec::new();
let mut quoted = false;
let mut chars = text.chars().peekable();
let mut started = false;
while let Some(c) = chars.next() {
started = true;
if quoted {
if c == '"' {
if chars.peek() == Some(&'"') {
chars.next();
field.push('"');
} else {
quoted = false;
}
} else {
field.push(c);
}
continue;
}
match c {
'"' if field.is_empty() => quoted = true,
_ if c == sep => row.push(std::mem::take(&mut field)),
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
row.push(std::mem::take(&mut field));
records.push(std::mem::take(&mut row));
started = false;
}
'\n' => {
row.push(std::mem::take(&mut field));
records.push(std::mem::take(&mut row));
started = false;
}
_ => field.push(c),
}
}
if quoted {
return Err(anyhow!("unterminated quoted field"));
}
if started {
row.push(field);
records.push(row);
}
let mut it = records.into_iter();
let header = it.next().ok_or_else(|| anyhow!("empty file"))?;
Ok(Csv {
header,
rows: it.collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_records_split_on_the_separator() {
let csv = parse("a,b\n1,2\n3,4\n", ',').unwrap();
assert_eq!(csv.header, ["a", "b"]);
assert_eq!(csv.rows, [["1", "2"], ["3", "4"]]);
}
#[test]
fn a_quoted_field_may_hold_the_separator_a_newline_and_a_quote() {
let csv = parse("a,b\n\"x,y\",\"line1\nline2\"\n\"say \"\"hi\"\"\",z\n", ',').unwrap();
assert_eq!(csv.rows[0], ["x,y", "line1\nline2"]);
assert_eq!(csv.rows[1], ["say \"hi\"", "z"]);
}
#[test]
fn crlf_and_a_missing_final_newline_are_both_accepted() {
let csv = parse("a,b\r\n1,2\r\n3,4", ',').unwrap();
assert_eq!(csv.rows, [["1", "2"], ["3", "4"]]);
assert_eq!(parse("a\n1\n", ',').unwrap().rows, [["1"]]);
}
#[test]
fn empty_fields_survive_and_tabs_work_as_a_separator() {
assert_eq!(parse("a,b,c\n1,,3\n", ',').unwrap().rows[0], ["1", "", "3"]);
let tsv = parse("a\tb\n1\t2\n", '\t').unwrap();
assert_eq!(tsv.header, ["a", "b"]);
assert_eq!(tsv.rows, [["1", "2"]]);
}
#[test]
fn a_file_that_cannot_be_parsed_is_an_error() {
assert!(parse("a,b\n\"unterminated\n", ',').is_err());
assert!(parse("", ',').is_err(), "an empty file has no header");
}
}