pub const DEFAULT_NEST: &str = "__";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Format {
Json,
Toml,
Yaml,
}
impl Format {
pub fn from_path(path: &std::path::Path) -> Result<Self, crate::Error> {
let named = path.to_str().unwrap_or_default();
let inner = inner_name(named).map_or(named, |(inner, _)| inner);
std::path::Path::new(inner)
.extension()
.and_then(|extension| extension.to_str())
.and_then(Self::from_extension)
.ok_or_else(|| crate::Error::unsupported(path))
}
#[must_use]
pub fn feature(self) -> &'static str {
match self {
Self::Json => "json",
Self::Toml => "toml",
Self::Yaml => "yaml",
}
}
#[must_use]
pub fn from_extension(extension: &str) -> Option<Self> {
match extension.to_ascii_lowercase().as_str() {
"json" => Some(Self::Json),
"toml" => Some(Self::Toml),
"yaml" | "yml" => Some(Self::Yaml),
_ => None,
}
}
#[must_use]
pub fn from_key(key: &str) -> Option<Self> {
std::path::Path::new(key)
.extension()
.and_then(|extension| extension.to_str())
.and_then(Self::from_extension)
}
}
#[derive(Clone, Copy)]
enum Kind<'a> {
File(&'a str),
Encrypted(&'a str),
Inline(&'a str),
#[cfg(feature = "figment")]
Provider(&'a (dyn figment::Provider + Send + Sync)),
}
#[derive(Clone, Copy)]
pub struct Source<'a> {
kind: Kind<'a>,
format: Format,
}
impl<'a> Source<'a> {
#[must_use]
pub const fn file(path: &'a str, format: Format) -> Self {
Self {
kind: Kind::File(path),
format,
}
}
#[must_use]
pub const fn inline(text: &'a str, format: Format) -> Self {
Self {
kind: Kind::Inline(text),
format,
}
}
#[cfg(feature = "figment")]
#[cfg_attr(docsrs, doc(cfg(feature = "figment")))]
#[must_use]
pub const fn provider(provider: &'a (dyn figment::Provider + Send + Sync)) -> Self {
Self {
kind: Kind::Provider(provider),
format: Format::Json,
}
}
#[must_use]
pub const fn format(&self) -> Option<Format> {
match self.kind {
#[cfg(feature = "figment")]
Kind::Provider(_) => None,
_ => Some(self.format),
}
}
#[must_use]
pub const fn encrypted(path: &'a str, format: Format) -> Self {
Self {
kind: Kind::Encrypted(path),
format,
}
}
#[must_use]
pub const fn path(&self) -> Option<&'a str> {
match self.kind {
Kind::File(path) | Kind::Encrypted(path) => Some(path),
_ => None,
}
}
#[must_use]
pub const fn is_encrypted(&self) -> bool {
matches!(self.kind, Kind::Encrypted(_))
}
pub(crate) fn inline_text(&self) -> Option<&'a str> {
match self.kind {
Kind::Inline(text) => Some(text),
_ => None,
}
}
#[cfg(feature = "figment")]
pub(crate) fn foreign(&self) -> Option<&'a (dyn figment::Provider + Send + Sync)> {
match self.kind {
Kind::Provider(provider) => Some(provider),
_ => None,
}
}
}
impl std::fmt::Debug for Source<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.kind {
Kind::File(path) => f.debug_tuple("File").field(&path).finish(),
Kind::Encrypted(path) => f.debug_tuple("Encrypted").field(&path).finish(),
Kind::Inline(text) => f
.debug_struct("Inline")
.field("bytes", &text.len())
.field("format", &self.format)
.finish(),
#[cfg(feature = "figment")]
Kind::Provider(provider) => f
.debug_tuple("Provider")
.field(&provider.metadata().name)
.finish(),
}
}
}
pub(crate) const ENCRYPTED_SUFFIX: &str = "age";
pub(crate) fn inner_name(path: &str) -> Option<(&str, &str)> {
let stripped = path.strip_suffix(ENCRYPTED_SUFFIX)?.strip_suffix('.')?;
let extension = std::path::Path::new(stripped)
.extension()
.and_then(|extension| extension.to_str())?;
Some((stripped, extension))
}
#[derive(Clone, Copy)]
pub struct LoadSpec<'a> {
pub key: &'a str,
pub sources: &'a [Source<'a>],
pub env_prefix: Option<&'a str>,
pub search: Option<crate::Search<'a>>,
pub profile_env: Option<&'a str>,
pub defaults: Option<&'a crate::Layer>,
pub remote: Option<&'a crate::Remote>,
pub env_files: &'a [&'a str],
pub aliases: Option<&'a crate::Aliases>,
pub env_bindings: Option<&'a crate::EnvBindings>,
pub flags: Option<&'a crate::Layer>,
pub overrides: Option<&'a crate::Layer>,
pub nest: &'a str,
pub allow_empty_env: bool,
}
impl std::fmt::Debug for LoadSpec<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadSpec")
.field("key", &self.key)
.field("sources", &self.sources)
.field("search", &self.search)
.field("profile_env", &self.profile_env)
.field("env_prefix", &self.env_prefix)
.field("defaults", &self.defaults.is_some())
.field("remote", &self.remote.is_some())
.field("flags", &self.flags.is_some())
.field("overrides", &self.overrides.is_some())
.field("nest", &self.nest)
.field("allow_empty_env", &self.allow_empty_env)
.finish()
}
}
impl<'a> LoadSpec<'a> {
#[must_use]
pub const fn new(key: &'a str, sources: &'a [Source<'a>]) -> Self {
Self {
key,
sources,
search: None,
profile_env: None,
env_prefix: None,
defaults: None,
remote: None,
env_files: &[],
aliases: None,
env_bindings: None,
flags: None,
overrides: None,
nest: DEFAULT_NEST,
allow_empty_env: false,
}
}
#[must_use]
pub const fn with_search(mut self, name: &'a str, paths: &'a [&'a str]) -> Self {
self.search = Some(crate::Search::new(name, paths));
self
}
#[must_use]
pub const fn with_profile_env(mut self, variable: &'a str) -> Self {
self.profile_env = Some(variable);
self
}
#[must_use]
pub const fn with_defaults(mut self, layer: &'a crate::Layer) -> Self {
self.defaults = Some(layer);
self
}
#[must_use]
pub const fn with_env_files(mut self, files: &'a [&'a str]) -> Self {
self.env_files = files;
self
}
#[must_use]
pub const fn with_aliases(mut self, aliases: &'a crate::Aliases) -> Self {
self.aliases = Some(aliases);
self
}
#[must_use]
pub const fn with_env_bindings(mut self, bindings: &'a crate::EnvBindings) -> Self {
self.env_bindings = Some(bindings);
self
}
#[must_use]
pub const fn with_remote(mut self, remote: &'a crate::Remote) -> Self {
self.remote = Some(remote);
self
}
#[must_use]
pub const fn with_flags(mut self, layer: &'a crate::Layer) -> Self {
self.flags = Some(layer);
self
}
#[must_use]
pub const fn with_overrides(mut self, layer: &'a crate::Layer) -> Self {
self.overrides = Some(layer);
self
}
#[must_use]
pub const fn with_env(mut self, prefix: &'a str) -> Self {
self.env_prefix = Some(prefix);
self
}
#[must_use]
pub const fn with_nest(mut self, separator: &'a str) -> Self {
self.nest = separator;
self
}
#[must_use]
pub const fn with_empty_env(mut self, allow: bool) -> Self {
self.allow_empty_env = allow;
self
}
pub(crate) fn profile_variable(&self) -> Option<&'a str> {
self.profile_env
}
pub(crate) fn profile(&self) -> Option<String> {
self.profile_env
.and_then(|variable| std::env::var(variable).ok())
.map(|profile| profile.trim().to_owned())
.filter(|profile| !profile.is_empty())
}
pub(crate) fn full_env_prefix(&self) -> Option<String> {
self.env_prefix
.map(|prefix| format!("{prefix}{}_", self.key.to_ascii_uppercase()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extensions_map_to_formats_case_insensitively() {
assert_eq!(Format::from_extension("JSON"), Some(Format::Json));
assert_eq!(Format::from_extension("yml"), Some(Format::Yaml));
assert_eq!(Format::from_extension("yaml"), Some(Format::Yaml));
assert_eq!(Format::from_extension("ini"), None);
}
#[test]
fn the_env_prefix_combines_the_caller_prefix_with_the_key() {
let spec = LoadSpec::new("db", &[]).with_env("APP_");
assert_eq!(spec.full_env_prefix().as_deref(), Some("APP_DB_"));
}
#[test]
fn no_env_prefix_means_no_environment() {
assert_eq!(LoadSpec::new("db", &[]).full_env_prefix(), None);
}
#[test]
fn an_empty_environment_variable_is_unset_by_default() {
assert!(!LoadSpec::new("db", &[]).allow_empty_env);
assert!(
LoadSpec::new("db", &[])
.with_empty_env(true)
.allow_empty_env
);
}
#[test]
fn only_file_sources_expose_a_path() {
assert_eq!(Source::file("a.json", Format::Json).path(), Some("a.json"));
assert_eq!(Source::inline("{}", Format::Json).path(), None);
}
#[test]
fn a_source_can_borrow_from_a_runtime_string() {
let text = String::from("{}");
let source = Source::inline(&text, Format::Json);
assert_eq!(source.path(), None);
}
}