use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use thiserror::Error;
#[derive(Clone, Eq, PartialEq, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NixConfig {
settings: HashMap<String, String>,
}
impl NixConfig {
pub fn new() -> Self {
Self {
settings: HashMap::new(),
}
}
pub fn settings(&self) -> &HashMap<String, String> {
&self.settings
}
pub fn settings_mut(&mut self) -> &mut HashMap<String, String> {
&mut self.settings
}
pub fn into_settings(self) -> HashMap<String, String> {
self.settings
}
pub fn parse_file(path: &Path) -> Result<Self, ParseError> {
if !path.exists() {
return Err(ParseError::FileNotFound(path.to_owned()));
}
let contents = std::fs::read_to_string(path)
.map_err(|e| ParseError::FailedToReadFile(path.to_owned(), e))?;
Self::parse_string(contents, Some(path))
}
pub fn parse_string(contents: String, origin: Option<&Path>) -> Result<Self, ParseError> {
let mut settings = NixConfig::new();
for line in contents.lines() {
let mut line = line;
if let Some(pos) = line.find('#') {
line = &line[..pos];
}
line = line.trim();
if line.is_empty() {
continue;
}
let mut tokens = line.split(&[' ', '\t', '\n', '\r']).collect::<Vec<_>>();
tokens.retain(|t| !t.is_empty());
if tokens.is_empty() {
continue;
}
if tokens.len() < 2 {
return Err(ParseError::IllegalConfiguration(
line.to_owned(),
origin.map(ToOwned::to_owned),
));
}
let mut include = false;
let mut ignore_missing = false;
if tokens[0] == "include" {
include = true;
} else if tokens[0] == "!include" {
include = true;
ignore_missing = true;
}
if include {
if tokens.len() != 2 {
return Err(ParseError::IllegalConfiguration(
line.to_owned(),
origin.map(ToOwned::to_owned),
));
}
let include_path = PathBuf::from(tokens[1]);
match Self::parse_file(&include_path) {
Ok(conf) => settings.settings_mut().extend(conf.into_settings()),
Err(_) if ignore_missing => {}
Err(_) if !ignore_missing => {
return Err(ParseError::IncludedFileNotFound(
include_path,
origin.map(ToOwned::to_owned),
));
}
_ => unreachable!(),
}
continue;
}
if tokens[1] != "=" {
return Err(ParseError::IllegalConfiguration(
line.to_owned(),
origin.map(ToOwned::to_owned),
));
}
let name = tokens[0];
let value = tokens[2..].join(" ");
settings.settings_mut().insert(name.into(), value);
}
Ok(settings)
}
}
#[derive(Debug, Error)]
pub enum ParseError {
#[error("file '{0}' not found")]
FileNotFound(PathBuf),
#[error("file '{0}' included from '{}' not found", .1.as_ref().map(|path| path.display().to_string()).unwrap_or(String::from("<unknown>")))]
IncludedFileNotFound(PathBuf, Option<PathBuf>),
#[error("illegal configuration line '{0}' in '{}'", .1.as_ref().map(|path| path.display().to_string()).unwrap_or(String::from("<unknown>")))]
IllegalConfiguration(String, Option<PathBuf>),
#[error("failed to read contents of '{0}': {1}")]
FailedToReadFile(PathBuf, #[source] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_config_from_string() {
let res = NixConfig::parse_string(
" cores = 4242\nexperimental-features = flakes nix-command\n # some comment\n# another comment\n#anotha one".into(),
None,
);
assert!(res.is_ok());
let map = res.unwrap();
assert_eq!(map.settings().get("cores"), Some(&"4242".into()));
assert_eq!(
map.settings().get("experimental-features"),
Some(&"flakes nix-command".into())
);
}
#[test]
fn parses_config_from_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let test_file = temp_dir
.path()
.join("recognizes_existing_different_files_and_fails_to_merge");
std::fs::write(
&test_file,
"cores = 4242\nexperimental-features = flakes nix-command",
)
.unwrap();
let res = NixConfig::parse_file(&test_file);
assert!(res.is_ok());
let map = res.unwrap();
assert_eq!(map.settings().get("cores"), Some(&"4242".into()));
assert_eq!(
map.settings().get("experimental-features"),
Some(&"flakes nix-command".into())
);
}
#[test]
fn errors_on_invalid_config() {
let temp_dir = tempfile::TempDir::new().unwrap();
let test_file = temp_dir.path().join("does-not-exist");
match NixConfig::parse_string("bad config".into(), None) {
Err(ParseError::IllegalConfiguration(_, _)) => (),
_ => assert!(
false,
"bad config should have returned ParseError::IllegalConfiguration"
),
}
match NixConfig::parse_file(&test_file) {
Err(ParseError::FileNotFound(path)) => assert_eq!(path, test_file),
_ => assert!(
false,
"nonexistent path should have returned ParseError::FileNotFound"
),
}
match NixConfig::parse_string(format!("include {}", test_file.display()), None) {
Err(ParseError::IncludedFileNotFound(path, _)) => assert_eq!(path, test_file),
_ => assert!(
false,
"nonexistent include path should have returned ParseError::IncludedFileNotFound"
),
}
match NixConfig::parse_file(temp_dir.path()) {
Err(ParseError::FailedToReadFile(path, _)) => assert_eq!(path, temp_dir.path()),
_ => assert!(
false,
"trying to read a dir to a string should have returned ParseError::FailedToReadFile"
),
}
}
#[test]
fn handles_consecutive_whitespace() {
let res = NixConfig::parse_string(
"substituters = https://hydra.iohk.io https://iohk.cachix.org https://cache.nixos.org/".into(),
None,
);
assert!(res.is_ok());
let map = res.unwrap();
assert_eq!(
map.settings().get("substituters"),
Some(&"https://hydra.iohk.io https://iohk.cachix.org https://cache.nixos.org/".into())
);
}
}