Skip to main content

effect_config/
provider.rs

1//! Effect.ts-style [`ConfigProvider`]: pluggable sources with path / sequence delimiters.
2//!
3//! Mirrors [`ConfigProvider.fromEnv`](https://effect.website/docs/configuration) and
4//! `ConfigProvider.fromMap` (path delimiter, sequence delimiter).
5
6use std::collections::HashMap;
7use std::fmt;
8use std::future::ready;
9use std::sync::Arc;
10
11use ::figment::Figment;
12use ::figment::value::{Num, Value};
13
14use ::id_effect::{BoxFuture, Get, Here, IntoBind};
15
16use crate::error::ConfigError;
17
18// ── Service tag, struct, and NeedsConfigProvider ──────────────────────────────
19
20::id_effect::service_key!(
21  /// Tag for [`ConfigProviderService`] in an [`id_effect::Context`] stack.
22  pub struct ConfigProviderKey
23);
24
25/// Injectable wrapper around an `Arc<dyn ConfigProvider>`.
26///
27/// Extract it with `Get::<ConfigProviderKey, Here>::get(r)` inside an `effect!`
28/// body, or use `~ConfigProviderService` for the async variant.
29#[derive(Clone)]
30pub struct ConfigProviderService(pub Arc<dyn ConfigProvider>);
31
32impl fmt::Debug for ConfigProviderService {
33  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34    f.debug_tuple("ConfigProviderService")
35      .field(&"<dyn ConfigProvider>")
36      .finish()
37  }
38}
39
40impl<'a, R> IntoBind<'a, R, ConfigProviderService, ConfigError> for ConfigProviderService
41where
42  R: Get<ConfigProviderKey, Here, Target = ConfigProviderService> + 'a,
43{
44  fn into_bind(self, r: &'a mut R) -> BoxFuture<'a, Result<ConfigProviderService, ConfigError>> {
45    Box::pin(ready(Ok(Get::<ConfigProviderKey, Here>::get(r).clone())))
46  }
47}
48
49/// Supertrait alias — write `R: NeedsConfigProvider` instead of the full `Get<…>` bound.
50pub trait NeedsConfigProvider:
51  Get<ConfigProviderKey, Here, Target = ConfigProviderService>
52{
53}
54impl<R: Get<ConfigProviderKey, Here, Target = ConfigProviderService>> NeedsConfigProvider for R {}
55
56/// Options aligned with Effect `ConfigProvider.fromEnv` (`pathDelim`, `seqDelim`).
57#[derive(Clone, Debug)]
58pub struct ProviderOptions {
59  /// Joins path segments for lookups (Effect default: `"_"`).
60  pub path_delim: &'static str,
61  /// Separates list elements in a single string (Effect default: `","`).
62  pub seq_delim: &'static str,
63}
64
65impl Default for ProviderOptions {
66  fn default() -> Self {
67    Self {
68      path_delim: "_",
69      seq_delim: ",",
70    }
71  }
72}
73
74/// Abstract configuration source (Effect `ConfigProvider`).
75pub trait ConfigProvider: Send + Sync {
76  /// Look up a scalar; segments are joined with [`ProviderOptions::path_delim`] (or provider-specific rules).
77  fn load_raw(&self, path: &[&str]) -> Result<Option<String>, ConfigError>;
78
79  /// Delimiter used by [`crate::read_string_list`] (Effect `seqDelim`).
80  fn seq_delim(&self) -> &'static str {
81    ","
82  }
83
84  /// Scope this provider under `prefix`, prepending those segments to every lookup
85  /// (Effect `ConfigProvider.within`).
86  ///
87  /// ```rust
88  /// use effect_config::{ConfigProvider, MapConfigProvider};
89  ///
90  /// let base = MapConfigProvider::from_pairs([("SERVER_HOST", "localhost")]);
91  /// let scoped = base.within("SERVER");
92  /// let host = scoped.load_raw(&["HOST"]).unwrap();
93  /// assert_eq!(host, Some("localhost".to_string()));
94  /// ```
95  fn within(self, prefix: impl Into<String>) -> ScopedConfigProvider<Self>
96  where
97    Self: Sized,
98  {
99    ScopedConfigProvider::new(self, prefix)
100  }
101
102  /// Try `self` first; fall back to `fallback` on missing keys (Effect `ConfigProvider.orElse`).
103  ///
104  /// This is the *provider-level* fallback.  For a *descriptor-level* fallback see
105  /// [`Config::with_default`](crate::Config::with_default).
106  fn or_else<B: ConfigProvider + Clone>(self, fallback: B) -> OrElseConfigProvider<Self, B>
107  where
108    Self: Sized + Clone,
109  {
110    OrElseConfigProvider::new(self, fallback)
111  }
112}
113
114fn join_path(path: &[&str], delim: &str) -> Result<String, ConfigError> {
115  if path.is_empty() {
116    return Err(ConfigError::Invalid {
117      path: String::new(),
118      value: String::new(),
119      reason: "empty configuration path".into(),
120    });
121  }
122  Ok(path.join(delim))
123}
124
125/// Reads from `std::env` using flattened keys (`SERVER_PORT` for path `["SERVER","PORT"]` with default delim).
126#[derive(Clone, Debug)]
127pub struct EnvConfigProvider {
128  options: ProviderOptions,
129}
130
131impl EnvConfigProvider {
132  /// `ConfigProvider.fromEnv()` with Effect defaults.
133  #[inline]
134  pub fn from_env() -> Self {
135    Self::new(ProviderOptions::default())
136  }
137
138  /// Build with explicit path and sequence delimiter options.
139  #[inline]
140  pub fn new(options: ProviderOptions) -> Self {
141    Self { options }
142  }
143}
144
145impl ConfigProvider for EnvConfigProvider {
146  fn load_raw(&self, path: &[&str]) -> Result<Option<String>, ConfigError> {
147    let key = join_path(path, self.options.path_delim)?;
148    match std::env::var(&key) {
149      Ok(s) => Ok(Some(s)),
150      Err(std::env::VarError::NotPresent) => Ok(None),
151      Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidUtf8 { var: key }),
152    }
153  }
154
155  fn seq_delim(&self) -> &'static str {
156    self.options.seq_delim
157  }
158}
159
160/// In-memory map for tests or static overrides (`ConfigProvider.fromMap`).
161#[derive(Clone, Debug)]
162pub struct MapConfigProvider {
163  map: HashMap<String, String>,
164  options: ProviderOptions,
165}
166
167impl MapConfigProvider {
168  /// In-memory provider with default [`ProviderOptions`].
169  #[inline]
170  pub fn from_map(map: HashMap<String, String>) -> Self {
171    Self::with_options(map, ProviderOptions::default())
172  }
173
174  /// In-memory provider with custom path and list delimiters.
175  #[inline]
176  pub fn with_options(map: HashMap<String, String>, options: ProviderOptions) -> Self {
177    Self { map, options }
178  }
179
180  /// Build a map provider from `(key, value)` pairs (Effect `ConfigProvider.fromMap(new Map(...))`).
181  #[inline]
182  pub fn from_pairs<I, K, V>(pairs: I) -> Self
183  where
184    I: IntoIterator<Item = (K, V)>,
185    K: Into<String>,
186    V: Into<String>,
187  {
188    Self::from_pairs_with_options(pairs, ProviderOptions::default())
189  }
190
191  /// Like [`Self::from_pairs`], with explicit [`ProviderOptions`].
192  #[inline]
193  pub fn from_pairs_with_options<I, K, V>(pairs: I, options: ProviderOptions) -> Self
194  where
195    I: IntoIterator<Item = (K, V)>,
196    K: Into<String>,
197    V: Into<String>,
198  {
199    Self {
200      map: pairs
201        .into_iter()
202        .map(|(k, v)| (k.into(), v.into()))
203        .collect(),
204      options,
205    }
206  }
207}
208
209impl ConfigProvider for MapConfigProvider {
210  fn load_raw(&self, path: &[&str]) -> Result<Option<String>, ConfigError> {
211    let key = join_path(path, self.options.path_delim)?;
212    Ok(self.map.get(&key).cloned())
213  }
214
215  fn seq_delim(&self) -> &'static str {
216    self.options.seq_delim
217  }
218}
219
220/// Adapts a merged [`Figment`] as a provider (paths joined with `.`, matching Figment key paths).
221#[derive(Clone, Debug)]
222pub struct FigmentConfigProvider {
223  figment: Arc<Figment>,
224}
225
226impl FigmentConfigProvider {
227  /// Scalar reads against an owned merged [`Figment`].
228  #[inline]
229  pub fn new(figment: Figment) -> Self {
230    Self {
231      figment: Arc::new(figment),
232    }
233  }
234
235  /// Share an existing [`Arc<Figment>`] across providers or layers.
236  #[inline]
237  pub fn from_shared(figment: Arc<Figment>) -> Self {
238    Self { figment }
239  }
240
241  /// Borrow the underlying merged [`Figment`].
242  #[inline]
243  pub fn figment(&self) -> &Figment {
244    self.figment.as_ref()
245  }
246}
247
248fn num_to_string(n: Num) -> String {
249  match n {
250    Num::U8(v) => v.to_string(),
251    Num::U16(v) => v.to_string(),
252    Num::U32(v) => v.to_string(),
253    Num::U64(v) => v.to_string(),
254    Num::U128(v) => v.to_string(),
255    Num::USize(v) => v.to_string(),
256    Num::I8(v) => v.to_string(),
257    Num::I16(v) => v.to_string(),
258    Num::I32(v) => v.to_string(),
259    Num::I64(v) => v.to_string(),
260    Num::I128(v) => v.to_string(),
261    Num::ISize(v) => v.to_string(),
262    Num::F32(v) => v.to_string(),
263    Num::F64(v) => v.to_string(),
264  }
265}
266
267fn figment_value_as_raw_string(v: &Value) -> Result<String, ConfigError> {
268  match v {
269    Value::String(_, s) => Ok(s.clone()),
270    Value::Char(_, c) => Ok(c.to_string()),
271    Value::Bool(_, b) => Ok(b.to_string()),
272    Value::Num(_, n) => Ok(num_to_string(*n)),
273    _ => Err(ConfigError::Invalid {
274      path: String::new(),
275      value: format!("{v:?}"),
276      reason: "expected a scalar string, bool, or number".into(),
277    }),
278  }
279}
280
281impl ConfigProvider for FigmentConfigProvider {
282  fn load_raw(&self, path: &[&str]) -> Result<Option<String>, ConfigError> {
283    let key_path = join_path(path, ".")?;
284    let fig = self.figment.as_ref();
285    if !fig.contains(&key_path) {
286      return Ok(None);
287    }
288    let v = fig.find_value(&key_path).map_err(ConfigError::from)?;
289    figment_value_as_raw_string(&v).map(Some)
290  }
291}
292
293/// Try `primary` first; if it returns [`None`], use `fallback` (Effect-style provider composition).
294#[derive(Clone, Debug)]
295pub struct OrElseConfigProvider<A, B> {
296  primary: A,
297  fallback: B,
298}
299
300impl<A, B> OrElseConfigProvider<A, B> {
301  /// Try `primary` first; on missing keys, delegate to `fallback`.
302  #[inline]
303  pub fn new(primary: A, fallback: B) -> Self {
304    Self { primary, fallback }
305  }
306}
307
308impl<A: ConfigProvider + Clone + 'static, B: ConfigProvider + Clone + 'static> ConfigProvider
309  for OrElseConfigProvider<A, B>
310{
311  fn load_raw(&self, path: &[&str]) -> Result<Option<String>, ConfigError> {
312    let path_refs_primary: Vec<&str> = path.to_vec();
313    match self.primary.load_raw(&path_refs_primary)? {
314      None => self.fallback.load_raw(path),
315      some => Ok(some),
316    }
317  }
318
319  /// Uses the primary provider’s delimiter; keep both providers on the same convention when using [`crate::read_string_list`].
320  fn seq_delim(&self) -> &'static str {
321    self.primary.seq_delim()
322  }
323}
324
325// ── ScopedConfigProvider ──────────────────────────────────────────────────────
326
327/// Wraps an inner provider, prepending fixed path segments to every lookup.
328///
329/// Created via [`ConfigProvider::within`].
330///
331/// ```rust
332/// use effect_config::{ConfigProvider, MapConfigProvider, config};
333///
334/// let base = MapConfigProvider::from_pairs([("DB_HOST", "localhost"), ("DB_PORT", "5432")]);
335/// let db = base.within("DB");
336/// assert_eq!(config::string(&db, "HOST").unwrap(), "localhost");
337/// assert_eq!(config::integer(&db, "PORT").unwrap(), 5432);
338/// ```
339#[derive(Clone, Debug)]
340pub struct ScopedConfigProvider<P> {
341  inner: P,
342  prefix: Vec<String>,
343}
344
345impl<P> ScopedConfigProvider<P> {
346  /// Build a scoped provider.  `prefix` is split on `'.'` to form path segments.
347  pub fn new(inner: P, prefix: impl Into<String>) -> Self {
348    Self {
349      inner,
350      prefix: prefix
351        .into()
352        .split('.')
353        .filter(|s| !s.is_empty())
354        .map(String::from)
355        .collect(),
356    }
357  }
358
359  /// Access the wrapped provider.
360  #[inline]
361  pub fn inner(&self) -> &P {
362    &self.inner
363  }
364
365  /// The prefix segments prepended to every lookup.
366  #[inline]
367  pub fn prefix_segments(&self) -> &[String] {
368    &self.prefix
369  }
370}
371
372impl<P: ConfigProvider> ConfigProvider for ScopedConfigProvider<P> {
373  fn load_raw(&self, path: &[&str]) -> Result<Option<String>, ConfigError> {
374    let mut full: Vec<String> = self.prefix.clone();
375    full.extend(path.iter().map(|s| (*s).to_string()));
376    let refs: Vec<&str> = full.iter().map(String::as_str).collect();
377    self.inner.load_raw(&refs)
378  }
379
380  fn seq_delim(&self) -> &'static str {
381    self.inner.seq_delim()
382  }
383}
384
385// ── Tests ─────────────────────────────────────────────────────────────────────
386
387#[cfg(test)]
388mod tests {
389  use std::collections::HashMap;
390  use std::sync::Arc;
391
392  use super::*;
393
394  fn pairs(entries: &[(&str, &str)]) -> MapConfigProvider {
395    MapConfigProvider::from_pairs(entries.iter().copied())
396  }
397
398  // ── ConfigProviderService Debug ───────────────────────────────────────────
399
400  #[test]
401  fn config_provider_service_debug_format() {
402    let p = pairs(&[("K", "v")]);
403    let svc = ConfigProviderService(Arc::new(p));
404    let s = format!("{svc:?}");
405    assert!(s.contains("ConfigProviderService"));
406  }
407
408  // ── ConfigProvider default seq_delim ─────────────────────────────────────
409
410  #[test]
411  fn default_seq_delim_is_comma() {
412    struct MinimalProvider;
413    impl ConfigProvider for MinimalProvider {
414      fn load_raw(&self, _path: &[&str]) -> Result<Option<String>, ConfigError> {
415        Ok(None)
416      }
417      // seq_delim not overridden → default impl returns ","
418    }
419    assert_eq!(MinimalProvider.seq_delim(), ",");
420  }
421
422  // ── ConfigProvider::within ────────────────────────────────────────────────
423
424  #[test]
425  fn within_scopes_lookup() {
426    let p = pairs(&[("SERVER_HOST", "localhost")]);
427    let scoped = p.within("SERVER");
428    assert_eq!(
429      scoped.load_raw(&["HOST"]).unwrap(),
430      Some("localhost".to_string())
431    );
432  }
433
434  // ── ConfigProvider::or_else (trait method) ────────────────────────────────
435
436  #[test]
437  fn or_else_trait_method_falls_back() {
438    let a = MapConfigProvider::from_map(HashMap::new());
439    let b = pairs(&[("K", "from-b")]);
440    let composed = a.or_else(b);
441    assert_eq!(
442      composed.load_raw(&["K"]).unwrap(),
443      Some("from-b".to_string())
444    );
445  }
446
447  // ── EnvConfigProvider::from_env ───────────────────────────────────────────
448
449  #[test]
450  fn env_config_provider_from_env_has_comma_seq_delim() {
451    let p = EnvConfigProvider::from_env();
452    assert_eq!(p.seq_delim(), ",");
453  }
454
455  // ── EnvConfigProvider::new with custom options ────────────────────────────
456
457  #[test]
458  fn env_config_provider_new_custom_options() {
459    let opts = ProviderOptions {
460      path_delim: ".",
461      seq_delim: ";",
462    };
463    let p = EnvConfigProvider::new(opts);
464    assert_eq!(p.seq_delim(), ";");
465  }
466
467  // ── ProviderOptions::default ───────────────────────────────────────────────
468
469  #[test]
470  fn provider_options_default_values() {
471    let opts = ProviderOptions::default();
472    assert_eq!(opts.path_delim, "_");
473    assert_eq!(opts.seq_delim, ",");
474  }
475
476  // ── FigmentConfigProvider::figment() accessor ─────────────────────────────
477
478  #[test]
479  fn figment_config_provider_figment_accessor() {
480    let fig = Figment::new();
481    let p = FigmentConfigProvider::new(fig);
482    // just verify it doesn't panic and returns a reference
483    let _ = p.figment();
484  }
485
486  // ── OrElseConfigProvider: primary found ───────────────────────────────────
487
488  #[test]
489  fn or_else_provider_primary_found_returns_primary() {
490    let a = pairs(&[("K", "from-a")]);
491    let b = pairs(&[("K", "from-b")]);
492    let composed = OrElseConfigProvider::new(a, b);
493    assert_eq!(
494      composed.load_raw(&["K"]).unwrap(),
495      Some("from-a".to_string())
496    );
497  }
498
499  // ── OrElseConfigProvider::seq_delim delegates to primary ─────────────────
500
501  #[test]
502  fn or_else_provider_seq_delim_uses_primary() {
503    let a = MapConfigProvider::with_options(
504      HashMap::new(),
505      ProviderOptions {
506        path_delim: "_",
507        seq_delim: ";",
508      },
509    );
510    let b = pairs(&[("K", "v")]);
511    let composed = OrElseConfigProvider::new(a, b);
512    assert_eq!(composed.seq_delim(), ";");
513  }
514
515  // ── ScopedConfigProvider::seq_delim delegates to inner ───────────────────
516
517  #[test]
518  fn scoped_provider_seq_delim_delegates_to_inner() {
519    let p = MapConfigProvider::with_options(
520      HashMap::new(),
521      ProviderOptions {
522        path_delim: "_",
523        seq_delim: "|",
524      },
525    );
526    let scoped = ScopedConfigProvider::new(p, "NS");
527    assert_eq!(scoped.seq_delim(), "|");
528  }
529
530  // ── join_path: empty path returns error ───────────────────────────────────
531
532  #[test]
533  fn map_provider_empty_path_returns_error() {
534    let p = MapConfigProvider::from_map(HashMap::new());
535    let err = p.load_raw(&[]).unwrap_err();
536    assert!(matches!(err, ConfigError::Invalid { .. }));
537  }
538
539  // ── FigmentConfigProvider: various Num variants via Serialized ────────────
540
541  #[cfg(feature = "toml")]
542  mod figment_num_variants {
543    use super::*;
544    use ::figment::providers::Serialized;
545    use serde::Serialize;
546
547    fn provider_from<T: Serialize>(value: T) -> FigmentConfigProvider {
548      FigmentConfigProvider::new(Figment::from(Serialized::defaults(value)))
549    }
550
551    #[derive(Serialize)]
552    struct U8Val {
553      val: u8,
554    }
555    #[derive(Serialize)]
556    struct U16Val {
557      val: u16,
558    }
559    #[derive(Serialize)]
560    struct U32Val {
561      val: u32,
562    }
563    #[derive(Serialize)]
564    struct U64Val {
565      val: u64,
566    }
567    #[derive(Serialize)]
568    struct I8Val {
569      val: i8,
570    }
571    #[derive(Serialize)]
572    struct I16Val {
573      val: i16,
574    }
575    #[derive(Serialize)]
576    struct I32Val {
577      val: i32,
578    }
579    #[derive(Serialize)]
580    struct F32Val {
581      val: f32,
582    }
583    #[derive(Serialize)]
584    struct CharVal {
585      val: char,
586    }
587
588    #[test]
589    fn num_u8() {
590      let p = provider_from(U8Val { val: 200 });
591      assert_eq!(p.load_raw(&["val"]).unwrap(), Some("200".to_string()));
592    }
593
594    #[test]
595    fn num_u16() {
596      let p = provider_from(U16Val { val: 1000 });
597      assert_eq!(p.load_raw(&["val"]).unwrap(), Some("1000".to_string()));
598    }
599
600    #[test]
601    fn num_u32() {
602      let p = provider_from(U32Val { val: 70000 });
603      assert_eq!(p.load_raw(&["val"]).unwrap(), Some("70000".to_string()));
604    }
605
606    #[test]
607    fn num_u64() {
608      let p = provider_from(U64Val { val: 1_000_000 });
609      assert_eq!(p.load_raw(&["val"]).unwrap(), Some("1000000".to_string()));
610    }
611
612    #[test]
613    fn num_i8() {
614      let p = provider_from(I8Val { val: -5 });
615      assert_eq!(p.load_raw(&["val"]).unwrap(), Some("-5".to_string()));
616    }
617
618    #[test]
619    fn num_i16() {
620      let p = provider_from(I16Val { val: -300 });
621      assert_eq!(p.load_raw(&["val"]).unwrap(), Some("-300".to_string()));
622    }
623
624    #[test]
625    fn num_i32() {
626      let p = provider_from(I32Val { val: 100_000 });
627      assert_eq!(p.load_raw(&["val"]).unwrap(), Some("100000".to_string()));
628    }
629
630    #[test]
631    fn num_f32() {
632      let p = provider_from(F32Val { val: 1.5 });
633      let s = p.load_raw(&["val"]).unwrap().unwrap();
634      let v: f64 = s.parse().unwrap();
635      assert!((v - 1.5).abs() < 0.01);
636    }
637
638    #[test]
639    fn char_variant() {
640      let p = provider_from(CharVal { val: 'x' });
641      assert_eq!(p.load_raw(&["val"]).unwrap(), Some("x".to_string()));
642    }
643  }
644}