use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::ffi::OsString;
use std::fmt::{Display, Formatter, Result as FmtResult};
use std::path::{Path, PathBuf};
use config::{Config, Environment, File, FileFormat, Source};
use err_context::prelude::*;
use fallible_iterator::FallibleIterator;
use log::{debug, trace, warn};
use serde::de::DeserializeOwned;
use serde::Serialize;
use structopt::clap::{App, Arg};
use structopt::StructOpt;
use toml::Value;
use crate::utils;
use crate::AnyError;
#[derive(Default)]
struct CommonOpts {
config_overrides: Vec<(String, String)>,
configs: Vec<PathBuf>,
}
struct OptWrapper<O> {
common: CommonOpts,
other: O,
}
impl<O: StructOpt> StructOpt for OptWrapper<O> {
fn clap<'a, 'b>() -> App<'a, 'b> {
O::clap()
.arg(
Arg::with_name("config-overrides")
.takes_value(true)
.multiple(true)
.validator(|s| {
utils::key_val(s.as_str())
.map(|_: (String, String)| ())
.map_err(|e| e.to_string())
})
.help("Override specific config values")
.short("C")
.long("config-override")
.number_of_values(1),
)
.arg(
Arg::with_name("configs")
.takes_value(true)
.multiple(true)
.help("Configuration files or directories to load"),
)
}
fn from_clap(matches: &structopt::clap::ArgMatches) -> Self {
let common = CommonOpts {
config_overrides: matches
.values_of("config-overrides")
.map_or_else(Vec::new, |v| {
v.map(|s| utils::key_val(s).unwrap()).collect()
}),
configs: matches
.values_of_os("configs")
.map_or_else(Vec::new, |v| v.map(utils::absolute_from_os_str).collect()),
};
OptWrapper {
common,
other: StructOpt::from_clap(matches),
}
}
}
#[derive(Clone, Debug)]
pub struct InvalidFileType(PathBuf);
impl Display for InvalidFileType {
fn fmt(&self, fmt: &mut Formatter) -> FmtResult {
write!(
fmt,
"Configuration path {} is not a file nor a directory",
self.0.display()
)
}
}
impl Error for InvalidFileType {}
#[derive(Clone, Debug)]
pub struct MissingFile(PathBuf);
impl Display for MissingFile {
fn fmt(&self, fmt: &mut Formatter) -> FmtResult {
write!(
fmt,
"Configuration path {} does not exist",
self.0.display()
)
}
}
impl Error for MissingFile {}
pub trait ConfigBuilder: Sized {
fn config_default_paths<P, I>(self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>;
fn config_defaults<D: Into<String>>(self, config: D) -> Self;
fn config_defaults_typed<C: Serialize>(self, config: &C) -> Result<Self, AnyError> {
let untyped = Value::try_from(config)?;
Ok(self.config_defaults(toml::to_string(&untyped)?))
}
fn config_env<E: Into<String>>(self, env: E) -> Self;
fn config_ext<E: Into<OsString>>(self, ext: E) -> Self {
let ext = ext.into();
self.config_filter(move |path| path.extension() == Some(&ext))
}
fn config_exts<I, E>(self, exts: I) -> Self
where
I: IntoIterator<Item = E>,
E: Into<OsString>,
{
let exts = exts.into_iter().map(Into::into).collect::<HashSet<_>>();
self.config_filter(move |path| {
path.extension()
.map(|ext| exts.contains(ext))
.unwrap_or(false)
})
}
#[allow(clippy::vec_init_then_push)]
fn config_supported_exts(self) -> Self {
let mut exts = Vec::new();
exts.push("toml");
#[cfg(feature = "json")]
exts.push("json");
#[cfg(feature = "yaml")]
exts.push("yaml");
#[cfg(feature = "ini")]
exts.push("ini");
#[cfg(feature = "hjson")]
exts.push("hjson");
self.config_exts(exts)
}
fn config_filter<F: FnMut(&Path) -> bool + Send + 'static>(self, filter: F) -> Self;
fn warn_on_unused(self, warn: bool) -> Self;
}
impl<C: ConfigBuilder, Error> ConfigBuilder for Result<C, Error> {
fn config_default_paths<P, I>(self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
self.map(|c| c.config_default_paths(paths))
}
fn config_defaults<D: Into<String>>(self, config: D) -> Self {
self.map(|c| c.config_defaults(config))
}
fn config_env<E: Into<String>>(self, env: E) -> Self {
self.map(|c| c.config_env(env))
}
fn config_filter<F: FnMut(&Path) -> bool + Send + 'static>(self, filter: F) -> Self {
self.map(|c| c.config_filter(filter))
}
fn warn_on_unused(self, warn: bool) -> Self {
self.map(|c| c.warn_on_unused(warn))
}
}
pub struct Builder {
default_paths: Vec<PathBuf>,
defaults: Option<String>,
env: Option<String>,
filter: Box<dyn FnMut(&Path) -> bool + Send>,
warn_on_unused: bool,
}
impl Default for Builder {
fn default() -> Self {
Self::new()
}
}
impl Builder {
pub fn new() -> Self {
Self {
default_paths: Vec::new(),
defaults: None,
env: None,
filter: Box::new(|_| false),
warn_on_unused: true,
}
}
fn build_inner(self, opts: CommonOpts) -> Loader {
let files = if opts.configs.is_empty() {
self.default_paths
} else {
opts.configs
};
trace!("Parsed command line arguments");
Loader {
files,
defaults: self.defaults,
env: self.env,
filter: self.filter,
overrides: opts.config_overrides.into_iter().collect(),
warn_on_unused: self.warn_on_unused,
}
}
pub fn build<O: StructOpt>(self) -> (O, Loader) {
let opts = OptWrapper::<O>::from_args();
let loader = self.build_inner(opts.common);
(opts.other, loader)
}
pub fn build_no_opts(self) -> Loader {
self.build_inner(Default::default())
}
pub fn build_explicit_opts<O, I>(self, args: I) -> Result<(O, Loader), AnyError>
where
O: StructOpt,
I: IntoIterator,
I::Item: Into<OsString> + Clone,
{
let opts = OptWrapper::<O>::from_iter_safe(args)?;
let loader = self.build_inner(opts.common);
Ok((opts.other, loader))
}
}
impl ConfigBuilder for Builder {
fn config_defaults<D: Into<String>>(self, config: D) -> Self {
Self {
defaults: Some(config.into()),
..self
}
}
fn config_default_paths<P, I>(self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
let paths = paths.into_iter().map(Into::into).collect();
Self {
default_paths: paths,
..self
}
}
fn config_env<E: Into<String>>(self, env: E) -> Self {
Self {
env: Some(env.into()),
..self
}
}
fn config_filter<F: FnMut(&Path) -> bool + Send + 'static>(self, filter: F) -> Self {
Self {
filter: Box::new(filter),
..self
}
}
fn warn_on_unused(self, warn: bool) -> Self {
Self {
warn_on_unused: warn,
..self
}
}
}
pub struct Loader {
files: Vec<PathBuf>,
defaults: Option<String>,
env: Option<String>,
overrides: HashMap<String, String>,
filter: Box<dyn FnMut(&Path) -> bool + Send>,
warn_on_unused: bool,
}
impl Loader {
pub fn load<C: DeserializeOwned>(&mut self) -> Result<C, AnyError> {
debug!("Loading configuration");
let mut config = Config::new();
let mut external_cfg = false;
config.merge(File::from_str("", FileFormat::Toml))?;
if let Some(ref defaults) = self.defaults {
trace!("Loading config defaults");
config
.merge(File::from_str(defaults, FileFormat::Toml))
.context("Failed to read defaults")?;
}
for path in &self.files {
if path.is_file() {
external_cfg = true;
trace!("Loading config file {:?}", path);
config
.merge(File::from(path as &Path))
.with_context(|_| format!("Failed to load config file {:?}", path))?;
} else if path.is_dir() {
trace!("Scanning directory {:?}", path);
let filter = &mut self.filter;
let mut files = fallible_iterator::convert(path.read_dir()?)
.map(|entry| -> Result<Option<PathBuf>, std::io::Error> {
let path = entry.path();
let meta = path.symlink_metadata()?;
if meta.is_file() && (filter)(&path) {
Ok(Some(path))
} else {
trace!("Skipping {:?}", path);
Ok(None)
}
})
.filter_map(Ok)
.collect::<Vec<_>>()?;
files.sort();
for file in files {
external_cfg = true;
trace!("Loading config file {:?}", file);
config
.merge(File::from(&file as &Path))
.with_context(|_| format!("Failed to load config file {:?}", file))?;
}
} else if path.exists() {
return Err(InvalidFileType(path.to_owned()).into());
} else {
return Err(MissingFile(path.to_owned()).into());
}
}
if let Some(env_prefix) = self.env.as_ref() {
trace!("Loading config from environment {}", env_prefix);
let env = Environment::with_prefix(env_prefix).separator("_");
if !external_cfg {
external_cfg = !env
.collect()
.context("Failed to include environment in config")?
.is_empty();
}
config
.merge(env)
.context("Failed to include environment in config")?;
}
for (ref key, ref value) in &self.overrides {
trace!("Config override {} => {}", key, value);
config.set(*key, *value as &str).with_context(|_| {
external_cfg = true;
format!("Failed to push override {}={} into config", key, value)
})?;
}
let mut ignored_cback = |ignored: serde_ignored::Path| {
if self.warn_on_unused {
warn!("Unused configuration key {}", ignored);
}
};
let config = serde_ignored::Deserializer::new(config, &mut ignored_cback);
let mut result: Result<_, AnyError> =
serde_path_to_error::deserialize(config).map_err(|e| {
let ctx = format!("Failed to decode configuration at {}", e.path());
e.into_inner().context(ctx).into()
});
if !external_cfg {
result = result
.context("No config passed to application")
.map_err(AnyError::from)
}
result
}
}
#[cfg(test)]
mod tests {
use maplit::hashmap;
use serde::Deserialize;
use super::*;
use crate::Empty;
#[test]
fn enum_keys() {
#[derive(Debug, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "kebab-case")]
enum Key {
A,
B,
}
#[derive(Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
struct Cfg {
map: HashMap<Key, String>,
}
const CFG: &str = r#"
[map]
a = "hello"
b = "world"
"#;
let cfg: Cfg = Builder::new()
.config_defaults(CFG)
.build_no_opts()
.load()
.unwrap();
assert_eq!(
cfg,
Cfg {
map: hashmap! {
Key::A => "hello".to_owned(),
Key::B => "world".to_owned(),
}
}
);
}
#[test]
fn usize_key() {
#[derive(Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
struct Cfg {
map: HashMap<usize, String>,
}
const CFG: &str = r#"
[map]
1 = "hello"
"2" = "world"
"#;
let cfg: Cfg = Builder::new()
.config_defaults(CFG)
.build_no_opts()
.load()
.unwrap();
assert_eq!(
cfg,
Cfg {
map: hashmap! {
1 => "hello".to_owned(),
2 => "world".to_owned(),
}
}
);
}
#[test]
fn str_to_int() {
#[derive(Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
struct Cfg {
value: usize,
}
const CFG: &str = r#"value = "42""#;
let cfg: Cfg = Builder::new()
.config_defaults(CFG)
.build_no_opts()
.load()
.unwrap();
assert_eq!(cfg, Cfg { value: 42 });
}
#[test]
fn cmd_overrides() {
#[derive(Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
struct Cfg {
value: usize,
}
#[derive(Debug, Eq, PartialEq, StructOpt)]
struct Opts {
#[structopt(short = "o")]
option: bool,
}
const CFG: &str = r#"
value = 42
"#;
let (opts, mut loader): (Opts, Loader) = Builder::new()
.config_defaults(CFG)
.build_explicit_opts(vec!["my-app", "-o", "-C", "value=12"])
.unwrap();
assert_eq!(opts, Opts { option: true });
let cfg: Cfg = loader.load().unwrap();
assert_eq!(cfg, Cfg { value: 12 });
}
#[test]
fn combine_dir() {
#[derive(Debug, Deserialize, Eq, PartialEq)]
struct Cfg {
value: usize,
option: bool,
another: String,
}
const CFG: &str = r#"
value = 42
another = "Hello"
"#;
let (Empty {}, mut loader) = Builder::new()
.config_supported_exts()
.config_defaults(CFG)
.build_explicit_opts(vec!["my-app", "tests/data"])
.unwrap();
let cfg: Cfg = loader.load().unwrap();
assert_eq!(
cfg,
Cfg {
value: 12, option: true, another: "Hello".to_owned(), }
);
}
}