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.
The brief
Build wordstats with Cargo:
wordstats report.txt --top 5
It should:
- Read the file named in the arguments (
std::env::args), with a clear error for a missing file or bad arguments (no panics). - 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). - Put the logic in a library module (
src/lib.rs) with aStatsstruct and unit tests (#[cfg(test)],cargo test). - Bonus: process large files in parallel with scoped threads, and add
--jsonoutput.
A head start
Here's the core of the tool working on a string, with the Stats struct, normalisation, ties and a Display implementation:
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.