Design Car Rental System
A pegboard behind the counter at a small car rental office is the whole business in one piece of plywood. Three hooks, one per car, with a key on each hook when the car is on the lot. A handwritten card hangs next to a hook the moment a customer drives off — name on the card, day the car is due back. When the car comes home the card comes down and the key goes back on its hook. Every question anybody asks the clerk gets answered by glancing at that pegboard. Which cars can I rent today. Who has the Tesla. When does it come back. The clerk never has to think. The board already knows.

Hertz did this for real in 1918 in Chicago — Walter Jacobs ran a fleet of 12 Model T's out of a garage with a clipboard and a hook for each set of keys, and he answered the phone with the clipboard in his hand. He sold the company to John Hertz in 1923 and the system did not change for 40 years. The clipboard became a card file, the card file became a green-screen terminal in 1968 when Hertz wired up the first Univac mainframe to do reservations across cities, and the terminal became the web app you book on today. The plywood pegboard never left. It only got faster. Every car rental system on Earth has the same two facts at its core — which cars exist and what each car is doing right now — and the trick to writing one well is to put those two facts into types the compiler can check, not into a flag named is_rented and a comment saying don't forget to update it.
The cards on the pegboard come in two shapes — a blank one meaning the hook is free, and a written one meaning the car is out. That is a state machine with two states, and the second state carries extra information that the first does not.
#[derive(Debug, Clone)]
enum State {
Available,
Rented { by: &'static str, until_day: u32 },
}
#[derive(Debug, Clone, Copy)]
enum Event {
Rent {
car_id: u32,
customer: &'static str,
days: u32,
},
Return {
car_id: u32,
},
AdvanceDay,
}
struct Car {
id: u32,
model: &'static str,
price_per_day: u32,
state: State,
}
struct Fleet {
cars: Vec<Car>,
current_day: u32,
revenue: u32,
}enum State is the card. State::Available is the blank card hanging on the hook. State::Rented { by, until_day } is the written card with the renter's name and the day the car comes back. The reason this is one enum instead of a struct with an is_rented boolean and an optional renter field is that nothing else in the program will ever read renter while is_rented is false. The two facts are welded into one value and the compiler will not let you forget to set them together. Every match against the state has to handle both arms, so the day someone adds a Reserved state for advance bookings, every place in the code that touches a car will light up with a compiler error pointing at the missing case.
enum Event is the list of things the customer at the counter can ask for — rent a specific car for a specific number of days, return a car, or advance the clock to the next day. The clock is an event because lesson binaries are not allowed to call SystemTime::now — the output of the program has to be byte-identical every time it runs, which means time has to be something the script controls, not the operating system. A real rental system would read the wall clock. The shape of the code does not change.
The Car struct holds the four facts about one car — its id, its model, what it costs per day, and its current state. The Fleet struct holds the three cars, a counter for what day it is, and a running total of revenue. The whole business is in those two structs. Nothing else needs to exist.

The handle method is the clerk. Every customer interaction is one call to handle with one Event, and the clerk's answer is a Result<String, String> — Ok with what happened or Err with why nothing did.
impl Fleet {
fn new() -> Self {
Self {
cars: vec![
Car { id: 1, model: "Tesla Model 3", price_per_day: 95, state: State::Available },
Car { id: 2, model: "Toyota Camry", price_per_day: 55, state: State::Available },
Car { id: 3, model: "Honda Civic", price_per_day: 45, state: State::Available },
],
current_day: 1,
revenue: 0,
}
}
fn find(&mut self, car_id: u32) -> Option<&mut Car> {
self.cars.iter_mut().find(|c| c.id == car_id)
}
fn handle(&mut self, event: Event) -> Result<String, String> {
match event {
Event::Rent { car_id, customer, days } => {
let today = self.current_day;
let car = self.find(car_id).ok_or_else(|| format!("no car with id {}", car_id))?;
let (msg, earned) = match car.state {
State::Available => {
let until = today + days;
let total = car.price_per_day * days;
car.state = State::Rented { by: customer, until_day: until };
let line = format!(
"rented {} to {} for {} days, total ${}, due day {}",
car.model, customer, days, total, until
);
(Ok(line), total)
}
State::Rented { by, until_day } => {
(Err(format!("{} already rented by {} until day {}", car.model, by, until_day)), 0)
}
};
self.revenue += earned;
msg
}
Event::Return { car_id } => {
let car = self.find(car_id).ok_or_else(|| format!("no car with id {}", car_id))?;
match car.state.clone() {
State::Rented { by, .. } => {
car.state = State::Available;
Ok(format!("{} returned by {}", car.model, by))
}
State::Available => Err(format!("{} was not rented", car.model)),
}
}
Event::AdvanceDay => {
self.current_day += 1;
Ok(format!("day advanced to {}", self.current_day))
}
}
}
}Read the three arms top to bottom. A Rent event looks up the car by id. If no car with that id exists, the early-return ? throws the error back to the caller — that is the "unknown car" path the script will exercise with car_id: 9. If the car exists, the inner match looks at its state. If the state is Available, the clerk writes a new card — flips the state to Rented, computes the bill, returns a success string, and adds the bill to the revenue. If the state is already Rented, the clerk hands back an error naming who has the car and when it comes back. Two states, two paths, both forced into the open by the match. There is no way to forget the busy case because the compiler will not let an enum match miss an arm.
A Return event is the mirror image. Find the car, check the state. If Rented, the clerk takes the card down and the hook is blank again. If Available, the clerk hands back an error — somebody is trying to return a car that nobody rented, which in the real world is the customer at the wrong counter.
The AdvanceDay event bumps the clock by one. Nothing else changes. Real time would tick on its own, but our time is a counter we move on command so the output is reproducible.

The render function is the part that prints the pegboard. It walks every car, asks each one for its state, and writes one line per car showing the model, the price, and either "available" or "rented by NAME until day N." This is the same trick the tic-tac-toe lesson used — rendering is a separate function from the logic, and it never mutates anything, so the test can call it after every event and the output is the same shape every time.
fn render(fleet: &Fleet) {
println!(" fleet on day {} (revenue ${})", fleet.current_day, fleet.revenue);
for car in &fleet.cars {
let status = match car.state {
State::Available => "available".to_string(),
State::Rented { by, until_day } => format!("rented by {} until day {}", by, until_day),
};
println!(" #{} {:<14} ${}/day | {}", car.id, car.model, car.price_per_day, status);
}
}Now drive the design through a scripted morning at the counter.
fn main() {
let script = [
Event::Rent { car_id: 1, customer: "Alice", days: 3 },
Event::Rent { car_id: 2, customer: "Bob", days: 2 },
Event::Rent { car_id: 1, customer: "Carol", days: 1 },
Event::Rent { car_id: 9, customer: "Dave", days: 1 },
Event::AdvanceDay,
Event::AdvanceDay,
Event::Return { car_id: 2 },
Event::Return { car_id: 3 },
Event::Rent { car_id: 3, customer: "Carol", days: 4 },
];
let mut fleet = Fleet::new();
println!("--- opening day ---");
render(&fleet);
for (i, event) in script.iter().enumerate() {
println!("event {}: {:?}", i + 1, event);
match fleet.handle(*event) {
Ok(msg) => println!(" ok: {}", msg),
Err(msg) => println!(" err: {}", msg),
}
render(&fleet);
}
}The script has nine events stacked to hit every interesting path. Alice rents the Tesla for 3 days at day 1. Bob rents the Camry for 2 days. Carol walks in and asks for the Tesla — busy, denied. Dave asks for car number 9 — does not exist, denied. The clock ticks twice. Bob's Camry comes back. Someone tries to return the Honda Civic — never rented, denied. Carol takes the Civic for 4 days. Every event prints the clerk's answer and the pegboard right after.
--- opening day ---
fleet on day 1 (revenue $0)
#1 Tesla Model 3 $95/day | available
#2 Toyota Camry $55/day | available
#3 Honda Civic $45/day | available
event 1: Rent { car_id: 1, customer: "Alice", days: 3 }
ok: rented Tesla Model 3 to Alice for 3 days, total $285, due day 4
fleet on day 1 (revenue $285)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | available
#3 Honda Civic $45/day | available
event 2: Rent { car_id: 2, customer: "Bob", days: 2 }
ok: rented Toyota Camry to Bob for 2 days, total $110, due day 3
fleet on day 1 (revenue $395)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | rented by Bob until day 3
#3 Honda Civic $45/day | available
event 3: Rent { car_id: 1, customer: "Carol", days: 1 }
err: Tesla Model 3 already rented by Alice until day 4
fleet on day 1 (revenue $395)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | rented by Bob until day 3
#3 Honda Civic $45/day | available
event 4: Rent { car_id: 9, customer: "Dave", days: 1 }
err: no car with id 9
fleet on day 1 (revenue $395)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | rented by Bob until day 3
#3 Honda Civic $45/day | available
event 5: AdvanceDay
ok: day advanced to 2
fleet on day 2 (revenue $395)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | rented by Bob until day 3
#3 Honda Civic $45/day | available
event 6: AdvanceDay
ok: day advanced to 3
fleet on day 3 (revenue $395)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | rented by Bob until day 3
#3 Honda Civic $45/day | available
event 7: Return { car_id: 2 }
ok: Toyota Camry returned by Bob
fleet on day 3 (revenue $395)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | available
#3 Honda Civic $45/day | available
event 8: Return { car_id: 3 }
err: Honda Civic was not rented
fleet on day 3 (revenue $395)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | available
#3 Honda Civic $45/day | available
event 9: Rent { car_id: 3, customer: "Carol", days: 4 }
ok: rented Honda Civic to Carol for 4 days, total $180, due day 7
fleet on day 3 (revenue $575)
#1 Tesla Model 3 $95/day | rented by Alice until day 4
#2 Toyota Camry $55/day | available
#3 Honda Civic $45/day | rented by Carol until day 7Read the revenue line through the run and the math falls out. Event 1 adds $285 — 95 dollars a day for 3 days. Event 2 adds $110 — 55 dollars a day for 2 days. Total $395. Events 3, 4, and 8 are rejections so revenue does not move. Event 9 adds $180 — 45 dollars a day for 4 days. Total $575 at the end. The reason revenue never increments on a rejection is that the (msg, earned) tuple in the Rent arm always pairs the message with the amount, and the busy/unknown branches pair their errors with zero. There is no way to credit revenue for a rental that did not happen because the only place that updates self.revenue reads from that tuple, and the tuple is built inside the match that already decided the rental was real.

A question worth asking from the trace — why did event 3 (Carol trying to rent the Tesla) print the exact day Alice's rental ends, day 4? Because the State::Rented { until_day } payload carries that day on the card itself, and the busy-arm formatter pulls it straight out of the enum. The clerk does not have to look it up in a separate book. The card and the date are the same value. The day Alice paid for is the day Carol is told to come back for the Tesla. No extra table, no risk of the two facts drifting apart, because there is only one fact.
The pegboard has one limit that shows up the moment the rental company opens a second location. The fleet is one struct with one set of cars and one current day, and a customer at the airport counter cannot see what is on the downtown counter's board. The next bottleneck is sharing a fleet across locations and accepting reservations for cars that are still out — which is where availability search over a date range, instead of a single yes-or-no check, has to enter the design.