use std::fmt;
pub use cli_engine_macros::EnvConfig;
pub use toml;
pub trait ConfigSource {
fn toml_value(&self, key: &str) -> Option<&toml::Value>;
fn env_var(&self, suffix: &str) -> Option<String> {
let _ = suffix;
None
}
fn env_name(&self) -> Option<&str> {
None
}
}
#[derive(Debug, Clone)]
pub struct EnvSource {
name: String,
table: toml::Table,
}
impl EnvSource {
#[must_use]
pub fn new(name: impl Into<String>, table: toml::Table) -> Self {
Self {
name: name.into(),
table,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn table(&self) -> &toml::Table {
&self.table
}
}
impl ConfigSource for EnvSource {
fn toml_value(&self, key: &str) -> Option<&toml::Value> {
self.table.get(key)
}
fn env_name(&self) -> Option<&str> {
Some(&self.name)
}
}
#[derive(Debug, Clone)]
pub struct EnvVarSource {
pub prefix: String,
}
impl ConfigSource for EnvVarSource {
fn toml_value(&self, _key: &str) -> Option<&toml::Value> {
None
}
fn env_var(&self, suffix: &str) -> Option<String> {
std::env::var(format!("{}_{suffix}", self.prefix)).ok()
}
}
#[derive(Debug, Clone, Default)]
pub struct ValueSource(toml::Table);
impl ValueSource {
#[must_use]
pub fn new() -> Self {
Self(toml::Table::new())
}
#[must_use]
pub fn with(mut self, key: impl Into<String>, value: impl Into<toml::Value>) -> Self {
self.0.insert(key.into(), value.into());
self
}
}
impl ConfigSource for ValueSource {
fn toml_value(&self, key: &str) -> Option<&toml::Value> {
self.0.get(key)
}
}
#[derive(Default)]
pub struct SourceChain<'src>(Vec<&'src dyn ConfigSource>);
impl fmt::Debug for SourceChain<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SourceChain")
.field("len", &self.0.len())
.finish()
}
}
impl<'src> SourceChain<'src> {
#[must_use]
pub fn new() -> Self {
Self(Vec::new())
}
#[must_use]
pub fn push(mut self, source: &'src dyn ConfigSource) -> Self {
self.0.push(source);
self
}
pub fn iter(&self) -> impl Iterator<Item = &'src dyn ConfigSource> + '_ {
self.0.iter().copied()
}
#[must_use]
pub fn toml_value(&self, key: &str) -> Option<&toml::Value> {
self.iter().find_map(|source| source.toml_value(key))
}
#[must_use]
pub fn env_name(&self) -> Option<&str> {
self.iter().find_map(|source| source.env_name())
}
}
pub trait EnvConfig: Sized {
fn assemble(sources: &SourceChain<'_>) -> Result<Self, EnvConfigError>;
}
#[derive(Debug, thiserror::Error)]
pub enum EnvConfigError {
#[error("field {field}: {reason}")]
InvalidField {
field: &'static str,
reason: String,
},
#[error("field {field} has no value in any source, and no default")]
MissingField {
field: &'static str,
},
#[error(transparent)]
Environment(Box<crate::error::CliCoreError>),
}
impl From<crate::error::CliCoreError> for EnvConfigError {
fn from(err: crate::error::CliCoreError) -> Self {
Self::Environment(Box::new(err))
}
}
pub fn default_from_toml<T: serde::de::DeserializeOwned>(value: &toml::Value) -> Result<T, String> {
value.clone().try_into::<T>().map_err(|err| err.to_string())
}
pub fn default_from_env<T>(raw: &str) -> Result<T, String>
where
T: std::str::FromStr,
T::Err: fmt::Display,
{
raw.parse::<T>().map_err(|err| err.to_string())
}
pub fn resolve_field<T>(
sources: &SourceChain<'_>,
field: &'static str,
key: &str,
env_suffix: Option<&str>,
allow_blank: bool,
from_toml: impl Fn(&toml::Value) -> Result<T, String>,
from_env: impl Fn(&str) -> Result<T, String>,
) -> Result<Option<T>, EnvConfigError> {
fn is_blank(s: &str) -> bool {
s.trim().is_empty()
}
fn is_blank_toml_value(value: &toml::Value) -> bool {
match value {
toml::Value::String(s) => is_blank(s),
toml::Value::Array(a) => a.is_empty(),
_ => false,
}
}
for source in sources.iter() {
if let Some(suffix) = env_suffix
&& let Some(raw) = source.env_var(suffix)
&& (allow_blank || !is_blank(&raw))
{
return from_env(&raw)
.map(Some)
.map_err(|reason| EnvConfigError::InvalidField { field, reason });
}
if let Some(value) = source.toml_value(key)
&& (allow_blank || !is_blank_toml_value(value))
{
return from_toml(value)
.map(Some)
.map_err(|reason| EnvConfigError::InvalidField { field, reason });
}
}
Ok(None)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, unsafe_code)]
mod tests {
use super::*;
#[derive(Debug, PartialEq, Eq)]
struct Section {
client_id: String,
port: u32,
}
impl EnvConfig for Section {
fn assemble(sources: &SourceChain<'_>) -> Result<Self, EnvConfigError> {
let client_id = match resolve_field::<String>(
sources,
"client_id",
"client_id",
None,
false,
default_from_toml::<String>,
default_from_env::<String>,
)? {
Some(v) => v,
None => {
return Err(EnvConfigError::MissingField { field: "client_id" });
}
};
let port = resolve_field::<u32>(
sources,
"port",
"port",
Some("PORT"),
false,
default_from_toml::<u32>,
default_from_env::<u32>,
)?
.unwrap_or(8080);
Ok(Self { client_id, port })
}
}
#[derive(Debug, PartialEq, Eq)]
struct WithDerivedField {
base: String,
derived: String,
env_name: String,
}
impl EnvConfig for WithDerivedField {
fn assemble(sources: &SourceChain<'_>) -> Result<Self, EnvConfigError> {
let base = resolve_field::<String>(
sources,
"base",
"base",
None,
true, default_from_toml::<String>,
default_from_env::<String>,
)?
.unwrap_or_default();
let derived = match resolve_field::<String>(
sources,
"derived",
"derived",
None,
false, default_from_toml::<String>,
default_from_env::<String>,
)? {
Some(v) => v,
None => format!("derived-from-{base}"),
};
let env_name = sources.env_name().unwrap_or_default().to_owned();
Ok(Self {
base,
derived,
env_name,
})
}
}
#[test]
fn toml_value_wins_when_no_env_var_set() {
let mut table = toml::Table::new();
table.insert("client_id".to_owned(), "from-toml".into());
let env = EnvSource::new("prod", table);
let chain = SourceChain::new().push(&env);
let section = Section::assemble(&chain).expect("assembles");
assert_eq!(section.client_id, "from-toml");
assert_eq!(section.port, 8080, "no source set port; default applies");
}
#[test]
fn env_var_source_outranks_toml_value_source() {
let mut table = toml::Table::new();
table.insert("client_id".to_owned(), "from-toml".into());
table.insert("port".to_owned(), toml::Value::Integer(1234));
let env = EnvSource::new("prod", table);
unsafe { std::env::set_var("GDDY_PORT", "9999") };
let app = EnvVarSource {
prefix: "GDDY".to_owned(),
};
let chain = SourceChain::new().push(&app).push(&env);
let section = Section::assemble(&chain).expect("assembles");
unsafe { std::env::remove_var("GDDY_PORT") };
assert_eq!(
section.client_id, "from-toml",
"app source has no client_id, falls through to env table"
);
assert_eq!(
section.port, 9999,
"app-scoped env var outranks the TOML value"
);
}
#[test]
fn missing_required_field_errors() {
let chain = SourceChain::new();
let err = Section::assemble(&chain).unwrap_err();
assert!(matches!(
err,
EnvConfigError::MissingField { field: "client_id" }
));
}
#[test]
fn malformed_value_is_a_hard_error() {
let mut table = toml::Table::new();
table.insert("client_id".to_owned(), "ok".into());
table.insert("port".to_owned(), "not-a-number".into());
let env = EnvSource::new("prod", table);
let chain = SourceChain::new().push(&env);
let err = Section::assemble(&chain).unwrap_err();
assert!(matches!(
err,
EnvConfigError::InvalidField { field: "port", .. }
));
}
#[test]
fn value_source_is_a_pure_table_lookup() {
let base = ValueSource::new().with("client_id", "base-client");
let chain = SourceChain::new().push(&base);
let section = Section::assemble(&chain).expect("assembles");
assert_eq!(section.client_id, "base-client");
}
#[test]
fn default_fn_derives_from_a_sibling_field_via_source_chain_toml_value() {
let mut table = toml::Table::new();
table.insert("base".to_owned(), "widget".into());
let env = EnvSource::new("prod", table);
let chain = SourceChain::new().push(&env);
let section = WithDerivedField::assemble(&chain).expect("assembles");
assert_eq!(section.derived, "derived-from-widget");
}
#[test]
fn blank_value_falls_through_to_default_fn_by_default() {
let mut table = toml::Table::new();
table.insert("base".to_owned(), "widget".into());
table.insert("derived".to_owned(), " ".into());
let env = EnvSource::new("prod", table);
let chain = SourceChain::new().push(&env);
let section = WithDerivedField::assemble(&chain).expect("assembles");
assert_eq!(
section.derived, "derived-from-widget",
"an explicit blank value is treated the same as an absent one by default"
);
}
#[test]
fn allow_blank_accepts_a_blank_value_as_is() {
let mut table = toml::Table::new();
table.insert("base".to_owned(), " ".into());
let env = EnvSource::new("prod", table);
let chain = SourceChain::new().push(&env);
let section = WithDerivedField::assemble(&chain).expect("assembles");
assert_eq!(
section.base, " ",
"`base` opts in via `allow_blank`, so its blank value is used as-is"
);
}
#[test]
fn empty_array_falls_through_to_the_next_source_by_default() {
let mut table = toml::Table::new();
table.insert("tags".to_owned(), toml::Value::Array(Vec::new()));
let env = EnvSource::new("prod", table);
let base = ValueSource::new().with("tags", vec!["real".to_owned()]);
let chain = SourceChain::new().push(&env).push(&base);
let tags = resolve_field::<Vec<String>>(
&chain,
"tags",
"tags",
None,
false, default_from_toml::<Vec<String>>,
|_raw: &str| -> Result<Vec<String>, String> { Err(String::new()) },
)
.expect("resolves")
.expect("some tier has a value");
assert_eq!(
tags,
vec!["real".to_owned()],
"the higher-priority source's empty array must defer to the base's real value"
);
}
#[test]
fn allow_blank_accepts_an_empty_array_as_is() {
let mut table = toml::Table::new();
table.insert("tags".to_owned(), toml::Value::Array(Vec::new()));
let env = EnvSource::new("prod", table);
let base = ValueSource::new().with("tags", vec!["real".to_owned()]);
let chain = SourceChain::new().push(&env).push(&base);
let tags = resolve_field::<Vec<String>>(
&chain,
"tags",
"tags",
None,
true, default_from_toml::<Vec<String>>,
|_raw: &str| -> Result<Vec<String>, String> { Err(String::new()) },
)
.expect("resolves")
.expect("some tier has a value");
assert_eq!(
tags,
Vec::<String>::new(),
"with allow_blank set, the empty array is accepted as-is, not skipped"
);
}
#[test]
fn source_chain_env_name_reflects_the_first_env_source() {
let table = toml::Table::new();
let env = EnvSource::new("staging", table);
let chain = SourceChain::new().push(&env);
let section = WithDerivedField::assemble(&chain).expect("assembles");
assert_eq!(section.env_name, "staging");
let base = ValueSource::new();
let no_env_chain = SourceChain::new().push(&base);
let section = WithDerivedField::assemble(&no_env_chain).expect("assembles");
assert_eq!(
section.env_name, "",
"a chain with no EnvSource has no env name to report"
);
}
}