Skip to main content

header_config/
lib.rs

1#![deny(missing_docs)]
2
3/*!
4This library parses hierarchic configuration files in a markdown inspired format.
5Therefore the `parse_config` function is provided.
6**/
7
8use header_parsing::{Line, Parser};
9use indexmap::IndexMap;
10use thiserror::Error;
11
12use std::{
13    fs::File,
14    io::{BufRead, BufReader},
15    path::Path,
16};
17
18/// Error if parsing fails.
19#[derive(Debug, Error)]
20pub enum Error {
21    /// Opening file failed.
22    #[error("Failed to open the configuration file")]
23    OpeningFile,
24    /// A subheader doesn't have a header of the right hierarchy level.
25    #[error("Subheader found without a corresponding header")]
26    SubheaderWithoutHeader,
27    /// A key appears multiple times.
28    #[error("Multiple values found for key: {0}")]
29    MultipleKeys(Box<str>),
30    /// Some input issue occured while reading the file.
31    #[error("Input issues")]
32    Input,
33}
34
35impl Error {
36    /// Prints an error message suitable for the specified path.
37    pub fn print_message(&self, path: &Path) {
38        use Error::*;
39        match self {
40            OpeningFile => eprintln!("Opening file \"{}\" failed", path.display()),
41            SubheaderWithoutHeader => {
42                eprintln!(
43                    "File \"{}\" has a subheader without a header",
44                    path.display()
45                )
46            }
47            MultipleKeys(key) => {
48                eprintln!("Path \"{}\" has a duplicate of key {key}", path.display())
49            }
50            Input => eprintln!("Input error while reading \"{}\"", path.display()),
51        }
52    }
53}
54
55/// Parses the configuration file.
56pub fn parse_config(path: &Path) -> Result<IndexMap<Box<str>, Box<str>>, Error> {
57    let Ok(file) = File::open(path) else {
58        return Err(Error::OpeningFile);
59    };
60    let reader = BufReader::new(file);
61
62    let mut map = IndexMap::new();
63    let mut parser = Parser::new();
64    let mut prefix = String::new();
65
66    for line in reader.lines() {
67        let Ok(line) = line else {
68            return Err(Error::Input);
69        };
70        parse_line(&mut map, &mut parser, &mut prefix, &line)?;
71    }
72
73    Ok(map)
74}
75
76/// Parses configuration held in a string, sharing the core with
77/// [`parse_config`] for callers that already have the text in memory (for
78/// example a `# Meta` block extracted from a larger file).
79pub fn parse_config_str(text: &str) -> Result<IndexMap<Box<str>, Box<str>>, Error> {
80    let mut map = IndexMap::new();
81    let mut parser = Parser::new();
82    let mut prefix = String::new();
83
84    for line in text.lines() {
85        parse_line(&mut map, &mut parser, &mut prefix, line)?;
86    }
87
88    Ok(map)
89}
90
91fn parse_line(
92    map: &mut IndexMap<Box<str>, Box<str>>,
93    parser: &mut Parser,
94    prefix: &mut String,
95    line: &str,
96) -> Result<(), Error> {
97    let Ok(parsed) = parser.line(line) else {
98        return Err(Error::SubheaderWithoutHeader);
99    };
100    if let Line::Header { .. } = parsed {
101        prefix.clear();
102        for header in parser.path() {
103            prefix.push_str(header);
104            prefix.push(':');
105        }
106        return Ok(());
107    }
108
109    let line = line.trim();
110    if line.is_empty() {
111        return Ok(());
112    }
113
114    let (key, value) = line
115        .split_once(char::is_whitespace)
116        .map_or_else(|| (line, ""), |(key, value)| (key, value.trim_start()));
117
118    let full_key = format!("{prefix}{key}").into();
119    if map.contains_key(&full_key) {
120        return Err(Error::MultipleKeys(full_key));
121    }
122    map.insert(full_key, value.into());
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    #![allow(clippy::unwrap_used)]
129
130    use super::{Error, parse_config, parse_config_str};
131    use std::fs::write;
132
133    fn parse(name: &str, content: &str) -> Result<super::IndexMap<Box<str>, Box<str>>, Error> {
134        let path = std::env::temp_dir().join(name);
135        write(&path, content).unwrap();
136        let result = parse_config(&path);
137        let _ = std::fs::remove_file(path);
138        result
139    }
140
141    #[test]
142    fn flattens_nested_headers() {
143        let map = parse(
144            "header_config_nested.hc",
145            "root value\n\n# A\n\nkey 1\n\n# B\n\nkey 2\n\n## Sub\n\ndeep 3\nempty\n",
146        )
147        .unwrap();
148
149        assert_eq!(map.get("root").map(AsRef::as_ref), Some("value"));
150        assert_eq!(map.get("A:key").map(AsRef::as_ref), Some("1"));
151        assert_eq!(map.get("B:key").map(AsRef::as_ref), Some("2"));
152        assert_eq!(map.get("B:Sub:deep").map(AsRef::as_ref), Some("3"));
153        assert_eq!(map.get("B:Sub:empty").map(AsRef::as_ref), Some(""));
154    }
155
156    #[test]
157    fn rejects_duplicate_keys() {
158        let result = parse("header_config_dup.hc", "# A\n\nkey 1\nkey 2\n");
159        assert!(matches!(result, Err(Error::MultipleKeys(key)) if &*key == "A:key"));
160    }
161
162    #[test]
163    fn string_entry_point_matches_file() {
164        let content = "root value\n\n# A\n\nkey 1\n\n## Sub\n\ndeep 3\nempty\n";
165        let from_file = parse("header_config_str.hc", content).unwrap();
166        let from_str = parse_config_str(content).unwrap();
167        assert_eq!(from_file, from_str);
168        assert_eq!(from_str.get("A:Sub:deep").map(AsRef::as_ref), Some("3"));
169        assert_eq!(from_str.get("A:Sub:empty").map(AsRef::as_ref), Some(""));
170    }
171
172    #[test]
173    fn rejects_orphan_subheader() {
174        let result = parse("header_config_orphan.hc", "## Sub\n\nkey 1\n");
175        assert!(matches!(result, Err(Error::SubheaderWithoutHeader)));
176    }
177}