use figment::providers::Env;
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
use figment::providers::Format as _;
#[cfg(feature = "json")]
use figment::providers::Json;
#[cfg(feature = "toml")]
use figment::providers::Toml;
#[cfg(feature = "yaml")]
use figment::providers::Yaml;
use figment::value::Dict;
use figment::{Figment, Metadata};
use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use crate::error::{Error, ErrorKind, Origin};
use crate::layer::{DEFAULTS_NAME, FLAGS_NAME, OVERRIDES_NAME};
use crate::snapshot::Snapshot;
use crate::source::{Format, LoadSpec, Source};
const INLINE_SUFFIX: &str = "source string";
pub(crate) const CACHED_NAME: &str = "the last configuration that worked";
const REMOTE_PREFIX: &str = "the remote store ";
const ENV_SUFFIX: &str = "environment variable(s)";
pub(crate) fn load<T: DeserializeOwned>(spec: &LoadSpec<'_>) -> Result<T, Error> {
apply_aliases(build(spec)?, spec)
.select(spec.key)
.extract()
.map_err(convert)
}
pub(crate) fn recover<T: DeserializeOwned>(
spec: &LoadSpec<'_>,
cached: &Snapshot,
) -> Result<T, Error> {
let mut figment = Figment::new().merge(Cached {
values: cached.values().clone(),
profile: figment::Profile::from(spec.key),
});
if let Some(prefix) = spec.full_env_prefix() {
figment = figment.merge(environment(
&prefix,
spec.key,
spec.nest,
spec.allow_empty_env,
));
}
figment = merge_env_files(figment, spec)?;
if let Some(bindings) = spec.env_bindings {
for binding in bindings.providers(spec.key) {
figment = figment.merge(binding);
}
}
if let Some(flags) = spec.flags {
figment = figment.merge(flags.provider(spec.key, FLAGS_NAME));
}
if let Some(overrides) = spec.overrides {
figment = figment.merge(overrides.provider(spec.key, OVERRIDES_NAME));
}
let figment = apply_aliases(figment, spec);
figment.select(spec.key).extract().map_err(convert)
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
struct Named<P> {
inner: P,
name: String,
file: Option<std::path::PathBuf>,
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
impl<P: figment::Provider> figment::Provider for Named<P> {
fn metadata(&self) -> Metadata {
let metadata = Metadata::named(self.name.clone());
match &self.file {
Some(path) => metadata.source(figment::Source::File(path.clone())),
None => metadata,
}
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
self.inner.data()
}
fn profile(&self) -> Option<figment::Profile> {
self.inner.profile()
}
}
struct Cached {
values: Dict,
profile: figment::Profile,
}
impl figment::Provider for Cached {
fn metadata(&self) -> Metadata {
Metadata::named(CACHED_NAME)
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
let mut map = figment::value::Map::new();
map.insert(self.profile.clone(), self.values.clone());
Ok(map)
}
}
fn apply_aliases(figment: Figment, spec: &LoadSpec<'_>) -> Figment {
let Some(aliases) = spec.aliases else {
return figment;
};
let mut figment = figment;
for (from, to) in aliases.pairs() {
let selected = figment.clone().select(spec.key);
if selected.find_value(&to).is_ok() {
continue;
}
let Ok(value) = selected.find_value(&from) else {
continue;
};
let mut values = Dict::new();
crate::layer::insert_path(&mut values, &to, value);
figment = figment.merge(Aliased {
values,
profile: figment::Profile::from(spec.key),
from,
});
}
figment
}
struct Aliased {
values: Dict,
profile: figment::Profile,
from: String,
}
impl figment::Provider for Aliased {
fn metadata(&self) -> Metadata {
Metadata::named(format!("{ALIAS_PREFIX}{}", self.from))
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
let mut map = figment::value::Map::new();
map.insert(self.profile.clone(), self.values.clone());
Ok(map)
}
}
const ALIAS_PREFIX: &str = "an alias for ";
pub(crate) fn snapshot(spec: &LoadSpec<'_>) -> Result<Snapshot, Error> {
resolved(spec).map(|(snapshot, _figment)| snapshot)
}
pub(crate) fn resolved(spec: &LoadSpec<'_>) -> Result<(Snapshot, Figment), Error> {
let figment = apply_aliases(build(spec)?, spec).select(spec.key);
let snapshot = figment
.extract::<Dict>()
.map(Snapshot::new)
.map_err(convert)?;
Ok((snapshot, figment))
}
pub(crate) fn origin_in(figment: &Figment, path: &str) -> Origin {
figment
.find_metadata(path)
.map_or(Origin::Unknown, origin_of)
}
pub(crate) fn source_of(spec: &LoadSpec<'_>, path: &str) -> Result<Option<Origin>, Error> {
let figment = apply_aliases(build(spec)?, spec).select(spec.key);
Ok(figment.find_metadata(path).map(origin_of))
}
pub(crate) fn is_set(spec: &LoadSpec<'_>, path: &str) -> Result<bool, Error> {
Ok(apply_aliases(build(spec)?, spec)
.select(spec.key)
.contains(path))
}
fn build(spec: &LoadSpec<'_>) -> Result<Figment, Error> {
let mut figment = Figment::new();
if let Some(defaults) = spec.defaults {
figment = figment.merge(defaults.provider(spec.key, DEFAULTS_NAME));
}
let profile = spec.profile();
if let Some(search) = &spec.search {
for (path, format) in search.resolve() {
figment = merge_file(figment, &path, format)?;
figment = merge_profile_variant(figment, &path, format, profile.as_deref())?;
}
}
for source in spec.sources {
figment = merge(figment, source)?;
if let (Some(path), Some(format)) = (source.path(), source.format()) {
figment = merge_profile_variant(figment, Path::new(path), format, profile.as_deref())?;
}
}
if let Some(remote) = spec.remote {
if let Some(document) = remote.document() {
let name = format!(
"{REMOTE_PREFIX}{}",
remote.describe().unwrap_or_else(|| "(unnamed)".to_owned())
);
figment = merge_named_text(figment, &document.text, document.format, &name, None)?;
}
}
figment = merge_env_files(figment, spec)?;
if let Some(prefix) = spec.full_env_prefix() {
figment = figment.merge(environment(
&prefix,
spec.key,
spec.nest,
spec.allow_empty_env,
));
}
if let Some(bindings) = spec.env_bindings {
for binding in bindings.providers(spec.key) {
figment = figment.merge(binding);
}
}
if let Some(flags) = spec.flags {
figment = figment.merge(flags.provider(spec.key, FLAGS_NAME));
}
if let Some(overrides) = spec.overrides {
figment = figment.merge(overrides.provider(spec.key, OVERRIDES_NAME));
}
Ok(figment)
}
fn environment(prefix: &str, key: &str, nest: &str, allow_empty: bool) -> Env {
let mut env = Env::prefixed(prefix);
if !allow_empty {
let empty = empty_keys(prefix);
if !empty.is_empty() {
env = env.filter_map(move |name| {
let is_empty = empty
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(name.as_str()));
(!is_empty).then(|| name.into())
});
}
}
env.split(nest).profile(key)
}
fn empty_keys(prefix: &str) -> Vec<String> {
std::env::vars()
.filter(|(_, value)| value.trim().is_empty())
.filter_map(|(name, _)| {
name.get(..prefix.len())
.filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
.map(|_| name[prefix.len()..].to_owned())
})
.collect()
}
fn merge(figment: Figment, source: &Source<'_>) -> Result<Figment, Error> {
#[cfg(feature = "figment")]
if let Some(provider) = source.foreign() {
return Ok(figment.merge(Foreign(provider)));
}
let Some(format) = source.format() else {
return Ok(figment);
};
match source.path() {
Some(path) if source.is_encrypted() => {
merge_encrypted_file(figment, Path::new(path), format)
}
Some(path) => merge_file(figment, Path::new(path), format),
None => merge_text(figment, source.inline_text().unwrap_or_default(), format),
}
}
#[cfg(feature = "figment")]
struct Foreign<'a>(&'a (dyn figment::Provider + Send + Sync));
#[cfg(feature = "figment")]
impl figment::Provider for Foreign<'_> {
fn metadata(&self) -> Metadata {
self.0.metadata()
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
self.0.data()
}
fn profile(&self) -> Option<figment::Profile> {
self.0.profile()
}
}
fn merge_encrypted_file(figment: Figment, path: &Path, format: Format) -> Result<Figment, Error> {
#[cfg(not(feature = "decrypt"))]
{
let _ = (&figment, format);
Err(Error::new(
ErrorKind::Backend,
format!(
"{} is encrypted, and this build has no decryption support; \
add features = [\"age\"] to your dynamic-config dependency",
path.display()
),
))
}
#[cfg(feature = "decrypt")]
{
let ciphertext = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(figment),
Err(error) => {
return Err(Error::new(ErrorKind::Io, error.to_string())
.with_origin(Origin::File(path.to_owned())))
}
};
let named = path.display().to_string();
let plaintext = crate::decrypt::decrypt(&ciphertext, &named)?;
merge_named_text(figment, plaintext.text(), format, &named, Some(path))
}
}
fn merge_profile_variant(
figment: Figment,
path: &Path,
format: Format,
profile: Option<&str>,
) -> Result<Figment, Error> {
let Some(profile) = profile else {
return Ok(figment);
};
let Some(variant) = profile_variant(path, profile) else {
return Ok(figment);
};
if variant
.to_str()
.and_then(crate::source::inner_name)
.is_some()
{
return merge_encrypted_file(figment, &variant, format);
}
merge_file(figment, &variant, format)
}
fn profile_variant(path: &Path, profile: &str) -> Option<PathBuf> {
if let Some((inner, _)) = path
.to_str()
.and_then(|path| crate::source::inner_name(path))
{
let variant = profile_variant(Path::new(inner), profile)?;
return Some(PathBuf::from(format!(
"{}.{}",
variant.display(),
crate::source::ENCRYPTED_SUFFIX
)));
}
let stem = path.file_stem()?.to_str()?;
let extension = path.extension()?.to_str()?;
Some(path.with_file_name(format!("{stem}.{profile}.{extension}")))
}
fn merge_file(figment: Figment, path: &Path, format: Format) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, path);
match format {
#[cfg(feature = "json")]
Format::Json => Ok(figment.merge(Sections::from(Json::file(path)))),
#[cfg(feature = "toml")]
Format::Toml => Ok(figment.merge(Sections::from(Toml::file(path)))),
#[cfg(feature = "yaml")]
Format::Yaml => Ok(figment.merge(Sections::from(Yaml::file(path)))),
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
const SCHEMA_KEY: &str = "$schema";
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
struct Sections<P> {
inner: P,
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
impl<P: figment::Provider> From<P> for Sections<P> {
fn from(inner: P) -> Self {
Self { inner }
}
}
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
impl<P: figment::Provider> figment::Provider for Sections<P> {
fn metadata(&self) -> Metadata {
self.inner.metadata()
}
fn data(&self) -> figment::Result<figment::value::Map<figment::Profile, Dict>> {
let mut sections = figment::value::Map::new();
for (_, document) in self.inner.data()? {
for (key, value) in document {
if key == SCHEMA_KEY {
continue;
}
let figment::value::Value::Dict(_, dict) = value else {
return Err(figment::Error::from(format!(
"top-level key `{key}` is not a table; every top-level key \
in a config file is a section, so a value there must be a \
table (`{SCHEMA_KEY}` is the one exception)"
)));
};
sections.insert(figment::Profile::from(key), dict);
}
}
Ok(sections)
}
}
fn merge_text(figment: Figment, text: &str, format: Format) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, text);
match format {
#[cfg(feature = "json")]
Format::Json => Ok(figment.merge(Sections::from(Json::string(text)))),
#[cfg(feature = "toml")]
Format::Toml => Ok(figment.merge(Sections::from(Toml::string(text)))),
#[cfg(feature = "yaml")]
Format::Yaml => Ok(figment.merge(Sections::from(Yaml::string(text)))),
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
fn merge_named_text(
figment: Figment,
text: &str,
format: Format,
name: &str,
file: Option<&Path>,
) -> Result<Figment, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = (&figment, text, name, file);
#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
fn named<P>(inner: P, name: &str, file: Option<&Path>) -> Named<P> {
Named {
inner,
name: name.to_owned(),
file: file.map(Path::to_owned),
}
}
match format {
#[cfg(feature = "json")]
Format::Json => Ok(figment.merge(named(Sections::from(Json::string(text)), name, file))),
#[cfg(feature = "toml")]
Format::Toml => Ok(figment.merge(named(Sections::from(Toml::string(text)), name, file))),
#[cfg(feature = "yaml")]
Format::Yaml => Ok(figment.merge(named(Sections::from(Yaml::string(text)), name, file))),
#[allow(unreachable_patterns)]
format => Err(disabled(format)),
}
}
fn disabled(format: Format) -> Error {
Error::new(
ErrorKind::Backend,
format!(
"cannot read {format:?} because the `{}` feature is not enabled; \
add features = [\"{}\"] to your dynamic-config dependency",
format.feature(),
format.feature(),
),
)
}
fn message(error: &figment::Error) -> String {
use figment::error::Kind;
match &error.kind {
Kind::InvalidType(actual, expected) => {
format!(
"invalid type: found {}, expected {expected}",
kind_of(actual)
)
}
Kind::InvalidValue(actual, expected) => {
format!(
"invalid value: found {}, expected {expected}",
kind_of(actual)
)
}
_ => error.to_string(),
}
}
fn kind_of(actual: &figment::error::Actual) -> &'static str {
use figment::error::Actual;
match actual {
Actual::Bool(_) => "a boolean",
Actual::Unsigned(_) => "an unsigned integer",
Actual::Signed(_) => "a signed integer",
Actual::Float(_) => "a float",
Actual::Char(_) => "a character",
Actual::Str(_) => "a string",
Actual::Bytes(_) => "a byte string",
Actual::Unit => "a unit",
Actual::Option => "an option",
Actual::NewtypeStruct => "a newtype struct",
Actual::Seq => "a list",
Actual::Map => "a table",
Actual::Enum => "an enum",
Actual::UnitVariant => "a unit variant",
Actual::NewtypeVariant => "a newtype variant",
Actual::TupleVariant => "a tuple variant",
Actual::StructVariant => "a struct variant",
Actual::Other(_) => "something else",
}
}
#[cfg(feature = "dotenv")]
fn merge_env_files(mut figment: Figment, spec: &LoadSpec<'_>) -> Result<Figment, Error> {
let Some(prefix) = spec.full_env_prefix() else {
return Ok(figment);
};
for file in spec.env_files {
let path = Path::new(file);
let entries = crate::dotenv::read(path)?;
if entries.is_empty() {
continue;
}
figment = figment.merge(crate::dotenv::DotenvProvider::new(
entries,
path,
&prefix,
spec.key,
spec.nest,
spec.allow_empty_env,
));
}
Ok(figment)
}
#[cfg(not(feature = "dotenv"))]
fn merge_env_files(figment: Figment, spec: &LoadSpec<'_>) -> Result<Figment, Error> {
if spec.env_files.is_empty() {
return Ok(figment);
}
Err(Error::new(
ErrorKind::Backend,
"`.env` files need the `dotenv` feature; add features = [\"dotenv\"] \
to your dynamic-config dependency",
))
}
fn convert(error: figment::Error) -> Error {
use figment::error::Kind;
let kind = match &error.kind {
Kind::MissingField(_) => ErrorKind::Missing,
Kind::InvalidType(..)
| Kind::InvalidValue(..)
| Kind::InvalidLength(..)
| Kind::ISizeOutOfRange(_)
| Kind::USizeOutOfRange(_) => ErrorKind::Type,
Kind::Message(_) if error.path.is_empty() => ErrorKind::Parse,
Kind::Message(_) => ErrorKind::Type,
_ => ErrorKind::Backend,
};
let mut path = error.path.clone();
if path.is_empty() {
if let Kind::MissingField(field) = &error.kind {
path.push(field.to_string());
}
}
let origin = error.metadata.as_ref().map_or(Origin::Unknown, origin_of);
let mut translated = Error::new(kind, message(&error)).with_origin(origin);
for segment in path.into_iter().rev() {
translated = translated.prepend_key(segment);
}
translated
}
fn env_prefix(name: &str) -> String {
let prefix = name.trim_end_matches(ENV_SUFFIX).trim().trim_matches('`');
if prefix.is_empty() {
return "the environment".to_owned();
}
format!("{prefix}*")
}
fn origin_of(metadata: &Metadata) -> Origin {
if metadata.name == DEFAULTS_NAME {
return Origin::Runtime("default");
}
if metadata.name == OVERRIDES_NAME {
return Origin::Runtime("override");
}
if metadata.name == FLAGS_NAME {
return Origin::Runtime("command-line flag");
}
if metadata.name == CACHED_NAME {
return Origin::Runtime("cached configuration");
}
if let Some(variable) = metadata.name.strip_prefix(crate::bindings::BINDING_PREFIX) {
return Origin::Env(variable.to_owned());
}
#[cfg(feature = "dotenv")]
if let Some(file) = metadata.name.strip_prefix(crate::dotenv::PREFIX) {
return Origin::File(std::path::PathBuf::from(file));
}
if let Some(store) = metadata.name.strip_prefix(REMOTE_PREFIX) {
return Origin::Remote(store.to_owned());
}
match metadata.source.as_ref() {
Some(figment::Source::File(path)) => Origin::File(path.clone()),
Some(figment::Source::Custom(name)) => Origin::Env(name.clone()),
Some(_) => Origin::Inline,
None if metadata.name.ends_with(ENV_SUFFIX) => Origin::Env(env_prefix(&metadata.name)),
None if metadata.name.ends_with(INLINE_SUFFIX) => Origin::Inline,
None => Origin::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_profile_variant_sits_next_to_its_base() {
assert_eq!(
profile_variant(Path::new("/etc/app/config.toml"), "production"),
Some(PathBuf::from("/etc/app/config.production.toml"))
);
assert_eq!(
profile_variant(Path::new("config.json"), "dev"),
Some(PathBuf::from("config.dev.json"))
);
}
#[test]
fn the_environment_prefix_is_recovered_from_figments_name() {
assert_eq!(env_prefix("`APP_DB_` environment variable(s)"), "APP_DB_*");
assert_eq!(env_prefix("environment variable(s)"), "the environment");
}
#[test]
fn a_path_without_an_extension_has_no_variant() {
assert_eq!(profile_variant(Path::new("config"), "production"), None);
}
}