Flashcard Quizzer 🃏
Build the tool this school would use to study itself: flashcards from a file, asked in random order, and the ones you miss come back around until you get every single one. A study app you will actually use.
📋 The spec
Build a program that:
- Loads flashcards from
cards.txt, one card per line, in the formatquestion|answer. - Shuffles them and asks each question, reading the answer from the keyboard.
- Accepts answers case-insensitively, with surrounding spaces ignored.
- Shows the correct answer whenever you miss, and reports a round score.
- Then re-asks only the cards you missed, round after round, until every card has been answered correctly.
- Treats a missing or empty
cards.txtas a friendly message, not a crash.
cd ~/rusty-lab
cargo new flashcards
cd flashcards
cargo add rand
The two new things
Shuffling a Vec
The rand crate you met in Project 1 also shuffles:
use rand::prelude::*;
let mut cards = vec!["a", "b", "c"];
cards.shuffle(&mut rand::rng());
Prompting on the same line
println! always ends the line. To show a >
prompt and let the user type next to it, use print!
plus a flush (output is buffered until a newline unless you push it out
yourself):
use std::io::{self, Write};
print!("> ");
io::stdout().flush().expect("could not flush");
Card struct, reading a file with unwrap_or_default,
split_once('|') with filter_map: this is Project 3's
loading pattern in new clothes. Go build it before opening any hints.
Hints, in order of desperation
Hint 1: What shape should the data have?
struct Card {
question: String,
answer: String,
}
The deck is a Vec<Card>. The program is: load, shuffle,
loop over cards asking each one, collect the misses, repeat on the
misses until none remain.
Hint 2: How do I load the deck?
Exactly like Project 3's todo file, with a different separator payload:
fn load(path: &str) -> Vec<Card> {
fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|line| {
let (q, a) = line.split_once('|')?;
Some(Card {
question: q.trim().to_string(),
answer: a.trim().to_string(),
})
})
.collect()
}
Hint 3: How do I compare answers fairly?
Trim the input, then compare without caring about case:
input.trim().eq_ignore_ascii_case(&card.answer)
Put the whole ask-and-check into one function returning
bool: fn ask(card: &Card) -> bool. Both the
first round and the retry rounds can then share it.
Hint 4: How do the retry rounds work?
Collect references to missed cards in a Vec,
then loop while it is not empty, building a fresh
still_missed each round:
let mut missed = Vec::new();
for card in &cards {
if !ask(card) { missed.push(card); }
}
while !missed.is_empty() {
let mut still_missed = Vec::new();
for card in missed {
if !ask(card) { still_missed.push(card); }
}
missed = still_missed;
}
Note missed holds &Card, not clones. The
borrow checker allows it because the deck itself never changes after
loading. When a design avoids mutation, borrowing gets easy: that is
not luck, it is a pattern worth noticing.
Hint 5: The full reference solution
use rand::prelude::*;
use std::fs;
use std::io::{self, Write};
struct Card {
question: String,
answer: String,
}
fn load(path: &str) -> Vec<Card> {
fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|line| {
let (q, a) = line.split_once('|')?;
Some(Card {
question: q.trim().to_string(),
answer: a.trim().to_string(),
})
})
.collect()
}
fn ask(card: &Card) -> bool {
print!("Q: {}\n> ", card.question);
io::stdout().flush().expect("could not flush");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("could not read");
input.trim().eq_ignore_ascii_case(&card.answer)
}
fn main() {
let mut cards = load("cards.txt");
if cards.is_empty() {
println!("No cards found. Put 'question|answer' lines in cards.txt");
return;
}
cards.shuffle(&mut rand::rng());
let mut missed = Vec::new();
for card in &cards {
if ask(card) {
println!("✅ correct!\n");
} else {
println!("❌ it was: {}\n", card.answer);
missed.push(card);
}
}
println!("Round 1 score: {} / {}", cards.len() - missed.len(), cards.len());
while !missed.is_empty() {
println!("\nRetrying the {} you missed:", missed.len());
let mut still_missed = Vec::new();
for card in missed {
if ask(card) {
println!("✅ got it this time!\n");
} else {
println!("❌ it was: {}\n", card.answer);
still_missed.push(card);
}
}
missed = still_missed;
}
println!("🎉 Every card answered correctly. Session complete.");
}
Seed cards.txt with this course: What keyword makes
a variable changeable?|mut and so on. Studying Rust with a tool
you wrote in Rust is peak Rusty School. 🦀
Make it yours
- Decks. Take the filename from
env::args()so you can keeprust.txt,spanish.txt, ... - Streaks. Track consecutive correct answers and celebrate at 5 and 10.
- Leitner lite. Save each card's miss count back to the file (Project 3 skills) and ask the most-missed cards first next session.
- Multiple answers. Let the answer field hold
ferris/craband accept either. - Timed mode. Show how long the session took with
std::time::Instant(Lesson 23).