use config_rs::{Map, Value, ValueKind};
const FROM_ENV_KEY: &str = "from_env";
const DEFAULT_KEY: &str = "default";
pub fn resolve_from_env_placeholders(root: &mut Value) -> Vec<String> {
let mut warnings = Vec::new();
if let ValueKind::Table(table) = &mut root.kind {
resolve_table(table, &mut warnings);
}
warnings
}
fn resolve_table(table: &mut Map<String, Value>, warnings: &mut Vec<String>) {
table.retain(|key, value| resolve_and_keep(value, key, warnings));
}
fn resolve_array(array: &mut Vec<Value>, warnings: &mut Vec<String>) {
array.retain_mut(|value| resolve_and_keep(value, "<array item>", warnings));
}
fn resolve_and_keep(value: &mut Value, label: &str, warnings: &mut Vec<String>) -> bool {
let Some(marker) = take_from_env_marker(value) else {
match &mut value.kind {
ValueKind::Table(table) => resolve_table(table, warnings),
ValueKind::Array(array) => resolve_array(array, warnings),
_ => {}
}
return true;
};
if let Ok(resolved) = std::env::var(&marker.var_name) {
value.kind = ValueKind::String(resolved);
return true;
}
match marker.default {
Some(default_value) => {
warnings.push(format!(
"environment variable `{}` referenced by `from_env` for '{label}' is not set; using its default value instead",
marker.var_name
));
*value = default_value;
true
}
None => {
warnings.push(format!(
"environment variable `{}` referenced by `from_env` for '{label}' is not set; treating '{label}' as unset",
marker.var_name
));
false
}
}
}
struct FromEnvMarker {
var_name: String,
default: Option<Value>,
}
fn take_from_env_marker(value: &mut Value) -> Option<FromEnvMarker> {
let ValueKind::Table(table) = &value.kind else {
return None;
};
let is_marker = matches!(table.get(FROM_ENV_KEY), Some(v) if matches!(v.kind, ValueKind::String(_)))
&& table.keys().all(|k| k == FROM_ENV_KEY || k == DEFAULT_KEY);
if !is_marker {
return None;
}
let ValueKind::Table(table) = &mut value.kind else {
unreachable!("checked above")
};
let var_name = match table.remove(FROM_ENV_KEY).map(|v| v.kind) {
Some(ValueKind::String(var_name)) => var_name,
_ => unreachable!("checked above"),
};
let default = table.remove(DEFAULT_KEY);
Some(FromEnvMarker { var_name, default })
}
#[cfg(test)]
mod tests {
use super::*;
use config_rs::{Config, File, FileFormat};
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq, Default)]
struct Nested {
#[serde(default)]
values: Vec<String>,
}
#[derive(Debug, Deserialize, PartialEq)]
struct Sample {
#[serde(default = "default_greeting")]
greeting: String,
#[serde(default)]
enabled: bool,
#[serde(default)]
nested: Nested,
required: String,
}
fn default_greeting() -> String {
"hi".to_string()
}
fn build(yaml: &str) -> Result<Sample, config_rs::ConfigError> {
build_with_warnings(yaml).0
}
fn build_with_warnings(yaml: &str) -> (Result<Sample, config_rs::ConfigError>, Vec<String>) {
let mut built = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap();
let warnings = resolve_from_env_placeholders(&mut built.cache);
(built.try_deserialize::<Sample>(), warnings)
}
#[test]
fn resolves_scalar_from_env() {
unsafe { std::env::set_var("FROM_ENV_TEST_GREETING", "hello there") };
let sample =
build("required: r\ngreeting: { from_env: FROM_ENV_TEST_GREETING }\n").unwrap();
assert_eq!(sample.greeting, "hello there");
unsafe { std::env::remove_var("FROM_ENV_TEST_GREETING") };
}
#[test]
fn coerces_bool_from_env_string() {
unsafe { std::env::set_var("FROM_ENV_TEST_ENABLED", "true") };
let sample = build("required: r\nenabled: { from_env: FROM_ENV_TEST_ENABLED }\n").unwrap();
assert!(sample.enabled);
unsafe { std::env::remove_var("FROM_ENV_TEST_ENABLED") };
}
#[test]
fn missing_env_var_falls_back_to_default() {
unsafe { std::env::remove_var("FROM_ENV_TEST_MISSING") };
let sample = build("required: r\ngreeting: { from_env: FROM_ENV_TEST_MISSING }\n").unwrap();
assert_eq!(sample.greeting, "hi");
}
#[test]
fn missing_env_var_produces_a_warning_instead_of_logging_directly() {
unsafe { std::env::remove_var("FROM_ENV_TEST_MISSING_WARN") };
let (result, warnings) = build_with_warnings(
"required: r\ngreeting: { from_env: FROM_ENV_TEST_MISSING_WARN }\n",
);
result.unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("FROM_ENV_TEST_MISSING_WARN"));
assert!(warnings[0].contains("greeting"));
}
#[test]
fn missing_env_var_on_required_field_fails() {
unsafe { std::env::remove_var("FROM_ENV_TEST_MISSING_REQUIRED") };
let err = build("required: { from_env: FROM_ENV_TEST_MISSING_REQUIRED }\n").unwrap_err();
assert!(err.to_string().contains("required"));
}
#[test]
fn resolves_inside_array_elements() {
unsafe { std::env::set_var("FROM_ENV_TEST_ITEM", "b") };
let sample = build(
"required: r\nnested:\n values:\n - a\n - { from_env: FROM_ENV_TEST_ITEM }\n",
)
.unwrap();
assert_eq!(sample.nested.values, vec!["a".to_string(), "b".to_string()]);
unsafe { std::env::remove_var("FROM_ENV_TEST_ITEM") };
}
#[test]
fn missing_env_var_uses_the_inline_default_instead_of_the_field_default() {
unsafe { std::env::remove_var("FROM_ENV_TEST_MISSING_WITH_DEFAULT") };
let sample = build(
"required: r\ngreeting: { from_env: FROM_ENV_TEST_MISSING_WITH_DEFAULT, default: bonjour }\n",
)
.unwrap();
assert_eq!(
sample.greeting, "bonjour",
"not the field's own default (\"hi\")"
);
}
#[test]
fn missing_env_var_with_default_still_warns_but_mentions_the_default_was_used() {
unsafe { std::env::remove_var("FROM_ENV_TEST_MISSING_WITH_DEFAULT_WARN") };
let (result, warnings) = build_with_warnings(
"required: r\ngreeting: { from_env: FROM_ENV_TEST_MISSING_WITH_DEFAULT_WARN, default: bonjour }\n",
);
result.unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("FROM_ENV_TEST_MISSING_WITH_DEFAULT_WARN"));
assert!(warnings[0].contains("default"));
}
#[test]
fn present_env_var_wins_over_the_inline_default() {
unsafe { std::env::set_var("FROM_ENV_TEST_PRESENT_WITH_DEFAULT", "hello there") };
let sample = build(
"required: r\ngreeting: { from_env: FROM_ENV_TEST_PRESENT_WITH_DEFAULT, default: bonjour }\n",
)
.unwrap();
assert_eq!(sample.greeting, "hello there");
unsafe { std::env::remove_var("FROM_ENV_TEST_PRESENT_WITH_DEFAULT") };
}
#[test]
fn missing_env_var_with_a_non_string_default_still_coerces_correctly() {
unsafe { std::env::remove_var("FROM_ENV_TEST_MISSING_BOOL_DEFAULT") };
let sample = build(
"required: r\nenabled: { from_env: FROM_ENV_TEST_MISSING_BOOL_DEFAULT, default: true }\n",
)
.unwrap();
assert!(sample.enabled);
}
#[test]
fn does_not_confuse_a_real_multi_key_table() {
let sample = build("required: r\ngreeting: hi\nnested: { values: [] }\n").unwrap();
assert_eq!(
sample,
Sample {
greeting: "hi".into(),
enabled: false,
nested: Nested { values: vec![] },
required: "r".into(),
}
);
}
#[test]
fn a_table_with_from_env_plus_an_unrelated_key_is_not_a_marker() {
let mut value = Value::new(
None,
ValueKind::Table(Map::from_iter([
(
"from_env".to_string(),
Value::new(None, ValueKind::String("X".to_string())),
),
(
"something_else".to_string(),
Value::new(None, ValueKind::String("y".to_string())),
),
])),
);
assert!(take_from_env_marker(&mut value).is_none());
}
}