You have a problem. You decide to use generics. Now you have a Problem<T> where T: Clone + Send + Sync + 'static.
This chapter parses .env-style configuration files.
Two new things show up:
split returns an iterator of all parts.
For "key=value" you usually want to split once and keep the rest of the line intact (in case the value itself contains the separator):
let line = "DATABASE_URL=postgres://user:pass@host/db";
match line.split_once('=') {
Some((key, value)) => println!("{key} -> {value}"),
None => println!("no '='"),
}
split_once returns Option<(&str, &str)>.
The two halves are slices of the original string; no allocation.
Sometimes you want one function that works for many types. Here, "parse this string into whatever the caller asks for" is a perfect fit:
fn get<T>(env: &HashMap<String, String>, key: &str) -> Option<T>
where
T: std::str::FromStr,
{
env.get(key)?.parse().ok()
}
let port: Option<u16> = get(&env, "PORT");
let debug: Option<bool> = get(&env, "DEBUG");
<T> declares a type parameter.
The where T: FromStr clause says "T must implement the FromStr trait", which is what makes .parse() work.
.parse() returns Result<T, T::Err>; .ok() discards the error type and gives back Option<T>, which combines nicely with the ? on the preceding line.
Real config files have empty lines, comments, and trailing whitespace. The usual handling chain is:
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
// ...parse the line
}
continue skips the rest of the current loop iteration and jumps to the next one.
Its sibling, break, exits the loop entirely.
Box<dyn Error>This chapter's parser uses one error type, the custom ParseError enum you'll build below, so ? propagates it cleanly.
Real programs often mix error types: read the file from disk and you get a std::io::Error; parse its contents and you get your own ParseError.
A function using ? insists on one error type, so you need something both can turn into.
Box<T> puts a value on the heap and owns it.
Box<dyn std::error::Error> is a return type that accepts almost any error, because nearly every error type converts into it automatically:
fn load(path: &str) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?; // io::Error
let env = parse_env_file(&content)?; // ParseError
Ok(env)
}
The two ? lines return different error types, and both convert into Box<dyn Error> on the way out.
That's the quick, lossy option: you give up the specific type and keep only "some error happened."
A custom enum (like the ParseError you're about to write, but with a variant per source) keeps the type information at the cost of more code, and the thiserror crate generates most of that boilerplate for you.
For quick programs, anyhow wraps the Box<dyn Error> approach with nicer ergonomics; for libraries where callers need to match on the error, prefer an enum.
r#"..."#The tests in this chapter use raw string literals to embed a multi-line .env snippet without escaping anything:
let content = r#"
HOST=localhost
PORT=5432
"#;
A raw string starts with r and zero or more #s, then a quote.
It ends with the matching closing quote and #s.
Inside, backslashes and quotes are literal: no escape sequences.
Use more #s on each side if the content itself contains "#.
Before tackling whole files, get one line right.
The .env format is KEY=value, but real-world files have surrounding whitespace too.
KEY = value should be accepted and produce ("KEY", "value").
str::split_once('=') is the right tool: it gives back Option<(&str, &str)> containing the part before and after the first =.
From there it's trim plus a couple of validity checks.
We also introduce a small ParseError enum that the rest of the chapter will reuse.
Useful from the standard library
str::split_oncesplits at the first match and returnsOption<(&str, &str)>. TheNonecase maps cleanly ontoParseError::InvalidFormat.str::trimon each half drops the surrounding whitespace.str::is_emptycatches the=valueandKEY=cases after trimming.- The
Okarm needs ownedStrings, so finish with.to_string()on each half before wrapping in the tuple.
#[derive(Debug, PartialEq, Eq)]
pub enum ParseError {
InvalidFormat,
EmptyKey,
EmptyValue,
}
/// Parses a single line of an .env file.
/// Format: `KEY=value`. Surrounding whitespace on either side of `=` is
/// trimmed (so `KEY = value` is accepted and yields `("KEY", "value")`).
/// Returns `Ok((key, value))` or `Err(ParseError)`.
fn parse_env_line(line: &str) -> Result<(String, String), ParseError> {
let (key, value) = line.split_once('=').ok_or(ParseError::InvalidFormat)?;
let key = key.trim();
let value = value.trim();
if key.is_empty() {
return Err(ParseError::EmptyKey);
}
if value.is_empty() {
return Err(ParseError::EmptyValue);
}
Ok((key.to_string(), value.to_string()))
}
#[test]
fn test_parse_env_line() {
assert_eq!(
parse_env_line("PORT=8080"),
Ok(("PORT".to_string(), "8080".to_string()))
);
assert_eq!(
parse_env_line("HOST=localhost"),
Ok(("HOST".to_string(), "localhost".to_string()))
);
assert!(parse_env_line("INVALID").is_err());
assert!(parse_env_line("=value").is_err());
assert!(parse_env_line("KEY=").is_err());
// Surrounding whitespace is trimmed, not rejected.
assert_eq!(
parse_env_line("KEY = value"),
Ok(("KEY".to_string(), "value".to_string()))
);
}
With single-line parsing solved, the file-level function is mostly plumbing: iterate over content.lines(), skip blank lines and # comments, and accumulate the rest into a HashMap.
Stop at the first malformed line and return an error.
Strict parsing makes configuration bugs obvious instead of silently dropping values.
Each step is self-contained, so the previous step's parse_env_line and ParseError are re-declared here with todo!() bodies.
Re-implement them (or paste your earlier solution) so this step compiles on its own.
Useful from the standard library
str::linesiterates over the lines of the file content, stripping\nand\r\nfor you.str::trimon each line lets you handle leading/trailing whitespace once.str::starts_withwith a'#'argument is the comment check. Combine withstr::is_emptyto skip blank lines.HashMap::insertfills in each parsed pair. The?afterparse_env_line(line)short-circuits on the first malformed line.- A
forloop reads more naturally here than an iterator chain because the body has both acontinueskip and a?early return.
use std::collections::HashMap;
#[derive(Debug, PartialEq, Eq)]
pub enum ParseError {
InvalidFormat,
EmptyKey,
EmptyValue,
}
/// Parses a single line of an .env file.
/// Format: `KEY=value`. Surrounding whitespace on either side of `=` is
/// trimmed (so `KEY = value` is accepted and yields `("KEY", "value")`).
/// Returns `Ok((key, value))` or `Err(ParseError)`.
fn parse_env_line(line: &str) -> Result<(String, String), ParseError> {
let (key, value) = line.split_once('=').ok_or(ParseError::InvalidFormat)?;
let key = key.trim();
let value = value.trim();
if key.is_empty() {
return Err(ParseError::EmptyKey);
}
if value.is_empty() {
return Err(ParseError::EmptyValue);
}
Ok((key.to_string(), value.to_string()))
}
/// Parses a complete .env file content.
/// Ignores empty lines and lines starting with `#` (after trimming, so
/// ` # comment` counts as a comment too). Stops at the first malformed
/// line and returns `Err`. Strict parsing is easier to debug than
/// silently dropping lines.
/// Returns `HashMap` of all valid key-value pairs.
fn parse_env_file(content: &str) -> Result<HashMap<String, String>, ParseError> {
let mut env = HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (key, value) = parse_env_line(trimmed)?;
env.insert(key, value);
}
Ok(env)
}
#[test]
fn test_parse_env_file() {
let content = r#"
# Database configuration
HOST=localhost
PORT=5432
DATABASE=myapp
# Empty line above should be ignored
DEBUG=true
"#;
let env = parse_env_file(content).unwrap();
assert_eq!(env.get("HOST"), Some(&"localhost".to_string()));
assert_eq!(env.get("PORT"), Some(&"5432".to_string()));
assert_eq!(env.get("DEBUG"), Some(&"true".to_string()));
assert_eq!(env.len(), 4);
}
Configuration values are stored as strings, but consumers want u16 ports, bool flags, and so on.
Rather than write one helper per type, declare a generic function bounded by FromStr and let the caller pick the type at the call site with a turbofish or a type annotation.
Inside the body, env.get(key)? short-circuits on a missing key and .parse().ok() collapses the parse Result into an Option.
Don't try to ? the parse: T::Err is unconstrained here and would need an extra From bound.
Useful from the standard library
HashMap::getreturnsOption<&String>. The?propagates the missing-key case asNone.str::parseusesFromStrto produceResult<T, T::Err>. That's the trait thewhereclause is asking for.Result::okdrops the error and yieldsOption<T>, exactly the function's return type.- The body fits on one line:
env.get(key)?.parse().ok().
use std::collections::HashMap;
/// Gets an environment variable with type conversion.
/// Parses the string value into the requested type.
///
/// Hint: the natural solution is `env.get(key)?.parse().ok()`. Don't try to
/// `?` the parse: `T::Err` is unconstrained here, so `?` would need a
/// `From<T::Err>` bound that we haven't added. `.ok()` collapses
/// `Result<T, T::Err>` into `Option<T>`, which is what the signature
/// returns anyway.
fn get_env_var<T>(env: &HashMap<String, String>, key: &str) -> Option<T>
where
T: std::str::FromStr,
{
env.get(key)?.parse().ok()
}
#[test]
fn test_get_env_var() {
let mut env = HashMap::new();
env.insert("PORT".to_string(), "8080".to_string());
env.insert("DEBUG".to_string(), "true".to_string());
let port: Option<u16> = get_env_var(&env, "PORT");
assert_eq!(port, Some(8080));
let debug: Option<bool> = get_env_var(&env, "DEBUG");
assert_eq!(debug, Some(true));
}
Most apps need some configuration to be present at startup: a database URL, a port, an API key. This last helper takes a list of required keys and reports the first one that's missing.
Iterator::find is a good fit: scan the slice, return the first key that isn't in the map, and turn that into an Err.
If find returns None, every required key was present and the result is Ok(()).
Useful from the standard library
<[T]>::iteronrequiredyields&&str(a reference to each&strin the slice). The closure parameter|key|is therefore&&&str, which auto-derefs through method calls.Iterator::findtakes a predicate and returns the first matching item asOption<&&&str>. Combine withHashMap::contains_keyto look for keys not in the map.Option::map_orcollapses the result into aResultin one call:find(...).map_or(Ok(()), |k| Err(k.to_string())).- A plain
for k in required { if !env.contains_key(*k) { return Err(...) } }loop reads just as well; pick whichever you like.
use std::collections::HashMap;
/// Validates required environment variables are present.
/// Returns Ok(()) if all required keys exist, Err with missing key otherwise.
fn validate_required_vars(env: &HashMap<String, String>, required: &[&str]) -> Result<(), String> {
for &key in required {
if !env.contains_key(key) {
return Err(format!("missing required variable: {key}"));
}
}
Ok(())
}
#[test]
fn test_validate_required_vars() {
let mut env = HashMap::new();
env.insert("HOST".to_string(), "localhost".to_string());
env.insert("PORT".to_string(), "8080".to_string());
assert!(validate_required_vars(&env, &["HOST", "PORT"]).is_ok());
assert!(validate_required_vars(&env, &["HOST", "MISSING"]).is_err());
}
You parsed structured text line by line, layered file-level handling on top, exposed configuration through a generic typed lookup, and wrote a quick presence check for required keys.
What we learned
split_once(delim)is the right tool for "key/value, split at the first separator". ReturnsOption<(&str, &str)>with no allocation.lines()+trim()+starts_with('#')+is_empty()is the standard recipe for walking a config file. Aforloop withcontinueand?reads better than an iterator chain when the body has both kinds of control flow.- A small custom error enum (
ParseError) lives nicely next to the parser.?propagates it without ceremony as long as the function returnsResult<_, ParseError>.- When a function mixes error types (reading the file gives
io::Error, parsing givesParseError),?needs one common type.Box<dyn std::error::Error>is the quick catch-all almost any error converts into; a custom enum (often generated bythiserror) keeps the type information when callers need it.- Generics let one function serve many caller types. The
where T: FromStrbound is what makes.parse()work, and the caller pinsTwith a type annotation or a turbofish.result.ok()is the easy way to drop an error and produceOption<T>when you genuinely don't care which kind of parse failure happened.- Raw string literals (
r#"..."#) embed multi-line text without escaping. The number of#s on each side just has to be enough to avoid colliding with the body.- For real apps, the
dotenvycrate reads.envfiles into the process environment; the parser you just wrote is a stripped-down version of the same idea.