1#![deny(missing_docs)]
2
3use 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#[derive(Debug, Error)]
20pub enum Error {
21 #[error("Failed to open the configuration file")]
23 OpeningFile,
24 #[error("Subheader found without a corresponding header")]
26 SubheaderWithoutHeader,
27 #[error("Multiple values found for key: {0}")]
29 MultipleKeys(Box<str>),
30 #[error("Input issues")]
32 Input,
33}
34
35impl Error {
36 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
55pub 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
76pub 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}