use std::{
fs::read,
path::{Path, PathBuf},
};
use directories::ProjectDirs;
use log::trace;
use serde::Deserialize;
pub trait ConfigFile<'a>: Default + Clone + Deserialize<'a> {
type Error: std::error::Error + 'static;
fn merge(&mut self, other: Self) -> Result<(), Self::Error>;
fn parse_json(json: &'a [u8]) -> Result<Self, ConfigError> {
serde_json::from_slice(json).map_err(ConfigError::ParseJson)
}
fn parse_toml(toml: &'a [u8]) -> Result<Self, ConfigError> {
toml::from_slice(toml).map_err(ConfigError::ParseToml)
}
fn parse_yaml(yaml: &'a [u8]) -> Result<Self, ConfigError> {
serde_norway::from_slice(yaml).map_err(ConfigError::ParseYaml)
}
}
pub trait ConfigValidator {
type File;
type Valid;
type Error: std::error::Error + 'static;
fn validate(&self, runtime: &Self::File) -> Result<Self::Valid, Self::Error>;
}
#[derive(Debug, Default)]
pub struct ConfigLoader {
xdg_basename: Option<PathBuf>,
to_load: Vec<FileToLoad>,
loaded: Vec<Loaded>,
}
impl ConfigLoader {
#[allow(clippy::panic)]
pub fn xdg_basename<P: AsRef<Path>>(&mut self, basename: P) {
let basename = basename.as_ref();
trace!(
"set XDG configuration file basename to {}",
basename.display()
);
if basename.is_absolute() {
panic!(
"basename must be relative, is absolute: {}",
basename.display()
);
}
self.xdg_basename = Some(basename.into());
}
pub fn xdg(&mut self, qual: &str, org: &str, app: &str) {
if let Some(dirs) = ProjectDirs::from(qual, org, app) {
let c = dirs.config_dir();
let basename = self.xdg_basename.clone().unwrap_or(PathBuf::from("config"));
trace!(
"load configuration files from XDG project config directory {}",
c.display()
);
self.allow_json(c.join(&basename).with_extension("json"));
self.allow_toml(c.join(&basename).with_extension("toml"));
self.allow_yaml(c.join(&basename).with_extension("yaml"));
}
}
pub fn require_json<P: AsRef<Path>>(&mut self, filename: P) {
let filename = filename.as_ref().to_path_buf();
trace!("require JSON configuration file {}", filename.display());
self.to_load.push(FileToLoad::RequiredJson(filename));
}
pub fn require_toml<P: AsRef<Path>>(&mut self, filename: P) {
let filename = filename.as_ref().to_path_buf();
trace!("require TOML configuration file {}", filename.display());
self.to_load.push(FileToLoad::RequiredToml(filename));
}
pub fn require_yaml<P: AsRef<Path>>(&mut self, filename: P) {
let filename = filename.as_ref().to_path_buf();
trace!("require YAML configuration file {}", filename.display());
self.to_load.push(FileToLoad::RequiredYaml(filename));
}
pub fn allow_json<P: AsRef<Path>>(&mut self, filename: P) {
let filename = filename.as_ref().to_path_buf();
trace!("allow JSON configuration file {}", filename.display());
self.to_load.push(FileToLoad::OptionalJson(filename));
}
pub fn allow_toml<P: AsRef<Path>>(&mut self, filename: P) {
let filename = filename.as_ref().to_path_buf();
trace!("allow TOML configuration file {}", filename.display());
self.to_load.push(FileToLoad::OptionalToml(filename));
}
pub fn allow_yaml<P: AsRef<Path>>(&mut self, filename: P) {
let filename = filename.as_ref().to_path_buf();
trace!("allow YAML configuration file {}", filename.display());
self.to_load.push(FileToLoad::OptionalYaml(filename));
}
pub fn filenames(&self) -> Vec<(&Path, bool)> {
fn pair(x: &FileToLoad) -> (&Path, bool) {
match x {
FileToLoad::OptionalJson(filename) => (filename.as_path(), false),
FileToLoad::OptionalToml(filename) => (filename.as_path(), false),
FileToLoad::OptionalYaml(filename) => (filename.as_path(), false),
FileToLoad::RequiredJson(filename) => (filename.as_path(), true),
FileToLoad::RequiredToml(filename) => (filename.as_path(), true),
FileToLoad::RequiredYaml(filename) => (filename.as_path(), true),
}
}
self.to_load.iter().map(pair).collect()
}
pub fn load<'a, C: ConfigFile<'a>, V: ConfigValidator<File = C>>(
&'a mut self,
defaults: Option<C>,
overrides: Option<C>,
validator: &V,
) -> Result<V::Valid, ConfigError> {
fn read_file(filename: &Path) -> Result<Vec<u8>, ConfigError> {
trace!("read configuration file {}", filename.display());
read(filename).map_err(|err| ConfigError::Read(filename.to_path_buf(), err))
}
fn merge<'a, C: ConfigFile<'a>>(merged: &mut C, file: C) -> Result<(), ConfigError> {
merged
.merge(file)
.map_err(|err| ConfigError::Runtime(Box::new(err)))?;
Ok(())
}
for to_load in self.to_load.iter() {
match to_load {
FileToLoad::RequiredJson(filename) => {
self.loaded.push(Loaded::Json(read_file(filename)?))
}
FileToLoad::RequiredToml(filename) => {
self.loaded.push(Loaded::Toml(read_file(filename)?))
}
FileToLoad::RequiredYaml(filename) => {
self.loaded.push(Loaded::Yaml(read_file(filename)?))
}
FileToLoad::OptionalJson(filename) => {
if filename.exists() {
self.loaded.push(Loaded::Json(read_file(filename)?))
}
}
FileToLoad::OptionalToml(filename) => {
if filename.exists() {
self.loaded.push(Loaded::Toml(read_file(filename)?))
}
}
FileToLoad::OptionalYaml(filename) => {
if filename.exists() {
self.loaded.push(Loaded::Yaml(read_file(filename)?))
}
}
}
}
trace!("merge loaded configuration files into defaults");
let mut merged = defaults.unwrap_or_default();
for data in self.loaded.iter() {
match data {
Loaded::Json(data) => {
merge(&mut merged, C::parse_json(data)?)?;
}
Loaded::Toml(data) => {
merge(&mut merged, C::parse_toml(data)?)?;
}
Loaded::Yaml(data) => {
merge(&mut merged, C::parse_yaml(data)?)?;
}
}
}
if let Some(overrides) = overrides {
trace!("apply overrides to merged configuration");
merged
.merge(overrides)
.map_err(|err| ConfigError::Runtime(Box::new(err)))?;
}
trace!("validate merged configuration");
let valid = validator
.validate(&merged)
.map_err(|err| ConfigError::Validate(Box::new(err)))?;
Ok(valid)
}
}
#[derive(Debug)]
enum FileToLoad {
RequiredJson(PathBuf),
RequiredToml(PathBuf),
RequiredYaml(PathBuf),
OptionalJson(PathBuf),
OptionalToml(PathBuf),
OptionalYaml(PathBuf),
}
#[derive(Debug)]
enum Loaded {
Json(Vec<u8>),
Toml(Vec<u8>),
Yaml(Vec<u8>),
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read configuration file {0}")]
Read(PathBuf, #[source] std::io::Error),
#[error("failed to parse configuration file as JSON")]
ParseJson(#[source] serde_json::Error),
#[error("failed to parse configuration file as TOML")]
ParseToml(#[source] toml::de::Error),
#[error("failed to parse configuration file as YAML")]
ParseYaml(#[source] serde_norway::Error),
#[error("can't merge config files into one run time configuration")]
Runtime(#[source] Box<dyn std::error::Error>),
#[error("can't validate run time configuration")]
Validate(#[source] Box<dyn std::error::Error>),
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use std::ffi::OsStr;
use super::*;
use serde::Serialize;
use tempfile::tempdir;
struct Simple {
foo: usize,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, Eq, PartialEq)]
struct SimpleFile {
foo: Option<usize>,
}
impl<'a> ConfigFile<'a> for SimpleFile {
type Error = SimpleError;
fn merge(&mut self, other: Self) -> Result<(), Self::Error> {
if let Some(v) = other.foo {
self.foo = Some(v);
}
Ok(())
}
}
#[derive(Default)]
struct SimpleValidator {}
impl ConfigValidator for SimpleValidator {
type File = SimpleFile;
type Valid = Simple;
type Error = SimpleError;
fn validate(&self, runtime: &Self::File) -> Result<Self::Valid, Self::Error> {
Ok(Simple {
foo: runtime.foo.ok_or(SimpleError::NoFoo)?,
})
}
}
#[derive(Debug, thiserror::Error)]
enum SimpleError {
#[error("'foo' has not been set")]
NoFoo,
}
#[test]
fn parse_json() {
let config = SimpleFile { foo: Some(42) };
let json = serde_json::to_string(&config).unwrap();
let parsed = ConfigFile::parse_json(json.as_bytes()).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn parse_toml() {
let config = SimpleFile { foo: Some(42) };
let toml = toml::to_string(&config).unwrap();
let parsed = ConfigFile::parse_toml(toml.as_bytes()).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn parse_yaml() {
let config = SimpleFile { foo: Some(42) };
let json = serde_norway::to_string(&config).unwrap();
let parsed = ConfigFile::parse_yaml(json.as_bytes()).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn merge_simple() {
let mut a = SimpleFile { foo: Some(42) };
let b = SimpleFile { foo: Some(0) };
a.merge(b).unwrap();
let validator = SimpleValidator::default();
let valid = validator.validate(&a).unwrap();
assert_eq!(valid.foo, 0);
}
#[test]
fn load_configs() {
let mut loader = ConfigLoader::default();
let config1 = SimpleFile { foo: None };
let config2 = SimpleFile { foo: Some(42) };
let json = serde_json::to_string(&config1).unwrap();
let tmp = tempdir().unwrap();
let filename = tmp.path().join("config1.json");
std::fs::write(&filename, json.as_bytes()).unwrap();
loader.require_json(&filename);
let json = serde_json::to_string(&config2).unwrap();
let tmp = tempdir().unwrap();
let filename = tmp.path().join("config2.yaml");
std::fs::write(&filename, json.as_bytes()).unwrap();
loader.require_yaml(&filename);
let validator = SimpleValidator::default();
let valid = loader.load(None, None, &validator).unwrap();
assert_eq!(valid.foo, 42);
}
#[test]
fn builtin_defaults() {
let mut loader = ConfigLoader::default();
let config = SimpleFile { foo: None };
let defaults = SimpleFile { foo: Some(42) };
let json = serde_json::to_string(&config).unwrap();
let tmp = tempdir().unwrap();
let filename = tmp.path().join("config1.json");
std::fs::write(&filename, json.as_bytes()).unwrap();
loader.require_json(&filename);
let validator = SimpleValidator::default();
let valid = loader.load(Some(defaults), None, &validator).unwrap();
assert_eq!(valid.foo, 42);
}
#[test]
fn overrides() {
let mut loader = ConfigLoader::default();
let config = SimpleFile { foo: Some(42) };
let overrides = SimpleFile { foo: Some(1) };
let json = serde_json::to_string(&config).unwrap();
let tmp = tempdir().unwrap();
let filename = tmp.path().join("config1.json");
std::fs::write(&filename, json.as_bytes()).unwrap();
loader.require_json(&filename);
let validator = SimpleValidator::default();
let valid = loader.load(None, Some(overrides), &validator).unwrap();
assert_eq!(valid.foo, 1);
}
#[test]
fn filenames() {
let mut loader = ConfigLoader::default();
loader.allow_json("foo.json");
loader.allow_toml("foo.toml");
loader.allow_yaml("foo.yaml");
loader.require_json("bar.json");
loader.require_toml("bar.toml");
loader.require_yaml("bar.yaml");
assert_eq!(
loader.filenames(),
[
(Path::new("foo.json"), false),
(Path::new("foo.toml"), false),
(Path::new("foo.yaml"), false),
(Path::new("bar.json"), true),
(Path::new("bar.toml"), true),
(Path::new("bar.yaml"), true),
]
);
}
#[test]
fn xdg() {
let mut loader = ConfigLoader::default();
loader.xdg("q", "o", "a");
assert_eq!(loader.filenames().len(), 3);
}
#[test]
fn xdg_basename() {
let mut loader = ConfigLoader::default();
loader.xdg_basename("xyzzy");
loader.xdg("q", "o", "a");
assert_eq!(loader.filenames().len(), 3);
let filenames: Vec<Option<&OsStr>> = loader
.filenames()
.iter()
.map(|(path, _)| path.file_name())
.collect();
assert!(filenames.contains(&Some(OsStr::new("xyzzy.json"))));
assert!(filenames.contains(&Some(OsStr::new("xyzzy.toml"))));
assert!(filenames.contains(&Some(OsStr::new("xyzzy.yaml"))));
}
}