netlify_headers 0.1.1

Netlify Headers is a parser for Netlify _headers file
Documentation
use http::header;
use http::header::{HeaderName, HeaderValue};
use std::collections::{hash_map::Entry, HashMap};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum Error {
    #[error("invalid line `{0}`")]
    InvalidLine(String),
    #[error("invalid header name")]
    InvalidHeaderName(#[from] header::InvalidHeaderName),
    #[error("invalid header value")]
    InvalidHeaderValue(#[from] header::InvalidHeaderValue),
    #[error("invalid file path")]
    InvalidPath(#[from] std::io::Error),
}

pub struct HeaderMap {
    inner: header::HeaderMap,
}

pub type Headers = HashMap<String, HeaderMap>;

#[derive(Debug, PartialEq)]
enum Line<'a> {
    Empty,
    Comment,
    Target(&'a str),
    Header((&'a str, &'a str)),
}

struct State {
    target: Option<String>,
    headers: Headers,
}

impl State {
    fn new() -> Self {
        State {
            target: None,
            headers: Headers::new(),
        }
    }
    fn set_target(&mut self, t: &str) {
        self.target = Some(t.to_owned())
    }

    fn append_headers(&mut self, key: &str, value: &str) -> Result<(), Error> {
        let hn = HeaderName::from_bytes(key.as_bytes()).map_err(Error::InvalidHeaderName)?;
        let hv = HeaderValue::from_str(value).map_err(Error::InvalidHeaderValue)?;

        if let Some(h) = &self.target {
            match self.headers.entry(h.to_owned()) {
                Entry::Occupied(mut e) => {
                    e.get_mut().append(hn, hv);
                }
                Entry::Vacant(e) => {
                    let mut values = HeaderMap::new();
                    values.insert(hn, hv);
                    e.insert(values);
                }
            }
        }

        Ok(())
    }
}

impl HeaderMap {
    fn new() -> Self {
        HeaderMap {
            inner: header::HeaderMap::new(),
        }
    }

    pub fn get_string(&self, header: &str) -> String {
        self.inner
            .get_all(header)
            .iter()
            .flat_map(|h| h.to_str().ok())
            .collect::<Vec<_>>()
            .join(",")
            .to_owned()
    }

    pub fn append(&mut self, key: HeaderName, value: HeaderValue) -> bool {
        self.inner.append(key, value)
    }

    pub fn insert(&mut self, key: HeaderName, value: HeaderValue) -> Option<HeaderValue> {
        self.inner.insert(key, value)
    }

    pub fn get_all(&self) -> header::HeaderMap {
        self.inner.clone()
    }
}

pub fn from_path<T: AsRef<Path>>(path: T) -> Result<Headers, Error> {
    let file = File::open(&path).map_err(Error::InvalidPath)?;
    parse(BufReader::new(file))
}

pub fn parse<T: BufRead>(io: T) -> Result<Headers, Error> {
    let mut state = State::new();

    for res in io.lines() {
        if let Ok(line) = res {
            match parse_line(&line)? {
                Line::Target(s) => state.set_target(s),
                Line::Header((key, value)) => state.append_headers(key, value)?,
                Line::Empty | Line::Comment => {}
            }
        }
    }

    Ok(state.headers)
}

fn parse_line(line: &str) -> Result<Line, Error> {
    let line = line.trim();
    if line.is_empty() {
        return Ok(Line::Empty);
    }

    let c = line.chars().next().unwrap_or_default();
    if c == '#' {
        return Ok(Line::Comment);
    }

    if c == '/' {
        return Ok(Line::Target(line));
    }

    if line.starts_with("http://") || line.starts_with("https://") {
        return Ok(Line::Target(line));
    }

    let mut header = line.splitn(2, ':');

    if let (Some(key), Some(value)) = (header.next(), header.next()) {
        return Ok(Line::Header((key.trim(), value.trim())));
    }

    Err(Error::InvalidLine(line.to_owned()))
}

#[cfg(test)]
mod tests {
    use crate::{parse_line, Line};
    #[test]
    fn test_parse_line_with_target() {
        let line = "/path/index.html";
        assert_eq!(Line::Target(line), parse_line(line).unwrap());
        let line = "https://example.com/*";
        assert_eq!(Line::Target(line), parse_line(line).unwrap());
        let line = "http://example.com/*";
        assert_eq!(Line::Target(line), parse_line(line).unwrap());
    }

    #[test]
    fn test_parse_line_with_ignored_lines() {
        assert_eq!(Line::Empty, parse_line("               ").unwrap());
        assert_eq!(Line::Comment, parse_line("# comment").unwrap());
    }

    #[test]
    fn test_parse_line_with_key_value_headers() {
        assert_eq!(
            Line::Header(("foo", "bar")),
            parse_line("foo: bar").unwrap()
        );
        assert_eq!(
            Line::Header(("foo", "bar : baz")),
            parse_line("foo: bar : baz").unwrap()
        );
    }

    #[test]
    fn test_parse_line_with_invalid_lines() {
        assert!(parse_line("text without any meaning").is_err());
    }
}