Rust Fundamentals: Ownership, Errors and Fearless Concurrency · Errors, Collections, Traits and Concurrency

Capstone: A Word Statistics Tool

Build a small command-line tool that reads text, reports word counts, the most common words and average word length, with proper error handling and tests. Then review it against the rubric.

Create a free account to save this page, take notes and track your progress. Sign in

The brief

Build wordstats with Cargo:

wordstats report.txt --top 5

It should:

  1. Read the file named in the arguments (std::env::args), with a clear error for a missing file or bad arguments (no panics).
  2. Normalise words (lowercase, strip punctuation), count them with a HashMap, and print the total, unique count, average length and the top N words (ties sorted alphabetically).
  3. Put the logic in a library module (src/lib.rs) with a Stats struct and unit tests (#[cfg(test)], cargo test).
  4. Bonus: process large files in parallel with scoped threads, and add --json output.

A head start

Here's the core of the tool working on a string, with the Stats struct, normalisation, ties and a Display implementation:

The core of wordstats
use std::collections::HashMap;
use std::fmt;

struct Stats {
    total: usize,
    unique: usize,
    avg_len: f64,
    top: Vec<(String, usize)>,
}

fn normalise(word: &str) -> String {
    word.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase()
}

fn analyse(text: &str, top_n: usize) -> Option<Stats> {
    let words: Vec<String> = text.split_whitespace().map(normalise).filter(|w| !w.is_empty()).collect();
    if words.is_empty() {
        return None;
    }
    let mut counts: HashMap<&str, usize> = HashMap::new();
    for w in &words {
        *counts.entry(w.as_str()).or_insert(0) += 1;
    }
    let mut top: Vec<(String, usize)> = counts.iter().map(|(w, c)| (w.to_string(), *c)).collect();
    top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); // count desc, then alphabetical
    top.truncate(top_n);
    Some(Stats {
        total: words.len(),
        unique: counts.len(),
        avg_len: words.iter().map(|w| w.chars().count()).sum::<usize>() as f64 / words.len() as f64,
        top,
    })
}

impl fmt::Display for Stats {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        writeln!(f, "words: {}, unique: {}, average length: {:.2}", self.total, self.unique, self.avg_len)?;
        for (i, (word, count)) in self.top.iter().enumerate() {
            writeln!(f, "{:>2}. {word:<10} {count}", i + 1)?;
        }
        Ok(())
    }
}

fn main() {
    let text = "It was the best of times, it was the worst of times; it was the age of wisdom.";
    match analyse(text, 4) {
        Some(stats) => print!("{stats}"),
        None => println!("no words found"),
    }
    println!("empty input -> {}", if analyse("  ...  ", 3).is_none() { "no words found" } else { "?" });
}

Output

words: 18, unique: 9, average length: 3.22
 1. it         3
 2. of         3
 3. the        3
 4. was        3
empty input -> no words found

Run this on your computer — see “Get Started” for setup.

Review your work

Open each question for the model answer and rubric.

Deliverables

Error handling: what does a strong solution do?

Model answer

main returns Result<(), Box<dyn Error>> or uses a custom error enum; argument parsing and file reading use ? with helpful messages ("cannot read report.txt: No such file or directory"), the exit code is non-zero on failure, and there are no unwrap() calls on user input. A crate like clap handles arguments in bigger tools.

Rubric

Rubric: no panics for user mistakes; clear messages; correct exit codes. Weak: unwrap()/expect() on file reading or argument parsing.

Design and tests: what does a strong solution look like?

Model answer

Logic in lib.rs (analyse returning Option<Stats> or Result) with small pure functions (normalise, count), main.rs only handling I/O and printing; unit tests for normalisation, ties ordering, empty input and Unicode words; cargo test and cargo clippy clean.

Rubric

Rubric: testable pure functions, deterministic output (sorted ties), edge cases covered. Bonus: parallel counting with thread::scope merged into one HashMap, benchmarked on a large file.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?