Writing a CLI App in Rust - Part 2
This chapter focuses on refactoring our command-line interface (CLI) tool, minigrep, to follow professional software engineering patterns in Rust. We will separate concerns by moving core logic into a library crate (lib.rs), implement search functionality using Test-Driven Development (TDD), leverage environment variables for configuration, and ensure error messages are properly routed to the standard error (stderr) stream rather than standard output (stdout).
1. The Problem of a Bloated main.rs
As programs grow, keeping all logic inside src/main.rs makes the codebase difficult to test, maintain, and read. In Rust, the idiomatic way to handle this is to separate the binary entry point from the core logic.
The Separation of Concerns Workflow
By splitting our application this way:
main.rsremains small, clean, and focused only on running the application, handling configuration setup, and printing errors.lib.rscontains all the actual logic, making it highly testable and reusable as a library.
2. Refactoring to a Library Crate (lib.rs)
To begin refactoring, we create a new file named src/lib.rs. This file serves as the root of our library crate. Because Rust modules and fields are private by default, we must explicitly use the pub keyword to expose structures, methods, and functions to our binary crate (main.rs).
The Library Implementation (src/lib.rs)
use std::error::Error;
use std::fs;
// We make the struct and its fields public so main.rs can access them
pub struct Config {
pub query: String,
pub filename: String,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename })
}
}
// The run function returns a Result, using a trait object Box<dyn Error>
// to allow returning any type of error.
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
Key Visibility Rules in Rust
pub struct Config: Makes the struct visible outside oflib.rs.pub query&pub filename: Struct fields are private by default, even if the struct itself is public. They must be markedpubto be accessed directly bymain.rs.pub fn run: Allows the binary crate to execute the core application loop.
3. Updating main.rs to Consume the Library
Now that our core logic resides in src/lib.rs, we update src/main.rs to import and call our library. The library crate is named after our package (defined in Cargo.toml), which is minigrep.
The Binary Implementation (src/main.rs)
use std::env;
use std::process;
// Import the Config struct from our library crate
use minigrep::Config;
fn main() {
let args: Vec<String> = env::args().collect();
// Set up configuration, handling errors gracefully
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
// Call the run function from our library crate
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
Verification
To ensure the refactoring was successful, run the program using a sample file (e.g., poem.txt):
cargo run test poem.txt
4. Test-Driven Development (TDD) Workflow
With our core logic safely inside src/lib.rs, we can easily write unit tests. We will practice Test-Driven Development (TDD) to implement our search functionality.
The TDD Cycle
Using TDD ensures that we write modular, well-designed code with high test coverage from the beginning.
5. Implementing Case-Sensitive Search with TDD
Our goal is to create a search function that looks through a file's contents and returns only the lines containing our query string.
Step 1: Write the Failing Test
We define a tests module at the bottom of src/lib.rs.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape."; // "Duct tape." should not match because of the capital 'D'
assert_eq!(
vec!["safe, fast, productive."],
search(query, contents)
);
}
}
Step 2: Write a Stub with Lifetime Annotations
If we try to compile this, it will fail because search does not exist. Let's define the function signature.
Because search returns string slices (&str) that reference data inside the contents parameter, we must use explicit lifetime specifiers to tell the compiler how long those references remain valid.
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
Note on Lifetimes: The lifetime
'ais tied to thecontentsparameter. This tells Rust that the data returned by thesearchfunction will live exactly as long as thecontentsstring passed into it. Thequeryparameter does not need to share this lifetime because we are not returning references to it.
Running cargo test now compiles successfully but fails the assertion:
failures:
tests::case_sensitive ...
left: ["safe, fast, productive."]
right: []
Step 3: Implement the Search Logic
To make the test pass, we iterate through each line of contents, check if it contains the query, and collect matching lines into a vector.
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
// Iterate over each line in the contents string
for line in contents.lines() {
// Check if the line contains our query substring
if line.contains(query) {
results.push(line);
}
}
results
}
Running cargo test now passes:
test tests::case_sensitive ... ok
test result: ok. 1 passed; 0 failed; 0 ignored;
6. Integrating Search into the Run Function
Now we integrate our tested search function into the run function in src/lib.rs. Instead of printing the entire file, we will only print the lines that match the query.
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
// Call search and iterate over matching lines
for line in search(&config.query, &contents) {
println!("{}", line);
}
Ok(())
}
Testing the CLI Integration
Run the program with a query that exists in poem.txt:
cargo run frog poem.txt
Output:
How public like a frog
Run the program with a query that does not exist:
cargo run dog poem.txt
Output: (Empty, as no lines contain "dog")
7. Adding Case-Insensitive Search via TDD
To make our tool more versatile, we will add a case-insensitive search option.
Step 1: Write the Failing Test
We add a new test to our tests module in src/lib.rs:
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
Step 2: Define the Stub
We define the search_case_insensitive function signature above our test module:
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
Running cargo test confirms that case_sensitive passes, but case_insensitive fails.
Step 3: Implement Case-Insensitive Logic
To compare strings case-insensitively, we convert both the query and the lines to lowercase before checking for a match.
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
// Convert query to lowercase. This allocates a new String.
let query = query.to_lowercase();
let mut results = Vec::new();
for line in contents.lines() {
// Convert the current line to lowercase and check if it contains the query
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
Note: Calling
to_lowercase()creates a new ownedStringrather than a string slice (&str). Therefore, we must pass a reference (&query) to the.contains()method, as it expects a string slice.
Running cargo test now shows both tests passing:
test tests::case_sensitive ... ok
test tests::case_insensitive ... ok
8. Controlling Behavior with Environment Variables
We want users to be able to toggle case sensitivity using an environment variable (CASE_INSENSITIVE).
Step 1: Update Config and run
First, we add a case_sensitive boolean field to our Config struct:
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
Next, we update the run function to choose the correct search function based on this configuration:
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
let results = if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
};
for line in results {
println!("{}", line);
}
Ok(())
}
Step 2: Read the Environment Variable in Config::new
We use the std::env module to check if the CASE_INSENSITIVE environment variable is set.
use std::env;
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
// env::var returns a Result. If the variable is set, it returns Ok.
// If it is not set (or empty), it returns Err.
// is_err() returns true if the variable is NOT set (meaning we want case-sensitive search).
let case_sensitive = env::var("CASE_INSENSITIVE").is_err();
Ok(Config {
query,
filename,
case_sensitive,
})
}
}
Testing the Environment Variable
By default, running the program is case-sensitive:
cargo run to poem.txt
Output:
Are you to be double?
To tell your name the livelong day
To enable case-insensitive search, set the environment variable in your terminal:
On Linux/macOS (Bash/Zsh):
export CASE_INSENSITIVE=true
cargo run to poem.txt
On Windows (PowerShell):
$env:CASE_INSENSITIVE="true"
cargo run to poem.txt
Output (includes "To" and "to"):
Are you to be double?
How dreary to be somebody!
To tell your name the livelong day
To turn it off, unset the variable:
unset CASE_INSENSITIVE
9. Error Handling & Redirecting to Standard Error (stderr)
Currently, all output from our program—including error messages—is printed using println!, which writes to the standard output (stdout) stream.
The Problem with Writing Errors to stdout
Command-line tools are expected to output normal program results to stdout and error messages to stderr. If a user redirects the program's output to a file, error messages will get written into that file instead of appearing on the screen.
Demonstrating the Bug
If we run our program with missing arguments and redirect the output to a file:
cargo run > output.txt
The terminal remains silent, but if we open output.txt, we find the error message:
Problem parsing arguments: not enough arguments
The Fix: Using eprintln!
Rust provides the eprintln! macro, which works exactly like println! but writes to stderr instead of stdout. We update src/main.rs to use eprintln! for all error handling blocks:
fn main() {
let args: Vec<String> = env::args().collect();
// Use eprintln! for configuration errors
let config = Config::new(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {}", err);
process::exit(1);
});
// Use eprintln! for runtime errors
if let Err(e) = minigrep::run(config) {
eprintln!("Application error: {}", e);
process::exit(1);
}
}
Verifying the Fix
Now, if we run the failing command again:
cargo run > output.txt
The error message is printed directly to the terminal screen, and output.txt remains empty:
Problem parsing arguments: not enough arguments
If we run a successful search and redirect the output:
cargo run to poem.txt > output.txt
No output is printed to the terminal, and the matching lines are successfully written to output.txt.
10. Architectural Comparison
Summary and Key Takeaways
- Separation of Concerns: Keep
main.rsfocused on startup, configuration, and error handling. Move all core execution logic intolib.rsto make it modular and testable. - Default Privacy: Rust items are private by default. Use the
pubkeyword on structs, fields, methods, and functions to expose them to external binary crates. - Lifetime Specifiers: When returning references from a function, use lifetime annotations (e.g.,
'a) to explicitly tie the lifetime of the returned reference to the lifetime of the input parameter it borrows from. - Test-Driven Development (TDD): Write failing tests first, implement minimal code to compile, write the logic to pass, and refactor with confidence.
- Case-Insensitive Search: Implement case-insensitivity by converting both the query and target text to lowercase using
.to_lowercase(). - Environment Variables: Use
std::env::varto read environment variables at runtime, allowing users to configure program behavior without changing command-line arguments. - Standard Streams: Always use
eprintln!to output errors tostderr. This ensures that diagnostic messages are visible to users even when standard output (stdout) is redirected to a file or piped to another program.