Chapter 21

Parsing Structured Text and Generics

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.

Splitting Once

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.

Generic Functions

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.

Whitespace without Allocation

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.

When Errors Mix: 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.

A Note on Raw Strings: 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 "#.

Parsing a Single Line

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_once splits at the first match and returns Option<(&str, &str)>. The None case maps cleanly onto ParseError::InvalidFormat.
  • str::trim on each half drops the surrounding whitespace.
  • str::is_empty catches the =value and KEY= cases after trimming.
  • The Ok arm needs owned Strings, so finish with .to_string() on each half before wrapping in the tuple.
Exercise 1 of 4
Open in Web Editor

Results

    Compiler / runtime output
    
                
    Reveal the full solution Spoiler: the complete answer
    #[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()))
        );
    }
    

    Parsing a Whole File

    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::lines iterates over the lines of the file content, stripping \n and \r\n for you.
    • str::trim returns a slice without surrounding whitespace.
    • str::starts_with checks a prefix; str::is_empty checks whether a string has any content.
    • HashMap::insert stores 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.

    Exercise 2 of 4
    Open in Web Editor

    Results

      Compiler / runtime output
      
                  
      Stuck? Show a hint No spoilers, just a nudge

      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.

      Reveal the full solution Spoiler: the complete answer
      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"));
      }
      

      Typed Lookup with Generics

      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::get returns Option<&String>.
      • str::parse uses FromStr to produce Result<T, T::Err>. That's the trait the where clause is asking for.
      • Result::ok drops the error and yields Option<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?

      Exercise 3 of 4
      Open in Web Editor

      Results

        Compiler / runtime output
        
                    
        Reveal the full solution Spoiler: the complete answer
        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()));
        }
        

        Validating Required Variables

        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]>::iter on required yields &&str (a reference to each &str in the slice).
        • Iterator::find borrows each item for its predicate, so |key| receives &&&str, but the returned item is Option<&&str>.
        • HashMap::contains_key accepts a &str lookup for a String key.
        • Option::map_or can turn the search result into a Result. Consider what the search returns when every required key is present.
        • A plain for loop works too; pick whichever you like.
        Exercise 4 of 4
        Open in Web Editor

        Results

          Compiler / runtime output
          
                      
          Reveal the full solution Spoiler: the complete answer
          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());
          }
          

          Wrapping Up the Env-File Parser

          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 returns Option<(&str, &str)> without allocating.
          • Walking a config file combines four small operations: lines(), trim(), starts_with('#'), and is_empty(). A for loop with continue and ? reads better than an iterator chain when the body has both kinds of control flow.
          • Keeping a small custom error enum such as ParseError next to the parser gives the parsing code one concrete error type. You can propagate it with ? as long as the function returns Result<_, ParseError>.
          • When a function mixes error types (reading the file gives io::Error, parsing gives ParseError), ? needs one common type. Owned errors implementing std::error::Error + 'static convert into Box<dyn std::error::Error>. A custom enum lets callers match on specific cases; thiserror can generate its Display and Error implementations.
          • Generics let one function serve many caller types. The where T: FromStr bound is what makes .parse() work, and the caller pins T with a type annotation or a turbofish.
          • Use result.ok() to drop an error and produce Option<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 dotenvy crate reads .env files into the process environment; the parser you just wrote is a stripped-down version of the same idea.
          Next chapter 22State Machines and Stateful Parsing

          Optional Chapters

          Extra practice to explore at your own pace. These chapters do not count toward course progress.

          ← Return to the Rust course curriculum