Before you begin
Why this matters
When a program gives a wrong answer, the lines that cause it are usually a few among many. If you can shrink the program until only those lines are left, you can often see the cause yourself, and anyone you ask for help can run it in a minute.
What this helps you reason about
- Write a failure down precisely enough that someone else can make it happen.
- Shrink a failing program one deletion at a time without losing the failure.
- Notice when a change made the failure disappear for a reason that has nothing to do with the cause.
Case file: a café menu lines up dish names and prices with dots, and one line comes out a dot short. Nothing crashes, and the code looks right. This lesson shrinks the program until the cause is the only thing left.
Already know this? The short version
- Write the expected result, the observed result, and the exact input before guessing; run the failure yourself.
- List explanations, each with an observation that would rule it out, and run the cheapest test first.
- Delete one piece of code or input at a time and keep a deletion only if the same wrong result remains; copy failing text exactly, because typing Crêpe as Crepe removes the cause.
- Here the cause is str::len, which counts UTF-8 bytes: "Crêpe".len() is 6, while chars().count() is 5.
- Write it up with expected and observed results and the rustc version, make it print pass or FAIL, and keep it as a check after the fix.
Words in this lesson (6)
- reproduction
- A program and an input that make a failure happen every time they are run.
- hypothesis
- A possible explanation for a failure, stated so that some observation could show it is wrong.
- minimal reproduction
- A reproduction from which no single piece of code or input can be deleted without the failure disappearing.
- byte
- A unit of 8 bits, the smallest piece of memory a program usually reads or writes. One byte can hold a number from 0 to 255.
- UTF-8
- The way Rust stores the characters of a String or &str: each character takes one to four bytes. Letters without accents take one byte; ê takes two.
- regression test
- A check, kept with the code, that fails while a bug is present and passes once it is fixed, so the bug cannot come back unnoticed.
Case file · one dot short
- Expected
- Every name and its dots fill 12 characters.
- Observed
- Crêpe gets 6 dots and fills 11.
- Input
- The dishes Soup (4) and Crêpe (6).
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / The failing case
What exactly is failing?
A café menu prints each dish name, then dots, then the price, and every name with its dots should fill 12 characters so the prices line up. Soup gets 8 dots. Crêpe, with 5 letters, should get 7, and it gets 6. Before guessing at a cause, write down three things: what you expected, what you saw, and the exact input. Together they are the failing case. Then run it yourself before changing anything. A failure you have seen happen is evidence. A failure someone only described to you may depend on something they did not mention, so it is not yet evidence.
Here the failing case is: expected 7 dots, observed 6, input the dish Crêpe. In the program, ".".repeat(dots) is a String made of dots copies of ".", and format! builds a String the way println! prints one.
fn menu_line(dish: &str, price: u32) -> String {
let dots = 12 - dish.len();
format!("{}{}{}", dish, ".".repeat(dots), price)
}
// menu_line("Crêpe", 6) has 6 dots, expected 7 Write the expected result, the observed result, and the exact input before you look for a cause.
Experiment
Run the failing case, then delete a part
Each line should fill 12 characters before the price, so menu_line subtracts the name’s length from 12. For Crêpe that length is 6, one more than its 5 letters, so the line gets one dot too few.
Why it matters A deletion after which the same wrong result remains shows the deleted piece is not needed for the failure. A change that makes the failure disappear shows something about what triggers it. Both are evidence, as long as you change one thing at a time.
Predict how many dots each line gets. Then make the first deletion: delete the line that prints the Soup line, and run again. Is the failure still there?
fn menu_line(dish: &str, price: u32) -> String {
let dots = 12 - dish.len();
format!("{}{}{}", dish, ".".repeat(dots), price)
}
fn main() {
println!("{}", menu_line("Soup", 4));
println!("{}", menu_line("Crêpe", 6));
}
What to look for
In menu_line, dots is 8 for Soup and 6 for Crêpe, so Soup........4 and Crêpe......6 do not line up. dish shows its text and its length in bytes: "Soup" (len=4, …), and "Crêpe" (len=6, …), although Crêpe has 5 letters. After the edit, the output is Crêpe......6: the same 6 dots, so the Soup line was not needed for the failure.
Expected output
Soup........4
Crêpe......602 / Explanations
Which explanations fit what you saw?
A hypothesis is a possible explanation, stated so that an observation could show it is wrong. List several before testing any: the first one you think of is often not the cause. For each, write what you would see if it were true, then run the cheapest test first. A test may change the program for a moment; undo the change before the next test. Here are three explanations for the missing dot.
Test the competing explanations
Predict first. These are recorded outcomes of the example, not tests running in your browser.
01Print dots for Crêpe
If the count is right and the terminal only draws ê oddly, dots is 7.
Reveal the observation
Observed dots is 6.
What this rules in or out The display is not to blame; the count is already wrong.
02Print "Crêpe".len()
If len counts letters, it prints 5.
Reveal the observation
Observed It prints 6.
What this rules in or out len counts something other than letters.
03Replace Crêpe with Crepe
If the accent matters, Crepe gets 7 dots.
Reveal the observation
Observed Crepe gets 7 dots.
What this rules in or out The failure needs a letter with an accent.
An explanation is worth testing when some observation could rule it out.
03 / Reduce
What can you delete and still see the failure?
Reduce in small steps. Delete one piece: a function, a line, a field, an element of the input. Run again and compare the result with the failing case. If the same wrong result appears, keep the deletion. If the result changes, undo the deletion: that piece is part of the cause, and you have learned something about it.
Only delete while you reduce. Rewriting a line to make it simpler can change what the code does, and then a failure that disappears tells you nothing. Treat the input the same way: shorten it, and check every time that it still fails. You are done when no single deletion keeps the failure. That is a minimal reproduction.
Here the Soup line, the price, format!, and menu_line all go, and the 6 stays. The input shrinks to the one name, copied exactly. Notice what is not a reduction: typing the name again without its accent changes the input, and the experiment shows what that does.
let dots = 12 - "Crêpe".len();
println!("{}", dots); // 6, expected 7 Keep a deletion only if the same wrong result is still there.
Precisely: minimal is not smallest
Minimal here means that no single deletion keeps the failure. A different order of deletions can end at a different minimal program, and a cleverer rewrite might be shorter still. Minimal is enough: every line left is needed for the failure, so every line is a clue. Automatic tools that shrink inputs this way exist for many languages; the general method is called delta debugging.
Experiment
Retype the name and lose the failure
ê is stored as two bytes and e as one. Typing the name again without the accent removed the one character that triggers the failure.
Why it matters Copy failing input exactly, character for character. Typing it again, or copying it through a tool that changes characters, can remove the cause.
Predict dots. Then replace "Crêpe" with "Crepe", the way the name is often typed on a keyboard without accents, and run again.
fn main() {
let dish = "Crêpe";
let dots = 12 - dish.len();
println!("{}", dots);
}
What to look for
dish shows len=6 and dots shows 6; the output is 6. After the edit, dish shows len=5, dots shows 7, and the output is 7: the right answer, and the failure is gone.
Expected output
604 / The rule
Which rule makes the smallest program fail?
Once the program is small, step through it and find the first line where a value differs from what you expected. Then look up the rule for that line in the documentation.
Here the first surprising value is "Crêpe".len(): 6. A Rust String or &str stores its text as UTF-8, where each character takes one to four bytes. A byte is the smallest unit of memory a program usually reads or writes. Letters without accents take one byte each; ê takes two. len() returns the number of bytes, not the number of characters. chars().count() goes through the text one character at a time and counts them.
The bug is not in len: its documentation says it returns the length in bytes. The bug was the assumption that it counts letters. Many reductions end like this, at a library function that behaves as documented.
let dots = 12 - "Crêpe".chars().count();
println!("{}", dots); // 7 The cause is the first line where a value differs from what you expected; the rule for that line explains why.
Coming from Python: Python’s len("Crêpe") is 5 because it counts characters; Rust’s "Crêpe".len() is 6 because it counts bytes.
Coming from JavaScript: "Crêpe".length is 5 in JavaScript, which counts UTF-16 units; Rust counts UTF-8 bytes, so any accented letter changes the answer.
Precisely: characters and letters
chars() yields Unicode scalar values. ê can also be written as e followed by a combining accent: two scalar values that display as one letter, so chars().count() would give 6 too. How wide text looks on screen is a third question, answered by crates such as unicode-width, not by the standard library. 12 - dish.len() also has a second bug: for a name longer than 12 bytes, a debug build panics with attempt to subtract with overflow.
Experiment
Count bytes and characters
len() returns the number of bytes in the string. chars().count() goes through the string one character at a time and counts them. Letters without accents are one byte each, so the two numbers differ only when the text contains other characters.
Why it matters When a count is wrong for some inputs only, ask what is being counted: bytes, characters, or what a reader sees as letters.
Predict both numbers for "Crêpe". Then replace "Crêpe" with "ê" and predict again.
fn main() {
let dish = "Crêpe";
let bytes = dish.len();
let chars = dish.chars().count();
println!("{} {}", bytes, chars);
}
What to look for
bytes shows 6 and chars shows 5; the output is 6 5. After the edit, the output is 2 1: the single ê is two bytes. dish now shows "ê" (len=2, …): one character stored in two bytes.
Expected output
6 505 / Write it up
Can someone else run your reproduction?
A reproduction is finished when someone else can run it and see the failure without asking you anything. Write four things: what you expected, what happened, the program, and where you ran it (the language version and the system). Make the program print its own verdict, pass or FAIL, so a reader does not need to know the right answer to read the output.
After the fix, keep the same program as a regression test. A regression is a bug that comes back after it was fixed, and a regression test is a check that fails while the bug is present and passes once it is fixed. If someone later brings the bug back, the check prints FAIL again.
// Expected 7 dots, observed 6. rustc 1.63, debug build.
fn main() {
let dots = 12 - "Crêpe".len();
if dots == 7 {
println!("pass");
} else {
println!("FAIL: {} dots", dots);
}
} A reproduction prints its own verdict, so anyone can run it and tell whether the bug is still there.
Why is this a better reproduction to send than the program you started with?
Reveal the reasoning
It has nothing left to delete: no menu_line, no price, no Soup, and one name copied exactly. A reader sees at once that the length of Crêpe is the question, and does not have to count dots in two lines. It also prints FAIL without the reader knowing the right answer, and after the fix it prints pass.
06 / Try it
Can you shrink a fresh failure?
Predict the output, then plan the reduction before you reveal the answer. A departure board should pad every city name with spaces to 8 characters.
fn main() {
let width = 8;
for city in ["Oslo", "Malmö"] {
let pad = width - city.len();
println!("[{}{}]", city, " ".repeat(pad));
}
}
What does this print, which lines can you delete and still see the failure, and what is the smallest input that still fails?
Reveal the reasoning
It prints [Oslo ] with 4 spaces, then [Malmö ] with 2 spaces where 3 were needed. You can delete Oslo and the loop and keep one name; the same short padding stays. The smallest input is the single character ö: "ö".len() is 2, not 1. Typing Malmo instead would hide the failure. The fix counts characters: width - city.chars().count().
Practice tickets
- REPRO-101 · Fix the longest streak → Free
- REPRO-102 · Pin the parking charge with a test → Sign in · Pro
- REPRO-103 · Shrink a crashing formula → Sign in · Pro
Keep exploring
Assertions, tests, and counterexamples →