Project · after Level 2

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 format question|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.txt as a friendly message, not a crash.
🛠️ This one needs a real install Keyboard input needs a terminal, so the browser playground will not work here. The setup guide takes about ten minutes if your lab is not ready yet. Start with:
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");
💡 You know the rest A 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. 🦀

Stretch goals

Make it yours

  • Decks. Take the filename from env::args() so you can keep rust.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/crab and accept either.
  • Timed mode. Show how long the session took with std::time::Instant (Lesson 23).