Skip to content

← The Expert Edge: Idiom, Review and Capstones step 1 of 14

Hard End-to-End

Enum state machines with data-carrying transitions

Drive a five-state vending machine through a script of commands and return the log it produces.

pub fn simulate(script: Vec<String>) -> Vec<String>

A state machine is a value that is always in exactly one of a fixed set of states and moves between them in response to events. Rust offers two common ways to model one. The typestate pattern gives each state its own type, so calling a method that is not allowed in the current state fails to compile. That only works when the compiler can tell, at each point in the program, which state the value is in. A vending machine cannot know at compile time which coin or button press comes next, so its state has to be stored as an ordinary runtime value. An enum with one variant per state is the usual way to do that, and this exercise builds one.

The machine

The starter defines both enums, a name() method on each, a price lookup and a script parser. Your work is the transition function step, plus one fix in simulate.

pub enum State {
    Idle,
    Collecting { credit: u32 },
    Dispensing { item: String, change: u32 },
    Refunding { amount: u32 },
    Fault { reason: String },
}

pub enum Event { Coin(u32), Select(String), Refund, Take, Collect, Service }

Each state carries its own data. A Dispensing value has an item and an amount of change, and it has no credit field at all. Compare a struct with a numeric state field and five Option fields: there, nothing stops code from setting a credit while dispensing, and every reader must check which fields are meaningful. With the enum, those combinations cannot be written.

Prices are cola 75, chips 50 and gum 25. Item names are case sensitive, and any other name is an unknown item.

The transition table

Implement fn step(self, ev: Event) -> (Self, String). It consumes the current state and the event and returns the next state together with the one log line that transition produces.

current state event next state log line
Idle Coin(n) Collecting with credit n credit {n}
Collecting { credit } Coin(n) Collecting with credit credit + n credit {credit + n}
Collecting { credit } Select(item), unknown item Fault { reason: item } fault unknown {item}
Collecting { credit } Select(item), price at most credit Dispensing { item, change: credit - price } vend {item} change {change}
Collecting { credit } Select(item), price above credit unchanged short {price - credit} more
Collecting { credit } Refund Refunding { amount: credit } refund {credit}
Dispensing { item, change } Take Idle took {item} with {change}
Refunding { amount } Collect Idle collected {amount}
Fault { reason } Service Idle serviced after {reason}
any other pair unchanged ignored {event} in {state}

In the last row, {event} and {state} are the lower-case names returned by the name() methods, such as ignored take in idle. Some consequences of the table:

  • Only Coin does anything in Idle. Selecting an item there, even an unknown one, is ignored rather than a fault.
  • An unknown item is a fault whatever the credit, and the credit is not kept: Fault stores only the item name.
  • A coin of 0 is accepted, so coin 0 in Idle moves to Collecting with credit 0.
  • Coins inserted while dispensing, refunding or in a fault are ignored, not added to any credit.

Reading the script

Each script line becomes one event through the starter's parse function, which you do not need to change. It splits the line on whitespace, so extra spaces are allowed, and recognises these forms:

  • coin <n>, where <n> must parse as a u32 (0 to 4294967295; Rust's integer parser also accepts a leading +).
  • select <item>.
  • refund, take, collect and service, with nothing after them.

Words after the argument of coin or select are ignored, so select gum now selects gum. Any other line, including an empty one, is a bad line: it logs bad {line} with the original line text and leaves the state unchanged.

After the last line, log end {state} using the state's name, so the result always ends with the final state. An empty script therefore returns ["end idle"]. The tests never let the credit in one transaction exceed u32::MAX, so plain + is enough. (If it did, credit + n would panic in a debug build and wrap around in a release build, with Rust's default overflow settings.)

Why step takes self by value

When step takes self rather than &mut self, the old state is moved into the function, so step owns it. Each arm can take the old variant apart and do anything an owner can with its fields, including moving a String into the new state or returning the whole old state unchanged, as the catch-all row does. The caller can no longer use the old state after the call; the only state it has is the one step returned, so it cannot keep acting on a state the machine has already left.

