use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use ::id_effect::{Effect, Get, Here, effect};
use effect_logger::LogLevel;
use id_effect::duration::duration;
use url::Url;
use crate::ambient::current_config_provider;
use crate::error::ConfigError;
use crate::provider::{ConfigProvider, ConfigProviderKey, NeedsConfigProvider};
use crate::secret::Secret;
type LoadFn<T> = Arc<dyn Fn(&dyn ConfigProvider) -> Result<T, ConfigError> + Send + Sync>;
struct PrefixProvider<'a> {
inner: &'a dyn ConfigProvider,
prefix: Arc<Vec<String>>,
}
impl ConfigProvider for PrefixProvider<'_> {
fn load_raw(&self, path: &[&str]) -> Result<Option<String>, ConfigError> {
let mut full: Vec<String> = self.prefix.iter().cloned().collect();
full.extend(path.iter().map(|s| (*s).to_string()));
let full_refs: Vec<&str> = full.iter().map(String::as_str).collect();
self.inner.load_raw(&full_refs)
}
fn seq_delim(&self) -> &'static str {
self.inner.seq_delim()
}
}
pub struct Config<T: 'static> {
loader: LoadFn<T>,
}
impl<T: 'static> fmt::Debug for Config<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Config<{}>", std::any::type_name::<T>())
}
}
impl<T: 'static> Clone for Config<T> {
fn clone(&self) -> Self {
Self {
loader: self.loader.clone(),
}
}
}
impl<T: Send + Sync + 'static> Config<T> {
fn new(
f: impl Fn(&dyn ConfigProvider) -> Result<T, ConfigError> + Send + Sync + 'static,
) -> Self {
Self {
loader: Arc::new(f),
}
}
#[inline]
pub fn load(&self, provider: &dyn ConfigProvider) -> Result<T, ConfigError> {
(self.loader)(provider)
}
pub fn run<A, E, R>(&self) -> Effect<A, E, R>
where
A: From<T> + 'static,
E: From<ConfigError> + 'static,
R: NeedsConfigProvider + 'static,
{
let loader = self.loader.clone();
effect!(|r: &mut R| {
let service = Get::<ConfigProviderKey, Here>::get(r);
let t = (loader)(service.0.as_ref()).map_err(E::from)?;
A::from(t)
})
}
#[inline]
pub fn load_current(&self) -> Result<T, ConfigError> {
match current_config_provider() {
Some(p) => (self.loader)(p.as_ref()),
None => Err(ConfigError::Missing {
path: "<ambient ConfigProvider>".into(),
}),
}
}
pub fn map<U: Send + Sync + 'static>(
self,
f: impl Fn(T) -> U + Send + Sync + 'static,
) -> Config<U> {
let loader = self.loader;
let f = Arc::new(f);
Config {
loader: Arc::new(move |p| {
let t = (loader)(p)?;
Ok(f(t))
}),
}
}
pub fn map_attempt<U: Send + Sync + 'static>(
self,
f: impl Fn(T) -> Result<U, ConfigError> + Send + Sync + 'static,
) -> Config<U> {
let loader = self.loader;
let f = Arc::new(f);
Config {
loader: Arc::new(move |p| {
let t = (loader)(p)?;
f(t)
}),
}
}
pub fn secret(self) -> Config<Secret<T>> {
let loader = self.loader;
Config {
loader: Arc::new(move |p| Ok(Secret::new((loader)(p)?))),
}
}
pub fn nested(self, prefix: impl Into<String>) -> Self {
let prefix_segs: Arc<Vec<String>> = Arc::new(
prefix
.into()
.split('.')
.filter(|s| !s.is_empty())
.map(String::from)
.collect(),
);
let inner = self.loader;
Self {
loader: Arc::new(move |p| {
let scoped = PrefixProvider {
inner: p,
prefix: prefix_segs.clone(),
};
inner(&scoped)
}),
}
}
}
impl<T: Clone + Send + Sync + 'static> Config<T> {
pub fn with_default(self, default: T) -> Self {
let loader = self.loader;
Self {
loader: Arc::new(move |p| match (loader)(p) {
Ok(v) => Ok(v),
Err(ConfigError::Missing { .. }) => Ok(default.clone()),
Err(e) => Err(e),
}),
}
}
pub fn validate(
self,
reason: &'static str,
predicate: impl Fn(&T) -> bool + Send + Sync + 'static,
) -> Self {
let loader = self.loader;
let predicate = Arc::new(predicate);
Self {
loader: Arc::new(move |p| {
let v = (loader)(p)?;
if predicate(&v) {
Ok(v)
} else {
Err(ConfigError::Invalid {
path: String::new(),
value: String::new(),
reason: reason.to_string(),
})
}
}),
}
}
}
fn split_dotted(path: &str) -> Arc<Vec<String>> {
Arc::new(
path
.split('.')
.filter(|s| !s.is_empty())
.map(String::from)
.collect(),
)
}
impl Config<String> {
pub fn string(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
match p.load_raw(&refs)? {
None => Err(ConfigError::Missing { path: path_str }),
Some(s) => Ok(s),
}
})
}
}
impl Config<Option<String>> {
pub fn optional_string(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
p.load_raw(&refs)
})
}
}
impl Config<f64> {
pub fn number(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
let s = match p.load_raw(&refs)? {
None => {
return Err(ConfigError::Missing {
path: path_str.clone(),
});
}
Some(s) => s,
};
s.parse::<f64>().map_err(|e| ConfigError::Invalid {
path: path_str,
value: s,
reason: e.to_string(),
})
})
}
}
impl Config<i64> {
pub fn integer(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
let s = match p.load_raw(&refs)? {
None => {
return Err(ConfigError::Missing {
path: path_str.clone(),
});
}
Some(s) => s,
};
s.parse::<i64>().map_err(|e| ConfigError::Invalid {
path: path_str,
value: s,
reason: e.to_string(),
})
})
}
}
impl Config<bool> {
pub fn boolean(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
let s = match p.load_raw(&refs)? {
None => {
return Err(ConfigError::Missing {
path: path_str.clone(),
});
}
Some(s) => s,
};
match s.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(ConfigError::Invalid {
path: path_str,
value: s,
reason: "expected boolean string".into(),
}),
}
})
}
}
impl Config<Vec<String>> {
pub fn string_list(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
let s = match p.load_raw(&refs)? {
None => return Err(ConfigError::Missing { path: path_str }),
Some(s) => s,
};
let delim = p.seq_delim();
Ok(
s.split(delim)
.map(str::trim)
.filter(|x| !x.is_empty())
.map(str::to_string)
.collect(),
)
})
}
pub fn repeat(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
let s = match p.load_raw(&refs)? {
None => return Err(ConfigError::Missing { path: path_str }),
Some(s) => s,
};
Ok(split_csv_list(&s))
})
}
}
fn split_csv_list(s: &str) -> Vec<String> {
s.split(',')
.map(str::trim)
.filter(|x| !x.is_empty())
.map(str::to_string)
.collect()
}
impl Config<Duration> {
pub fn duration(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
let s = match p.load_raw(&refs)? {
None => {
return Err(ConfigError::Missing {
path: path_str.clone(),
});
}
Some(s) => s,
};
duration::decode(s.trim()).map_err(|e| ConfigError::Invalid {
path: path_str,
value: e.input.clone(),
reason: e.to_string(),
})
})
}
}
impl Config<LogLevel> {
pub fn log_level(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
let s = match p.load_raw(&refs)? {
None => {
return Err(ConfigError::Missing {
path: path_str.clone(),
});
}
Some(s) => s,
};
LogLevel::from_str(s.trim()).map_err(|reason| ConfigError::Invalid {
path: path_str,
value: s,
reason,
})
})
}
}
impl Config<Url> {
pub fn url(path: impl Into<String>) -> Self {
let segs = split_dotted(&path.into());
Self::new(move |p| {
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let path_str = refs.join(".");
let s = match p.load_raw(&refs)? {
None => {
return Err(ConfigError::Missing {
path: path_str.clone(),
});
}
Some(s) => s,
};
s.parse::<Url>().map_err(|e| ConfigError::Invalid {
path: path_str,
value: s,
reason: e.to_string(),
})
})
}
}
pub fn all<A, B>(a: Config<A>, b: Config<B>) -> Config<(A, B)>
where
A: Send + Sync + 'static,
B: Send + Sync + 'static,
{
Config {
loader: Arc::new(move |p| {
let av = (a.loader)(p)?;
let bv = (b.loader)(p)?;
Ok((av, bv))
}),
}
}
pub fn all3<A, B, C>(a: Config<A>, b: Config<B>, c: Config<C>) -> Config<(A, B, C)>
where
A: Send + Sync + 'static,
B: Send + Sync + 'static,
C: Send + Sync + 'static,
{
Config {
loader: Arc::new(move |p| {
let av = (a.loader)(p)?;
let bv = (b.loader)(p)?;
let cv = (c.loader)(p)?;
Ok((av, bv, cv))
}),
}
}
pub fn zip_with<A, B, C>(
a: Config<A>,
b: Config<B>,
f: impl Fn(A, B) -> C + Send + Sync + 'static,
) -> Config<C>
where
A: Send + Sync + 'static,
B: Send + Sync + 'static,
C: Send + Sync + 'static,
{
let f = Arc::new(f);
Config {
loader: Arc::new(move |p| {
let av = (a.loader)(p)?;
let bv = (b.loader)(p)?;
Ok(f(av, bv))
}),
}
}
pub fn or_else<T: Send + Sync + 'static>(primary: Config<T>, fallback: Config<T>) -> Config<T> {
Config {
loader: Arc::new(move |p| match (primary.loader)(p) {
Ok(v) => Ok(v),
Err(ConfigError::Missing { .. }) => (fallback.loader)(p),
Err(e) => Err(e),
}),
}
}
pub fn all_vec<T: Send + Sync + 'static>(configs: Vec<Config<T>>) -> Config<Vec<T>> {
let configs = Arc::new(configs);
Config {
loader: Arc::new(move |p| configs.iter().map(|c| (c.loader)(p)).collect()),
}
}
#[inline]
pub fn nest<T: Send + Sync + 'static>(prefix: impl Into<String>, inner: Config<T>) -> Config<T> {
inner.nested(prefix)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use crate::MapConfigProvider;
use effect_logger::LogLevel;
use super::*;
fn map_provider(pairs: &[(&str, &str)]) -> MapConfigProvider {
MapConfigProvider::from_pairs(pairs.iter().copied())
}
fn go<T: Send + Sync + 'static>(c: Config<T>, p: &dyn ConfigProvider) -> Result<T, ConfigError> {
c.load(p)
}
#[test]
fn string_required_found() {
let p = map_provider(&[("HOST", "localhost")]);
assert_eq!(go(Config::string("HOST"), &p).unwrap(), "localhost");
}
#[test]
fn string_required_missing_is_error() {
let p = map_provider(&[]);
assert!(go(Config::string("HOST"), &p).is_err());
}
#[test]
fn with_default_on_missing() {
let p = map_provider(&[]);
let port = go(Config::integer("PORT").with_default(3000), &p).unwrap();
assert_eq!(port, 3000);
}
#[test]
fn with_default_not_used_when_present() {
let p = map_provider(&[("PORT", "8080")]);
let port = go(Config::integer("PORT").with_default(3000), &p).unwrap();
assert_eq!(port, 8080);
}
#[test]
fn map_transforms_value() {
let p = map_provider(&[("PORT", "8080")]);
let port_u16: u16 = go(Config::integer("PORT").map(|n| n as u16), &p).unwrap();
assert_eq!(port_u16, 8080);
}
#[test]
fn map_attempt_success() {
let p = map_provider(&[("VAL", "42")]);
let v = go(
Config::string("VAL").map_attempt(|s| {
s.parse::<i32>().map_err(|e| ConfigError::Invalid {
path: "VAL".into(),
value: s,
reason: e.to_string(),
})
}),
&p,
)
.unwrap();
assert_eq!(v, 42i32);
}
#[test]
fn validate_passes() {
let p = map_provider(&[("PORT", "8080")]);
let port = go(
Config::integer("PORT").validate("must be > 0", |n| *n > 0),
&p,
)
.unwrap();
assert_eq!(port, 8080);
}
#[test]
fn validate_fails() {
let p = map_provider(&[("PORT", "99999")]);
let err = go(
Config::integer("PORT").validate("port must be 1–65535", |n| (1..=65535).contains(n)),
&p,
)
.unwrap_err();
assert!(err.to_string().contains("port must be 1–65535"));
}
#[test]
fn secret_wraps_and_redacts() {
let p = map_provider(&[("KEY", "s3cr3t")]);
let k = go(Config::string("KEY").secret(), &p).unwrap();
assert_eq!(format!("{k:?}"), "<redacted>");
assert_eq!(k.expose(), "s3cr3t");
}
#[test]
fn nested_prepends_prefix() {
let p = map_provider(&[("SERVER_HOST", "0.0.0.0")]);
let host = go(Config::string("HOST").nested("SERVER"), &p).unwrap();
assert_eq!(host, "0.0.0.0");
}
#[test]
fn all_combines_two() {
let p = map_provider(&[("HOST", "::1"), ("PORT", "9000")]);
let (host, port) = go(all(Config::string("HOST"), Config::integer("PORT")), &p).unwrap();
assert_eq!(host, "::1");
assert_eq!(port, 9000);
}
#[test]
fn all3_combines_three() {
let p = map_provider(&[("A", "1"), ("B", "2"), ("C", "3")]);
let (a, b, c) = go(
all3(
Config::integer("A"),
Config::integer("B"),
Config::integer("C"),
),
&p,
)
.unwrap();
assert_eq!((a, b, c), (1, 2, 3));
}
#[test]
fn zip_with_merges() {
let p = map_provider(&[("HOST", "localhost"), ("PORT", "8080")]);
let addr = go(
zip_with(Config::string("HOST"), Config::integer("PORT"), |h, po| {
format!("{h}:{po}")
}),
&p,
)
.unwrap();
assert_eq!(addr, "localhost:8080");
}
#[test]
fn or_else_falls_back_on_missing() {
let p = map_provider(&[("FALLBACK_PORT", "4000")]);
let port = go(
or_else(Config::integer("PORT"), Config::integer("FALLBACK_PORT")),
&p,
)
.unwrap();
assert_eq!(port, 4000);
}
#[test]
fn or_else_uses_primary_when_present() {
let p = map_provider(&[("PORT", "8080"), ("FALLBACK_PORT", "4000")]);
let port = go(
or_else(Config::integer("PORT"), Config::integer("FALLBACK_PORT")),
&p,
)
.unwrap();
assert_eq!(port, 8080);
}
#[test]
fn all_vec_collects() {
let p = map_provider(&[("K0", "10"), ("K1", "20"), ("K2", "30")]);
let vals = go(
all_vec(vec![
Config::integer("K0"),
Config::integer("K1"),
Config::integer("K2"),
]),
&p,
)
.unwrap();
assert_eq!(vals, vec![10, 20, 30]);
}
#[test]
fn boolean_true_and_false() {
let p = map_provider(&[("A", "true"), ("B", "false"), ("C", "1"), ("D", "0")]);
assert!(go(Config::boolean("A"), &p).unwrap());
assert!(!go(Config::boolean("B"), &p).unwrap());
assert!(go(Config::boolean("C"), &p).unwrap());
assert!(!go(Config::boolean("D"), &p).unwrap());
}
#[test]
fn string_list_splits() {
let p = map_provider(&[("TAGS", "a,b,c")]);
let tags = go(Config::string_list("TAGS"), &p).unwrap();
assert_eq!(tags, vec!["a", "b", "c"]);
}
mod duration {
use super::*;
fn dur(pairs: &[(&str, &str)], key: &'static str) -> Result<Duration, ConfigError> {
let p = map_provider(pairs);
go(Config::duration(key), &p)
}
#[test]
fn bare_integer_is_millis() {
assert_eq!(
dur(&[("T", "500")], "T").unwrap(),
Duration::from_millis(500)
);
}
#[test]
fn ms_suffix() {
assert_eq!(
dur(&[("T", "250ms")], "T").unwrap(),
Duration::from_millis(250)
);
}
#[test]
fn seconds_suffix() {
assert_eq!(dur(&[("T", "5s")], "T").unwrap(), Duration::from_secs(5));
}
#[test]
fn minutes_suffix() {
assert_eq!(
dur(&[("T", "2min")], "T").unwrap(),
Duration::from_secs(120)
);
}
#[test]
fn hours_suffix() {
assert_eq!(dur(&[("T", "1h")], "T").unwrap(), Duration::from_secs(3600));
}
#[test]
fn days_suffix() {
assert_eq!(
dur(&[("T", "1d")], "T").unwrap(),
Duration::from_secs(86400)
);
}
#[test]
fn unknown_unit_is_error() {
assert!(dur(&[("T", "5x")], "T").is_err());
}
}
#[test]
fn config_duration_parses_30_seconds() {
let p = map_provider(&[("D", "30 seconds")]);
assert_eq!(
go(Config::duration("D"), &p).unwrap(),
Duration::from_secs(30)
);
}
#[test]
fn config_log_level_parses_info() {
let p = map_provider(&[("LVL", "info")]);
assert_eq!(go(Config::log_level("LVL"), &p).unwrap(), LogLevel::Info);
}
#[test]
fn config_url_parses_valid_url() {
let p = map_provider(&[("U", "https://example.com/path?x=1")]);
let u = go(Config::url("U"), &p).unwrap();
assert_eq!(u.as_str(), "https://example.com/path?x=1");
}
#[test]
fn config_nested_prefixes_keys() {
let p = map_provider(&[("APP_SERVER_HOST", "h")]);
let host = go(nest("APP_SERVER", Config::string("HOST")), &p).unwrap();
assert_eq!(host, "h");
}
#[test]
fn config_repeat_splits_csv() {
let p = map_provider(&[("ITEMS", "a, b ,c")]);
let v = go(Config::repeat("ITEMS"), &p).unwrap();
assert_eq!(v, vec!["a", "b", "c"]);
}
#[test]
fn config_debug_contains_type_name() {
let c = Config::<String>::string("X");
let s = format!("{c:?}");
assert!(s.contains("Config"));
}
#[test]
fn config_clone_independent_from_original() {
let p = map_provider(&[("X", "hello")]);
let c = Config::string("X");
let c2 = c.clone();
assert_eq!(go(c2, &p).unwrap(), "hello");
}
#[test]
fn config_run_via_effect() {
use crate::{MapConfigProvider, config_env};
let p = MapConfigProvider::from_pairs([("HOST", "localhost")]);
let env = config_env(p);
let host: String =
id_effect::run_blocking(Config::string("HOST").run::<String, ConfigError, _>(), env).unwrap();
assert_eq!(host, "localhost");
}
#[test]
fn config_run_error_propagated() {
use crate::{MapConfigProvider, config_env};
let p = MapConfigProvider::from_pairs::<[(&str, &str); 0], _, _>([]);
let env = config_env(p);
let result: Result<String, ConfigError> = id_effect::run_blocking(
Config::string("MISSING").run::<String, ConfigError, _>(),
env,
);
assert!(result.is_err());
}
#[test]
fn load_current_no_ambient_returns_missing() {
let err = Config::<String>::string("X").load_current().unwrap_err();
assert!(matches!(err, ConfigError::Missing { .. }));
}
#[test]
fn load_current_with_ambient_provider() {
use crate::ambient::with_config_provider;
use std::sync::Arc;
let p = Arc::new(map_provider(&[("X", "from-ambient")]));
let eff = with_config_provider(
id_effect::Effect::new(|_| Config::string("X").load_current()),
p,
);
assert_eq!(id_effect::run_blocking(eff, ()).unwrap(), "from-ambient");
}
#[test]
fn with_default_propagates_invalid_error() {
let p = map_provider(&[("PORT", "not_a_number")]);
let err = go(Config::integer("PORT").with_default(3000), &p).unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn optional_string_present() {
let p = map_provider(&[("OPT", "value")]);
assert_eq!(
go(Config::optional_string("OPT"), &p).unwrap(),
Some("value".to_string())
);
}
#[test]
fn optional_string_absent() {
let p = map_provider(&[]);
assert_eq!(go(Config::optional_string("OPT"), &p).unwrap(), None);
}
#[test]
fn optional_string_dotted_path() {
let p = map_provider(&[("SERVER_HOST", "localhost")]);
assert_eq!(
go(Config::optional_string("SERVER.HOST"), &p).unwrap(),
Some("localhost".to_string())
);
}
#[test]
fn boolean_yes_no_variants() {
let p = map_provider(&[("A", "yes"), ("B", "no"), ("C", "YES"), ("D", "NO")]);
assert!(go(Config::boolean("A"), &p).unwrap());
assert!(!go(Config::boolean("B"), &p).unwrap());
assert!(go(Config::boolean("C"), &p).unwrap());
assert!(!go(Config::boolean("D"), &p).unwrap());
}
#[test]
fn boolean_invalid_string_is_error() {
let p = map_provider(&[("FLAG", "maybe")]);
let err = go(Config::boolean("FLAG"), &p).unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn boolean_missing_is_error() {
let p = map_provider(&[]);
let err = go(Config::boolean("FLAG"), &p).unwrap_err();
assert!(matches!(err, ConfigError::Missing { .. }));
}
#[test]
fn map_attempt_failure_propagates_error() {
let p = map_provider(&[("VAL", "not-a-number")]);
let err = go(
Config::string("VAL").map_attempt(|s| {
s.parse::<i32>().map_err(|e| ConfigError::Invalid {
path: "VAL".into(),
value: s,
reason: e.to_string(),
})
}),
&p,
)
.unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn number_missing_is_error() {
let p = map_provider(&[]);
let err = go(Config::number("N"), &p).unwrap_err();
assert!(matches!(err, ConfigError::Missing { .. }));
}
#[test]
fn number_invalid_is_error() {
let p = map_provider(&[("N", "not_a_float")]);
let err = go(Config::number("N"), &p).unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn integer_missing_is_error() {
let p = map_provider(&[]);
let err = go(Config::integer("N"), &p).unwrap_err();
assert!(matches!(err, ConfigError::Missing { .. }));
}
#[test]
fn integer_invalid_is_error() {
let p = map_provider(&[("N", "not_an_int")]);
let err = go(Config::integer("N"), &p).unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn string_list_missing_is_error() {
let p = map_provider(&[]);
let err = go(Config::string_list("TAGS"), &p).unwrap_err();
assert!(matches!(err, ConfigError::Missing { .. }));
}
#[test]
fn repeat_missing_is_error() {
let p = map_provider(&[]);
let err = go(Config::repeat("ITEMS"), &p).unwrap_err();
assert!(matches!(err, ConfigError::Missing { .. }));
}
#[test]
fn or_else_propagates_invalid_error() {
let p = map_provider(&[("PORT", "bad")]);
let err = go(
or_else(Config::integer("PORT"), Config::integer("FALLBACK_PORT")),
&p,
)
.unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn nested_prefix_provider_seq_delim_delegates() {
use crate::MapConfigProvider;
use crate::provider::ProviderOptions;
let opts = ProviderOptions {
path_delim: "_",
seq_delim: "|",
};
let map: std::collections::HashMap<String, String> =
[("NS_ITEMS".to_string(), "a|b|c".to_string())]
.into_iter()
.collect();
let p = MapConfigProvider::with_options(map, opts);
let items = go(Config::string_list("ITEMS").nested("NS"), &p).unwrap();
assert_eq!(items, vec!["a", "b", "c"]);
}
}