Skip to main content

effect_config/
lib.rs

1//! Configuration loading in three complementary styles, aligned with
2//! [Effect.ts configuration](https://effect.website/docs/configuration):
3//!
4//! ## 1. `Config<T>` descriptor (Effect `Config.string` / `Config.withDefault` / `Config.all`)
5//!
6//! The recommended approach.  Compose lazy descriptors, then evaluate with [`Config::run`]
7//! (service-injected) or [`Config::load`] (direct provider reference):
8//!
9//! ```rust
10//! use std::sync::Arc;
11//! use effect_config::{Config, MapConfigProvider, config_env, config, ConfigError};
12//! use id_effect::run_blocking;
13//!
14//! let p = MapConfigProvider::from_pairs([("HOST", "localhost"), ("PORT", "8080")]);
15//!
16//! // Descriptors — nothing is read yet
17//! let host_cfg = Config::string("HOST");
18//! let port_cfg = Config::integer("PORT").with_default(3000);
19//!
20//! // Evaluate with a direct provider (synchronous)
21//! let (host, port) = config::all(host_cfg.clone(), port_cfg.clone()).load(&p).unwrap();
22//! assert_eq!(host, "localhost");
23//! assert_eq!(port, 8080);
24//!
25//! // Evaluate as Effect with service injection
26//! let env = config_env(p);
27//! let host2: String = run_blocking(host_cfg.run::<String, ConfigError, _>(), env.clone()).unwrap();
28//! let port2: i64 = run_blocking(port_cfg.run::<i64, ConfigError, _>(), env).unwrap();
29//! assert_eq!(host2, "localhost");
30//! assert_eq!(port2, 8080);
31//! ```
32//!
33//! ## 2. Figment + serde (whole-document extract)
34//!
35//! Build a [`Figment`](https://docs.rs/figment/latest/figment/struct.Figment.html) (layering TOML, JSON, env, …), then [`extract`] /
36//! [`FigmentLayer`] — good for structured config files.
37//!
38//! ## 3. Low-level Effect reads via `load::read_*` with `NeedsConfigProvider`
39//!
40//! Inject the provider via the effect environment and call the free functions directly:
41//!
42//! ```ignore
43//! use effect_config::{read_string, NeedsConfigProvider, ConfigError};
44//!
45//! fn load_host<A, E, R>() -> ::id_effect::Effect<A, E, R>
46//! where
47//!   A: From<String> + 'static,
48//!   E: From<ConfigError> + 'static,
49//!   R: NeedsConfigProvider + 'static,
50//! {
51//!   read_string(&["HOST"])
52//! }
53//! ```
54
55#![forbid(unsafe_code)]
56#![deny(missing_docs)]
57
58mod ambient;
59mod config_desc;
60mod error;
61mod load;
62mod provider;
63mod secret;
64
65pub use ambient::{current_config_provider, with_config_provider};
66pub use config_desc::{Config, all, all_vec, all3, nest, or_else as or_else_config, zip_with};
67pub use error::ConfigError;
68pub use load::{
69  WithConfigDefault, nested_path, read_bool, read_i64, read_nested_string, read_nested_string_list,
70  read_number, read_string, read_string_list, read_string_opt,
71};
72pub use provider::{
73  ConfigProvider, ConfigProviderKey, ConfigProviderService, EnvConfigProvider,
74  FigmentConfigProvider, MapConfigProvider, NeedsConfigProvider, OrElseConfigProvider,
75  ProviderOptions, ScopedConfigProvider,
76};
77pub use secret::Secret;
78
79// ── Service environment helper ────────────────────────────────────────────────
80
81use std::sync::Arc;
82
83/// Type alias for a minimal effect context containing only [`ConfigProviderService`].
84///
85/// Use with [`config_env`] and `run_blocking` to evaluate `Config<T>::run()` or `read_*` effects
86/// in tests and CLI entry points.
87pub type ConfigEnv = ::id_effect::Context<
88  ::id_effect::Cons<
89    ::id_effect::Service<ConfigProviderKey, ConfigProviderService>,
90    ::id_effect::Nil,
91  >,
92>;
93
94/// Build a minimal [`ConfigEnv`] wrapping `provider`.
95///
96/// ```rust
97/// use std::sync::Arc;
98/// use effect_config::{Config, MapConfigProvider, config_env, ConfigError};
99/// use id_effect::run_blocking;
100///
101/// let p = MapConfigProvider::from_pairs([("HOST", "localhost")]);
102/// let host: String = run_blocking(
103///   Config::string("HOST").run::<String, ConfigError, _>(),
104///   config_env(p),
105/// )
106/// .unwrap();
107/// assert_eq!(host, "localhost");
108/// ```
109pub fn config_env<P: ConfigProvider + 'static>(provider: P) -> ConfigEnv {
110  use ::id_effect::{Cons, Context, Nil, Service};
111  Context::new(Cons(
112    Service::<ConfigProviderKey, _>::new(ConfigProviderService(Arc::new(provider))),
113    Nil,
114  ))
115}
116
117/// Mirrors `import { Config } from "effect"` — scalars, combinators, and free functions.
118///
119/// The *descriptor* ([`Config`]) and *free functions* ([`all`], [`zip_with`], …) are
120/// re-exported here so call sites can write `config::all(…)` and `config::Config::string(…)`.
121pub mod config {
122  pub use crate::config_desc::{Config, all, all_vec, all3, nest, or_else, zip_with};
123  pub use crate::secret::Secret;
124  pub use crate::{current_config_provider, with_config_provider};
125
126  use crate::ConfigError;
127  use crate::ConfigProvider;
128
129  /// Required string at a single-segment path.
130  #[inline]
131  pub fn string(p: &impl ConfigProvider, name: &str) -> Result<String, ConfigError> {
132    Config::string(name).load(p)
133  }
134
135  /// Optional string at a single-segment path.
136  #[inline]
137  pub fn optional_string(
138    p: &impl ConfigProvider,
139    name: &str,
140  ) -> Result<Option<String>, ConfigError> {
141    Config::optional_string(name).load(p)
142  }
143
144  /// Floating-point scalar.
145  #[inline]
146  pub fn number(p: &impl ConfigProvider, name: &str) -> Result<f64, ConfigError> {
147    Config::number(name).load(p)
148  }
149
150  /// Signed integer scalar.
151  #[inline]
152  pub fn integer(p: &impl ConfigProvider, name: &str) -> Result<i64, ConfigError> {
153    Config::integer(name).load(p)
154  }
155
156  /// Boolean scalar.
157  #[inline]
158  pub fn boolean(p: &impl ConfigProvider, name: &str) -> Result<bool, ConfigError> {
159    Config::boolean(name).load(p)
160  }
161
162  /// String under `namespace` / `name` (two path segments).
163  #[inline]
164  pub fn nested_string(
165    p: &impl ConfigProvider,
166    namespace: &str,
167    name: &str,
168  ) -> Result<String, ConfigError> {
169    Config::string(name).nested(namespace).load(p)
170  }
171
172  /// Optional string under `namespace` / `name`.
173  #[inline]
174  pub fn nested_optional_string(
175    p: &impl ConfigProvider,
176    namespace: &str,
177    name: &str,
178  ) -> Result<Option<String>, ConfigError> {
179    Config::optional_string(name).nested(namespace).load(p)
180  }
181
182  /// Floating-point under `namespace` / `name`.
183  #[inline]
184  pub fn nested_number(
185    p: &impl ConfigProvider,
186    namespace: &str,
187    name: &str,
188  ) -> Result<f64, ConfigError> {
189    Config::number(name).nested(namespace).load(p)
190  }
191
192  /// Signed integer under `namespace` / `name`.
193  #[inline]
194  pub fn nested_integer(
195    p: &impl ConfigProvider,
196    namespace: &str,
197    name: &str,
198  ) -> Result<i64, ConfigError> {
199    Config::integer(name).nested(namespace).load(p)
200  }
201
202  /// Boolean under `namespace` / `name`.
203  #[inline]
204  pub fn nested_boolean(
205    p: &impl ConfigProvider,
206    namespace: &str,
207    name: &str,
208  ) -> Result<bool, ConfigError> {
209    Config::boolean(name).nested(namespace).load(p)
210  }
211
212  /// Delimiter-separated string list.
213  #[inline]
214  pub fn string_list(p: &impl ConfigProvider, name: &str) -> Result<Vec<String>, ConfigError> {
215    Config::string_list(name).load(p)
216  }
217
218  /// String list under `namespace` / `name`.
219  #[inline]
220  pub fn nested_string_list(
221    p: &impl ConfigProvider,
222    namespace: &str,
223    name: &str,
224  ) -> Result<Vec<String>, ConfigError> {
225    Config::string_list(name).nested(namespace).load(p)
226  }
227
228  /// Fall back to `default` when `r` is [`ConfigError::Missing`].
229  #[inline]
230  pub fn with_default<T>(r: Result<T, ConfigError>, default: T) -> Result<T, ConfigError> {
231    match r {
232      Ok(v) => Ok(v),
233      Err(ConfigError::Missing { .. }) => Ok(default),
234      Err(e) => Err(e),
235    }
236  }
237}
238
239use std::marker::PhantomData;
240
241use ::figment::Figment;
242use id_effect::{Layer, Never};
243use serde::de::DeserializeOwned;
244
245/// Deserialize `T` from a prepared [`Figment`].
246#[inline]
247pub fn extract<T: DeserializeOwned>(figment: &Figment) -> Result<T, ConfigError> {
248  figment.extract().map_err(ConfigError::from)
249}
250
251/// Same as [`extract`], explicit name for boolean-heavy call sites.
252#[inline]
253pub fn try_extract<T: DeserializeOwned>(figment: &Figment) -> Result<T, ConfigError> {
254  extract(figment)
255}
256
257/// [`Layer`] that deserializes `T` from a shared [`Figment`] on each [`Layer::build`].
258#[derive(Debug)]
259pub struct FigmentLayer<T> {
260  figment: Arc<Figment>,
261  _marker: PhantomData<fn() -> T>,
262}
263
264impl<T> FigmentLayer<T> {
265  /// Layer that deserializes from an owned [`Figment`] on each build.
266  #[inline]
267  pub fn new(figment: Figment) -> Self {
268    Self {
269      figment: Arc::new(figment),
270      _marker: PhantomData,
271    }
272  }
273
274  /// Layer that reuses an existing shared [`Arc<Figment>`] on each build.
275  #[inline]
276  pub fn from_shared(figment: Arc<Figment>) -> Self {
277    Self {
278      figment,
279      _marker: PhantomData,
280    }
281  }
282
283  /// Borrow the merged [`Figment`] used for deserialization.
284  #[inline]
285  pub fn figment(&self) -> &Figment {
286    self.figment.as_ref()
287  }
288}
289
290impl<T: DeserializeOwned + Send + Sync + 'static> Layer for FigmentLayer<T> {
291  type Output = T;
292  type Error = ConfigError;
293
294  fn build(&self) -> Result<Self::Output, Self::Error> {
295    extract(self.figment.as_ref())
296  }
297}
298
299/// Infallible [`Layer`] that builds a [`FigmentConfigProvider`] sharing the same merged [`Figment`].
300#[derive(Clone, Debug)]
301pub struct FigmentProviderLayer {
302  figment: Arc<Figment>,
303}
304
305impl FigmentProviderLayer {
306  /// Infailable layer wrapping an owned [`Figment`] as a [`FigmentConfigProvider`].
307  #[inline]
308  pub fn new(figment: Figment) -> Self {
309    Self {
310      figment: Arc::new(figment),
311    }
312  }
313
314  /// Share an existing [`Arc<Figment>`] with the built provider.
315  #[inline]
316  pub fn from_shared(figment: Arc<Figment>) -> Self {
317    Self { figment }
318  }
319
320  /// Borrow the underlying [`Figment`].
321  #[inline]
322  pub fn figment(&self) -> &Figment {
323    self.figment.as_ref()
324  }
325}
326
327impl Layer for FigmentProviderLayer {
328  type Output = FigmentConfigProvider;
329  type Error = Never;
330
331  fn build(&self) -> Result<Self::Output, Self::Error> {
332    Ok(FigmentConfigProvider::from_shared(self.figment.clone()))
333  }
334}
335
336/// Infallible [`Layer`] that yields [`EnvConfigProvider`] with fixed [`ProviderOptions`].
337#[derive(Clone, Debug)]
338pub struct EnvProviderLayer {
339  options: ProviderOptions,
340}
341
342impl EnvProviderLayer {
343  /// Layer using [`ProviderOptions::default`] and `std::env`.
344  #[inline]
345  pub fn from_env() -> Self {
346    Self::new(ProviderOptions::default())
347  }
348
349  /// Layer with explicit env path and list delimiter options.
350  #[inline]
351  pub fn new(options: ProviderOptions) -> Self {
352    Self { options }
353  }
354
355  /// Delimiters used when the built [`EnvConfigProvider`] flattens paths and splits lists.
356  #[inline]
357  pub fn options(&self) -> &ProviderOptions {
358    &self.options
359  }
360}
361
362impl Layer for EnvProviderLayer {
363  type Output = EnvConfigProvider;
364  type Error = Never;
365
366  fn build(&self) -> Result<Self::Output, Self::Error> {
367    Ok(EnvConfigProvider::new(self.options.clone()))
368  }
369}
370
371/// Common [`Figment`] builders.
372pub mod figment {
373  #[cfg(any(feature = "env", feature = "toml", feature = "json", feature = "yaml"))]
374  use ::figment::Figment;
375  #[cfg(feature = "env")]
376  use ::figment::providers::Env;
377  #[cfg(feature = "toml")]
378  use ::figment::providers::{Format, Toml};
379  #[cfg(any(feature = "toml", feature = "json", feature = "yaml"))]
380  use std::path::Path;
381
382  /// Figment containing all environment variables (no prefix filter).
383  #[must_use]
384  #[cfg(feature = "env")]
385  pub fn from_env_raw() -> Figment {
386    Figment::from(Env::raw())
387  }
388
389  /// Figment from environment variables whose names start with `prefix`.
390  #[must_use]
391  #[cfg(feature = "env")]
392  pub fn from_env_prefixed(prefix: impl AsRef<str>) -> Figment {
393    Figment::from(Env::prefixed(prefix.as_ref()))
394  }
395
396  /// Merge a TOML file into an existing [`Figment`].
397  #[must_use]
398  #[cfg(feature = "toml")]
399  pub fn merge_toml(figment: Figment, path: impl AsRef<Path>) -> Figment {
400    figment.merge(Toml::file(path))
401  }
402
403  /// New [`Figment`] consisting only of a single TOML file.
404  #[must_use]
405  #[cfg(feature = "toml")]
406  pub fn from_toml_file(path: impl AsRef<Path>) -> Figment {
407    Figment::new().merge(Toml::file(path))
408  }
409
410  /// Merge a JSON file into an existing [`Figment`].
411  #[must_use]
412  #[cfg(feature = "json")]
413  pub fn merge_json(figment: Figment, path: impl AsRef<Path>) -> Figment {
414    use ::figment::providers::{Format, Json};
415    figment.merge(Json::file(path))
416  }
417
418  /// Merge a YAML file into an existing [`Figment`].
419  #[must_use]
420  #[cfg(feature = "yaml")]
421  pub fn merge_yaml(figment: Figment, path: impl AsRef<Path>) -> Figment {
422    use ::figment::providers::{Format, Yaml};
423    figment.merge(Yaml::file(path))
424  }
425
426  /// Default TOML, local override TOML, then prefixed environment (common app layering).
427  #[must_use]
428  #[cfg(all(feature = "toml", feature = "env"))]
429  pub fn layered_toml_env(
430    default_toml: impl AsRef<Path>,
431    local_toml: impl AsRef<Path>,
432    env_prefix: impl AsRef<str>,
433  ) -> Figment {
434    Figment::new()
435      .merge(Toml::file(default_toml))
436      .merge(Toml::file(local_toml))
437      .merge(Env::prefixed(env_prefix.as_ref()))
438  }
439}
440
441/// Load `.env` from the current directory (fails if the file is missing or invalid).
442#[cfg(feature = "dotenv")]
443#[inline]
444pub fn load_dotenv() -> Result<(), dotenvy::Error> {
445  dotenvy::dotenv().map(|_| ())
446}
447
448/// Best-effort `.env` load; ignores missing files and parse errors.
449#[cfg(feature = "dotenv")]
450#[inline]
451pub fn load_dotenv_optional() {
452  let _ = dotenvy::dotenv();
453}
454
455#[cfg(all(test, feature = "toml"))]
456mod tests {
457  use super::*;
458  use serde::Deserialize;
459  use std::io::Write;
460  use temp_env::with_var;
461
462  #[derive(Debug, Deserialize, PartialEq)]
463  struct Cfg {
464    n: u32,
465    s: String,
466  }
467
468  #[test]
469  fn extract_from_toml_file() {
470    let dir = tempfile::tempdir().expect("tempdir");
471    let path = dir.path().join("app.toml");
472    let mut f = std::fs::File::create(&path).expect("create");
473    writeln!(f, "n = 7\ns = \"hi\"").expect("write");
474    drop(f);
475
476    let fig = figment::from_toml_file(&path);
477    let cfg: Cfg = extract(&fig).expect("extract");
478    assert_eq!(
479      cfg,
480      Cfg {
481        n: 7,
482        s: "hi".into()
483      }
484    );
485  }
486
487  #[test]
488  fn figment_layer_build() {
489    let dir = tempfile::tempdir().expect("tempdir");
490    let path = dir.path().join("x.toml");
491    std::fs::write(&path, "n = 1\ns = \"a\"").expect("write");
492
493    let layer = FigmentLayer::<Cfg>::new(figment::from_toml_file(&path));
494    let cfg = Layer::build(&layer).expect("build");
495    assert_eq!(cfg.n, 1);
496    assert_eq!(cfg.s, "a");
497  }
498
499  #[test]
500  fn env_provider_nested_and_default() {
501    let key = "EFFECT_CONFIG_TEST_SERVER_HOST";
502    with_var(key, Some("localhost"), || {
503      let p = EnvConfigProvider::from_env();
504      let host = config::nested_string(&p, "EFFECT_CONFIG_TEST_SERVER", "HOST").expect("host");
505      assert_eq!(host, "localhost");
506      let port = config::with_default(
507        config::nested_integer(&p, "EFFECT_CONFIG_TEST_SERVER", "PORT"),
508        9,
509      )
510      .expect("port default");
511      assert_eq!(port, 9);
512    });
513  }
514
515  #[test]
516  fn map_provider_seq_delim() {
517    let mut m = std::collections::HashMap::new();
518    m.insert("TAGS".into(), "a,b, c".into());
519    let opts = ProviderOptions {
520      path_delim: "_",
521      seq_delim: ",",
522    };
523    let p = MapConfigProvider::with_options(m, opts);
524    let tags = config::string_list(&p, "TAGS").expect("tags");
525    assert_eq!(tags, vec!["a", "b", "c"]);
526  }
527
528  #[test]
529  fn figment_provider_scalar_from_toml() {
530    let dir = tempfile::tempdir().expect("tempdir");
531    let path = dir.path().join("c.toml");
532    std::fs::write(
533      &path,
534      r#"
535[server]
536host = "0.0.0.0"
537port = 3000
538"#,
539    )
540    .expect("write");
541    let fig = figment::from_toml_file(&path);
542    let p = FigmentConfigProvider::new(fig);
543    assert_eq!(
544      config::nested_string(&p, "server", "host").unwrap(),
545      "0.0.0.0"
546    );
547    assert_eq!(config::nested_integer(&p, "server", "port").unwrap(), 3000);
548  }
549
550  #[test]
551  fn map_from_pairs() {
552    let p = MapConfigProvider::from_pairs([("A_B", "x")]);
553    assert_eq!(config::nested_string(&p, "A", "B").unwrap(), "x");
554  }
555
556  #[test]
557  fn or_else_fallback() {
558    let a = MapConfigProvider::from_map(std::collections::HashMap::new());
559    let b = MapConfigProvider::from_pairs([("K", "from-b")]);
560    let p = OrElseConfigProvider::new(a, b);
561    assert_eq!(config::string(&p, "K").unwrap(), "from-b");
562  }
563
564  #[test]
565  fn figment_provider_layer_builds_provider() {
566    let dir = tempfile::tempdir().expect("tempdir");
567    let path = dir.path().join("d.toml");
568    std::fs::write(&path, "k = \"v\"").expect("write");
569    let layer = FigmentProviderLayer::new(figment::from_toml_file(&path));
570    let prov = Layer::build(&layer).expect("infallible");
571    assert_eq!(config::string(&prov, "k").unwrap(), "v");
572  }
573
574  #[test]
575  fn env_provider_layer() {
576    let key = "EFFECT_CONFIG_TEST_LAYER_X";
577    with_var(key, Some("42"), || {
578      let layer = EnvProviderLayer::from_env();
579      let p = Layer::build(&layer).expect("infallible");
580      assert_eq!(config::integer(&p, key).unwrap(), 42);
581    });
582  }
583
584  #[test]
585  fn scoped_provider_prefix_segments_and_nested_lookup() {
586    let p = MapConfigProvider::from_pairs([("A_B_C_D", "nested")]);
587    let scoped = ScopedConfigProvider::new(p, "A.B");
588    assert_eq!(scoped.prefix_segments(), &["A", "B"]);
589    assert_eq!(config::nested_string(&scoped, "C", "D").unwrap(), "nested");
590    assert_eq!(config::string(scoped.inner(), "A_B_C_D").unwrap(), "nested");
591  }
592
593  #[test]
594  fn figment_bool_float_and_non_scalar_error() {
595    let dir = tempfile::tempdir().expect("tempdir");
596    let path = dir.path().join("mix.toml");
597    std::fs::write(
598      &path,
599      r#"
600flag = true
601pi = 2.5
602bad = [1, 2]
603"#,
604    )
605    .expect("write");
606    let fig = figment::from_toml_file(&path);
607    let p = FigmentConfigProvider::new(fig);
608    assert!(config::boolean(&p, "flag").unwrap());
609    assert!((config::number(&p, "pi").unwrap() - 2.5).abs() < f64::EPSILON);
610    assert!(config::string(&p, "bad").is_err());
611  }
612
613  // ── config_env ────────────────────────────────────────────────────────────
614
615  #[test]
616  fn config_env_runs_effect() {
617    use id_effect::run_blocking;
618
619    let p = MapConfigProvider::from_pairs([("K", "v")]);
620    let env = config_env(p);
621    let result: String =
622      run_blocking(Config::string("K").run::<String, ConfigError, _>(), env).unwrap();
623    assert_eq!(result, "v");
624  }
625
626  // ── config::optional_string ───────────────────────────────────────────────
627
628  #[test]
629  fn config_optional_string_present_and_absent() {
630    let p = MapConfigProvider::from_pairs([("PRESENT", "yes")]);
631    assert_eq!(
632      config::optional_string(&p, "PRESENT").unwrap(),
633      Some("yes".to_string())
634    );
635    assert_eq!(config::optional_string(&p, "ABSENT").unwrap(), None);
636  }
637
638  // ── config::number ────────────────────────────────────────────────────────
639
640  #[test]
641  fn config_number_scalar() {
642    let p = MapConfigProvider::from_pairs([("PI", "3.14")]);
643    let v = config::number(&p, "PI").unwrap();
644    #[allow(clippy::approx_constant)]
645    let expected = 3.14_f64;
646    assert!((v - expected).abs() < f64::EPSILON);
647  }
648
649  // ── config::integer ───────────────────────────────────────────────────────
650
651  #[test]
652  fn config_integer_scalar() {
653    let p = MapConfigProvider::from_pairs([("N", "99")]);
654    assert_eq!(config::integer(&p, "N").unwrap(), 99);
655  }
656
657  // ── config::boolean ───────────────────────────────────────────────────────
658
659  #[test]
660  fn config_boolean_scalar() {
661    let p = MapConfigProvider::from_pairs([("F", "true")]);
662    assert!(config::boolean(&p, "F").unwrap());
663  }
664
665  // ── config::nested_optional_string ───────────────────────────────────────
666
667  #[test]
668  fn config_nested_optional_string() {
669    let p = MapConfigProvider::from_pairs([("NS_KEY", "val")]);
670    assert_eq!(
671      config::nested_optional_string(&p, "NS", "KEY").unwrap(),
672      Some("val".to_string())
673    );
674    assert_eq!(
675      config::nested_optional_string(&p, "NS", "MISSING").unwrap(),
676      None
677    );
678  }
679
680  // ── config::nested_number / nested_integer / nested_boolean ──────────────
681
682  #[test]
683  fn config_nested_number() {
684    let p = MapConfigProvider::from_pairs([("SRV_RATE", "1.5")]);
685    let v = config::nested_number(&p, "SRV", "RATE").unwrap();
686    assert!((v - 1.5).abs() < f64::EPSILON);
687  }
688
689  #[test]
690  fn config_nested_integer() {
691    let p = MapConfigProvider::from_pairs([("SRV_PORT", "5432")]);
692    assert_eq!(config::nested_integer(&p, "SRV", "PORT").unwrap(), 5432);
693  }
694
695  #[test]
696  fn config_nested_boolean() {
697    let p = MapConfigProvider::from_pairs([("SRV_TLS", "true")]);
698    assert!(config::nested_boolean(&p, "SRV", "TLS").unwrap());
699  }
700
701  // ── config::nested_string_list ────────────────────────────────────────────
702
703  #[test]
704  fn config_nested_string_list() {
705    let p = MapConfigProvider::from_pairs([("NS_TAGS", "a,b,c")]);
706    let tags = config::nested_string_list(&p, "NS", "TAGS").unwrap();
707    assert_eq!(tags, vec!["a", "b", "c"]);
708  }
709
710  // ── config::string_list ───────────────────────────────────────────────────
711
712  #[test]
713  fn config_string_list_free_fn() {
714    let p = MapConfigProvider::from_pairs([("HOSTS", "h1,h2")]);
715    let hosts = config::string_list(&p, "HOSTS").unwrap();
716    assert_eq!(hosts, vec!["h1", "h2"]);
717  }
718
719  // ── config::with_default ──────────────────────────────────────────────────
720
721  #[test]
722  fn config_with_default_missing_uses_default() {
723    let p = MapConfigProvider::from_pairs::<[(&str, &str); 0], _, _>([]);
724    let v = config::with_default(config::string(&p, "MISSING"), "fallback".to_string()).unwrap();
725    assert_eq!(v, "fallback");
726  }
727
728  #[test]
729  fn config_with_default_present_ignores_default() {
730    let p = MapConfigProvider::from_pairs([("K", "real")]);
731    let v = config::with_default(config::string(&p, "K"), "fallback".to_string()).unwrap();
732    assert_eq!(v, "real");
733  }
734
735  #[test]
736  fn config_with_default_invalid_propagates() {
737    let p = MapConfigProvider::from_pairs([("N", "bad")]);
738    let err = config::with_default(config::integer(&p, "N"), 0_i64).unwrap_err();
739    assert!(matches!(err, ConfigError::Invalid { .. }));
740  }
741
742  // ── FigmentLayer::from_shared / figment() ─────────────────────────────────
743
744  #[test]
745  fn figment_layer_from_shared_and_figment_accessor() {
746    let dir = tempfile::tempdir().expect("tempdir");
747    let path = dir.path().join("shared.toml");
748    std::fs::write(&path, "n = 3\ns = \"hi\"").expect("write");
749    let shared = Arc::new(figment::from_toml_file(&path));
750    let layer = FigmentLayer::<Cfg>::from_shared(Arc::clone(&shared));
751    // figment() accessor
752    let _ = layer.figment();
753    // build still works
754    let cfg = Layer::build(&layer).expect("build");
755    assert_eq!(cfg.n, 3);
756  }
757
758  // ── FigmentProviderLayer::from_shared / figment() ─────────────────────────
759
760  #[test]
761  fn figment_provider_layer_from_shared_and_figment_accessor() {
762    let dir = tempfile::tempdir().expect("tempdir");
763    let path = dir.path().join("ps.toml");
764    std::fs::write(&path, "k = \"shared\"").expect("write");
765    let shared = Arc::new(figment::from_toml_file(&path));
766    let layer = FigmentProviderLayer::from_shared(Arc::clone(&shared));
767    let _ = layer.figment();
768    let prov = Layer::build(&layer).expect("infallible");
769    assert_eq!(config::string(&prov, "k").unwrap(), "shared");
770  }
771
772  // ── EnvProviderLayer::new / options() ────────────────────────────────────
773
774  #[test]
775  fn env_provider_layer_new_and_options_accessor() {
776    let opts = ProviderOptions {
777      path_delim: ".",
778      seq_delim: ";",
779    };
780    let layer = EnvProviderLayer::new(opts.clone());
781    assert_eq!(layer.options().seq_delim, ";");
782    let p = Layer::build(&layer).expect("infallible");
783    assert_eq!(p.seq_delim(), ";");
784  }
785}