use std::string::{String, ToString};
use std::vec::Vec;
use facet_reflect::Span;
use indexmap::IndexMap;
use crate::config_value::{ConfigValue, ConfigValueVisitorMut};
use crate::driver::LayerOutput;
use crate::path::Path;
use crate::provenance::Provenance;
use crate::schema::{ConfigStructSchema, ConfigValueSchema, Schema};
use crate::value_builder::{LeafValue, ValueBuilder};
pub trait EnvSource {
fn get(&self, name: &str) -> Option<String>;
fn vars(&self) -> Box<dyn Iterator<Item = (String, String)> + '_>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StdEnv;
impl EnvSource for StdEnv {
fn get(&self, name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn vars(&self) -> Box<dyn Iterator<Item = (String, String)> + '_> {
Box::new(std::env::vars())
}
}
#[derive(Debug, Clone, Default)]
pub struct MockEnv {
vars: IndexMap<String, String, std::hash::RandomState>,
}
impl MockEnv {
pub fn new() -> Self {
Self::default()
}
pub fn from_pairs<I, K, V>(iter: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
Self {
vars: iter
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
}
}
pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
self.vars.insert(name.into(), value.into());
}
}
impl EnvSource for MockEnv {
fn get(&self, name: &str) -> Option<String> {
self.vars.get(name).cloned()
}
fn vars(&self) -> Box<dyn Iterator<Item = (String, String)> + '_> {
Box::new(self.vars.iter().map(|(k, v)| (k.clone(), v.clone())))
}
}
pub struct EnvConfig {
pub prefix: String,
pub strict: bool,
pub source: Option<Box<dyn EnvSource>>,
}
impl EnvConfig {
pub fn new(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
strict: false,
source: None,
}
}
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
pub fn source(&self) -> &dyn EnvSource {
self.source.as_ref().map(|s| s.as_ref()).unwrap_or(&StdEnv)
}
}
#[derive(Default)]
pub struct EnvConfigBuilder {
prefix: String,
strict: bool,
source: Option<Box<dyn EnvSource>>,
}
impl EnvConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
pub fn source(mut self, source: impl EnvSource + 'static) -> Self {
self.source = Some(Box::new(source));
self
}
pub fn build(self) -> EnvConfig {
let mut config = EnvConfig::new(self.prefix);
if self.strict {
config = config.strict();
}
config.source = self.source;
config
}
}
pub fn parse_env(schema: &Schema, env_config: &EnvConfig, source: &dyn EnvSource) -> LayerOutput {
let Some(config_schema) = schema.config() else {
return parse_env_no_config(env_config, source);
};
let prefix = if env_config.prefix.is_empty() {
config_schema.env_prefix().unwrap_or("")
} else {
&env_config.prefix
};
let prefix_with_sep = format!("{}__", prefix);
let mut builder = ValueBuilder::new(config_schema);
let mut prefixed_paths: Vec<Vec<String>> = Vec::new();
for (name, value) in source.vars() {
if !name.starts_with(&prefix_with_sep) {
continue;
}
let rest = &name[prefix_with_sep.len()..];
if rest.is_empty() {
builder.warn(format!(
"invalid environment variable name: {} (empty after prefix)",
name
));
continue;
}
let segments: Vec<&str> = rest.split("__").collect();
if segments.iter().any(|s| s.is_empty()) {
builder.warn(format!(
"invalid environment variable name: {} (contains empty segment)",
name
));
continue;
}
let path: Vec<String> = segments.iter().map(|s| s.to_lowercase()).collect();
let prov = Provenance::env(&name, &value);
validate_enum_value_if_applicable(&mut builder, config_schema, &path, &value, &name);
let leaf_value = parse_env_value(&value);
if builder.set(&path, leaf_value, None, prov) {
prefixed_paths.push(path);
}
}
check_env_aliases(&mut builder, config_schema, source, &[], &prefixed_paths);
let mut output = builder.into_output(config_schema.field_name());
if let Some(ref mut value) = output.value {
let source_text = assign_env_spans(value);
if !source_text.is_empty() {
output.source_text = Some(source_text);
}
}
output
}
fn check_env_aliases(
builder: &mut ValueBuilder,
schema: &ConfigStructSchema,
source: &dyn EnvSource,
parent_path: &[String],
prefixed_paths: &[Vec<String>],
) {
for (field_name, field_schema) in schema.fields() {
let mut field_path = parent_path.to_vec();
field_path.push(field_name.clone());
let already_set = prefixed_paths.contains(&field_path);
if !already_set {
for alias in field_schema.env_aliases() {
if let Some(value) = source.get(alias) {
let prov = Provenance::env(alias, &value);
let leaf_value = parse_env_value(&value);
builder.set(&field_path, leaf_value, None, prov);
break;
}
}
}
match field_schema.value() {
ConfigValueSchema::Struct(nested) => {
check_env_aliases(builder, nested, source, &field_path, prefixed_paths);
}
ConfigValueSchema::Option { value, .. } => {
if let ConfigValueSchema::Struct(nested) = value.as_ref() {
check_env_aliases(builder, nested, source, &field_path, prefixed_paths);
}
}
_ => {}
}
}
}
fn parse_env_no_config(env_config: &EnvConfig, source: &dyn EnvSource) -> LayerOutput {
use crate::config_value::{ConfigValue, Sourced};
use crate::driver::UnusedKey;
let prefix = &env_config.prefix;
let prefix_with_sep = format!("{}__", prefix);
let mut unused_keys = Vec::new();
for (name, _value) in source.vars() {
if name.starts_with(&prefix_with_sep) {
let rest = &name[prefix_with_sep.len()..];
if !rest.is_empty() {
let segments: Vec<&str> = rest.split("__").collect();
if !segments.iter().any(|s| s.is_empty()) {
let path: Vec<String> = segments.iter().map(|s| s.to_lowercase()).collect();
unused_keys.push(UnusedKey {
key: path,
provenance: Provenance::env(&name, ""),
});
}
}
}
}
LayerOutput {
value: Some(ConfigValue::Object(Sourced::new(IndexMap::default()))),
unused_keys,
diagnostics: Vec::new(),
source_text: None,
config_file_path: None,
help_list_mode: None,
}
}
fn parse_env_value(value: &str) -> LeafValue {
if value.contains(',') {
let elements = parse_comma_separated(value);
if elements.len() > 1 {
return LeafValue::StringArray(elements);
} else if elements.len() == 1 {
return LeafValue::String(elements.into_iter().next().unwrap());
}
}
LeafValue::String(value.to_string())
}
fn validate_enum_value_if_applicable(
builder: &mut ValueBuilder,
schema: &ConfigStructSchema,
path: &[String],
value: &str,
var_name: &str,
) {
if let Some(value_schema) = schema.get_by_path(&path.to_vec()) {
let inner_schema = match value_schema {
ConfigValueSchema::Option { value: inner, .. } => inner.as_ref(),
other => other,
};
if let ConfigValueSchema::Enum(enum_schema) = inner_schema {
let variants = enum_schema.variants();
if !variants.contains_key(value) {
let valid_variants: Vec<&str> = variants.keys().map(|s| s.as_str()).collect();
let suggestion =
crate::suggest::format_suggestion(value, valid_variants.iter().copied());
builder.warn(format!(
"{}: unknown variant '{}' for {}{} Valid variants are: {}",
var_name,
value,
path.join("."),
suggestion,
valid_variants
.iter()
.map(|v| format!("'{}'", v))
.collect::<Vec<_>>()
.join(", ")
));
}
}
}
}
fn parse_comma_separated(input: &str) -> Vec<String> {
let mut result = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(&next) = chars.peek() {
if next == ',' {
chars.next();
current.push(',');
} else {
current.push(ch);
}
} else {
current.push(ch);
}
} else if ch == ',' {
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
result.push(trimmed);
}
current.clear();
} else {
current.push(ch);
}
}
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
result.push(trimmed);
}
if result.is_empty() {
result.push(input.to_string());
}
result
}
pub fn assign_env_spans(value: &mut ConfigValue) -> String {
let mut visitor = EnvSpanVisitor::new();
let mut path = Path::new();
value.visit_mut(&mut visitor, &mut path);
visitor.document
}
struct EnvSpanVisitor {
document: String,
var_spans: IndexMap<String, (usize, usize), std::hash::RandomState>,
}
impl EnvSpanVisitor {
fn new() -> Self {
Self {
document: String::new(),
var_spans: IndexMap::default(),
}
}
fn ensure_var(&mut self, var: &str, env_value: &str) -> Span {
if let Some(&(offset, len)) = self.var_spans.get(var) {
return Span::new(offset, len);
}
self.document.push_str(var);
self.document.push_str("=\"");
let value_offset = self.document.len();
self.document.push_str(env_value);
let value_len = env_value.len();
self.document.push_str("\"\n");
self.var_spans
.insert(var.to_string(), (value_offset, value_len));
Span::new(value_offset, value_len)
}
}
impl ConfigValueVisitorMut for EnvSpanVisitor {
fn visit_value(&mut self, _path: &Path, value: &mut ConfigValue) {
if let Some(Provenance::Env {
var,
value: env_value,
}) = value.provenance().cloned()
{
*value.span_mut() = Some(self.ensure_var(&var, &env_value));
}
}
}
#[cfg(test)]
mod tests {
use facet::Facet;
use figue_attrs as args;
use crate::config_value::ConfigValue;
use crate::driver::Severity;
use crate::schema::Schema;
use super::*;
#[derive(Facet)]
struct ArgsWithConfig {
#[facet(args::named)]
verbose: bool,
#[facet(args::config)]
config: ServerConfig,
}
#[derive(Facet)]
struct ServerConfig {
port: u16,
host: String,
}
#[derive(Facet)]
struct ArgsWithNestedConfig {
#[facet(args::config)]
settings: AppSettings,
}
#[derive(Facet)]
struct AppSettings {
port: u16,
smtp: SmtpConfig,
}
#[derive(Facet)]
struct SmtpConfig {
host: String,
connection_timeout: u64,
}
#[derive(Facet)]
struct ArgsWithListConfig {
#[facet(args::config)]
config: ListConfig,
}
#[derive(Facet)]
struct ListConfig {
ports: Vec<u16>,
allowed_hosts: Vec<String>,
}
fn env_config(prefix: &str) -> EnvConfig {
EnvConfigBuilder::new().prefix(prefix).build()
}
fn env_config_strict(prefix: &str) -> EnvConfig {
EnvConfigBuilder::new().prefix(prefix).strict().build()
}
fn get_nested<'a>(cv: &'a ConfigValue, path: &[&str]) -> Option<&'a ConfigValue> {
let mut current = cv;
for key in path {
match current {
ConfigValue::Object(obj) => {
current = obj.value.get(*key)?;
}
_ => return None,
}
}
Some(current)
}
fn get_string(cv: &ConfigValue) -> Option<&str> {
match cv {
ConfigValue::String(s) => Some(&s.value),
_ => None,
}
}
fn get_array_len(cv: &ConfigValue) -> Option<usize> {
match cv {
ConfigValue::Array(arr) => Some(arr.value.len()),
_ => None,
}
}
#[test]
fn test_empty_env() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::new();
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
assert!(output.unused_keys.is_empty());
}
#[test]
fn test_single_flat_field() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("should have config.port");
assert_eq!(get_string(port), Some("8080"));
}
#[test]
fn test_multiple_flat_fields() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "8080"), ("REEF__HOST", "localhost")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("should have config.port");
assert_eq!(get_string(port), Some("8080"));
let host = get_nested(&value, &["config", "host"]).expect("should have config.host");
assert_eq!(get_string(host), Some("localhost"));
}
#[test]
fn test_nested_field() {
let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__SMTP__HOST", "mail.example.com")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
let host = get_nested(&value, &["settings", "smtp", "host"])
.expect("should have settings.smtp.host");
assert_eq!(get_string(host), Some("mail.example.com"));
}
#[test]
fn test_deeply_nested() {
let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([
("REEF__PORT", "8080"),
("REEF__SMTP__HOST", "mail.example.com"),
("REEF__SMTP__CONNECTION_TIMEOUT", "30"),
]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
let port = get_nested(&value, &["settings", "port"]).expect("port");
assert_eq!(get_string(port), Some("8080"));
let host = get_nested(&value, &["settings", "smtp", "host"]).expect("smtp.host");
assert_eq!(get_string(host), Some("mail.example.com"));
let timeout = get_nested(&value, &["settings", "smtp", "connection_timeout"])
.expect("smtp.connection_timeout");
assert_eq!(get_string(timeout), Some("30"));
}
#[test]
fn test_comma_separated_list() {
let schema = Schema::from_shape(ArgsWithListConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORTS", "8080,8081,8082")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
let ports = get_nested(&value, &["config", "ports"]).expect("config.ports");
assert_eq!(get_array_len(ports), Some(3));
}
#[test]
fn test_escaped_comma() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__HOST", r"hello\, world")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
let host = get_nested(&value, &["config", "host"]).expect("config.host");
assert_eq!(get_string(host), Some("hello, world"));
}
#[test]
fn test_values_stay_as_strings() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
let value = output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("config.port");
assert!(matches!(port, ConfigValue::String(_)));
}
#[test]
fn test_provenance_is_set() {
use crate::provenance::Provenance;
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
let value = output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("config.port");
if let ConfigValue::String(s) = port {
let prov = s.provenance.as_ref().expect("should have provenance");
assert!(matches!(prov, Provenance::Env { .. }));
if let Provenance::Env { var, value } = prov {
assert_eq!(var, "REEF__PORT");
assert_eq!(value, "8080");
}
} else {
panic!("expected string");
}
}
#[test]
fn test_empty_segment_diagnostic() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__FOO____BAR", "x")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(!output.diagnostics.is_empty());
assert!(
output
.diagnostics
.iter()
.any(|d| d.message.contains("empty segment") || d.message.contains("invalid"))
);
}
#[test]
fn test_just_prefix_diagnostic() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__", "x")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(!output.diagnostics.is_empty());
}
#[test]
fn test_wrong_prefix_ignored() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("OTHER__PORT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
assert!(output.unused_keys.is_empty());
}
#[test]
fn test_single_underscore_ignored() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF_PORT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
assert!(output.unused_keys.is_empty());
}
#[test]
fn test_unknown_field_unused_key() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORTT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(!output.unused_keys.is_empty());
assert!(output.unused_keys.iter().any(|k| {
k.key.iter().any(|s| s == "portt")
}));
}
#[test]
fn test_unknown_nested_field_unused_key() {
let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__SMTP__HOSTT", "x")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(!output.unused_keys.is_empty());
}
#[test]
fn test_strict_mode_tracks_unknown_keys() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORTT", "8080")]);
let config = env_config_strict("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
!output.unused_keys.is_empty(),
"should track unknown key in unused_keys"
);
assert!(
output
.unused_keys
.iter()
.any(|uk| uk.key.join(".") == "portt"),
"unused_keys should contain 'portt': {:?}",
output.unused_keys
);
let errors: Vec<_> = output
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(
errors.is_empty(),
"should not have error diagnostics at parse time, got: {:?}",
errors
);
}
#[test]
fn test_case_matching() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
assert!(get_nested(&value, &["config", "port"]).is_some());
}
#[test]
fn test_field_with_underscore() {
let schema = Schema::from_shape(ArgsWithNestedConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__SMTP__CONNECTION_TIMEOUT", "30")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
assert!(get_nested(&value, &["settings", "smtp", "connection_timeout"]).is_some());
}
#[test]
fn test_empty_value() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(output.diagnostics.is_empty());
let value = output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("config.port");
assert_eq!(get_string(port), Some(""));
}
#[derive(Facet)]
struct ArgsWithoutConfig {
#[facet(args::named)]
verbose: bool,
}
#[test]
fn test_no_config_field_in_schema() {
let schema = Schema::from_shape(ArgsWithoutConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(!output.unused_keys.is_empty());
}
#[derive(Facet)]
struct CommonConfig {
log_level: String,
debug: bool,
}
#[derive(Facet)]
struct ServerConfigWithFlatten {
port: u16,
#[facet(flatten)]
common: CommonConfig,
}
#[derive(Facet)]
struct ArgsWithFlattenConfig {
#[facet(args::named)]
verbose: bool,
#[facet(args::config)]
config: ServerConfigWithFlatten,
}
#[test]
fn test_flatten_config_parses_flattened_field() {
let schema = Schema::from_shape(ArgsWithFlattenConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__LOG_LEVEL", "debug")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"diagnostics: {:?}",
output.diagnostics
);
assert!(
output.unused_keys.is_empty(),
"unused keys: {:?}",
output.unused_keys
);
let value = output.value.expect("should have value");
let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
assert_eq!(get_string(log_level), Some("debug"));
}
#[test]
fn test_flatten_config_top_level_and_flattened() {
let schema = Schema::from_shape(ArgsWithFlattenConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "8080"), ("REEF__DEBUG", "true")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"diagnostics: {:?}",
output.diagnostics
);
assert!(
output.unused_keys.is_empty(),
"unused keys: {:?}",
output.unused_keys
);
let value = output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("config.port");
assert_eq!(get_string(port), Some("8080"));
let debug = get_nested(&value, &["config", "debug"]).expect("config.debug");
assert_eq!(get_string(debug), Some("true"));
}
#[derive(Facet)]
struct DeepConfig {
trace: bool,
}
#[derive(Facet)]
struct MiddleConfig {
#[facet(flatten)]
deep: DeepConfig,
verbose: bool,
}
#[derive(Facet)]
struct OuterConfigWithDeepFlatten {
name: String,
#[facet(flatten)]
middle: MiddleConfig,
}
#[derive(Facet)]
struct ArgsWithDeepFlattenConfig {
#[facet(args::config)]
config: OuterConfigWithDeepFlatten,
}
#[test]
fn test_two_level_flatten_config() {
let schema = Schema::from_shape(ArgsWithDeepFlattenConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([
("REEF__NAME", "myapp"),
("REEF__VERBOSE", "true"),
("REEF__TRACE", "true"),
]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"diagnostics: {:?}",
output.diagnostics
);
assert!(
output.unused_keys.is_empty(),
"unused keys: {:?}",
output.unused_keys
);
let value = output.value.expect("should have value");
let name = get_nested(&value, &["config", "name"]).expect("config.name");
assert_eq!(get_string(name), Some("myapp"));
let verbose = get_nested(&value, &["config", "verbose"]).expect("config.verbose");
assert_eq!(get_string(verbose), Some("true"));
let trace = get_nested(&value, &["config", "trace"]).expect("config.trace");
assert_eq!(get_string(trace), Some("true"));
}
#[test]
fn test_nested_path_rejected_for_flattened_field() {
let schema = Schema::from_shape(ArgsWithFlattenConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__COMMON__LOG_LEVEL", "debug")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
!output.unused_keys.is_empty(),
"should reject nested path for flattened field"
);
assert!(
output
.unused_keys
.iter()
.any(|k| k.key.contains(&"common".to_string())),
"unused key should contain 'common': {:?}",
output.unused_keys
);
}
#[derive(Facet)]
struct ConfigWithAlias {
#[facet(args::env_alias = "DATABASE_URL")]
database_url: String,
port: u16,
}
#[derive(Facet)]
struct ArgsWithAliasConfig {
#[facet(args::config)]
config: ConfigWithAlias,
}
#[test]
fn test_env_alias_basic() {
let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("DATABASE_URL", "postgres://localhost/mydb")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"diagnostics: {:?}",
output.diagnostics
);
let value = output.value.expect("should have value");
let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
assert_eq!(get_string(db_url), Some("postgres://localhost/mydb"));
}
#[test]
fn test_env_alias_prefixed_wins() {
let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([
("DATABASE_URL", "alias_value"),
("REEF__DATABASE_URL", "prefixed_value"),
]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"diagnostics: {:?}",
output.diagnostics
);
let value = output.value.expect("should have value");
let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
assert_eq!(get_string(db_url), Some("prefixed_value"));
}
#[test]
fn test_env_alias_only_alias_set() {
let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("DATABASE_URL", "alias_value"), ("REEF__PORT", "8080")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"diagnostics: {:?}",
output.diagnostics
);
let value = output.value.expect("should have value");
let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
assert_eq!(get_string(db_url), Some("alias_value"));
let port = get_nested(&value, &["config", "port"]).expect("config.port");
assert_eq!(get_string(port), Some("8080"));
}
#[derive(Facet)]
struct ConfigWithMultipleAliases {
#[facet(args::env_alias = "DATABASE_URL", args::env_alias = "DB_URL")]
database_url: String,
}
#[derive(Facet)]
struct ArgsWithMultipleAliasConfig {
#[facet(args::config)]
config: ConfigWithMultipleAliases,
}
#[test]
fn test_env_alias_multiple_aliases_first_wins() {
let schema = Schema::from_shape(ArgsWithMultipleAliasConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("DB_URL", "second_alias_value")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"diagnostics: {:?}",
output.diagnostics
);
let value = output.value.expect("should have value");
let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
assert_eq!(get_string(db_url), Some("second_alias_value"));
}
#[test]
fn test_env_alias_provenance() {
use crate::provenance::Provenance;
let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("DATABASE_URL", "postgres://localhost/mydb")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
let value = output.value.expect("should have value");
let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
if let ConfigValue::String(s) = db_url {
let prov = s.provenance.as_ref().expect("should have provenance");
if let Provenance::Env { var, value } = prov {
assert_eq!(var, "DATABASE_URL");
assert_eq!(value, "postgres://localhost/mydb");
} else {
panic!("expected Env provenance");
}
} else {
panic!("expected string");
}
}
#[derive(Facet)]
struct NestedConfigWithAlias {
db: DbConfig,
}
#[derive(Facet)]
struct DbConfig {
#[facet(args::env_alias = "DATABASE_URL")]
url: String,
}
#[derive(Facet)]
struct ArgsWithNestedAliasConfig {
#[facet(args::config)]
config: NestedConfigWithAlias,
}
#[test]
fn test_env_alias_in_nested_struct() {
let schema = Schema::from_shape(ArgsWithNestedAliasConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("DATABASE_URL", "postgres://localhost/mydb")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"diagnostics: {:?}",
output.diagnostics
);
let value = output.value.expect("should have value");
let url = get_nested(&value, &["config", "db", "url"]).expect("config.db.url");
assert_eq!(get_string(url), Some("postgres://localhost/mydb"));
}
#[test]
fn test_env_alias_nested_prefixed_wins() {
let schema = Schema::from_shape(ArgsWithNestedAliasConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([
("DATABASE_URL", "alias_value"),
("REEF__DB__URL", "prefixed_value"),
]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
let value = output.value.expect("should have value");
let url = get_nested(&value, &["config", "db", "url"]).expect("config.db.url");
assert_eq!(get_string(url), Some("prefixed_value"));
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum LogLevel {
Debug,
Info,
Warn,
Error,
}
#[derive(Facet)]
struct ConfigWithEnum {
log_level: LogLevel,
port: u16,
}
#[derive(Facet)]
struct ArgsWithEnumConfig {
#[facet(args::config)]
config: ConfigWithEnum,
}
#[test]
fn test_enum_valid_variant_no_warning() {
let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__LOG_LEVEL", "Debug")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"valid enum variant should not produce warnings: {:?}",
output.diagnostics
);
let value = output.value.expect("should have value");
let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
assert_eq!(get_string(log_level), Some("Debug"));
}
#[test]
fn test_enum_invalid_variant_produces_warning() {
let schema = Schema::from_shape(ArgsWithEnumConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__LOG_LEVEL", "Debugg")]); let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
!output.diagnostics.is_empty(),
"invalid enum variant should produce a warning"
);
let warning = &output.diagnostics[0];
assert!(
warning.message.contains("Debugg"),
"warning should mention the invalid value: {}",
warning.message
);
assert!(
warning.message.contains("Debug")
&& warning.message.contains("Info")
&& warning.message.contains("Warn")
&& warning.message.contains("Error"),
"warning should list valid variants: {}",
warning.message
);
assert!(
warning.message.contains("Did you mean 'Debug'?"),
"warning should suggest similar variant: {}",
warning.message
);
let value = output.value.expect("should have value");
let log_level = get_nested(&value, &["config", "log_level"]).expect("config.log_level");
assert_eq!(get_string(log_level), Some("Debugg"));
}
#[derive(Facet)]
struct ConfigWithOptionalEnum {
log_level: Option<LogLevel>,
}
#[derive(Facet)]
struct ArgsWithOptionalEnumConfig {
#[facet(args::config)]
config: ConfigWithOptionalEnum,
}
#[test]
fn test_optional_enum_validation() {
let schema = Schema::from_shape(ArgsWithOptionalEnumConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__LOG_LEVEL", "invalid")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
!output.diagnostics.is_empty(),
"invalid optional enum variant should produce a warning"
);
}
#[derive(Facet)]
struct NestedConfigWithEnum {
logging: LoggingConfig,
}
#[derive(Facet)]
struct LoggingConfig {
level: LogLevel,
}
#[derive(Facet)]
struct ArgsWithNestedEnumConfig {
#[facet(args::config)]
config: NestedConfigWithEnum,
}
#[test]
fn test_nested_enum_validation() {
let schema = Schema::from_shape(ArgsWithNestedEnumConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__LOGGING__LEVEL", "unknown")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
!output.diagnostics.is_empty(),
"invalid nested enum variant should produce a warning"
);
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum Storage {
S3 { bucket: String, region: String },
Gcp { project: String, zone: String },
Local { path: String },
}
#[derive(Facet)]
struct ConfigWithEnumVariants {
storage: Storage,
port: u16,
}
#[derive(Facet)]
struct ArgsWithEnumVariantConfig {
#[facet(args::config)]
config: ConfigWithEnumVariants,
}
#[test]
fn test_enum_variant_field_single() {
let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__STORAGE__S3__BUCKET", "my-bucket")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"should not have diagnostics: {:?}",
output.diagnostics
);
assert!(
output.unused_keys.is_empty(),
"should not have unused keys: {:?}",
output.unused_keys
);
let value = output.value.expect("should have value");
let bucket = get_nested(&value, &["config", "storage", "S3", "bucket"])
.expect("should have config.storage.S3.bucket");
assert_eq!(get_string(bucket), Some("my-bucket"));
}
#[test]
fn test_enum_variant_field_multiple() {
let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([
("REEF__STORAGE__S3__BUCKET", "my-bucket"),
("REEF__STORAGE__S3__REGION", "us-east-1"),
]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"should not have diagnostics: {:?}",
output.diagnostics
);
assert!(
output.unused_keys.is_empty(),
"should not have unused keys: {:?}",
output.unused_keys
);
let value = output.value.expect("should have value");
let bucket = get_nested(&value, &["config", "storage", "S3", "bucket"])
.expect("should have config.storage.S3.bucket");
assert_eq!(get_string(bucket), Some("my-bucket"));
let region = get_nested(&value, &["config", "storage", "S3", "region"])
.expect("should have config.storage.S3.region");
assert_eq!(get_string(region), Some("us-east-1"));
}
#[test]
fn test_enum_variant_field_different_variant() {
let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__STORAGE__GCP__PROJECT", "my-project")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"should not have diagnostics: {:?}",
output.diagnostics
);
assert!(
output.unused_keys.is_empty(),
"should not have unused keys: {:?}",
output.unused_keys
);
let value = output.value.expect("should have value");
let project = get_nested(&value, &["config", "storage", "Gcp", "project"])
.expect("should have config.storage.Gcp.project");
assert_eq!(get_string(project), Some("my-project"));
}
#[test]
fn test_enum_variant_field_with_regular_field() {
let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([
("REEF__STORAGE__S3__BUCKET", "my-bucket"),
("REEF__PORT", "8080"),
]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"should not have diagnostics: {:?}",
output.diagnostics
);
assert!(
output.unused_keys.is_empty(),
"should not have unused keys: {:?}",
output.unused_keys
);
let value = output.value.expect("should have value");
let bucket = get_nested(&value, &["config", "storage", "S3", "bucket"])
.expect("should have config.storage.S3.bucket");
assert_eq!(get_string(bucket), Some("my-bucket"));
let port = get_nested(&value, &["config", "port"]).expect("should have config.port");
assert_eq!(get_string(port), Some("8080"));
}
#[test]
fn test_enum_variant_unknown_variant_rejected() {
let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__STORAGE__AZURE__CONTAINER", "my-container")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
!output.unused_keys.is_empty(),
"unknown variant should produce unused key"
);
assert!(
output
.unused_keys
.iter()
.any(|k| k.key.iter().any(|s| s == "azure")),
"unused key should mention azure: {:?}",
output.unused_keys
);
}
#[test]
fn test_enum_variant_unknown_field_rejected() {
let schema = Schema::from_shape(ArgsWithEnumVariantConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__STORAGE__S3__UNKNOWN_FIELD", "value")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
!output.unused_keys.is_empty(),
"unknown field in variant should produce unused key"
);
}
#[derive(Facet)]
struct ConfigWithOptionalEnumVariants {
storage: Option<Storage>,
}
#[derive(Facet)]
struct ArgsWithOptionalEnumVariantConfig {
#[facet(args::config)]
config: ConfigWithOptionalEnumVariants,
}
#[test]
fn test_optional_enum_variant_field() {
let schema = Schema::from_shape(ArgsWithOptionalEnumVariantConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__STORAGE__LOCAL__PATH", "/data")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.diagnostics.is_empty(),
"should not have diagnostics: {:?}",
output.diagnostics
);
assert!(
output.unused_keys.is_empty(),
"should not have unused keys: {:?}",
output.unused_keys
);
let value = output.value.expect("should have value");
let path = get_nested(&value, &["config", "storage", "Local", "path"])
.expect("should have config.storage.Local.path");
assert_eq!(get_string(path), Some("/data"));
}
#[test]
fn test_env_spans_are_assigned() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("REEF__PORT", "8080"), ("REEF__HOST", "localhost")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.source_text.is_some(),
"source_text should be set when env vars are parsed"
);
let source_text = output.source_text.as_ref().unwrap();
assert!(
source_text.contains("REEF__PORT=\"8080\""),
"source_text should contain REEF__PORT: {}",
source_text
);
assert!(
source_text.contains("REEF__HOST=\"localhost\""),
"source_text should contain REEF__HOST: {}",
source_text
);
let value = output.value.expect("should have value");
let port = get_nested(&value, &["config", "port"]).expect("config.port");
assert!(port.span().is_some(), "port should have a span");
let span = port.span().unwrap();
let offset = span.offset as usize;
let len = span.len as usize;
let pointed_text = &source_text[offset..offset + len];
assert_eq!(
pointed_text, "8080",
"span should point to the value in source_text"
);
}
#[test]
fn test_env_spans_with_alias() {
let schema = Schema::from_shape(ArgsWithAliasConfig::SHAPE).unwrap();
let env = MockEnv::from_pairs([("DATABASE_URL", "postgres://localhost/db")]);
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.source_text.is_some(),
"source_text should be set for aliased env vars"
);
let source_text = output.source_text.as_ref().unwrap();
assert!(
source_text.contains("DATABASE_URL=\"postgres://localhost/db\""),
"source_text should contain DATABASE_URL: {}",
source_text
);
let value = output.value.expect("should have value");
let db_url = get_nested(&value, &["config", "database_url"]).expect("config.database_url");
let span = db_url.span().expect("db_url should have a span");
let offset = span.offset as usize;
let len = span.len as usize;
let pointed_text = &source_text[offset..offset + len];
assert_eq!(
pointed_text, "postgres://localhost/db",
"span should point to the value in source_text"
);
}
#[test]
fn test_env_spans_no_env_vars_no_source_text() {
let schema = Schema::from_shape(ArgsWithConfig::SHAPE).unwrap();
let env = MockEnv::new();
let config = env_config("REEF");
let output = parse_env(&schema, &config, &env);
assert!(
output.source_text.is_none(),
"source_text should be None when no env vars are parsed"
);
}
}