When hardcoded values fail you
You built a CLI tool that processes logs. It works perfectly on your machine. Now you hand it to a teammate. They run it, and it crashes because it's trying to connect to your local database. Or worse, it spams the terminal with debug output during a production run. Hardcoding values works until the moment it doesn't. You need a way to let users tweak behavior without touching the source code. That's configuration.
Configuration gives users control over the app's behavior while the app is running. In Rust, you usually pull config from three places: command-line arguments, environment variables, and config files. The standard library handles the first two. Config files usually need a crate. Treat configuration as the user's contract with your app. If they can't change it, they can't trust it.
The three sources of truth
CLI applications read inputs from the command line, the environment, and files. Each source serves a different purpose. Command-line arguments are for one-off overrides. Environment variables are for secrets and machine-specific settings. Config files are for structured, persistent preferences.
Think of it like a remote control. The TV is your app. The buttons on the TV are hardcoded defaults. The remote lets you change channels without opening the back panel. The remote has different buttons for different jobs. Some buttons change the volume immediately. Some buttons open a menu where you can set parental controls. Configuration works the same way. You layer sources on top of each other, with more specific sources overriding general ones.
Reading arguments and environment variables
The standard library provides std::env::args for command-line arguments and std::env::var for environment variables. These functions return iterators and results, which forces you to handle errors explicitly. This is a feature. It stops your app from silently using wrong values.
use std::env;
fn main() {
// env::args() returns an iterator. The first item is the binary name.
let args: Vec<String> = env::args().collect();
// env::var() returns a Result. Use unwrap_or_else for lazy defaults.
let log_level = env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
println!("Args: {:?}", args);
println!("Log level: {}", log_level);
}
env::args() gives you an iterator over strings. The first item is always the program name. The rest are what the user typed. If you run my-tool --verbose, args[0] is my-tool and args[1] is --verbose. Many beginners miss the binary name and off-by-one errors creep in.
env::var() returns a Result<String, VarError>. If the variable exists, you get the value. If not, you get an error. Using unwrap() here is a trap. If the user forgets to set the variable, your app panics. unwrap_or_else lets you provide a default value when the variable is missing. The closure inside unwrap_or_else runs only if the variable is absent. This is lazy evaluation. It avoids computing the default when you don't need it. Don't unwrap environment variables. Defaults keep your app polite.
Parsing config files with Serde
Config files contain structured data. You need a parser to turn text into Rust types. The community standard is serde. It derives serialization and deserialization traits for your structs. Pair it with a format crate like toml, json, or yaml. TOML is the preferred format for Rust configuration. It's readable, unambiguous, and handles types well.
use serde::Deserialize;
use std::fs;
use std::path::Path;
/// Configuration for the application.
#[derive(Debug, Deserialize)]
struct AppConfig {
/// The host for the database connection.
db_host: String,
/// Port defaults to 5432 if not specified in the file.
#[serde(default = "default_port")]
db_port: u16,
/// Enable verbose logging. Renamed from 'verbose' in TOML.
#[serde(rename = "verbose", default)]
enable_verbose: bool,
}
fn default_port() -> u16 {
5432
}
fn load_config(path: &Path) -> Result<AppConfig, Box<dyn std::error::Error>> {
// Read the file contents as a string.
let contents = fs::read_to_string(path)?;
// Parse the TOML string into our struct.
let config: AppConfig = toml::from_str(&contents)?;
Ok(config)
}
The #[derive(Deserialize)] attribute generates the code needed to parse TOML into AppConfig. If you forget this, the compiler rejects the code with E0277 (trait bound not satisfied). The parser needs the trait to know how to build the struct.
Serde attributes let you customize parsing. #[serde(default = "default_port")] calls a function to get the default value when the key is missing. #[serde(rename = "verbose")] maps the TOML key verbose to the Rust field enable_verbose. This lets you use idiomatic Rust names in code while keeping user-friendly keys in the config file. Serde turns text into types. Let the compiler check the structure, not your runtime logic.
Finding the config file
Users expect config files to live in standard locations. On Linux, that's ~/.config/your-app/config.toml. On macOS, ~/Library/Application Support/your-app/config.toml. On Windows, %APPDATA%/your-app/config.toml. Hardcoding paths breaks portability. Use the directories crate to find the right path for the current OS. It follows the XDG Base Directory Specification on Linux and uses native conventions elsewhere.
use directories::ProjectDirs;
use std::path::PathBuf;
/// Returns the path where the config file should live.
fn get_config_path() -> Option<PathBuf> {
// Identify the project using domain, organization, and app name.
let project = ProjectDirs::from("org.rustfaq", "example", "my-cli");
// Get the config directory and append the filename.
project.as_ref().map(|p| p.config_dir().join("config.toml"))
}
ProjectDirs::from takes three arguments: the domain, the organization, and the application name. It returns an Option<ProjectDirs>. If the system doesn't support the directory structure, you get None. This is rare but possible in constrained environments. Always handle the None case. Treat the config path as a discovery problem, not a string concatenation.
Merging sources with precedence
Real apps combine multiple sources. A user might set a value in a config file, override it with an environment variable, and then override that with a command-line argument. You need a merge strategy. The standard convention is: arguments override environment variables, which override config files, which override hardcoded defaults. This gives users the most control at the command line.
fn merge_config(
file_host: Option<String>,
env_host: Option<String>,
arg_host: Option<String>,
) -> String {
// Arguments win, then env, then file, then default.
arg_host
.or(env_host)
.or(file_host)
.unwrap_or("localhost".to_string())
}
The or method on Option returns the current value if it's Some, otherwise it returns the argument. Chaining or calls creates a priority chain. This pattern scales to any number of sources. Always define precedence before you write the parser. Ambiguity is the enemy of usability.
Validation beyond parsing
Parsing checks syntax. Validation checks semantics. A config file might have port: 99999. That parses as a u16? No, it fails. But port: 0 parses successfully. Port 0 is usually invalid. You need validation logic to catch bad values after parsing.
fn validate_config(config: &AppConfig) -> Result<(), String> {
if config.db_port == 0 {
return Err("db_port cannot be 0".to_string());
}
if config.db_host.is_empty() {
return Err("db_host cannot be empty".to_string());
}
Ok(())
}
Run validation after parsing but before using the config. Return clear error messages. The user needs to know exactly what's wrong. Validation catches bad data before it crashes your app.
Pitfalls and compiler errors
If you forget #[derive(Deserialize)], the compiler rejects the code with E0277 (trait bound not satisfied). The parser needs the trait to know how to build the struct. If your TOML has a string where you expect a number, toml::from_str returns an error at runtime. You must handle that error. Don't unwrap it in production code. Return the error up the chain so the user sees a clear message.
A common trap is ignoring environment variable precedence. If a user sets a value in a file and also passes an argument, which one wins? If you don't define the order, users get confused. Another trap is putting secrets in config files. Config files often end up in version control. Environment variables stay out of git. Never commit secrets.
Decision matrix
Use std::env::args for simple scripts where you only need a few flags and don't want external dependencies. Use clap when you need auto-generated help text, subcommands, or complex flag parsing. Use environment variables for secrets like API keys or database passwords. Never put secrets in config files that might end up in version control. Use TOML config files for structured settings that users need to edit manually. Use JSON only if your tool integrates with other systems that already output JSON. Use the config crate when you need a single library to merge files, environment variables, and command-line arguments with automatic precedence. Use the directories crate when your app needs to find platform-specific paths for config or data files.
Pick the tool that matches the complexity. A sledgehammer breaks eggs.