nix_config_parser/
lib.rs

1//! # nix-config-parser
2//!
3//! A simple parser for the Nix configuration file format.
4use indexmap::IndexMap;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8/// A newtype wrapper around a [`HashMap`], where the key is the name of the Nix
9/// setting, and the value is the value of that setting. If the setting accepts
10/// a list of values, the value will be space delimited.
11#[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    /// Attempt to parse the `nix.conf` at the provided path.
37    ///
38    /// ```rust
39    /// # use std::error::Error;
40    /// #
41    /// # fn main() -> Result<(), Box<dyn Error>> {
42    /// std::fs::write(
43    ///     "nix.conf",
44    ///     b"experimental-features = flakes nix-command\nwarn-dirty = false\n",
45    /// )?;
46    ///
47    /// let nix_conf = nix_config_parser::NixConfig::parse_file(&std::path::Path::new("nix.conf"))?;
48    ///
49    /// assert_eq!(
50    ///     nix_conf.settings().get("experimental-features").unwrap(),
51    ///     "flakes nix-command"
52    /// );
53    /// assert_eq!(nix_conf.settings().get("warn-dirty").unwrap(), "false");
54    ///
55    /// std::fs::remove_file("nix.conf")?;
56    /// # Ok(())
57    /// # }
58    /// ```
59    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    /// Attempt to parse the `nix.conf` out of the provided [`String`]. The `origin`
71    /// parameter is [`Option`]al, and only influences potential error messages.
72    ///
73    /// ```rust
74    /// # use std::error::Error;
75    /// #
76    /// # fn main() -> Result<(), Box<dyn Error>> {
77    /// let nix_conf_string = String::from("experimental-features = flakes nix-command");
78    /// let nix_conf = nix_config_parser::NixConfig::parse_string(nix_conf_string, None)?;
79    ///
80    /// assert_eq!(
81    ///     nix_conf.settings().get("experimental-features").unwrap(),
82    ///     "flakes nix-command"
83    /// );
84    /// # Ok(())
85    /// # }
86    /// ```
87    // Mostly a carbon copy of AbstractConfig::applyConfig from Nix:
88    // https://github.com/NixOS/nix/blob/0079d2943702a7a7fbdd88c0f9a5ad677c334aa8/src/libutil/config.cc#L80
89    // Some things were adjusted to be more idiomatic, as well as to account for the lack of
90    // `try { ... } catch (SpecificErrorType &) { }`
91    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            // skip comments
98            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/// An error that occurred while attempting to parse a `nix.conf` [`Path`] or
172/// [`String`].
173#[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        // Leading space of ` cores = 4242` is intentional and exercises an edge case.
192        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        // Ensure it's not just luck that it's the same order...
306        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}