Reach for split(',') and CSV looks solved in one line.
Then a field contains a comma: the row "a,b",c is meant to hold two fields, a,b and c.
Split on every comma and you get three pieces ("a, b", c), the quotes still attached and the first field torn in half.
The fix is to stop treating every comma as a separator. Walk the input one character at a time and track a single piece of state: am I currently inside a quoted field? A comma inside quotes is data; a comma outside quotes is a separator.
This "for each character, update some state, occasionally emit a result" pattern is called a state machine. It comes up in any non-trivial parsing task: JSON, command-line arguments, terminal escape sequences, markup languages.
fn parse(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match (c, in_quotes) {
('"', false) => in_quotes = true,
('"', true) if chars.peek() == Some(&'"') => {
// Escaped quote inside a quoted field.
current.push('"');
chars.next();
}
('"', true) => in_quotes = false,
(',', false) => {
fields.push(std::mem::take(&mut current));
}
(c, _) => current.push(c),
}
}
fields.push(current);
fields
}
Each less familiar tool removes one bit of bookkeeping from the loop:
peekable() lets you look at the next character without consuming it.
That lookahead matters when one character's meaning depends on the one after it, as in the "" -> " rule.match on a tuple (c, in_quotes) lets you express each transition as one arm.
The alternatives stay flatter than they would with nested if/else blocks.std::mem::take gives you the current string and replaces it with an empty one in a single move.
The old buffer moves into fields without a clone.while letYou used if let with Option earlier; while let repeats the same pattern until it stops matching.
That extra control matters here because the loop sometimes calls chars.next() again to consume the second ".
You also used tuple patterns in let (a, b) = pair; here, match inspects the character and quote state together.
A guard adds the lookahead check only to the escaped-quote arm.
These tests use the raw strings you met in the env-file parser, so the CSV examples can contain literal commas and quotes without an escape forest.
When stateful parsing gets hairy, write the simple version first (split_once, split(',')) and let the easy tests pass.
Then upgrade to the state-machine version for the harder cases.
Failing tests give you concrete examples to think against, instead of trying to imagine every edge case up front.
For real CSV in production code, reach for the csv crate; it handles all the corners that this exercise glosses over.
This exercise focuses on the state-machine loop rather than a production-ready CSV implementation.
Before tackling the messy realities of CSV (quotes, escapes, embedded commas), let's handle the trivial case: a line that's nothing but plain values separated by commas, possibly with surrounding whitespace. This is what most "I'll just split on commas" CSV parsers do, and it's also why so many of them break.
Use str::split and str::trim.
Collect into a Vec<String>.
Useful from the standard library
str::splitwith a','argument yields each comma-separated piece as a&str.str::trimdrops leading/trailing whitespace from each piece.str::to_stringin amapstep turns the borrowed pieces into the ownedStrings the return type wants.Iterator::collectfinishes the chain. The body fits on one line:line.split(',').map(|s| s.trim().to_string()).collect().
&str that splits on a delimiter and gives you an iterator.
Combine it with trim and collect.
/// Parses a simple CSV line without quotes.
/// Splits on commas and trims whitespace.
fn parse_simple_csv_line(line: &str) -> Vec<String> {
line.split(',')
.map(|field| field.trim().to_string())
.collect()
}
#[test]
fn test_parse_simple_csv_line() {
let line = "name, age, city";
let fields = parse_simple_csv_line(line);
assert_eq!(fields, vec!["name", "age", "city"]);
}
Real CSV is a state machine in disguise.
A field can be wrapped in double quotes, in which case any commas inside the quotes are part of the field, not separators.
And a literal " inside a quoted field is encoded as "" (two quotes).
Implement the cases in the order shown by the tests:
a,b,c and simply quoted "a","b","c" (the basic test)."a,b",c."a""b",c -> [a"b, c].Walk the string character by character with a peekable iterator and keep a small in_quotes: bool flag.
When you see " while already inside quotes, peek the next char: if it's another ", push a literal " and consume both; otherwise close the field.
Useful from the standard library
str::charsis the entry point for character-level iteration.Iterator::peekablewraps the iterator so you can look ahead one character. Essential for the""->"rule.Peekable::peekreturnsOption<&Item>without advancing.std::mem::takeswaps the currentStringwith a fresh empty one in a single move. Cleaner thancurrent.clone()followed bycurrent.clear().- A
match (c, in_quotes)on the tuple lets you express each state transition as a single arm. Add a guard (if chars.peek() == Some(&'"')) for the escape rule.
bool (in_quotes) is enough state.
Walk the input with line.chars().peekable() so you can look one character ahead.(c, in_quotes).
There are only five interesting cases:
('"', false) β enter quoted mode.('"', true) and the next char is also " β push a literal ", consume the second one with chars.next().('"', true) β exit quoted mode.(',', false) β finish the current field, start a new one.std::mem::take(&mut current) to harvest a field without cloning.
/// Parses a CSV line with proper quote handling.
/// Handles: "field,with,commas", "field with \"quotes\"", etc.
fn parse_csv_line(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes => {
// A doubled quote inside a quoted field is a literal quote;
// a lone quote ends the quoted section.
if chars.peek() == Some(&'"') {
field.push('"');
chars.next();
} else {
in_quotes = false;
}
}
'"' => in_quotes = true,
',' if !in_quotes => {
fields.push(field.clone());
field.clear();
}
_ => field.push(c),
}
}
fields.push(field);
fields
}
#[test]
fn test_parse_csv_line_basic() {
// Warm-up: every field is quoted, no commas inside, no escapes.
// Get this passing first; it forces you to enter and exit a quoted
// field, but nothing trickier.
let line = r#""a","b","c""#;
let fields = parse_csv_line(line);
assert_eq!(fields, vec!["a", "b", "c"]);
}
#[test]
fn test_parse_csv_line_quoted() {
let line = r#"name,"age, years","city""#;
let fields = parse_csv_line(line);
assert_eq!(fields, vec!["name", "age, years", "city"]);
}
#[test]
fn test_parse_csv_line_escaped_quotes() {
let line = r#""John ""Johnny"" Doe","25","New York""#;
let fields = parse_csv_line(line);
assert_eq!(fields, vec![r#"John "Johnny" Doe"#, "25", "New York"]);
}
With a working line parser, the file-level parser is mostly plumbing: split on newlines, treat the first line as headers, and parse the rest as data rows.
Use str::lines to split: it handles trailing newlines gracefully, so "a,b\n" gives one line, not two.
You'll reuse parse_csv_line from the previous page.
To keep this page independently runnable, its signature is re-declared here as a stub with todo!().
Paste your earlier solution into the stub or call into it.
Useful from the standard library
str::linesyields each line as a&str, stripping\nand\r\n. A trailing newline does not create an empty trailing line.Iterator::nexton the iterator pulls off the header line; an empty file should return empty headers and rows.Iterator::map+parse_csv_lineover the remaining lines builds the rows.Iterator::collectto materialize both the headers and the rows intoVecs.
content.lines() gives you an iterator over &str lines.next() on the iterator pulls the first one off; the rest you can map(parse_csv_line).collect().
/// Parses a CSV line with proper quote handling.
/// (Re-stubbed from step 4 so this file compiles on its own.)
fn parse_csv_line(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes => {
if chars.peek() == Some(&'"') {
field.push('"');
chars.next();
} else {
in_quotes = false;
}
}
'"' => in_quotes = true,
',' if !in_quotes => {
fields.push(field.clone());
field.clear();
}
_ => field.push(c),
}
}
fields.push(field);
fields
}
/// Parses a complete CSV file.
/// First line is headers, remaining lines are data.
///
/// Use [`str::lines`](https://doc.rust-lang.org/std/primitive.str.html#method.lines)
/// to split on newlines. `lines()` already handles a trailing `\n`
/// gracefully; it won't yield an empty last line for `"a,b\n"`. Real
/// CSVs often end with a newline, so this is the right tool.
/// Returns (headers, rows).
fn parse_csv_file(content: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut lines = content.lines();
let headers = lines.next().map(parse_csv_line).unwrap_or_default();
let rows = lines.map(parse_csv_line).collect();
(headers, rows)
}
#[test]
fn test_parse_csv_file() {
let content = "name,age,city\nAlice,30,Boston\nBob,25,Seattle";
let (headers, rows) = parse_csv_file(content);
assert_eq!(headers, vec!["name", "age", "city"]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], vec!["Alice", "30", "Boston"]);
}
The parser begins with split and trim, then adds state for quoted fields and escaped quotes before collecting headers and rows.
What we learned
- The same stateful parsing pattern appears in JSON, command lines, and terminal escape sequences. In each case, read one item, consult the current state, then update the state or emit a result.
- A peekable iterator lets you inspect what comes next without consuming it, as the
""->"escape rule requires.match (token, state) { ... }over a tuple expresses each state transition in one line. Match guards (if cond) handle the cases where the transition depends on the lookahead.std::mem::take(&mut s)gives you the current value and replaces it withDefaultin one move. Cleaner than clone-then-clear when you're harvesting an accumulator.- The simple
split/trimversion is worth writing first. It passes the easy tests and gives you a baseline; the state-machine upgrade then has concrete failing cases to react to.- Hand production CSV files to the
csvcrate, which handles BOMs, custom delimiters, and escaped newlines inside fields. Keep the state-machine loop for parsers you do need to write yourself.