use confroid::{ConfroidError, from_pairs};
use std::collections::HashMap;
#[derive(confroid::Config, Debug)]
struct Defaults {
#[confroid(default = 8080)]
port: u16,
#[confroid(default)] retries: u16,
#[confroid(default = "Hello")]
greeting: String,
#[confroid(default)] note: String,
}
#[test]
fn explicit_and_bare_defaults_apply_when_unset() {
let config: Defaults = from_pairs(Vec::<(String, String)>::new()).unwrap();
assert_eq!(config.port, 8080);
assert_eq!(config.retries, 0);
assert_eq!(config.greeting, "Hello");
assert_eq!(config.note, "");
}
#[test]
fn defaults_are_overridden_when_set() {
let config: Defaults = from_pairs([
("PORT", "1"),
("RETRIES", "5"),
("GREETING", "Hi"),
("NOTE", "n"),
])
.unwrap();
assert_eq!(config.port, 1);
assert_eq!(config.retries, 5);
assert_eq!(config.greeting, "Hi");
assert_eq!(config.note, "n");
}
#[test]
fn default_does_not_mask_a_parse_error() {
let err = from_pairs::<Defaults, _, _, _>([("PORT", "not-a-number")]).unwrap_err();
assert!(matches!(err, ConfroidError::EnvVarInvalid { .. }));
}
#[derive(confroid::Config, Debug)]
struct WithTls {
tls: Option<TlsConfig>,
}
#[derive(confroid::Config, Debug)]
struct TlsConfig {
cert: String,
key: String,
}
#[test]
fn optional_struct_absent_is_none() {
let config: WithTls = from_pairs(Vec::<(String, String)>::new()).unwrap();
assert!(config.tls.is_none());
}
#[test]
fn optional_struct_fully_set_is_some() {
let config: WithTls = from_pairs([("TLS__CERT", "c.pem"), ("TLS__KEY", "k.pem")]).unwrap();
let tls = config.tls.unwrap();
assert_eq!(tls.cert, "c.pem");
assert_eq!(tls.key, "k.pem");
}
#[test]
fn empty_parent_marker_does_not_hide_optional_struct_children() {
let config: WithTls =
from_pairs([("TLS", ""), ("TLS__CERT", "c.pem"), ("TLS__KEY", "k.pem")]).unwrap();
assert_eq!(config.tls.unwrap().cert, "c.pem");
}
#[test]
fn optional_struct_partially_set_errors() {
let err = from_pairs::<WithTls, _, _, _>([("TLS__CERT", "c.pem")]).unwrap_err();
assert!(matches!(
err,
ConfroidError::EnvVarNotFound { var_name, field }
if var_name == "TLS__KEY" && field == "tls.key"
));
}
#[derive(confroid::Config, Debug)]
#[allow(dead_code)] struct ReqVec {
names: Vec<String>,
}
#[derive(confroid::Config, Debug)]
struct OptVec {
#[confroid(default)]
names: Vec<String>,
}
#[derive(confroid::Config, Debug)]
struct AutoVec {
#[confroid(auto_vec)]
names: Vec<String>,
}
#[test]
fn missing_vector_without_default_is_missing_error() {
let err = from_pairs::<ReqVec, _, _, _>(Vec::<(String, String)>::new()).unwrap_err();
assert!(matches!(
err,
ConfroidError::EnvVarNotFound { var_name, field }
if var_name == "NAMES" && field == "names"
));
}
#[test]
fn missing_vector_with_bare_default_is_empty() {
let config: OptVec = from_pairs(Vec::<(String, String)>::new()).unwrap();
assert!(config.names.is_empty());
}
#[test]
fn vector_index_gap_is_an_error() {
let err = from_pairs::<ReqVec, _, _, _>([("NAMES__0", "a"), ("NAMES__2", "c")]).unwrap_err();
assert!(matches!(
err,
ConfroidError::VecIndexGap {
var_name,
field,
missing: 1,
} if var_name == "NAMES" && field == "names"
));
}
#[test]
fn empty_delimited_vector_is_empty() {
let config: AutoVec = from_pairs([("NAMES", "")]).unwrap();
assert!(config.names.is_empty());
}
#[test]
fn delimited_vector_of_numbers() {
#[derive(confroid::Config, Debug)]
struct Ports {
#[confroid(auto_vec)]
ports: Vec<u16>,
}
let config: Ports = from_pairs([("PORTS", "80,443,8080")]).unwrap();
assert_eq!(config.ports, vec![80, 443, 8080]);
}
#[test]
fn delimited_vector_element_parse_error() {
#[derive(confroid::Config, Debug)]
#[allow(dead_code)] struct Ports {
#[confroid(auto_vec)]
ports: Vec<u16>,
}
let err = from_pairs::<Ports, _, _, _>([("PORTS", "80,nope")]).unwrap_err();
match err {
ConfroidError::EnvVarInvalid { value, field, .. } => {
assert_eq!(value, "nope");
assert_eq!(field, "ports");
}
other => panic!("expected EnvVarInvalid, got {other:?}"),
}
}
#[derive(confroid::Config, Debug)]
struct Renamed {
#[confroid(name = "LISTEN_PORT")]
port: u16,
#[confroid(name = "SERVER")]
http: Inner,
}
#[derive(confroid::Config, Debug)]
struct Inner {
port: u16,
}
#[test]
fn leaf_rename() {
let config: Renamed = from_pairs([("LISTEN_PORT", "1234"), ("SERVER__PORT", "80")]).unwrap();
assert_eq!(config.port, 1234);
}
#[test]
fn nested_rename_propagates_to_children() {
let config: Renamed = from_pairs([("LISTEN_PORT", "1234"), ("SERVER__PORT", "8443")]).unwrap();
assert_eq!(config.http.port, 8443);
}
#[derive(confroid::Config, Debug)]
#[confroid(prefix = "GUPPY")]
struct Prefixed {
port: u16,
}
#[test]
fn container_prefix_applies_to_fields() {
let config: Prefixed = from_pairs([("GUPPY__PORT", "8080")]).unwrap();
assert_eq!(config.port, 8080);
}
#[test]
fn container_prefix_is_included_in_missing_variable_errors() {
let err = from_pairs::<Prefixed, _, _, _>(Vec::<(String, String)>::new()).unwrap_err();
assert!(matches!(
err,
ConfroidError::EnvVarNotFound { var_name, field }
if var_name == "GUPPY__PORT" && field == "port"
));
}
#[derive(confroid::Config, Debug)]
struct WithPrefixedOptional {
service: Option<Prefixed>,
}
#[test]
fn nested_container_prefix_participates_in_presence_checks() {
let config: WithPrefixedOptional = from_pairs([("SERVICE__GUPPY__PORT", "9090")]).unwrap();
assert_eq!(config.service.unwrap().port, 9090);
}
#[derive(confroid::Config, Debug)]
struct Scalars {
a: i64,
b: f64,
c: bool,
d: char,
e: std::net::IpAddr,
f: std::path::PathBuf,
}
#[test]
fn many_scalar_types() {
let config: Scalars = from_pairs([
("A", "-42"),
("B", "3.5"),
("C", "true"),
("D", "x"),
("E", "127.0.0.1"),
("F", "/etc/config"),
])
.unwrap();
assert_eq!(config.a, -42);
assert_eq!(config.b, 3.5);
assert!(config.c);
assert_eq!(config.d, 'x');
assert_eq!(config.e, "127.0.0.1".parse::<std::net::IpAddr>().unwrap());
assert_eq!(config.f, std::path::PathBuf::from("/etc/config"));
}
#[test]
fn hashmap_of_scalars_preserves_key_case() {
#[derive(confroid::Config, Debug)]
struct Weights {
weights: HashMap<String, u32>,
}
let config: Weights = from_pairs([("WEIGHTS__Alice", "3"), ("WEIGHTS__bob", "7")]).unwrap();
assert_eq!(config.weights.len(), 2);
assert_eq!(config.weights.get("Alice"), Some(&3));
assert_eq!(config.weights.get("bob"), Some(&7));
}
#[derive(confroid::Config, Debug)]
#[allow(dead_code)]
struct ManyErrors {
alpha: u16,
beta: bool,
nested: ManyNestedErrors,
}
#[derive(confroid::Config, Debug)]
#[allow(dead_code)]
struct ManyNestedErrors {
gamma: String,
delta: u32,
}
fn error_field(error: &ConfroidError) -> &str {
match error {
ConfroidError::EnvVarNotFound { field, .. }
| ConfroidError::EnvVarInvalid { field, .. }
| ConfroidError::VecIndexInvalid { field, .. }
| ConfroidError::VecIndexGap { field, .. } => field,
ConfroidError::Multiple { .. } => panic!("aggregates must be flattened"),
_ => panic!("unexpected future error variant"),
}
}
#[test]
fn derive_collects_and_flattens_all_field_errors() {
let err = from_pairs::<ManyErrors, _, _, _>(Vec::<(String, String)>::new()).unwrap_err();
let fields: Vec<_> = err.errors().iter().map(error_field).collect();
assert_eq!(fields, ["alpha", "beta", "nested.gamma", "nested.delta"]);
let rendered = err.to_string();
assert!(rendered.contains("configuration contains multiple errors"));
assert!(rendered.contains("`ALPHA`"));
assert!(rendered.contains("`NESTED__DELTA`"));
}
#[derive(confroid::Config, Debug, Default)]
struct DefaultInner {
first: u16,
second: bool,
}
impl std::fmt::Display for DefaultInner {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{},{}", self.first, self.second)
}
}
#[derive(confroid::Config, Debug)]
struct WithDefaultInner {
#[confroid(default)]
inner: DefaultInner,
}
#[test]
fn nested_default_applies_only_when_the_whole_field_is_absent() {
let config: WithDefaultInner = from_pairs(Vec::<(String, String)>::new()).unwrap();
assert_eq!(config.inner.first, 0);
assert!(!config.inner.second);
let err = from_pairs::<WithDefaultInner, _, _, _>([("INNER__FIRST", "bad")]).unwrap_err();
let fields: Vec<_> = err.errors().iter().map(error_field).collect();
assert_eq!(fields, ["inner.first", "inner.second"]);
}
#[test]
fn vectors_and_maps_collect_all_element_errors() {
#[derive(confroid::Config, Debug)]
#[allow(dead_code)]
struct Collections {
numbers: Vec<u16>,
weights: HashMap<String, u16>,
}
let err = from_pairs::<Collections, _, _, _>([
("NUMBERS__0", "bad"),
("NUMBERS__1", "worse"),
("WEIGHTS__alpha", "nope"),
("WEIGHTS__beta", "nah"),
])
.unwrap_err();
let fields: Vec<_> = err.errors().iter().map(error_field).collect();
assert_eq!(
fields,
["numbers[0]", "numbers[1]", "weights.alpha", "weights.beta"]
);
}
#[test]
fn auto_vec_collects_every_invalid_part() {
#[derive(confroid::Config, Debug)]
#[allow(dead_code)]
struct Numbers {
#[confroid(auto_vec)]
numbers: Vec<u16>,
}
let err = from_pairs::<Numbers, _, _, _>([("NUMBERS", "bad,2,worse")]).unwrap_err();
let values: Vec<_> = err
.errors()
.iter()
.map(|error| match error {
ConfroidError::EnvVarInvalid { value, .. } => value.as_str(),
other => panic!("expected EnvVarInvalid, got {other:?}"),
})
.collect();
assert_eq!(values, ["bad", "worse"]);
}
#[test]
fn invalid_value_preserves_the_parser_error_source() {
#[derive(confroid::Config, Debug)]
#[allow(dead_code)]
struct Number {
number: u16,
}
let err = from_pairs::<Number, _, _, _>([("NUMBER", "not-a-number")]).unwrap_err();
let source = std::error::Error::source(&err).expect("invalid values have a parser source");
assert!(source.downcast_ref::<std::num::ParseIntError>().is_some());
}