Recommended resources
The freely available resources this lesson will direct you to.
Welcome back. In the previous lesson, you used a vector to preserve order and a hash map to count repeated values. The example cloned each string before using it as a map key. This module project gives you a chance to make a different ownership choice: when all the words come from one input string, the collections can borrow slices of that string instead.
You’ll build a command-line text analyser that reports total words, unique words, each word’s frequency, and the most frequent word. It will run locally with Cargo and need no additional crates. The main work is understanding why the borrowed words remain valid while the collections use them.
Decide what counts as a word
For this first version, a word is a nonempty piece of text separated by whitespace. Rust’s split_whitespace() gives you those pieces as &str values. It skips repeated whitespace, so "rust api" contains two words.
We’ll count words exactly as written. "Rust" and "rust" are different keys, and "api," includes its comma. That is a deliberately clear rule, not a claim about how every text analyser should define a word.
The program will keep two collections:
- A
HashMap<&str, usize>associates each borrowed word with its count. - A
Vec<&str>remembers unique words in the order they first appeared.
The map makes counting convenient; the vector makes output order predictable. A hash map alone cannot promise first-seen order.
The Rust book’s counting example is the key building block. Read it before assembling the command-line program.
Storing Keys with Associated Values in Hash Maps - The Rust Programming Language
Read the Rust book’s word-frequency example to see how borrowed words and the entry API work together.
In “Updating a Value Based on the Old Value,” start with the paragraph beginning “Another common use case for hash maps is to look up a key’s value.” Read the counting example through the explanation immediately after its code, stopping before “Hashing Functions.” Focus on what split_whitespace() yields and why *count += 1 changes a value stored in the map.
Notice the important distinction from last lesson’s cloned status strings. A map with &str keys stores references to text, not owned copies of each word. That works here because the input text will remain alive until the analyser finishes. It also means the map must not outlive that text.
Receive text from the shell
We’ll accept exactly one command-line argument: the text to analyse. Quoting it in the shell keeps spaces inside that single argument. For example:
cargo run -- "rust api rust Rust"
The -- separates Cargo’s options from arguments passed to your program. Inside the program, std::env::args() supplies the arguments as String values. The first is the program path, so the quoted text is at index 1.
Watch the opening argument-handling demonstration in Writing a CLI App in Rust! - Part 1 by Let’s Get Rusty. The video selects arguments by index; our version will first check the number of arguments, so missing input produces a useful message rather than an indexing panic.
Writing a CLI App in Rust! - Part 1
Watch the opening of Writing a CLI App in Rust! - Part 1 by Let’s Get Rusty for the command-line setup and env::args() pattern.
Watch argument collection, from creating a Cargo project through selecting arguments from the vector. Pay particular attention to why the collected vector includes the program path at index 0. You can stop before the file-reading portion; this analyser processes the argument directly.
The command-line code will own the input String. It can then pass an &str view into the analysis function. That arrangement gives the function permission to read the text without transferring its ownership.
Build the analyser
Create a new binary project on macOS:
cargo new text_analyser
cd text_analyser
Replace src/main.rs with this complete program:
use std::collections::HashMap;
use std::{env, process};
fn analyse(text: &str) {
let mut frequencies: HashMap<&str, usize> = HashMap::new();
let mut first_seen: Vec<&str> = Vec::new();
let mut total_words: usize = 0;
for word in text.split_whitespace() {
total_words += 1;
let count = frequencies.entry(word).or_insert(0);
if *count == 0 {
first_seen.push(word);
}
*count += 1;
}
println!("words: {total_words}");
println!("unique words: {}", first_seen.len());
println!("frequencies (first-seen order):");
let mut top_word = "";
let mut top_count = 0;
for &word in &first_seen {
let count = *frequencies
.get(word)
.expect("a first-seen word must have a count");
println!("{word}: {count}");
if count > top_count {
top_word = word;
top_count = count;
}
}
if top_count == 0 {
println!("most frequent: none");
} else {
println!("most frequent: {top_word} ({top_count})");
}
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!("Usage: cargo run -- \"text to analyse\"");
process::exit(2);
}
let text: &str = args[1].as_str();
analyse(text);
}
Start with the input’s ownership. args owns its String elements. as_str() borrows the text in args[1]; it does not make a second string. During the call to analyse, the input String is still alive. Every word produced by split_whitespace() is a slice of that input, so both collections can hold those slices while analyse runs. The collections are discarded before main discards args.
Now follow one word through the counting loop. entry(word).or_insert(0) finds its existing count or inserts zero, then returns a mutable reference to that count. Zero therefore means this is the word’s first appearance: we put its borrowed slice in first_seen. Incrementing through *count records the occurrence. Later appearances increment the same map entry but do not add another vector element.
The reporting loop walks first_seen, rather than the map, to preserve order. for &word in &first_seen borrows the vector and gives each iteration a &str value. The expect is appropriate for an internal invariant: this program adds a word to first_seen only when it has a map entry. It is not being used to handle missing user input; main handles that case explicitly before indexing args.
Finally, top_count begins at zero. Counts of real words are positive, so the first word becomes the current leader. A later word replaces it only with a strictly greater count. If counts tie, the earlier word stays the leader. For empty input, the loop has no iterations and the program prints none.
Run it and inspect the result
From the project directory, run:
cargo run -- "rust api rust Rust"
After Cargo’s build messages, the program’s output should be:
words: 4
unique words: 3
frequencies (first-seen order):
rust: 2
api: 1
Rust: 1
most frequent: rust (2)
The result illustrates both the counting rule and the output rule: lowercase rust appears twice, uppercase Rust is a separate word, and the frequency lines follow first appearance rather than hash-map iteration order.
An empty quoted argument is still one argument, so it is valid input:
cargo run -- ""
It reports zero words, zero unique words, and no most-frequent word. By contrast, running cargo run with no text, or passing multiple unquoted words, prints the usage message to standard error. In a backend application later, separating expected input mistakes from valid requests will matter just as much; here, the shell makes that boundary easy to see.
Conclusion
You have a working command-line analyser built from one owned input String, borrowed &str word slices, a frequency HashMap, and an order-preserving Vec. The crucial ownership rule is that borrowed keys are safe only while their source text remains alive. Keeping the collections inside analyse makes that relationship straightforward.
This completes the ownership-and-collections module project. Next, you’ll study enums and pattern matching directly, giving you more expressive ways to represent and handle the different cases a Rust program can encounter.