12.6.0 Before We Begin
In Chapter 12, we will build a real project: a command-line program. This program is a grep (Global Regular Expression Print), a tool for global regular-expression search and output. Its job is to search for the specified text in the specified file.
This project has several steps:
- Receive command-line arguments
- Read files
- Refactor: improve modules and error handling
- Use TDD (test-driven development) to develop library functionality (this article)
- Use environment variables
- Write error messages to standard error instead of standard output
If you find this helpful, please like, bookmark, and follow. To keep learning along, follow this series.
12.6.1 Review
Here is all the code written up to the previous article.
lib.rs:
use std::error::Error;
use std::fs;
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})
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
main.rs:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
In the previous sections, we moved the business logic into lib.rs. That helps a lot with writing tests, because the logic in lib.rs can be called directly with different parameters without running the program from the command line, and we can verify its return values. In other words, we can test the business logic directly.
12.6.2 What Is Test-Driven Development?
TDD stands for Test-Driven Development. It usually follows these steps:
- Write a failing test, run it, and make sure it fails for the expected reason
- Write or modify just enough code to make the new test pass
- Refactor the code you just added or changed to make sure the tests still pass
- Return to step 1 and continue
TDD is just one of many software development methods, but it can guide and help code design. Writing tests first and then writing code to pass those tests also helps maintain a high level of test coverage during development.
In this article, we will use TDD to implement the search logic: search for the specified string in the file contents and put the matching lines into a list. This function will be named search.
12.6.3 Modifying the Code
Follow the TDD steps:
1. Write a Failing Test
First, write a test module in lib.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."],search(query, contents));
}
}
That is, because "duct" stored in query appears in the line "safe, fast, productive.", the return value should be a Vector whose element type is String, and it should contain only one element: "safe, fast, productive.".
The return value is a Vector because search is expected to handle multiple matching results. Of course, this particular test can only have one result, which is why the test is named one_result.
After writing the test module, write the search function:
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
- To make the function callable from outside, it must be declared
pub. - The function needs lifetime annotations because it has more than one non-
selfparameter, so Rust cannot tell which parameter’s lifetime matches the return value. - The elements in the returned
Vectorare string slices taken fromcontents, so the return value should have the same lifetime ascontents. That is why both are annotated with the same lifetime'a, whilequerydoes not need a lifetime annotation. - The function body only needs to compile, because the first step of TDD is to write a failing test. Failure is the desired outcome right now.
The test will fail, and that is exactly what we want in the first step of TDD.
2. Write Just Enough Code for the New Test to Pass
The code for search_case_insensitive is very similar to search; we only need a few changes. The logic is simple: convert both the query and the text to lowercase.
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
let query = query.to_lowercase();
for line in contents.to_lowercase().lines() {
if line.contains(&query) {
results.push(line);
}
}
results
}
- The
to_lowercasemethod converts a string to lowercase. - The result of
to_lowercaseis aString, which owns its data. That means the newqueryis aString, not a&str. In theifinside the loop, we use&querybecausecontainsdoes not acceptString, so we must pass a reference.
Run the tests again:
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized +debuginfo] target(s) in 1.33s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Both tests pass.
3. Use This Function in run
Now that the function works, we can call it from run.
But first, we need to add a field to the Config struct to decide whether to use the normal search or the case-insensitive search_case_insensitive:
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
Update run so it checks the 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(())
}
The new constructor on Config also needs to change, and it should set case_sensitive based on an environment variable:
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();
let case_sensitive = std::env::var("CASE_INSENSITIVE").is_err();
Ok(Config { query, filename, case_sensitive})
}
}
This uses std::env::var (you can also import std::env first and then use env::var). Its argument is the name of the environment variable, usually written in all caps. Here I use CASE_INSENSITIVE, which means “case-insensitive.” If this environment variable exists, we treat the search as case-insensitive; if it does not exist, we treat it as case-sensitive.
std::env::var returns a Result. If CASE_INSENSITIVE is set, it returns Ok(String) containing the variable’s value; otherwise it returns Err(std::env::VarError).
The is_err method is chained after std::env::var. If the result is Err, it returns true and assigns true to case_sensitive; otherwise it assigns false.
PS: honestly, writing this little program in such a rigid way is a teaching compromise. When I was halfway through, even I was amused by how much code there was. In real work, you do not need to make it this rigid.
12.6.3 The Full Code and a Trial Run
Here is all the code written so far.
lib.rs:
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
impl Config {
Top comments (0)