Frontend Labs
⋯

15. Terminal project: a /etc/hosts-style config parser with real errors

This project uses enums, Result, custom errors, FromStr, and tests, all of which a real config loader needs.

// src/bin/hostsparse.rs
use std::collections::BTreeMap;
use std::fmt;
use std::net::IpAddr;
use std::str::FromStr;

#[derive(Debug, PartialEq)]
pub enum ParseError {
    BadAddress { line: usize, value: String },
    NoHostnames { line: usize },
    BadHostname { line: usize, value: String },
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::BadAddress { line, value } => write!(f, "line {line}: {value:?} is not an IP address"),
            Self::NoHostnames { line } => write!(f, "line {line}: address with no hostnames"),
            Self::BadHostname { line, value } => write!(f, "line {line}: invalid hostname {value:?}"),
        }
    }
}
impl std::error::Error for ParseError {}

#[derive(Debug, Default)]
pub struct Hosts {
    by_name: BTreeMap<String, Vec<IpAddr>>,
}

fn valid_hostname(h: &str) -> bool {
    // RFC 1123-ish: labels of [a-z0-9-], not starting/ending with '-', total <= 253
    h.len() <= 253
        && h.split('.').all(|label| {
            !label.is_empty()
                && label.len() <= 63
                && !label.starts_with('-')
                && !label.ends_with('-')
                && label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
        })
}

impl FromStr for Hosts {
    type Err = ParseError;

    fn from_str(text: &str) -> Result<Self, Self::Err> {
        let mut hosts = Hosts::default();
        for (idx, raw) in text.lines().enumerate() {
            let line = idx + 1;
            let content = raw.split('#').next().unwrap_or("").trim(); // strip comments
            if content.is_empty() { continue; }

            let mut fields = content.split_whitespace();
            let addr_text = fields.next().expect("non-empty line has a first field");
            let addr: IpAddr = addr_text
                .parse()
                .map_err(|_| ParseError::BadAddress { line, value: addr_text.into() })?;

            let names: Vec<&str> = fields.collect();
            if names.is_empty() { return Err(ParseError::NoHostnames { line }); }
            for name in names {
                if !valid_hostname(name) {
                    return Err(ParseError::BadHostname { line, value: name.into() });
                }
                hosts.by_name.entry(name.to_ascii_lowercase()).or_default().push(addr);
            }
        }
        Ok(hosts)
    }
}

impl Hosts {
    pub fn lookup(&self, name: &str) -> &[IpAddr] {
        self.by_name.get(&name.to_ascii_lowercase()).map(Vec::as_slice).unwrap_or(&[])
    }
}

fn main() {
    let text = std::fs::read_to_string("/etc/hosts").unwrap_or_else(|_| "127.0.0.1 localhost".into());
    match text.parse::<Hosts>() {
        Ok(h) => println!("localhost -> {:?}", h.lookup("LOCALHOST")),
        Err(e) => eprintln!("error: {e}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_and_looks_up() {
        let h: Hosts = "127.0.0.1 localhost  # loopback\n::1 localhost ip6-localhost\n".parse().unwrap();
        assert_eq!(h.lookup("localhost").len(), 2);
        assert!(h.lookup("nope").is_empty());
    }

    #[test]
    fn reports_line_numbers() {
        let err = "127.0.0.1 ok\n999.1.1.1 bad\n".parse::<Hosts>().unwrap_err();
        assert_eq!(err, ParseError::BadAddress { line: 2, value: "999.1.1.1".into() });
        assert!(matches!("10.0.0.1 -bad-".parse::<Hosts>(), Err(ParseError::BadHostname { .. })));
    }
}

Across the series: this is the file your resolver reads before it sends any DNS query (see Net Ch 18). Ch 45 builds the DNS query that runs when the name is not in this file.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36