Rust Exercises
Reading about Rust is useful. Writing Rust is how it sticks. This page has 18 small problems, ordered from easy to harder and grouped by topic.
For each one, read the task, try it yourself first, and only then open the solution. It’s completely fine to get stuck or to see compiler errors. That’s part of learning, and Rust’s error messages are often the best hint you’ll get.
How to practice: open the Rust Playground in another tab, or make a project with cargo new practice (see Get Started). Write your answer there. Then compare with the solution. If your answer is different but prints the same thing, it’s probably fine. There is almost always more than one good way.
Basics
Section titled “Basics”1. FizzBuzz
Section titled “1. FizzBuzz”Print the numbers from 1 to 15. But for multiples of 3 print Fizz, for multiples of 5 print Buzz, and for multiples of both print FizzBuzz.
Hint: use a for loop over 1..=15 and the % (remainder) operator. Check “multiple of both” first.
Show solution
fn main() { for n in 1..=15 { if n % 15 == 0 { println!("FizzBuzz"); } else if n % 3 == 0 { println!("Fizz"); } else if n % 5 == 0 { println!("Buzz"); } else { println!("{n}"); } }}Output:
12Fizz4BuzzFizz78FizzBuzz11Fizz1314FizzBuzzThe order matters. If you check n % 3 == 0 first, then 15 prints Fizz and never reaches the FizzBuzz branch. A number divisible by both 3 and 5 is divisible by 15.
2. Reverse a string
Section titled “2. Reverse a string”Write a function reverse(s: &str) -> String that returns the text backwards. "stressed" should become "desserts".
Hint: .chars() gives you the characters one at a time, .rev() reverses an iterator, and .collect() can build a String.
Show solution
fn reverse(s: &str) -> String { s.chars().rev().collect()}
fn main() { println!("{}", reverse("stressed")); println!("{}", reverse("hello")); println!("{}", reverse("año"));}Output:
dessertsollehoñaWhy .chars() and not bytes? A Rust String is UTF-8, so a character like ñ takes two bytes. Reversing the bytes would break it into invalid text. .chars() works on whole characters. See Strings for more.
3. Count the vowels
Section titled “3. Count the vowels”Write count_vowels(s: &str) -> usize that counts the vowels (a, e, i, o, u) in a string. Both lowercase and uppercase count.
Hint: .chars().filter(...).count(). A string like "aeiouAEIOU" has a .contains(c) method you can use to test each character.
Show solution
fn count_vowels(s: &str) -> usize { s.chars().filter(|c| "aeiouAEIOU".contains(*c)).count()}
fn main() { println!("{}", count_vowels("Hello, World")); println!("{}", count_vowels("Rust is fun")); println!("{}", count_vowels("AEIOU and sometimes y")); println!("{}", count_vowels("rhythm"));}Output:
33100filter gives the closure a reference to each item (&char), so we write *c to get the char itself. The Closures and Iterators pages explain this pattern.
Ownership & Borrowing
Section titled “Ownership & Borrowing”4. Fix the move error
Section titled “4. Fix the move error”This program doesn’t compile. Change only the shout function’s parameter and the two calls so it prints HELLO! twice.
fn shout(text: String) { println!("{}!", text.to_uppercase());}
fn main() { let word = String::from("hello"); shout(word); shout(word);}error[E0382]: use of moved value: `word` --> src/main.rs:8:11 |6 | let word = String::from("hello"); | ---- move occurs because `word` has type `String`, which does not implement the `Copy` trait7 | shout(word); | ---- value moved here8 | shout(word); | ^^^^ value used here after moveHint: shout only needs to look at the text, not own it. See Ownership.
Show solution
fn shout(text: &str) { println!("{}!", text.to_uppercase());}
fn main() { let word = String::from("hello"); shout(&word); shout(&word);}Output:
HELLO!HELLO!When a function takes String, calling it moves the string in, and main can’t use it again. Taking &str means the function only borrows it. The compiler also suggests word.clone(), which works too, but it copies the whole string for no reason. Borrowing is the better fix here.
5. Fix the borrow error
Section titled “5. Fix the borrow error”This program also doesn’t compile. Make it print Ana, while still adding Ben to the list.
fn main() { let mut names = vec![String::from("Ana")]; let first = &names[0]; names.push(String::from("Ben")); println!("{first}");}error[E0502]: cannot borrow `names` as mutable because it is also borrowed as immutable --> src/main.rs:4:5 |3 | let first = &names[0]; | ----- immutable borrow occurs here4 | names.push(String::from("Ben")); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here5 | println!("{first}"); | ----- immutable borrow later used hereHint: the problem is the order. first is still in use when push changes the vector. See Borrowing.
Show solution
fn main() { let mut names = vec![String::from("Ana")]; names.push(String::from("Ben")); let first = &names[0]; println!("{first}");}Output:
AnaWhy is Rust so strict here? push might need more room, so the vector can move all its items to a new, bigger place in memory. Then first would point at the old place, which is no longer valid. Rust’s rule (many readers or one writer, never both at once) stops that bug before your program runs. Taking the reference after the push solves it.
6. First word of a string
Section titled “6. First word of a string”Write first_word(s: &str) -> &str that returns the first word (everything before the first space). If there’s no space, return the whole string. Don’t create a new String; return a slice of the original.
Hint: s.find(' ') returns Option<usize> with the position of the first space. &s[..i] is a slice from the start up to i.
Show solution
fn first_word(s: &str) -> &str { match s.find(' ') { Some(i) => &s[..i], None => s, }}
fn main() { println!("{:?}", first_word("hello world")); println!("{:?}", first_word("Rust")); println!("{:?}", first_word(""));}Output:
"hello""Rust"""The returned &str points into the same memory as the input, so nothing is copied. The compiler knows the result can’t outlive the input. That’s lifetime elision at work: with one reference in and one out, you don’t have to write the lifetimes yourself.
A shorter version is s.split_whitespace().next().unwrap_or(""), which also skips extra spaces at the start.
Collections
Section titled “Collections”7. Largest number in a vector
Section titled “7. Largest number in a vector”Write largest(numbers: &[i32]) -> Option<i32>. It returns the biggest number, or None if the list is empty. Don’t use the built-in .max() this time; write the loop yourself.
Hint: start with the first item as “the biggest so far”. numbers.first() returns an Option, so it handles the empty case for you.
Show solution
fn largest(numbers: &[i32]) -> Option<i32> { let mut biggest = *numbers.first()?; for &n in numbers { if n > biggest { biggest = n; } } Some(biggest)}
fn main() { println!("{:?}", largest(&[3, 41, 7, 12])); println!("{:?}", largest(&[-5, -2, -9])); println!("{:?}", largest(&[]));}Output:
Some(41)Some(-2)Nonenumbers.first()? returns None early if the list is empty. That’s why the function returns Option: “the largest of nothing” has no good answer, and Rust makes you say so. In real code, numbers.iter().max() does the same job.
Starting with 0 instead of the first item is a classic bug: the list [-5, -2, -9] would wrongly give 0.
8. Word frequency
Section titled “8. Word frequency”Count how many times each word appears in a sentence. Ignore upper/lower case and punctuation, and print the words in alphabetical order.
Hint: use a BTreeMap<String, usize>. It works like a HashMap, but keeps its keys sorted. map.entry(word).or_insert(0) gives you a counter to add 1 to.
Show solution
use std::collections::BTreeMap;
fn main() { let text = "The cat sat on the mat. The end!"; let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for word in text.split_whitespace() { let clean = word .trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase(); if clean.is_empty() { continue; } *counts.entry(clean).or_insert(0) += 1; }
for (word, count) in &counts { println!("{word}: {count}"); }}Output:
cat: 1end: 1mat: 1on: 1sat: 1the: 3Why BTreeMap and not HashMap? A HashMap gives you its items in an unpredictable order, which can change from one run to the next. If you need sorted output, use a BTreeMap, or collect the HashMap into a Vec and sort it.
trim_matches removes the . from mat. and the ! from end!, and to_lowercase makes The and the the same word.
9. Squares of the even numbers
Section titled “9. Squares of the even numbers”Given vec![1, 2, 3, 4, 5, 6, 7, 8], build a new vector with the squares of only the even numbers: [4, 16, 36, 64]. Then print their sum. Use iterator methods, not a for loop.
Hint: chain .iter(), .filter(...), .map(...), and .collect(). Then .iter().sum() adds them up.
Show solution
fn main() { let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8];
let squares: Vec<i32> = numbers .iter() .filter(|n| *n % 2 == 0) .map(|n| n * n) .collect();
let total: i32 = squares.iter().sum();
println!("{:?}", squares); println!("Sum: {total}");}Output:
[4, 16, 36, 64]Sum: 120Read the chain from top to bottom like a recipe: take the numbers, keep the even ones, square each, put them in a Vec. Iterators are lazy, so nothing actually runs until .collect() asks for the results. See Iterators.
Error Handling
Section titled “Error Handling”10. Parse and sum numbers
Section titled “10. Parse and sum numbers”Write sum_numbers(input: &str) -> Result<i32, ParseIntError>. It takes text like "1, 2, 3", parses each number, and returns the sum. If any piece isn’t a number, return the error instead of crashing.
Hint: split on ',', .trim() each piece, then .parse(). Use the ? operator to return early on an error.
Show solution
use std::num::ParseIntError;
fn sum_numbers(input: &str) -> Result<i32, ParseIntError> { let mut total = 0; for piece in input.split(',') { let n: i32 = piece.trim().parse()?; total += n; } Ok(total)}
fn main() { for input in ["1, 2, 3", "10,20, 30", "4, five, 6", ""] { match sum_numbers(input) { Ok(sum) => println!("{input:?} -> sum is {sum}"), Err(e) => println!("{input:?} -> error: {e}"), } }}Output:
"1, 2, 3" -> sum is 6"10,20, 30" -> sum is 60"4, five, 6" -> error: invalid digit found in string"" -> error: cannot parse integer from empty string? does two things in one character: if parse() gives Ok(n), you get n. If it gives Err(e), the function returns Err(e) right away. That’s why the function must return a Result too.
Bonus: Result works with .sum(), so this one-liner does the same thing and stops at the first error:
input.split(',').map(|p| p.trim().parse::<i32>()).sum()11. Option chaining
Section titled “11. Option chaining”Each user may or may not have an email. Write email_domain(user: &User) -> Option<&str> that returns the part after the @. Return None if there’s no email, or if the email has no @. Then look up users by name and print their domain.
Hint: user.email.as_deref() turns an Option<String> into an Option<&str>. email.split_once('@') returns Option<(&str, &str)>. The ? operator works on Option too. See Option.
Show solution
struct User { name: String, email: Option<String>,}
fn email_domain(user: &User) -> Option<&str> { let email = user.email.as_deref()?; let (_, domain) = email.split_once('@')?; Some(domain)}
fn main() { let users = vec![ User { name: String::from("Ben"), email: None }, User { name: String::from("Cleo"), email: Some(String::from("cleo-at-home")) }, ];
for name in ["Ana", "Ben", "Cleo", "Dan"] { let domain = users .iter() .find(|u| u.name == name) .and_then(email_domain) .unwrap_or("(no domain)"); println!("{name}: {domain}"); }}Output:
Ana: example.comBen: (no domain)Cleo: (no domain)Dan: (no domain)There are three different ways to “fail” here: Dan doesn’t exist, Ben has no email, and Cleo’s email has no @. Each one is an Option step, and ? or .and_then() stops at the first None. No if checks, no null pointer errors. .unwrap_or(...) gives a default at the very end.
12. A bank account
Section titled “12. A bank account”Make an Account struct with an owner and a balance. Give it deposit(amount) and withdraw(amount). withdraw must return Result<u64, String>: the new balance if it worked, or an error message if there isn’t enough money. The balance must never go below zero.
Hint: methods that change the account take &mut self. Check the balance before you subtract. See Structs and Result.
Show solution
struct Account { owner: String, balance: u64,}
impl Account { fn new(owner: &str) -> Account { Account { owner: owner.to_string(), balance: 0 } }
fn deposit(&mut self, amount: u64) { self.balance += amount; }
fn withdraw(&mut self, amount: u64) -> Result<u64, String> { if amount > self.balance { return Err(format!( "cannot withdraw {amount}: balance is only {}", self.balance )); } self.balance -= amount; Ok(self.balance) }}
fn main() { let mut account = Account::new("Ana"); account.deposit(100);
for amount in [30, 50, 40] { match account.withdraw(amount) { Ok(left) => println!("Withdrew {amount}, {left} left"), Err(e) => println!("Error: {e}"), } }
println!("{} has {} at the end", account.owner, account.balance);}Output:
Withdrew 30, 70 leftWithdrew 50, 20 leftError: cannot withdraw 40: balance is only 20Ana has 20 at the endUsing u64 (an unsigned number) is a nice extra safety net: it can’t hold a negative balance at all. If you forgot the check, self.balance -= amount would panic with “attempt to subtract with overflow” in debug mode, instead of silently going negative.
Traits & Generics
Section titled “Traits & Generics”13. A Shape trait
Section titled “13. A Shape trait”Define a trait Shape with two methods: area(&self) -> f64 and name(&self) -> String. Implement it for a Circle (with a radius) and a Rectangle (with a width and height). Put both in one Vec and print each name and area with 2 decimal places.
Hint: to store different types in one Vec, use Vec<Box<dyn Shape>>. See Traits and Trait Objects. {:.2} prints 2 decimals.
Show solution
trait Shape { fn area(&self) -> f64; fn name(&self) -> String;}
struct Circle { radius: f64,}
struct Rectangle { width: f64, height: f64,}
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } fn name(&self) -> String { format!("Circle (r = {})", self.radius) }}
impl Shape for Rectangle { fn area(&self) -> f64 { self.width * self.height } fn name(&self) -> String { format!("Rectangle ({} x {})", self.width, self.height) }}
fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 1.0 }), Box::new(Rectangle { width: 3.0, height: 4.0 }), Box::new(Circle { radius: 2.5 }), ];
let mut total = 0.0; for shape in &shapes { println!("{}: {:.2}", shape.name(), shape.area()); total += shape.area(); } println!("Total area: {total:.2}");}Output:
Circle (r = 1): 3.14Rectangle (3 x 4): 12.00Circle (r = 2.5): 19.63Total area: 34.78The loop doesn’t know or care which shape it has. It only knows each item “is a Shape”, so it can call area() and name(). Adding a Triangle later means writing one new impl, and the loop doesn’t change at all.
14. A generic largest
Section titled “14. A generic largest”Go back to exercise 7, but make it work for any type that can be compared: numbers, decimals, characters, and strings. The signature is largest<T: PartialOrd>(items: &[T]) -> Option<&T>.
Hint: T: PartialOrd means “T supports < and >”. Return a reference (&T) so you don’t need to copy or clone the items. See Generics.
Show solution
fn largest<T: PartialOrd>(items: &[T]) -> Option<&T> { let mut biggest = items.first()?; for item in items { if item > biggest { biggest = item; } } Some(biggest)}
fn main() { println!("{:?}", largest(&[3, 41, 7, 12])); println!("{:?}", largest(&[2.5, -1.0, 9.75])); println!("{:?}", largest(&['r', 'u', 's', 't'])); println!("{:?}", largest(&["pear", "apple", "zucchini"]));
let empty: Vec<i32> = Vec::new(); println!("{:?}", largest(&empty));}Output:
Some(41)Some(9.75)Some('u')Some("zucchini")NoneThe body is almost the same as exercise 7. The only real change is the <T: PartialOrd> part. Without that bound, the compiler refuses item > biggest, because not every type can be compared. Strings compare alphabetically, which is why "zucchini" wins.
Notice Vec<i32> on the empty vector: with no items, Rust can’t guess what T is, so you have to tell it.
Sets, Sorting, Patterns & Iterators
Section titled “Sets, Sorting, Patterns & Iterators”15. Find the repeats
Section titled “15. Find the repeats”A guest list has some names written more than once. Write find_repeats(words: &[&str]) -> Vec<String> that returns every name that appears more than once, each listed only once, in alphabetical order. Also print how many different guests there are.
Hint: a HashSet remembers what you’ve already seen. Its insert method returns false when the value was already there. A HashSet has no fixed order, so sort the result at the end.
Show solution
use std::collections::HashSet;
fn find_repeats(words: &[&str]) -> Vec<String> { let mut seen = HashSet::new(); let mut repeats = HashSet::new();
for &word in words { // insert returns false if the word was already in the set if !seen.insert(word) { repeats.insert(word); } }
let mut result: Vec<String> = repeats.into_iter().map(String::from).collect(); result.sort(); result}
fn main() { let guests = ["ana", "ben", "cleo", "ben", "dan", "ana", "ben"]; println!("Repeats: {:?}", find_repeats(&guests));
let unique: HashSet<&str> = guests.iter().copied().collect(); println!("Different guests: {}", unique.len());
println!("Repeats: {:?}", find_repeats(&["x", "y", "z"]));}Output:
Repeats: ["ana", "ben"]Different guests: 4Repeats: []Why two sets? seen holds every name so far. repeats holds the names we met a second time. "ben" appears three times, but because repeats is a set, it’s only stored once.
Collecting into a HashSet is also the quickest way to count unique values: duplicates simply disappear. Without the result.sort() line, the order of the repeats could change from one run to the next.
16. Sort words by length
Section titled “16. Sort words by length”Sort a list of words from shortest to longest. When two words have the same length, put them in A-to-Z order, ignoring upper and lower case. Then compare your result with a plain .sort().
Hint: sort_by takes a closure that compares two items and returns an Ordering. .then_with(...) adds a second comparison that only matters on a tie. See Sorting.
Show solution
fn main() { let mut words = vec!["pear", "Fig", "banana", "apple", "kiwi", "Cherry", "date"];
let mut plain = words.clone(); plain.sort(); println!("Plain sort: {plain:?}");
words.sort_by(|a, b| { a.len() .cmp(&b.len()) .then_with(|| a.to_lowercase().cmp(&b.to_lowercase())) }); println!("By length: {words:?}");}Output:
Plain sort: ["Cherry", "Fig", "apple", "banana", "date", "kiwi", "pear"]By length: ["Fig", "date", "kiwi", "pear", "apple", "banana", "Cherry"]Look at the plain sort: "Cherry" and "Fig" come first. Strings compare by their character codes, and every uppercase letter comes before every lowercase one. That’s why we compare to_lowercase() versions in the tie-breaker.
A shorter way to write the same rule is words.sort_by_key(|w| (w.len(), w.to_lowercase())). Tuples compare field by field, so the length decides first and the lowercase word breaks ties.
17. Shipping costs with match guards
Section titled “17. Shipping costs with match guards”Write shipping_cost(p: Package) -> String for this enum, using one match:
enum Package { Letter { grams: u32 }, Parcel { kg: u32, express: bool }, Pickup,}The rules: pickup is free. Letters up to 20 g cost $1, heavier letters cost $3. Parcels over 30 kg can’t be shipped at all. Express parcels cost $10 plus $2 per kg. Other parcels cost $5 plus $1 per kg.
Hint: a match arm can have an if after the pattern, called a guard: Package::Letter { grams } if grams <= 20 => .... Arms are checked from top to bottom, so put the “too heavy” rule before the express rule. See Pattern Matching.
Show solution
enum Package { Letter { grams: u32 }, Parcel { kg: u32, express: bool }, Pickup,}
fn shipping_cost(p: Package) -> String { match p { Package::Pickup => String::from("free (pickup)"), Package::Letter { grams } if grams <= 20 => String::from("$1 (standard letter)"), Package::Letter { grams } => format!("$3 (large letter, {grams} g)"), Package::Parcel { kg, .. } if kg > 30 => format!("too heavy to ship ({kg} kg)"), Package::Parcel { kg, express: true } => format!("${} (express)", 10 + kg * 2), Package::Parcel { kg, express: false } => format!("${} (standard parcel)", 5 + kg), }}
fn main() { let packages = vec![ Package::Letter { grams: 15 }, Package::Letter { grams: 80 }, Package::Parcel { kg: 4, express: false }, Package::Parcel { kg: 4, express: true }, Package::Parcel { kg: 35, express: true }, Package::Pickup, ];
for p in packages { println!("{}", shipping_cost(p)); }}Output:
$1 (standard letter)$3 (large letter, 80 g)$9 (standard parcel)$18 (express)too heavy to ship (35 kg)free (pickup)The 35 kg express parcel is the interesting one. It matches two arms, but only the first one wins. If you put the express arm above the “too heavy” arm, it would cheerfully charge $80 for a parcel you can’t ship.
Notice the last arm says express: false instead of ... That’s not required, but it makes the arm say exactly what it handles. The compiler still checks that every case is covered. Guards don’t count toward that check, which is why the plain Package::Letter { grams } arm without a guard has to be there.
18. Clean up a stock report
Section titled “18. Clean up a stock report”A warehouse report has one item per line, like "apples 12". Some lines are broken: empty, or the count isn’t a number. Using iterator methods (no for loop), keep only the lines that parse and have a count above zero. Print them as name: count separated by commas, then print the total.
Hint: .lines() splits the text into lines. filter_map keeps only the Some values its closure returns, so it can filter and convert in one step. .ok() turns a Result into an Option. See Iterators.
Show solution
fn main() { let report = "apples 12\nbananas x\npears 0\n\ncherries 40\nplums 7";
let in_stock: Vec<(&str, u32)> = report .lines() .filter_map(|line| line.split_once(' ')) .filter_map(|(name, count)| count.parse::<u32>().ok().map(|n| (name, n))) .filter(|&(_, n)| n > 0) .collect();
let list: Vec<String> = in_stock .iter() .map(|(name, n)| format!("{name}: {n}")) .collect(); let total: u32 = in_stock.iter().map(|(_, n)| n).sum();
println!("{}", list.join(", ")); println!("Total items: {total}");}Output:
apples: 12, cherries: 40, plums: 7Total items: 59Follow one line at a time. The empty line has no space, so split_once gives None and the first filter_map drops it. "bananas x" splits fine, but "x" doesn’t parse, so the second filter_map drops it. "pears 0" survives both, and then filter removes it because the count is zero.
We collect into a Vec once, then walk it twice: once to build the text, once for the total. An iterator can only be used once, so storing the cleaned data is simpler than parsing everything two times.
Where to go next
Section titled “Where to go next”If you finished all 18, well done. That’s real progress. 🦀
- Build a whole program: try the Guessing Game, the Word Counter, or the To-Do List CLI project.
- Getting ready for a job? Read the Rust Interview Questions.
- Want even more practice? The links below have lots of extra problems.
Read more
Section titled “Read more”- Rustlings: the official set of small, broken programs you fix one by one in your terminal. A great next step after this page.
- Exercism Rust track: about 100 problems with tests, and free feedback on your solutions from human mentors.
- Rust by Example: short, runnable examples for each topic. Handy when you’re stuck on the syntax for one exercise.
Iteratortrait — std docs: every iterator method (filter_map,fold,zip, and many more), each with a tiny example.HashSet— std docs: all the set methods, includingunion,intersection, anddifference.