1use indexmap::IndexMap;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8#[derive(Clone, Eq, PartialEq, Debug, Default)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct NixConfig {
14 settings: IndexMap<String, String>,
15}
16
17impl NixConfig {
18 pub fn new() -> Self {
19 Self {
20 settings: IndexMap::new(),
21 }
22 }
23
24 pub fn settings(&self) -> &IndexMap<String, String> {
25 &self.settings
26 }
27
28 pub fn settings_mut(&mut self) -> &mut IndexMap<String, String> {
29 &mut self.settings
30 }
31
32 pub fn into_settings(self) -> IndexMap<String, String> {
33 self.settings
34 }
35
36 pub fn parse_file(path: &Path) -> Result<Self, ParseError> {
60 if !path.exists() {
61 return Err(ParseError::FileNotFound(path.to_owned()));
62 }
63
64 let contents = std::fs::read_to_string(path)
65 .map_err(|e| ParseError::FailedToReadFile(path.to_owned(), e))?;
66
67 Self::parse_string(contents, Some(path))
68 }
69
70 pub fn parse_string(contents: String, origin: Option<&Path>) -> Result<Self, ParseError> {
92 let mut settings = NixConfig::new();
93
94 for line in contents.lines() {
95 let mut line = line;
96
97 if let Some(pos) = line.find('#') {
99 line = &line[..pos];
100 }
101
102 line = line.trim();
103
104 if line.is_empty() {
105 continue;
106 }
107
108 let mut tokens = line.split(&[' ', '\t', '\n', '\r']).collect::<Vec<_>>();
109 tokens.retain(|t| !t.is_empty());
110
111 if tokens.is_empty() {
112 continue;
113 }
114
115 if tokens.len() < 2 {
116 return Err(ParseError::IllegalConfiguration(
117 line.to_owned(),
118 origin.map(ToOwned::to_owned),
119 ));
120 }
121
122 let mut include = false;
123 let mut ignore_missing = false;
124 if tokens[0] == "include" {
125 include = true;
126 } else if tokens[0] == "!include" {
127 include = true;
128 ignore_missing = true;
129 }
130
131 if include {
132 if tokens.len() != 2 {
133 return Err(ParseError::IllegalConfiguration(
134 line.to_owned(),
135 origin.map(ToOwned::to_owned),
136 ));
137 }
138
139 let include_path = PathBuf::from(tokens[1]);
140 match Self::parse_file(&include_path) {
141 Ok(conf) => settings.settings_mut().extend(conf.into_settings()),
142 Err(_) if ignore_missing => {}
143 Err(_) if !ignore_missing => {
144 return Err(ParseError::IncludedFileNotFound(
145 include_path,
146 origin.map(ToOwned::to_owned),
147 ));
148 }
149 _ => unreachable!(),
150 }
151
152 continue;
153 }
154
155 if tokens[1] != "=" {
156 return Err(ParseError::IllegalConfiguration(
157 line.to_owned(),
158 origin.map(ToOwned::to_owned),
159 ));
160 }
161
162 let name = tokens[0];
163 let value = tokens[2..].join(" ");
164 settings.settings_mut().insert(name.into(), value);
165 }
166
167 Ok(settings)
168 }
169}
170
171#[derive(Debug, Error)]
174pub enum ParseError {
175 #[error("file '{0}' not found")]
176 FileNotFound(PathBuf),
177 #[error("file '{0}' included from '{}' not found", .1.as_ref().map(|path| path.display().to_string()).unwrap_or(String::from("<unknown>")))]
178 IncludedFileNotFound(PathBuf, Option<PathBuf>),
179 #[error("illegal configuration line '{0}' in '{}'", .1.as_ref().map(|path| path.display().to_string()).unwrap_or(String::from("<unknown>")))]
180 IllegalConfiguration(String, Option<PathBuf>),
181 #[error("failed to read contents of '{0}': {1}")]
182 FailedToReadFile(PathBuf, #[source] std::io::Error),
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn parses_config_from_string() {
191 let res = NixConfig::parse_string(
193 " cores = 4242\nexperimental-features = flakes nix-command\n # some comment\n# another comment\n#anotha one".into(),
194 None,
195 );
196
197 assert!(res.is_ok());
198
199 let map = res.unwrap();
200
201 assert_eq!(map.settings().get("cores"), Some(&"4242".into()));
202 assert_eq!(
203 map.settings().get("experimental-features"),
204 Some(&"flakes nix-command".into())
205 );
206 }
207
208 #[test]
209 fn parses_config_from_file() {
210 let temp_dir = tempfile::TempDir::new().unwrap();
211 let test_file = temp_dir
212 .path()
213 .join("recognizes_existing_different_files_and_fails_to_merge");
214
215 std::fs::write(
216 &test_file,
217 "cores = 4242\nexperimental-features = flakes nix-command",
218 )
219 .unwrap();
220
221 let res = NixConfig::parse_file(&test_file);
222
223 assert!(res.is_ok());
224
225 let map = res.unwrap();
226
227 assert_eq!(map.settings().get("cores"), Some(&"4242".into()));
228 assert_eq!(
229 map.settings().get("experimental-features"),
230 Some(&"flakes nix-command".into())
231 );
232 }
233
234 #[test]
235 fn errors_on_invalid_config() {
236 let temp_dir = tempfile::TempDir::new().unwrap();
237 let test_file = temp_dir.path().join("does-not-exist");
238
239 match NixConfig::parse_string("bad config".into(), None) {
240 Err(ParseError::IllegalConfiguration(_, _)) => (),
241 _ => assert!(
242 false,
243 "bad config should have returned ParseError::IllegalConfiguration"
244 ),
245 }
246
247 match NixConfig::parse_file(&test_file) {
248 Err(ParseError::FileNotFound(path)) => assert_eq!(path, test_file),
249 _ => assert!(
250 false,
251 "nonexistent path should have returned ParseError::FileNotFound"
252 ),
253 }
254
255 match NixConfig::parse_string(format!("include {}", test_file.display()), None) {
256 Err(ParseError::IncludedFileNotFound(path, _)) => assert_eq!(path, test_file),
257 _ => assert!(
258 false,
259 "nonexistent include path should have returned ParseError::IncludedFileNotFound"
260 ),
261 }
262
263 match NixConfig::parse_file(temp_dir.path()) {
264 Err(ParseError::FailedToReadFile(path, _)) => assert_eq!(path, temp_dir.path()),
265 _ => assert!(
266 false,
267 "trying to read a dir to a string should have returned ParseError::FailedToReadFile"
268 ),
269 }
270 }
271
272 #[test]
273 fn handles_consecutive_whitespace() {
274 let res = NixConfig::parse_string(
275 "substituters = https://hydra.iohk.io https://iohk.cachix.org https://cache.nixos.org/".into(),
276 None,
277 );
278
279 assert!(res.is_ok());
280
281 let map = res.unwrap();
282
283 assert_eq!(
284 map.settings().get("substituters"),
285 Some(&"https://hydra.iohk.io https://iohk.cachix.org https://cache.nixos.org/".into())
286 );
287 }
288
289 #[test]
290 fn returns_the_same_order() {
291 let res = NixConfig::parse_string(
292 r#"
293 cores = 32
294 experimental-features = flakes nix-command
295 max-jobs = 16
296 "#
297 .into(),
298 None,
299 );
300
301 assert!(res.is_ok());
302
303 let map = res.unwrap();
304
305 for _ in 0..10 {
307 let settings = map.settings();
308
309 let mut settings_order = settings.into_iter();
310 assert_eq!(settings_order.next(), Some((&"cores".into(), &"32".into())),);
311 assert_eq!(
312 settings_order.next(),
313 Some((
314 &"experimental-features".into(),
315 &"flakes nix-command".into()
316 )),
317 );
318 assert_eq!(
319 settings_order.next(),
320 Some((&"max-jobs".into(), &"16".into())),
321 );
322 }
323 }
324}