Why this matters
Many programs are naturally described as state machines: a traffic light, a vending machine, a network protocol, a text parser. The program is always in one of a few states, and each input moves it to a next state, possibly doing something along the way. Rust's enums and exhaustive match are an excellent fit: states become variants, transitions become match arms, and the compiler checks that you handled every combination. This project builds a parser for CSV lines with quoted fields, a classic problem where ad-hoc code with flags gets subtly wrong.
The problem
CSV (comma-separated values) looks trivial until a field contains a comma:
name,age,city
"Smith, Jane",42,"New York"
"She said ""hi""",7,
Quoted fields may contain commas, and "" inside quotes means a literal quote. line.split(',') breaks "Smith, Jane" in two, so we need to process the line character by character, remembering where we are: that memory is the state.
Designing the states
Think about what the parser must know at each character:
FieldStart -- at the beginning of a field (start of line, or just after a comma)
Unquoted -- inside an unquoted field
Quoted -- inside a quoted field
QuoteInQuoted -- inside a quoted field, just saw a quote: either "" (escaped) or the closing quote
The transitions, as a table (input on the left, state across the top):
input | FieldStart | Unquoted | Quoted | QuoteInQuoted
--------+-------------------+-------------------+-------------------+------------------------
" | -> Quoted | error | -> QuoteInQuoted | push '"', -> Quoted
, | end field | end field | push ',', stay | end field
| -> FieldStart | -> FieldStart | | -> FieldStart
other | push, -> Unquoted | push, stay | push, stay | error
end of | end field (empty) | end field | error: unterminated | end field
line | | | |
Drawing this table before coding is the main design work. Every cell has an answer, including the errors and end of input.
From table to code
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
FieldStart,
Unquoted,
Quoted,
QuoteInQuoted,
}
fn parse(line: &str) -> Result<Vec<String>, String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut state = State::FieldStart;
for (i, c) in line.chars().enumerate() {
state = match (state, c) {
(State::FieldStart, '"') => State::Quoted,
(State::FieldStart | State::Unquoted, ',') => {
fields.push(std::mem::take(&mut field));
State::FieldStart
}
(State::Unquoted, '"') => return Err(format!("unexpected quote at column {}", i + 1)),
(State::FieldStart | State::Unquoted, ch) => {
field.push(ch);
State::Unquoted
}
(State::Quoted, '"') => State::QuoteInQuoted,
(State::Quoted, ch) => {
field.push(ch);
State::Quoted
}
(State::QuoteInQuoted, '"') => {
field.push('"');
State::Quoted
}
(State::QuoteInQuoted, ',') => {
fields.push(std::mem::take(&mut field));
State::FieldStart
}
(State::QuoteInQuoted, ch) => {
return Err(format!("unexpected character '{ch}' after closing quote at column {}", i + 1))
}
};
}
if state == State::Quoted {
return Err(String::from("unterminated quote"));
}
fields.push(field);
Ok(fields)
}
fn main() {
for line in ["a,b,c", "\"Smith, Jane\",42", "\"say \"\"hi\"\"\",,x", "ab\"c", "\"oops"] {
println!("{line:24} -> {:?}", parse(line));
}
}
a,b,c -> Ok(["a", "b", "c"])
"Smith, Jane",42 -> Ok(["Smith, Jane", "42"])
"say ""hi""",,x -> Ok(["say \"hi\"", "", "x"])
ab"c -> Err("unexpected quote at column 3")
"oops -> Err("unterminated quote")
Points worth noticing:
match (state, c)matches on a tuple of the current state and the input: each arm is one cell (or a group of cells) of the table. Arm order matters where patterns overlap:(Unquoted, '"')must come before the catch-all(FieldStart | Unquoted, ch).- The match is an expression producing the next state, assigned back to
state. Arms that hit an errorreturnimmediately. std::mem::take(&mut field)moves the String out and leaves an empty one behind: no clone, no reallocation of a new buffer for the finished field.- End of input is handled after the loop. It is part of the machine and easy to forget: a line ending in
Quotedstate is an error; otherwise the last field is pushed (which also makes"", the empty line, one empty field). - Error positions come from
enumerate()overchars(), so they count characters, not bytes: correct for non-ASCII text.
Why not booleans?
The same parser could be written with in_quotes: bool and prev_was_quote: bool. Two booleans make four combinations; one of them (!in_quotes && prev_was_quote) is meaningless, yet nothing stops the code from reaching it. Each if must reason about all flags at once. With an enum there are exactly four states, each with a name, and the compiler checks the match for exhaustiveness: remove an arm and you get E0004 listing the missing (state, char) combination.
Testing a parser
Parsers are ideal for unit tests: a string in, a result out. Good cases come straight from the table: one per cell, plus end of input in each state:
"" -> [""] empty line: one empty field
"a," -> ["a", ""] trailing comma
"\"\",x" -> ["", "x"] empty quoted field
"\"a\"\"b\"" -> ["a\"b"] escaped quote
"\"ab\"c" -> error at column 5 junk after closing quote
"é\"x" -> error at column 2 columns count characters
The exercise's hidden tests use exactly these.
Extensions to try
- Support a configurable separator (
;or tab) by making it a parameter. - Quoted fields spanning several lines: the state must survive across
read_linecalls, so the machine becomes a struct with afeed(&mut self, line: &str)method. - Trim spaces around unquoted fields, and decide (and document) what a space before an opening quote means.
Recap
- A state machine is a set of states, inputs, and transitions; draw the table first, including errors and end of input.
- Represent states with an
enum, transitions withmatch (state, input)returning the next state. - Exhaustive matching makes the compiler check that every combination is handled.
- Handle end of input explicitly after the loop.
- Each table cell is a test case.
Going further: model a vending machine (states Idle, HasMoney(u32), Vending) with events like Coin(u32), Select(item), Refund, as an enum of events and a fn step(self, event: Event) -> State method.