use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use crate::{
ConfigError,
load::files::{Files, Filesystem},
tree::{ConfigMerger, Configurable, Merge, PartialValue},
};
mod files;
pub trait PartialTree: DeserializeOwned + Default + Merge + PartialValue {}
impl<T> PartialTree for T where T: DeserializeOwned + Default + Merge + PartialValue {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemConfigSetting {
Unset,
None,
DefaultPath(PathBuf),
UserSpecifiedPath(PathBuf),
}
pub trait RootConfig: Configurable<Partial: PartialTree<Resolved = Self>> {
fn use_system_config(partial: &Self::Partial) -> SystemConfigSetting;
fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError>
where
Self: Sized,
{
load_from(&Filesystem, path.as_ref())
}
}
fn load_from<T: RootConfig>(files: &impl Files, main_path: &Path) -> Result<T, ConfigError> {
let main = parse::<T::Partial>(files, main_path)?;
let mut merger = ConfigMerger::new();
for fragment in system_fragments(files, T::use_system_config(&main))? {
let partial = parse::<T::Partial>(files, &fragment)?;
if T::use_system_config(&partial) != SystemConfigSetting::Unset {
return Err(ConfigError::DirectiveNotAllowed { path: fragment });
}
merger.add_system(fragment, partial)?;
}
merger.add_main(main_path.to_path_buf(), main)?;
merger.apply_defaults();
let (config, _provenance) = merger.finish()?;
Ok(config)
}
fn parse<T: DeserializeOwned>(files: &impl Files, path: &Path) -> Result<T, ConfigError> {
let contents = files
.read(path)
.map_err(|cause| ConfigError::CouldNotRead {
path: path.to_path_buf(),
cause,
})?;
toml::from_str(&contents).map_err(|cause| ConfigError::CouldNotParse {
path: path.to_path_buf(),
cause,
})
}
fn fragment_directory(setting: SystemConfigSetting) -> Option<(PathBuf, bool)> {
match setting {
SystemConfigSetting::Unset => None,
SystemConfigSetting::None => None,
SystemConfigSetting::DefaultPath(path_buf) => Some((path_buf, false)),
SystemConfigSetting::UserSpecifiedPath(path_buf) => Some((path_buf, true)),
}
}
fn system_fragments(
files: &impl Files,
setting: SystemConfigSetting,
) -> Result<Vec<PathBuf>, ConfigError> {
let Some((directory, named)) = fragment_directory(setting) else {
return Ok(Vec::new());
};
let entries = match files.list(&directory) {
Ok(entries) => entries,
Err(cause) if cause.kind() == std::io::ErrorKind::NotFound && !named => {
return Ok(Vec::new());
}
Err(cause) => {
return Err(ConfigError::CouldNotRead {
path: directory,
cause,
});
}
};
let mut fragments: Vec<_> = entries
.into_iter()
.filter(|path| {
path.extension()
.is_some_and(|extension| extension == "toml")
})
.collect();
fragments.sort();
Ok(fragments)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ConfigError,
fixture::{Fixture, Level, Server, Source, UseSystemConfig},
load::files::Memory,
};
fn load(documents: Memory) -> Result<Fixture, ConfigError> {
load_from::<Fixture>(&documents, Path::new("/etc/main.toml"))
}
#[test]
fn fragments_contribute_to_the_configuration() {
let documents = Memory::new()
.document("/etc/main.toml", "use-system-config = '/etc/system.d'")
.document("/etc/system.d/10-logging.toml", "logging.level = 'warn'")
.document(
"/etc/system.d/20-sources.toml",
"[[sources]]\nkind = 'server'\naddress = 'example.com'",
)
.document("/etc/system.d/README.md", "not a configuration");
let config = load(documents).unwrap();
assert_eq!(config.logging.level, Level::Warn);
assert_eq!(
config.sources,
vec![Source::Server(Server {
address: "example.com".to_owned(),
version: 4,
})]
);
}
#[test]
fn the_main_config_overrides_a_fragment() {
let documents = Memory::new()
.document(
"/etc/main.toml",
"use-system-config = '/etc/system.d'\nlogging.level = 'debug'",
)
.document("/etc/system.d/10-logging.toml", "logging.level = 'warn'");
let config = load(documents).unwrap();
assert_eq!(config.logging.level, Level::Debug);
}
#[test]
fn a_fragment_may_not_steer_the_loader() {
let documents = Memory::new()
.document("/etc/main.toml", "use-system-config = '/etc/system.d'")
.document("/etc/system.d/10-logging.toml", "use-system-config = true");
let error = load(documents).unwrap_err();
let ConfigError::DirectiveNotAllowed { path } = error else {
panic!("expected a rejected directive, got {error:?}");
};
assert_eq!(path, PathBuf::from("/etc/system.d/10-logging.toml"));
}
#[test]
fn the_default_directory_is_read_when_enabled() {
let documents = Memory::new()
.document("/etc/main.toml", "use-system-config = true")
.document(
"/usr/lib/ntpd-rs/system-config/10-logging.toml",
"logging.level = 'warn'",
);
let config = load(documents).unwrap();
assert_eq!(config.logging.level, Level::Warn);
}
#[test]
fn an_unset_setting_means_no_system_configuration() {
let documents = Memory::new()
.document("/etc/main.toml", "")
.document(
"/usr/lib/ntpd-rs/system-config/10-logging.toml",
"logging.level = 'warn'",
);
let config = load(documents).unwrap();
assert_eq!(config.logging.level, Level::Info);
}
#[test]
fn the_system_config_setting_is_part_of_the_configuration() {
let documents = Memory::new()
.document("/etc/main.toml", "use-system-config = '/etc/system.d'")
.directory("/etc/system.d");
let config = load(documents).unwrap();
assert_eq!(
config.use_system_config,
UseSystemConfig::Directory("/etc/system.d".into())
);
let unset = Memory::new().document("/etc/main.toml", "");
assert_eq!(
load(unset).unwrap().use_system_config,
UseSystemConfig::Enabled(false)
);
}
#[test]
fn an_empty_fragment_directory_is_not_an_error() {
let documents = Memory::new()
.document("/etc/main.toml", "use-system-config = '/etc/system.d'")
.directory("/etc/system.d");
let config = load(documents).unwrap();
assert_eq!(config.logging.level, Level::Info);
}
#[test]
fn a_named_directory_that_is_missing_is_an_error() {
let documents =
Memory::new().document("/etc/main.toml", "use-system-config = '/etc/system.d'");
let error = load(documents).unwrap_err();
let ConfigError::CouldNotRead { path, .. } = error else {
panic!("expected a read failure, got {error:?}");
};
assert_eq!(path, PathBuf::from("/etc/system.d"));
}
#[test]
fn errors_describe_themselves() {
let missing_value = Memory::new().document(
"/etc/main.toml",
"[[sources]]\nkind = 'server'\nversion = 5",
);
assert_eq!(
load(missing_value).unwrap_err().to_string(),
"`sources[0].address` is required, but was never set"
);
let unreadable = Memory::new();
assert_eq!(
load(unreadable).unwrap_err().to_string(),
"could not read `/etc/main.toml`: no such document"
);
let set_in_fragment = Memory::new()
.document("/etc/main.toml", "use-system-config = '/etc/system.d'")
.document("/etc/system.d/10-logging.toml", "use-system-config = true");
assert_eq!(
load(set_in_fragment).unwrap_err().to_string(),
"`/etc/system.d/10-logging.toml` sets `use-system-config`, \
which only the main configuration may do"
);
}
fn rejects(document: &str, offender: &str) {
let documents = Memory::new().document("/etc/main.toml", document);
let error = load(documents).unwrap_err();
assert!(
matches!(error, ConfigError::CouldNotParse { .. }),
"expected {document:?} to be rejected while parsing, got {error:?}"
);
assert!(
error.to_string().contains(offender),
"the complaint about {document:?} does not mention `{offender}`: {error}"
);
}
#[test]
fn an_unknown_key_is_rejected_wherever_it_appears() {
rejects("loggign = 'warn'", "loggign");
rejects("[logging]\nlevle = 'warn'", "levle");
rejects(
"[[sources]]\nkind = 'server'\naddress = 'a'\naddres = 'b'",
"addres",
);
rejects("[[sources]]\nkind = 'nonsense'", "nonsense");
}
#[test]
fn the_default_directory_need_not_exist() {
let documents = Memory::new().document("/etc/main.toml", "use-system-config = true");
let config = load(documents).unwrap();
assert_eq!(config.logging.level, Level::Info);
}
}