Word Counter Pro 📊
Feed it any text file and it tells you the shape of the writing: lines, words, characters, and the words used most. This is the project where iterator chains stop being a lesson topic and start being how you think.
📋 The spec
Build a command-line tool that runs like this:
$ cargo run -- speech.txt 3
📄 speech.txt
lines: 42
words: 310
characters: 1804
unique words: 187
top 3 words:
12 the
9 rust
7 and
Requirements:
- Takes a filename and an optional top-N (default 10) from the arguments.
- Counting is case-insensitive (
Crabandcrabare the same word) and ignores surrounding punctuation ("crab!"counts ascrab). - The top list is sorted by count, ties broken alphabetically, counts right-aligned.
- A missing file or missing argument prints a helpful message to stderr and exits with a non-zero code.
- The counting logic lives in functions with at least three unit tests.
The two new things
Complaining properly
Real tools print errors to stderr, not stdout, and exit with a code so scripts can tell success from failure:
use std::process;
eprintln!("usage: wordcount <file> [top_n]");
process::exit(1);
Sorting by two things at once
sort_by takes a closure comparing two elements. Chain
comparisons with .then(...): if the first is a tie, the second
decides:
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
// count, descending ^^^ ^^^ then word, ascending
Hints, in order of desperation
Hint 1: How do I clean a word?
trim_matches with a closure strips unwanted characters
from both ends:
fn clean_word(raw: &str) -> String {
raw.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase()
}
Note it can return an empty string (for input like ---),
so skip empties when counting. Why only trim the ends? So
don't keeps its apostrophe. Text is sneaky; Lesson 18
warned you.
Hint 2: How do I count into a HashMap?
The entry pattern from Lesson 10, at the center of the whole tool:
fn count_words(text: &str) -> HashMap<String, u32> {
let mut counts = HashMap::new();
for raw in text.split_whitespace() {
let word = clean_word(raw);
if !word.is_empty() {
*counts.entry(word).or_insert(0) += 1;
}
}
counts
}
Keeping this a pure function (text in, map out) is what makes it testable without any files.
Hint 3: How do I get the top N?
A HashMap has no order, so convert to a Vec of pairs, sort, truncate:
fn top_words(counts: &HashMap<String, u32>, n: usize) -> Vec<(String, u32)> {
let mut pairs: Vec<(String, u32)> = counts
.iter()
.map(|(w, c)| (w.clone(), *c))
.collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs.truncate(n);
pairs
}
Hint 4: How do I line up the output?
Format specifiers pad for you: {count:>6} right-aligns
the count in six columns. That is the whole trick behind pretty tables:
for (word, count) in top_words(&counts, top_n) {
println!(" {count:>6} {word}");
}
Hint 5: The full reference solution
use std::collections::HashMap;
use std::env;
use std::fs;
use std::process;
fn clean_word(raw: &str) -> String {
raw.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, u32> {
let mut counts = HashMap::new();
for raw in text.split_whitespace() {
let word = clean_word(raw);
if !word.is_empty() {
*counts.entry(word).or_insert(0) += 1;
}
}
counts
}
fn top_words(counts: &HashMap<String, u32>, n: usize) -> Vec<(String, u32)> {
let mut pairs: Vec<(String, u32)> = counts
.iter()
.map(|(w, c)| (w.clone(), *c))
.collect();
// most frequent first; ties broken alphabetically
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs.truncate(n);
pairs
}
fn main() {
let args: Vec<String> = env::args().collect();
let Some(path) = args.get(1) else {
eprintln!("usage: wordcount <file> [top_n]");
process::exit(1);
};
let top_n: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(10);
let text = match fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!("could not read {path}: {e}");
process::exit(1);
}
};
let counts = count_words(&text);
let total_words: u32 = counts.values().sum();
println!("📄 {path}");
println!(" lines: {}", text.lines().count());
println!(" words: {total_words}");
println!(" characters: {}", text.chars().count());
println!(" unique words: {}", counts.len());
println!("\n top {top_n} words:");
for (word, count) in top_words(&counts, top_n) {
println!(" {count:>6} {word}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleans_punctuation_and_case() {
assert_eq!(clean_word("Hello,"), "hello");
assert_eq!(clean_word("(rust)"), "rust");
assert_eq!(clean_word("---"), "");
}
#[test]
fn counts_are_case_insensitive() {
let counts = count_words("Crab crab CRAB! ocean");
assert_eq!(counts["crab"], 3);
assert_eq!(counts["ocean"], 1);
}
#[test]
fn top_words_sorts_by_count_then_alpha() {
let counts = count_words("b b a a c");
let top = top_words(&counts, 2);
assert_eq!(top, vec![("a".to_string(), 2), ("b".to_string(), 2)]);
}
}
Run it on something fun: your own main.rs, a downloaded
book from Project Gutenberg, or this course's launch post. Note
.chars().count() instead of .len() for the
character count: Lesson 18 paying rent already.
Make it yours
- Stop words. Skip boring words (the, a, and, of) with a
HashSet, behind a--no-stopflag. - Multiple files. Accept many paths and print a combined report plus a per-file table.
- Histogram. After each top word, print a bar of
#characters scaled to its count. - Reading level. Average word length and average sentence
length (split on
.!?) make a crude complexity score. - Speed check. Time it on a big book with
Instant(Lesson 23), then try--releaseand enjoy the difference.