A &mut self version is also possible: each arm assigns the new state with *self = State::Dispensing { .. } and returns only the log line. For this table that works, because the vended item comes from the Select event and the old states' Strings are only formatted into messages. What &mut self cannot do is move a field out of the old state, since a field cannot be moved out from behind a reference; a transition that needed to would require a helper such as std::mem::take or a clone.

Check your understanding The starter's simulate loop does not compile. Before you look Show answer

at the error, predict what the compiler objects to. It reports E0382, "borrow of moved value: state". The error points at state.name() after the loop, and the label on state.step(ev) says the value was moved "due to this method call, in previous iteration of loop".

state.step(ev) moves state, because step takes self. The loop body never puts a state back, so after the first iteration the variable holds no value. The compiler tracks whether a variable holds a value at each point in the program, so moving out of a local is allowed as long as it is assigned again before the next use. The fix is one line: assign the returned state back to state (state = next;). The same compiler output also warns that next is unused and that state does not need mut; both warnings go away with that fix.

When the state lives in a struct field

A consuming step is easy to call when the state is a local variable, as it is here. When the state is a field and you only have &mut access to the struct, the direct call does not compile:

struct Machine { state: State, log: Vec<String> }

impl Machine {
    fn feed(&mut self, ev: Event) {
        let (next, msg) = self.state.step(ev); // error[E0507]
        self.state = next;
        self.log.push(msg);
    }
}

The error is E0507, "cannot move out of self.state which is behind a mutable reference". A mutable reference must leave a valid value behind. If the move were allowed and step then panicked, the struct would be left with no state while other code could still reach it, for example during unwinding when it is dropped.

One common fix is to put a placeholder in the field first with std::mem::replace, which stores a new value and returns the old one. This needs an extra variant, here called Poisoned, that no transition ever produces (plus an arm for it in name() and any other match that lists every variant):

let taken = std::mem::replace(&mut self.state, State::Poisoned);
let (next, msg) = taken.step(ev);
self.state = next;
self.log.push(msg);

If step panics, the field is left as Poisoned, which later code can detect. Two alternatives are storing Option<State> and calling Option::take, or giving State a Default implementation and calling std::mem::take(&mut self.state), which is mem::replace with the default value. Clippy's mem_replace_with_default lint suggests mem::take when the replacement is the type's default value, written as T::default() or as a standard constructor such as String::new(); it does not fire for an explicit variant such as State::Poisoned.

Two trade-offs

The catch-all arm. The last row of the table is one (state, ev) => ... arm. It keeps the match short and gives every unexpected pair a defined result. The cost is that if someone later adds a seventh event, the compiler will not point out the transitions that should handle it, because the catch-all already matches it. Listing every state and event pair explicitly is longer, but then a missing pair is a compile error (E0004, "non-exhaustive patterns"). Which is better depends on how often the set of events changes and how costly a silently ignored event would be.

Enum or trait object. The object-oriented chapter of The Rust Programming Language builds a state machine from a State trait, with each state a separate type and transitions such as fn request_review(self: Box<Self>) -> Box<dyn State>. That design is open: if the trait is public, code in another module or crate can add a state by implementing it. The enum design is closed: only the enum's definition can add a state, but every match without a catch-all arm is checked for exhaustiveness, so adding a variant produces a compile error at each match that does not handle it. An enum is usually the simpler choice when one module owns the complete set of states, as it does here.

The lint gate and unused code

Rust submissions must pass Clippy with warnings treated as errors. That includes the dead_code lint, which reports private items that nothing in the program uses. It is a check on the source code, not on which paths the tests run. Once the loop compiles, the starter still gets two such warnings: price and Event::name are private and unused while step is todo!(). A step that follows the table calls both, so the warnings disappear.

dead_code does not catch everything. With Rust 1.98 in this single-file program, unused variants and fields of the pub enums are not reported, so adding a Poisoned variant that nothing constructs would still pass the gate. The test cases are what check the behaviour: together they drive every state and every event kind through the machine at least once.