use std::collections::BTreeMap;
use std::convert::TryInto;
use std::path::{Path, PathBuf};
use clap::Parser;
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::{fs, io};
use environment::Environment;
use crate::command::Command;
use crate::config::builder::var_defaults::{EnvVarDefaults, EnvVarDefaultsError};
use crate::hoard::PileConfig;
use crate::newtypes::{EnvironmentName, HoardName};
use crate::CONFIG_FILE_STEM;
use super::Config;
use self::hoard::Hoard;
pub mod environment;
pub mod envtrie;
pub mod hoard;
pub mod var_defaults;
const DEFAULT_CONFIG_EXT: &str = "toml";
const SUPPORTED_CONFIG_EXTS: [&str; 3] = ["toml", "yaml", "yml"];
#[derive(Debug, Error)]
pub enum Error {
#[error("failed to parse TOML configuration file: {0}")]
DeserializeTOML(toml::de::Error),
#[error("failed to parse YAML configuration file: {0}")]
DeserializeYAML(serde_yaml::Error),
#[error("failed to read configuration file: {0}")]
ReadConfig(io::Error),
#[error("failed to determine current environment: {0}")]
Environment(#[from] environment::Error),
#[error("failed to process hoard configuration: {0}")]
ProcessHoard(#[from] hoard::Error),
#[error(
"configuration file does not have file extension \".toml\", \".yaml\", or \".yml\": {0}"
)]
InvalidExtension(PathBuf),
#[error(transparent)]
EnvVarDefaults(#[from] EnvVarDefaultsError),
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Parser)]
#[clap(author, version, about, long_about = None, rename_all = "kebab")]
#[serde(rename_all = "snake_case")]
#[serde(deny_unknown_fields)]
pub struct Builder {
#[clap(skip)]
#[serde(rename = "envs")]
environments: Option<BTreeMap<EnvironmentName, Environment>>,
#[clap(skip)]
#[serde(default, rename = "defaults")]
var_defaults: EnvVarDefaults,
#[clap(skip)]
exclusivity: Option<Vec<Vec<EnvironmentName>>>,
#[clap(long)]
data_dir: Option<PathBuf>,
#[clap(long)]
config_dir: Option<PathBuf>,
#[clap(short, long)]
#[serde(skip)]
config_file: Option<PathBuf>,
#[serde(skip)]
#[clap(subcommand)]
command: Option<Command>,
#[serde(skip)]
#[clap(short, long)]
force: bool,
#[clap(skip)]
hoards: Option<BTreeMap<HoardName, Hoard>>,
#[clap(skip)]
#[serde(rename = "config")]
global_config: Option<PileConfig>,
}
impl Default for Builder {
fn default() -> Self {
Self::new()
}
}
impl Builder {
#[tracing::instrument]
fn default_config_file() -> PathBuf {
tracing::debug!("getting default configuration file");
crate::dirs::config_dir().join(format!("{CONFIG_FILE_STEM}.{DEFAULT_CONFIG_EXT}"))
}
#[must_use]
pub fn new() -> Self {
tracing::trace!("creating new config builder");
Self {
hoards: None,
config_dir: None,
data_dir: None,
var_defaults: EnvVarDefaults::default(),
config_file: None,
command: None,
environments: None,
exclusivity: None,
force: false,
global_config: None,
}
}
#[tracing::instrument(level = "debug", name = "config_builder_from_file")]
pub async fn from_file(path: &Path) -> Result<Self, Error> {
tracing::debug!("reading configuration");
let s = fs::read_to_string(path)
.await
.map_err(crate::map_log_error(Error::ReadConfig))?;
match path.extension().and_then(std::ffi::OsStr::to_str) {
None => crate::create_log_error(Error::InvalidExtension(path.to_owned())),
Some(ext) => match ext {
"toml" | "TOML" => toml::from_str(&s).map_err(crate::map_log_error_msg(
&format!("failed to parse TOML from {}", path.display()),
Error::DeserializeTOML,
)),
"yaml" | "yml" | "YAML" | "YML" => {
serde_yaml::from_str(&s).map_err(crate::map_log_error_msg(
&format!("failed to parse YAML from {}", path.display()),
Error::DeserializeYAML,
))
}
_ => crate::create_log_error(Error::InvalidExtension(path.to_owned())),
},
}
}
#[tracing::instrument(level = "debug", name = "config_builder_from_default_file")]
pub async fn from_default_file() -> Result<Self, Error> {
let error_closure = || {
let path = Self::default_config_file();
let error = Error::ReadConfig(io::Error::new(
io::ErrorKind::NotFound,
format!(
"could not find any of {name}.toml, {name}.yaml, or {name}.yml in {dir}",
name = path
.file_stem()
.expect("default config should always have a file name")
.to_string_lossy(),
dir = path
.parent()
.expect("default config should always have a parent")
.to_string_lossy()
),
));
crate::tap_log_error(&error);
error
};
let parent = Self::default_config_file()
.parent()
.expect("default config file should always have a file name")
.canonicalize()
.map_err(crate::map_log_error(Error::ReadConfig))?;
Box::pin(
tokio_stream::iter(
SUPPORTED_CONFIG_EXTS
.iter()
.map(|suffix| Ok((suffix, parent.clone()))),
)
.try_filter_map(|(suffix, parent)| async move {
let path = PathBuf::from(format!("{CONFIG_FILE_STEM}.{suffix}"));
let path = parent.join(path);
match Self::from_file(&path).await {
Err(Error::ReadConfig(err)) => {
if let io::ErrorKind::NotFound = err.kind() {
Ok(None)
} else {
crate::create_log_error(Error::ReadConfig(err))
}
}
Ok(config) => Ok(Some(config)),
Err(err) => crate::create_log_error(err),
}
}),
)
.try_next()
.await?
.ok_or_else(error_closure)
}
#[tracing::instrument(level = "debug", name = "config_builder_from_args_then_file")]
pub async fn from_args_then_file() -> Result<Self, Error> {
tracing::debug!("loading configuration from cli arguments");
let from_args = Self::parse();
tracing::trace!("attempting to get configuration file from cli arguments or use default");
let from_file = match from_args.config_file.as_ref() {
Some(config_file) => {
tracing::trace!(
?config_file,
"configuration file is \"{}\"",
config_file.to_string_lossy()
);
Self::from_file(config_file).await?
}
None => Self::from_default_file().await?,
};
tracing::debug!("merging configuration file and cli arguments");
Ok(from_file.layer(from_args))
}
#[must_use]
#[tracing::instrument(level = "trace")]
pub fn layer(mut self, other: Self) -> Self {
if let Some(path) = other.config_dir {
self = self.set_config_dir(path);
}
if let Some(path) = other.data_dir {
self = self.set_data_dir(path);
}
if let Some(path) = other.config_file {
self = self.set_config_file(path);
}
if let Some(path) = other.command {
self = self.set_command(path);
}
self.var_defaults.merge_with(other.var_defaults);
self.force = self.force || other.force;
self
}
#[must_use]
pub fn set_config_dir(mut self, config_dir: PathBuf) -> Self {
tracing::trace!(?config_dir, "setting config dir");
self.config_dir = Some(config_dir);
self
}
#[must_use]
pub fn set_data_dir(mut self, data_dir: PathBuf) -> Self {
tracing::trace!(?data_dir, "setting data dir");
self.data_dir = Some(data_dir);
self
}
#[must_use]
pub fn set_hoards(mut self, hoards: BTreeMap<HoardName, Hoard>) -> Self {
tracing::trace!(?hoards, "setting hoards");
self.hoards = Some(hoards);
self
}
#[must_use]
pub fn set_environments(
mut self,
environments: BTreeMap<EnvironmentName, Environment>,
) -> Self {
tracing::trace!(?environments, "setting environments");
self.environments = Some(environments);
self
}
#[must_use]
pub fn set_config_file(mut self, path: PathBuf) -> Self {
tracing::trace!(
config_file = ?path,
"setting config file",
);
self.config_file = Some(path);
self
}
#[must_use]
pub fn set_command(mut self, cmd: Command) -> Self {
self.command = Some(cmd);
self
}
#[must_use]
pub fn unset_hoards(mut self) -> Self {
tracing::trace!("unsetting hoards");
self.hoards = None;
self
}
#[tracing::instrument(level = "trace", skip_all)]
fn evaluated_environments(&self) -> Result<BTreeMap<EnvironmentName, bool>, Error> {
if let Some(envs) = &self.environments {
for (key, env) in envs {
tracing::trace!(%key, %env);
}
}
self.environments
.as_ref()
.map_or_else(
|| Ok(BTreeMap::new()),
|map| {
map.iter()
.map(|(key, env)| Ok((key.clone(), env.clone().try_into()?)))
.collect()
},
)
.map_err(crate::map_log_error(Error::Environment))
}
#[tracing::instrument(name = "build_config", skip_all)]
pub fn build(mut self) -> Result<Config, Error> {
tracing::debug!("building configuration from builder");
tracing::trace!(builder=?self);
let environments = self.evaluated_environments()?;
tracing::debug!(?environments);
let exclusivity = self.exclusivity.unwrap_or_default();
tracing::debug!(?exclusivity);
let config_file = self.config_file.unwrap_or_else(Self::default_config_file);
tracing::debug!(?config_file);
let command = self.command.unwrap_or_default();
tracing::debug!(?command);
let force = self.force;
tracing::debug!(?force);
self.var_defaults.apply()?;
if let Some(path) = self.config_dir {
crate::dirs::set_config_dir(&path);
}
if let Some(path) = self.data_dir {
crate::dirs::set_data_dir(&path);
}
if let Some(hoards) = &mut self.hoards {
tracing::debug!("layering global config onto hoards");
for hoard in hoards.values_mut() {
hoard.layer_config(self.global_config.as_ref());
}
}
tracing::debug!("processing hoards...");
let hoards = self
.hoards
.unwrap_or_default()
.into_iter()
.map(|(name, hoard)| {
let _span = tracing::debug_span!("processing_hoard", %name).entered();
hoard
.process_with(&environments, &exclusivity)
.map(|hoard| (name, hoard))
})
.collect::<Result<_, Error>>()?;
tracing::debug!("processed hoards");
Ok(Config {
command,
config_file,
hoards,
force,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
mod builder {
use super::*;
const DEFAULT_VAR: &str = "UNSET";
const DEFAULT_VAR_VALUE: &str = "no longer unset";
fn get_default_populated_builder() -> Builder {
Builder {
config_file: Some(Builder::default_config_file()),
command: Some(Command::Validate),
config_dir: Some(PathBuf::from("/config/dir")),
data_dir: Some(PathBuf::from("/data/dir")),
environments: None,
exclusivity: None,
hoards: None,
force: false,
global_config: None,
var_defaults: EnvVarDefaults::default(),
}
}
fn get_non_default_populated_builder() -> Builder {
Builder {
config_dir: Some(PathBuf::from("/other/config/dir")),
data_dir: Some(PathBuf::from("/other/data/dir")),
config_file: Some(PathBuf::from("/testing/config.toml")),
command: Some(Command::Restore {
hoards: vec!["test".parse().unwrap()],
}),
environments: None,
exclusivity: None,
hoards: None,
force: false,
global_config: None,
var_defaults: {
let mut defaults = EnvVarDefaults::default();
defaults.insert(DEFAULT_VAR.into(), DEFAULT_VAR_VALUE.into());
defaults
},
}
}
#[test]
fn default_builder_is_new() {
assert_eq!(Builder::new(), Builder::default());
}
#[test]
fn new_builder_is_all_none() {
let expected = Builder {
config_dir: None,
data_dir: None,
config_file: None,
command: None,
environments: None,
hoards: None,
exclusivity: None,
force: false,
global_config: None,
var_defaults: EnvVarDefaults::default(),
};
assert_eq!(
expected,
Builder::new(),
"ConfigBuild::new() should have all None fields"
);
}
#[test]
fn layered_builder_prefers_some_over_none() {
let some = get_default_populated_builder();
let none = Builder::new();
assert_ne!(some, none, "both builders cannot be identical");
assert_eq!(
some,
none.clone().layer(some.clone()),
"Some fields atop None prefers Some"
);
assert_eq!(
some,
some.clone().layer(none),
"None fields atop Some prefers Some"
);
}
#[test]
fn layered_builder_prefers_argument_to_self() {
let mut layer1 = get_default_populated_builder();
let mut layer2 = get_non_default_populated_builder();
layer1.var_defaults.insert("ENV1".into(), "1".into());
layer1
.var_defaults
.insert(DEFAULT_VAR.into(), "non default".into());
layer2.var_defaults.insert("ENV2".into(), "2".into());
let mut expected = layer2.clone();
expected.var_defaults.insert("ENV1".into(), "1".into());
assert_eq!(
expected,
layer1.clone().layer(layer2.clone()),
"layer() should prefer the argument"
);
let mut expected = layer1.clone();
expected.var_defaults.insert("ENV2".into(), "2".into());
assert_eq!(
expected,
layer2.layer(layer1.clone()),
"layer() should prefer the argument"
);
}
#[test]
fn builder_config_file_sets_correctly() {
let mut builder = Builder::new();
assert_eq!(
None, builder.config_file,
"config_file should start as None"
);
let path = PathBuf::from("/testing/config.toml");
builder = builder.set_config_file(path.clone());
assert_eq!(
Some(path),
builder.config_file,
"config_file should now be set"
);
}
#[test]
fn builder_command_sets_correctly() {
let mut builder = Builder::new();
assert_eq!(None, builder.command, "command should start as None");
let cmd = Command::Validate;
builder = builder.set_command(cmd.clone());
assert_eq!(Some(cmd), builder.command, "command should now be set");
}
#[test]
fn builder_with_nothing_set_uses_defaults() {
let builder = get_default_populated_builder();
let config = Builder::new().build().expect("failed to build config");
assert_eq!(Some(config.config_file), builder.config_file);
assert_eq!(Some(config.command), builder.command);
}
#[test]
fn builder_with_options_set_uses_options() {
let builder = get_non_default_populated_builder();
let config = builder.clone().build().expect("failed to build config");
assert_eq!(Some(config.config_file), builder.config_file);
assert_eq!(Some(config.command), builder.command);
}
#[test]
#[serial_test::serial]
fn builder_sets_env_vars_correctly() {
let builder = get_non_default_populated_builder();
builder.build().unwrap();
assert_eq!(std::env::var(DEFAULT_VAR).unwrap(), DEFAULT_VAR_VALUE);
}
}
}