You have a problem. You decide to use generics. Now you have a
Problem<T> where T: Clone + Send + Sync + 'static.
You'll build a parser for .env-style configuration files. You'll split each
entry only once, then use a generic function to read values as the types your
callers need.
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, so you don't allocate anything.
You could write separate functions to read a port as a u16 and a flag as a
bool. I'd rather write the lookup once and let the caller choose the type.
First, here's a smaller generic helper with no map lookup yet:
fn parse_value<T: std::str::FromStr>(text: &str) -> Result<T, T::Err> {
text.parse()
}
assert_eq!(parse_value::<u16>("8080"), Ok(8080));
assert_eq!(parse_value::<bool>("true"), Ok(true));
<T> declares a type parameter. The FromStr bound makes .parse() available.
The equivalent where T: std::str::FromStr spelling puts the bound after the
parameters. T::Err is the error type chosen by that implementation of
FromStr. Your lookup exercise will combine this conversion with HashMap::get
and decide how to represent a missing key or a failed conversion.
trim returns a borrowed slice rather than changing or copying the original
string:
let name = String::from(" Ada ");
let trimmed = name.trim();
assert_eq!(trimmed, "Ada");
assert_eq!(name, " Ada ");
You can pass that slice to another function without allocating a new String.
The file parser will need to distinguish ignorable lines from malformed entries.
Box<dyn Error>The parser uses one error type, the custom ParseError enum, 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 ? needs a return error type that both can convert into. This
is a fiddly part of ?: when it won't compile, check the error types as well as
the success types.
Box<T> puts a value on the heap and owns it. Box<dyn std::error::Error> can
hold different error types through a shared interface. An owned error
implementing std::error::Error + 'static converts into it automatically. For
this example to compile, you'd first need to implement Display and
std::error::Error for the exercise's ParseError:
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. The box preserves the error's message and
source chain, but callers no longer get a concrete error type to match on
directly. A custom enum (like ParseError, but with a variant per source) lets
callers match on each case, and the thiserror
crate generates the trait implementations for you. For application code,
anyhow offers a similar approach with convenient
error context; for libraries where callers need to match on the error, prefer an
enum.
r#"..."#The tests use raw string literals so you can 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. Your parser
should accept KEY = value 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 =. Then trim both halves and
check that neither is empty.
Later pages reuse the small ParseError enum introduced here.
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()))
);
}
You can now reuse your line parser for a whole file. Return a HashMap of its
entries, ignoring blank lines and lines whose first non-whitespace character is
#. Stop at the first malformed entry and return its error rather than a
partial map. Strict parsing makes configuration bugs easier to spot.
To keep this page runnable on its own, ParseError is re-declared and
parse_env_line has a todo!() stub. Paste or reimplement your earlier
solution before building the file-level parser.
Useful from the Standard Library
str::linesiterates over the lines of the file content, stripping\nand\r\nfor you.str::trimreturns a slice without surrounding whitespace.str::starts_withchecks a prefix;str::is_emptychecks whether a string has any content.HashMap::insertstores a key-value pair, replacing the value if the key is already present.
If your parser returns a partial map for HOST=localhost\nBROKEN\nPORT=8080,
trace where the line parser's error goes. A malformed entry is not a comment.
A blank line or comment is allowed; a malformed entry is not. Decide which kind of line you have before asking the line parser to handle it. Leading spaces shouldn't hide a comment marker.
The line parser and file parser return the same error type. What does that let you do with an error from the helper? A loop is fine here; you don't need to fit everything into one iterator chain.
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);
}
#[test]
fn malformed_entry_between_valid_entries_is_not_skipped() {
for (bad, error) in [
("BROKEN", ParseError::InvalidFormat),
(" =value", ParseError::EmptyKey),
("KEY= ", ParseError::EmptyValue),
] {
let content = format!("HOST=localhost\n{bad}\nPORT=8080");
assert_eq!(parse_env_file(&content), Err(error));
}
}
#[test]
fn first_error_wins() {
assert_eq!(
parse_env_file("HOST=localhost\nKEY=\nBROKEN\nPORT=8080"),
Err(ParseError::EmptyValue)
);
}
#[test]
fn indented_comments_and_blank_lines_are_not_errors() {
let env = parse_env_file(" # comment\n \nHOST=localhost\n").unwrap();
assert_eq!(env.len(), 1);
assert_eq!(env.get("HOST").map(String::as_str), Some("localhost"));
}
Your map stores strings, but you need a u16 for a port or a bool for a flag.
Rather than write one helper per type, declare a generic function with a
FromStr bound. The caller picks the type with a turbofish or a type
annotation.
Return None both when the key is missing and when its value cannot be parsed
as the requested type. The standard library methods below provide the pieces;
your task is to combine them.
Useful from the Standard Library
HashMap::getreturnsOption<&String>.str::parseusesFromStrto produceResult<T, T::Err>. That's the trait thewhereclause is asking for.Result::okdrops the error and yieldsOption<T>, matching the function's return type.
Predict what happens when the stored text is "256" and the caller asks for
u8, u16, or String. Also test a missing key and a present but invalid
value. This API deliberately returns None for both failures. If a caller
needed to explain the difference, what information would the return type need to
preserve?
use std::collections::HashMap;
/// Gets an environment variable with type conversion. Parses the string value
/// into the requested type.
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));
}
#[test]
fn missing_key_returns_none() {
let env = HashMap::new();
assert_eq!(get_env_var::<u16>(&env, "PORT"), None);
assert_eq!(get_env_var::<bool>(&env, "DEBUG"), None);
}
#[test]
fn present_but_invalid_values_return_none() {
let mut env = HashMap::new();
for value in ["eight", "65536", "-1", ""] {
env.insert("PORT".into(), value.into());
assert_eq!(get_env_var::<u16>(&env, "PORT"), None, "{value:?}");
}
env.insert("DEBUG".into(), "yes".into());
assert_eq!(get_env_var::<bool>(&env, "DEBUG"), None);
}
#[test]
fn caller_type_decides_whether_conversion_succeeds() {
let env = HashMap::from([("VALUE".into(), "256".into())]);
assert_eq!(get_env_var::<u8>(&env, "VALUE"), None);
assert_eq!(get_env_var::<u16>(&env, "VALUE"), Some(256));
assert_eq!(get_env_var::<String>(&env, "VALUE"), Some("256".into()));
}
At startup, an app often needs to verify its database URL, port, or API key before doing any work. Your 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).Iterator::findborrows each item for its predicate, so|key|receives&&&str, but the returned item isOption<&&str>.HashMap::contains_keyaccepts a&strlookup for aStringkey.Option::map_orcan turn the search result into aResult. Consider what the search returns when every required key is present.- A plain
forloop works too; 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 can now parse a configuration file, read its values as the types you need,
and check for missing required keys. The file parser builds on the line parser,
so you only had to write the rules for KEY=value once.
What We Learned
split_once(delim)is the right tool for "key/value, split at the first separator". It returnsOption<(&str, &str)>without allocating.- Walking a config file combines four small operations:
lines(),trim(),starts_with('#'), andis_empty(). Aforloop withcontinueand?reads better than an iterator chain when the body has both kinds of control flow.- Keeping a small custom error enum such as
ParseErrornext to the parser gives the parsing code one concrete error type. You can propagate it with?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. Owned errors implementingstd::error::Error + 'staticconvert intoBox<dyn std::error::Error>. A custom enum lets callers match on specific cases;thiserrorcan generate itsDisplayandErrorimplementations.- 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.- Use
result.ok()to drop an error and produceOption<T>when you don't need to know why parsing failed.- 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.
Extra practice to explore at your own pace. These chapters do not count toward course progress.