DEV Community

Cover image for [Rust Guide] 12.7. Using Environment Variables
SomeB1oody
SomeB1oody

Posted on

[Rust Guide] 12.7. Using Environment Variables

12.7.0 Before We Begin

Chapter 12 builds a sample project: a command-line program. The program is grep (Global Regular Expression Print), a tool for global regular-expression searching and output. Its function is to search for specified text in a specified file.

This project is divided into these steps:

  • Receiving command-line arguments
  • Reading files
  • Refactoring: improving modules and error handling
  • Using TDD (test-driven development) to develop library functionality
  • Using environment variables (this article)
  • Writing 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.7.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)?;
    for line in search(&config.query, &contents) {
        println!("{}", line);
    }
    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();
    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }
    results
}

#[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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

In these earlier sections, we completed the move of the business logic into lib.rs. That is very helpful for writing tests, because the logic in lib.rs can be called directly with different arguments without running the program from the command line, and its return values can be checked. In other words, we can test the business logic directly.

12.7.2 What Is TDD?

TDD is short for Test-Driven Development, and 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, and make sure the tests still pass
  • Return to step 1 and continue

TDD is only one of many software development methods, but it can guide and support code design. Writing tests first and then writing code that passes them also helps maintain a high level of test coverage during development.

In this article, we will use TDD to implement the search logic for the program: search for a specified string in the file contents and put the matching lines into a list. This function will be called search_case_insensitive.

12.7.3 Write a Failing Test

Let’s start by naming the case-insensitive function search_case_insensitive.

First, modify the test module so that it contains a case-sensitive test and a case-insensitive test:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Then write the body of search_case_insensitive:

pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}
Enter fullscreen mode Exit fullscreen mode
  • To make this function callable from outside, it must be declared public with pub
  • The function needs a lifetime annotation because it has multiple non-self parameters, and Rust cannot determine which parameter’s lifetime should match the lifetime of the return value
  • The elements in the returned Vector are string slices taken from contents, so the return value should have the same lifetime as contents; that is why both are annotated with the same lifetime 'a, while query does not need a lifetime annotation
  • The function body only needs to compile, because the first step of TDD is to write a test that fails, so failure is the desired outcome

At this point, running the tests will definitely fail, but that is fine. This is exactly what the first TDD step is supposed to produce.

12.7.4 Write or Modify Just Enough Code for the New Test to Pass

The code for search_case_insensitive is very similar to search, so only a few small changes are needed. The logic is simple: lowercase the query and compare it against lowercase versions of the text:

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.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }
    results
}
Enter fullscreen mode Exit fullscreen mode
  • The to_lowercase method converts a string to all lowercase
  • The result of to_lowercase is a String, so the new query is owned String rather than &str. Inside the loop, we use &query because contains does not accept String directly, so we 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
Enter fullscreen mode Exit fullscreen mode

Both tests pass.

12.7.5 Use This Function in run

Now that the function works, we can call it in run.

First, add a field to the Config struct so it can 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,
}
Enter fullscreen mode Exit fullscreen mode

Modify 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(())
}
Enter fullscreen mode Exit fullscreen mode

The new constructor on Config also needs to change so it assigns case_sensitive based on the 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("IGNORE_CASE").is_err();
        Ok(Config {
            query,
            filename,
            case_sensitive,
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we use std::env::var (of course, you could also bring std::env into scope first and then use env::var). Its argument is the name of the environment variable, which conventionally is all uppercase. Here I use IGNORE_CASE, which means case-insensitive. If this environment variable is present, the search is treated as case-insensitive; if it is absent, the search is case-sensitive.

std::env::var returns a Result. If the IGNORE_CASE environment variable is set, it returns Ok(String) containing the variable’s value; otherwise it returns Err(std::env::VarError).

After std::env::var, we call is_err. If is_err sees the Err variant, it returns true, which is assigned to case_sensitive; otherwise it assigns false.

PS: honestly, writing this little program in such a rigid way is also a teaching necessity. Halfway through, I was already laughing at how much code there was. When you actually write it, there is no need to be this formal.

12.7.6 The Full Code and a Trial Run

After writing all that, here is the full code up to this point.

lib.rs:

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

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("IGNORE_CASE").is_err();
        Ok(Config {
            query,
            filename,
            case_sensitive,
        })
    }
}

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(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();
    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }
    results
}

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.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }
    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Let’s try running it:

First, run the program without setting the environment variable and use the query to, which should match any line containing the lowercase word to:

$ cargo run -- to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
Enter fullscreen mode Exit fullscreen mode

Now set IGNORE_CASE to 1 and keep everything else the same:

$ IGNORE_CASE=1 cargo run -- to poem.txt
Enter fullscreen mode Exit fullscreen mode

You will get:

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!
Enter fullscreen mode Exit fullscreen mode

There are no problems.

Note that if you are using powershell, you set an environment variable like this:

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt
Enter fullscreen mode Exit fullscreen mode

This keeps the environment variable available for the entire session. If you want to remove it, write:

PS> Remove-Item Env:IGNORE_CASE
Enter fullscreen mode Exit fullscreen mode

Top comments (0)