Project · after Level 3

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 (Crab and crab are the same word) and ignores surrounding punctuation ("crab!" counts as crab).
  • 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.
🧪 Playground-friendly, mostly No keyboard input here, so you can prototype the counting functions in the Playground with a hardcoded string, then move to a real project for the file and argument handling.

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.

Stretch goals

Make it yours

  • Stop words. Skip boring words (the, a, and, of) with a HashSet, behind a --no-stop flag.
  • 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 --release and enjoy the difference.