Working with Environment Variables

Many CLI programs allow configuration through environment variables. Let's add case-insensitive search support using OXGREP_IGNORE_CASE.

Reading Environment Variables

The std.env module provides functions to read environment variables:

import std.env

fn main() {
    // Get a variable - returns String?
    let debugMode = std.env.var("DEBUG")

    match debugMode {
        Ok(value) -> println!("DEBUG is set to: \(value)"),
        Err(_) -> println!("DEBUG is not set"),
    }
}

Checking for a Flag

To check if an environment variable exists, just see if the result is Ok:

import std.env

fn main() {
    let ignoreCase = std.env.var("OXGREP_IGNORE_CASE").isOk()

    if ignoreCase {
        println!("Case-insensitive search enabled")
    }
}

Updating Our Config Struct

Let's integrate environment variable support into our Config struct:

import std.env

struct Config {
    query: String,
    filename: String,
    ignoreCase: Bool,
}

extension Config {
    static fn new(args: &Vec<String>): Result<Config, String> {
        if args.len() < 3 {
            return Err(
                "not enough arguments\nusage: oxgrep <query> <filename>".toString()
            )
        }

        let query = args[1].clone()
        let filename = args[2].clone()

        // Check both command-line flag and environment variable
        let commandLineFlag = args.len() > 3 && args[3] == "--ignore-case"
        let envVariable = std.env.var("OXGREP_IGNORE_CASE").isOk()

        let ignoreCase = commandLineFlag || envVariable

        Ok(Config {
            query,
            filename,
            ignoreCase,
        })
    }
}

Now update the search function to use the ignoreCase field:

fn search(config: &Config, contents: &str): Vec<String> {
    var results = Vec.new()

    for line in contents.lines() {
        if config.ignoreCase {
            if line.toLowercase().contains(&config.query.toLowercase()) {
                results.push(line.toString())
            }
        } else {
            if line.contains(&config.query) {
                results.push(line.toString())
            }
        }
    }

    results
}

Or more concisely:

fn search(config: &Config, contents: &str): Vec<String> {
    var results = Vec.new()

    let query = if config.ignoreCase {
        config.query.toLowercase()
    } else {
        config.query.clone()
    }

    for line in contents.lines() {
        let searchLine = if config.ignoreCase {
            line.toLowercase()
        } else {
            line.toString()
        }

        if searchLine.contains(&query) {
            results.push(line.toString())
        }
    }

    results
}

Updated Main Function

Update main and run to pass the config around:

import std.fs
import std.env
import std.error.Error

fn main(): Result<(), Box<dyn Error>> {
    let args = std.env.args().collect<Vec<String>>()

    let config = Config.new(&args)
        .unwrapOrElse { err ->
            eprintln!("Problem parsing arguments: {}", err)
            std.process.exit(1)
        }?

    run(&config)
}

fn run(config: &Config): Result<(), Box<dyn Error>> {
    let contents = std.fs.readToString(&config.filename)?

    let results = search(config, &contents)

    for line in results {
        println!("{}", line)
    }

    Ok(())
}

Testing Environment Variable Behavior

You can test environment variable behavior from the command line:

# Case-sensitive (default)
cargo run -- "is" poem.txt
# Output: Lines containing "is" (lowercase)

# Case-insensitive via environment variable
OXGREP_IGNORE_CASE=1 cargo run -- "is" poem.txt
# Output: Lines containing "is" or "IS" or "Is"

# Case-insensitive via command-line flag
cargo run -- "is" poem.txt --ignore-case
# Output: Same as above

Getting Multiple Values

For more complex configuration, you might read multiple environment variables:

import std.env

struct AppConfig {
    query: String,
    filename: String,
    ignoreCase: Bool,
    maxResults: UInt,
    verbose: Bool,
}

extension AppConfig {
    static fn fromEnv(): Self {
        let ignoreCase = std.env.var("OXGREP_IGNORE_CASE").isOk()

        let maxResults = std.env.var("OXGREP_MAX_RESULTS")
            .ok()
            .andThen { s -> s.parse<UInt>().ok() }
            .unwrapOr(1000)

        let verbose = std.env.var("OXGREP_VERBOSE").isOk()

        AppConfig {
            query: String.new(),
            filename: String.new(),
            ignoreCase,
            maxResults,
            verbose,
        }
    }
}

Environment Variables in Tests

When writing tests, you can set environment variables programmatically:

#[test]
fn testIgnoreCaseFromEnv() {
    // In real tests, you'd need to set the environment before creating Config
    // This is more complex due to test concurrency
}

Important Notes About Environment Variables

  1. Thread Safety - Reading environment variables is thread-safe, but setting them is not. Set variables before spawning threads.

  2. Performance - Environment variable lookups are relatively expensive. Cache values if you use them frequently.

  3. Naming Conventions - Use UPPERCASE_WITH_UNDERSCORES for environment variable names.

  4. Documentation - Always document which environment variables your program recognizes.

  5. Security - Be careful with sensitive data in environment variables (passwords, API keys). They're visible in process listings.

Summary

Environment variables allow flexible configuration:

  • std.env.var - Read a variable, returns Result<String, VarError>
  • Defaults - Use unwrapOr to provide defaults
  • Combining sources - Command-line flags and env vars work together
  • Caching - Read environment variables early, not in loops
  • Testing - Be aware that environment variable state is global

Next, we'll write comprehensive tests for our program.