netlify_headers/
lib.rs

1use http::header;
2use http::header::{HeaderName, HeaderValue};
3use std::collections::{hash_map::Entry, HashMap};
4use std::fs::File;
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum Error {
11    #[error("invalid line `{0}`")]
12    InvalidLine(String),
13    #[error("invalid header name")]
14    InvalidHeaderName(#[from] header::InvalidHeaderName),
15    #[error("invalid header value")]
16    InvalidHeaderValue(#[from] header::InvalidHeaderValue),
17    #[error("invalid file path")]
18    InvalidPath(#[from] std::io::Error),
19}
20
21pub struct HeaderMap {
22    inner: header::HeaderMap,
23}
24
25pub type Headers = HashMap<String, HeaderMap>;
26
27#[derive(Debug, PartialEq)]
28enum Line<'a> {
29    Empty,
30    Comment,
31    Target(&'a str),
32    Header((&'a str, &'a str)),
33}
34
35struct State {
36    target: Option<String>,
37    headers: Headers,
38}
39
40impl State {
41    fn new() -> Self {
42        State {
43            target: None,
44            headers: Headers::new(),
45        }
46    }
47    fn set_target(&mut self, t: &str) {
48        self.target = Some(t.to_owned())
49    }
50
51    fn append_headers(&mut self, key: &str, value: &str) -> Result<(), Error> {
52        let hn = HeaderName::from_bytes(key.as_bytes()).map_err(Error::InvalidHeaderName)?;
53        let hv = HeaderValue::from_str(value).map_err(Error::InvalidHeaderValue)?;
54
55        if let Some(h) = &self.target {
56            match self.headers.entry(h.to_owned()) {
57                Entry::Occupied(mut e) => {
58                    e.get_mut().append(hn, hv);
59                }
60                Entry::Vacant(e) => {
61                    let mut values = HeaderMap::new();
62                    values.insert(hn, hv);
63                    e.insert(values);
64                }
65            }
66        }
67
68        Ok(())
69    }
70}
71
72impl HeaderMap {
73    fn new() -> Self {
74        HeaderMap {
75            inner: header::HeaderMap::new(),
76        }
77    }
78
79    pub fn get_string(&self, header: &str) -> String {
80        self.inner
81            .get_all(header)
82            .iter()
83            .flat_map(|h| h.to_str().ok())
84            .collect::<Vec<_>>()
85            .join(",")
86            .to_owned()
87    }
88
89    pub fn append(&mut self, key: HeaderName, value: HeaderValue) -> bool {
90        self.inner.append(key, value)
91    }
92
93    pub fn insert(&mut self, key: HeaderName, value: HeaderValue) -> Option<HeaderValue> {
94        self.inner.insert(key, value)
95    }
96
97    pub fn get_all(&self) -> header::HeaderMap {
98        self.inner.clone()
99    }
100}
101
102pub fn from_path<T: AsRef<Path>>(path: T) -> Result<Headers, Error> {
103    let file = File::open(&path).map_err(Error::InvalidPath)?;
104    parse(BufReader::new(file))
105}
106
107pub fn parse<T: BufRead>(io: T) -> Result<Headers, Error> {
108    let mut state = State::new();
109
110    for res in io.lines() {
111        if let Ok(line) = res {
112            match parse_line(&line)? {
113                Line::Target(s) => state.set_target(s),
114                Line::Header((key, value)) => state.append_headers(key, value)?,
115                Line::Empty | Line::Comment => {}
116            }
117        }
118    }
119
120    Ok(state.headers)
121}
122
123fn parse_line(line: &str) -> Result<Line, Error> {
124    let line = line.trim();
125    if line.is_empty() {
126        return Ok(Line::Empty);
127    }
128
129    let c = line.chars().next().unwrap_or_default();
130    if c == '#' {
131        return Ok(Line::Comment);
132    }
133
134    if c == '/' {
135        return Ok(Line::Target(line));
136    }
137
138    if line.starts_with("http://") || line.starts_with("https://") {
139        return Ok(Line::Target(line));
140    }
141
142    let mut header = line.splitn(2, ':');
143
144    if let (Some(key), Some(value)) = (header.next(), header.next()) {
145        return Ok(Line::Header((key.trim(), value.trim())));
146    }
147
148    Err(Error::InvalidLine(line.to_owned()))
149}
150
151#[cfg(test)]
152mod tests {
153    use crate::{parse_line, Line};
154    #[test]
155    fn test_parse_line_with_target() {
156        let line = "/path/index.html";
157        assert_eq!(Line::Target(line), parse_line(line).unwrap());
158        let line = "https://example.com/*";
159        assert_eq!(Line::Target(line), parse_line(line).unwrap());
160        let line = "http://example.com/*";
161        assert_eq!(Line::Target(line), parse_line(line).unwrap());
162    }
163
164    #[test]
165    fn test_parse_line_with_ignored_lines() {
166        assert_eq!(Line::Empty, parse_line("               ").unwrap());
167        assert_eq!(Line::Comment, parse_line("# comment").unwrap());
168    }
169
170    #[test]
171    fn test_parse_line_with_key_value_headers() {
172        assert_eq!(
173            Line::Header(("foo", "bar")),
174            parse_line("foo: bar").unwrap()
175        );
176        assert_eq!(
177            Line::Header(("foo", "bar : baz")),
178            parse_line("foo: bar : baz").unwrap()
179        );
180    }
181
182    #[test]
183    fn test_parse_line_with_invalid_lines() {
184        assert!(parse_line("text without any meaning").is_err());
185    }
186}