Skip to main content

effect_config/
config_desc.rs

1//! Effect.ts-style `Config<T>` descriptor — a lazy, composable configuration description.
2//!
3//! Unlike the low-level [`crate::load`] functions, a [`Config<T>`] is a *description* of what to
4//! load.  Compose it with [`Config::with_default`], [`Config::map`], [`all`], [`zip_with`], etc.,
5//! then evaluate with [`Config::run`] (service-injected) or [`Config::load`] (direct provider).
6//!
7//! # Path conventions
8//!
9//! Path strings use `.` as the segment separator: `"server.host"` splits into
10//! `["server", "host"]` which each [`crate::ConfigProvider`] then joins using its own
11//! key delimiter (`_` for env, `.` for figment).
12//!
13//! # Example
14//!
15//! ```rust
16//! use std::sync::Arc;
17//! use effect_config::{Config, MapConfigProvider, config_env, ConfigError};
18//! use id_effect::run_blocking;
19//!
20//! let p = MapConfigProvider::from_pairs([
21//!   ("HOST", "localhost"),
22//!   ("PORT", "8080"),
23//! ]);
24//! let env = config_env(p);
25//!
26//! let host: String = run_blocking(
27//!   Config::string("HOST").run::<String, ConfigError, _>(),
28//!   env.clone(),
29//! )
30//! .unwrap();
31//! let port: i64 = run_blocking(
32//!   Config::integer("PORT").with_default(3000).run::<i64, ConfigError, _>(),
33//!   env.clone(),
34//! )
35//! .unwrap();
36//! assert_eq!(host, "localhost");
37//! assert_eq!(port, 8080);
38//! ```
39
40use std::fmt;
41use std::str::FromStr;
42use std::sync::Arc;
43use std::time::Duration;
44
45use ::id_effect::{Effect, Get, Here, effect};
46use effect_logger::LogLevel;
47use id_effect::duration::duration;
48use url::Url;
49
50use crate::ambient::current_config_provider;
51use crate::error::ConfigError;
52use crate::provider::{ConfigProvider, ConfigProviderKey, NeedsConfigProvider};
53use crate::secret::Secret;
54
55// Shared loading function: takes a provider reference, returns a Result.
56type LoadFn<T> = Arc<dyn Fn(&dyn ConfigProvider) -> Result<T, ConfigError> + Send + Sync>;
57
58// ── Internal adapter: prepends fixed segments to every path lookup ────────────
59
60struct PrefixProvider<'a> {
61  inner: &'a dyn ConfigProvider,
62  prefix: Arc<Vec<String>>,
63}
64
65impl ConfigProvider for PrefixProvider<'_> {
66  fn load_raw(&self, path: &[&str]) -> Result<Option<String>, ConfigError> {
67    let mut full: Vec<String> = self.prefix.iter().cloned().collect();
68    full.extend(path.iter().map(|s| (*s).to_string()));
69    let full_refs: Vec<&str> = full.iter().map(String::as_str).collect();
70    self.inner.load_raw(&full_refs)
71  }
72
73  fn seq_delim(&self) -> &'static str {
74    self.inner.seq_delim()
75  }
76}
77
78// ── Config<T> ─────────────────────────────────────────────────────────────────
79
80/// A lazy, composable configuration descriptor (Effect.ts `Config<T>`).
81///
82/// Create with type-specific constructors ([`Config::string`], [`Config::integer`], …),
83/// compose with combinators ([`Config::with_default`], [`Config::map`], [`Config::nested`], …),
84/// and evaluate with [`Config::run`] (service-injected) or [`Config::load`] (direct provider).
85pub struct Config<T: 'static> {
86  loader: LoadFn<T>,
87}
88
89impl<T: 'static> fmt::Debug for Config<T> {
90  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91    write!(f, "Config<{}>", std::any::type_name::<T>())
92  }
93}
94
95impl<T: 'static> Clone for Config<T> {
96  fn clone(&self) -> Self {
97    Self {
98      loader: self.loader.clone(),
99    }
100  }
101}
102
103// ── Core impl (no Clone bound on T) ──────────────────────────────────────────
104
105impl<T: Send + Sync + 'static> Config<T> {
106  fn new(
107    f: impl Fn(&dyn ConfigProvider) -> Result<T, ConfigError> + Send + Sync + 'static,
108  ) -> Self {
109    Self {
110      loader: Arc::new(f),
111    }
112  }
113
114  /// Evaluate against `provider` directly (synchronous; useful for tests).
115  #[inline]
116  pub fn load(&self, provider: &dyn ConfigProvider) -> Result<T, ConfigError> {
117    (self.loader)(provider)
118  }
119
120  /// Evaluate this descriptor as an [`Effect`], pulling the provider from the environment.
121  ///
122  /// `R` only needs to satisfy `NeedsConfigProvider`; callers compose whatever layer stack
123  /// they like.  See [`config_env`](crate::config_env) for building a minimal context.
124  pub fn run<A, E, R>(&self) -> Effect<A, E, R>
125  where
126    A: From<T> + 'static,
127    E: From<ConfigError> + 'static,
128    R: NeedsConfigProvider + 'static,
129  {
130    let loader = self.loader.clone();
131    effect!(|r: &mut R| {
132      let service = Get::<ConfigProviderKey, Here>::get(r);
133      let t = (loader)(service.0.as_ref()).map_err(E::from)?;
134      A::from(t)
135    })
136  }
137
138  /// Like [`load`](Self::load), using the innermost ambient provider from
139  /// [`with_config_provider`](crate::with_config_provider).
140  ///
141  /// Runs eagerly when called (reads the thread-local immediately).
142  #[inline]
143  pub fn load_current(&self) -> Result<T, ConfigError> {
144    match current_config_provider() {
145      Some(p) => (self.loader)(p.as_ref()),
146      None => Err(ConfigError::Missing {
147        path: "<ambient ConfigProvider>".into(),
148      }),
149    }
150  }
151
152  /// Transform the loaded value (Effect `Config.map`).
153  ///
154  /// ```rust
155  /// use effect_config::{Config, MapConfigProvider};
156  ///
157  /// let p = MapConfigProvider::from_pairs([("PORT", "8080")]);
158  /// let port_u16: u16 = Config::integer("PORT").map(|n| n as u16).load(&p).unwrap();
159  /// assert_eq!(port_u16, 8080u16);
160  /// ```
161  pub fn map<U: Send + Sync + 'static>(
162    self,
163    f: impl Fn(T) -> U + Send + Sync + 'static,
164  ) -> Config<U> {
165    let loader = self.loader;
166    let f = Arc::new(f);
167    Config {
168      loader: Arc::new(move |p| {
169        let t = (loader)(p)?;
170        Ok(f(t))
171      }),
172    }
173  }
174
175  /// Transform the loaded value, allowing failure (Effect `Config.mapAttempt`).
176  pub fn map_attempt<U: Send + Sync + 'static>(
177    self,
178    f: impl Fn(T) -> Result<U, ConfigError> + Send + Sync + 'static,
179  ) -> Config<U> {
180    let loader = self.loader;
181    let f = Arc::new(f);
182    Config {
183      loader: Arc::new(move |p| {
184        let t = (loader)(p)?;
185        f(t)
186      }),
187    }
188  }
189
190  /// Wrap the loaded value in [`Secret`] so it is never printed (Effect `Config.secret`).
191  ///
192  /// ```rust
193  /// use effect_config::{Config, MapConfigProvider};
194  ///
195  /// let p = MapConfigProvider::from_pairs([("API_KEY", "s3cr3t")]);
196  /// let key = Config::string("API_KEY").secret().load(&p).unwrap();
197  /// assert_eq!(format!("{key:?}"), "<redacted>");
198  /// assert_eq!(key.expose(), "s3cr3t");
199  /// ```
200  pub fn secret(self) -> Config<Secret<T>> {
201    let loader = self.loader;
202    Config {
203      loader: Arc::new(move |p| Ok(Secret::new((loader)(p)?))),
204    }
205  }
206
207  /// Scope all path lookups under `prefix` segments (Effect `Config.nested`).
208  ///
209  /// `Config::string("PORT").nested("SERVER")` looks up `["SERVER", "PORT"]`.
210  ///
211  /// ```rust
212  /// use effect_config::{Config, MapConfigProvider};
213  ///
214  /// let p = MapConfigProvider::from_pairs([("SERVER_HOST", "127.0.0.1")]);
215  /// let host = Config::string("HOST").nested("SERVER").load(&p).unwrap();
216  /// assert_eq!(host, "127.0.0.1");
217  /// ```
218  pub fn nested(self, prefix: impl Into<String>) -> Self {
219    let prefix_segs: Arc<Vec<String>> = Arc::new(
220      prefix
221        .into()
222        .split('.')
223        .filter(|s| !s.is_empty())
224        .map(String::from)
225        .collect(),
226    );
227    let inner = self.loader;
228    Self {
229      loader: Arc::new(move |p| {
230        let scoped = PrefixProvider {
231          inner: p,
232          prefix: prefix_segs.clone(),
233        };
234        inner(&scoped)
235      }),
236    }
237  }
238}
239
240// ── Combinators that need T: Clone ────────────────────────────────────────────
241
242impl<T: Clone + Send + Sync + 'static> Config<T> {
243  /// Fall back to `default` when the key is missing (Effect `Config.withDefault`).
244  ///
245  /// Only [`ConfigError::Missing`] triggers the fallback; parse errors still propagate.
246  ///
247  /// ```rust
248  /// use effect_config::{Config, MapConfigProvider};
249  ///
250  /// let p = MapConfigProvider::from_pairs::<[(&str, &str); 0], _, _>([]);
251  /// let port = Config::integer("PORT").with_default(3000).load(&p).unwrap();
252  /// assert_eq!(port, 3000);
253  /// ```
254  pub fn with_default(self, default: T) -> Self {
255    let loader = self.loader;
256    Self {
257      loader: Arc::new(move |p| match (loader)(p) {
258        Ok(v) => Ok(v),
259        Err(ConfigError::Missing { .. }) => Ok(default.clone()),
260        Err(e) => Err(e),
261      }),
262    }
263  }
264
265  /// Validate the loaded value; produces `ConfigError::Invalid` when `predicate` is false.
266  ///
267  /// ```rust
268  /// use effect_config::{Config, MapConfigProvider};
269  ///
270  /// let p = MapConfigProvider::from_pairs([("PORT", "99999")]);
271  /// let err = Config::integer("PORT")
272  ///   .validate("port must be 1–65535", |n| (1..=65535).contains(n))
273  ///   .load(&p)
274  ///   .unwrap_err();
275  /// assert!(err.to_string().contains("port must be 1–65535"));
276  /// ```
277  pub fn validate(
278    self,
279    reason: &'static str,
280    predicate: impl Fn(&T) -> bool + Send + Sync + 'static,
281  ) -> Self {
282    let loader = self.loader;
283    let predicate = Arc::new(predicate);
284    Self {
285      loader: Arc::new(move |p| {
286        let v = (loader)(p)?;
287        if predicate(&v) {
288          Ok(v)
289        } else {
290          Err(ConfigError::Invalid {
291            path: String::new(),
292            value: String::new(),
293            reason: reason.to_string(),
294          })
295        }
296      }),
297    }
298  }
299}
300
301// ── Type-specific constructors ────────────────────────────────────────────────
302
303fn split_dotted(path: &str) -> Arc<Vec<String>> {
304  Arc::new(
305    path
306      .split('.')
307      .filter(|s| !s.is_empty())
308      .map(String::from)
309      .collect(),
310  )
311}
312
313impl Config<String> {
314  /// Load a required string (Effect `Config.string`).
315  ///
316  /// `path` may be dotted to express nesting: `"server.host"` → `["server", "host"]`.
317  pub fn string(path: impl Into<String>) -> Self {
318    let segs = split_dotted(&path.into());
319    Self::new(move |p| {
320      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
321      let path_str = refs.join(".");
322      match p.load_raw(&refs)? {
323        None => Err(ConfigError::Missing { path: path_str }),
324        Some(s) => Ok(s),
325      }
326    })
327  }
328}
329
330impl Config<Option<String>> {
331  /// Load an optional string; a missing key yields `None` (Effect `Config.option`).
332  pub fn optional_string(path: impl Into<String>) -> Self {
333    let segs = split_dotted(&path.into());
334    Self::new(move |p| {
335      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
336      p.load_raw(&refs)
337    })
338  }
339}
340
341impl Config<f64> {
342  /// Load a floating-point number (Effect `Config.number`).
343  pub fn number(path: impl Into<String>) -> Self {
344    let segs = split_dotted(&path.into());
345    Self::new(move |p| {
346      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
347      let path_str = refs.join(".");
348      let s = match p.load_raw(&refs)? {
349        None => {
350          return Err(ConfigError::Missing {
351            path: path_str.clone(),
352          });
353        }
354        Some(s) => s,
355      };
356      s.parse::<f64>().map_err(|e| ConfigError::Invalid {
357        path: path_str,
358        value: s,
359        reason: e.to_string(),
360      })
361    })
362  }
363}
364
365impl Config<i64> {
366  /// Load a signed integer (Effect `Config.integer`).
367  pub fn integer(path: impl Into<String>) -> Self {
368    let segs = split_dotted(&path.into());
369    Self::new(move |p| {
370      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
371      let path_str = refs.join(".");
372      let s = match p.load_raw(&refs)? {
373        None => {
374          return Err(ConfigError::Missing {
375            path: path_str.clone(),
376          });
377        }
378        Some(s) => s,
379      };
380      s.parse::<i64>().map_err(|e| ConfigError::Invalid {
381        path: path_str,
382        value: s,
383        reason: e.to_string(),
384      })
385    })
386  }
387}
388
389impl Config<bool> {
390  /// Load a boolean (Effect `Config.boolean`).
391  pub fn boolean(path: impl Into<String>) -> Self {
392    let segs = split_dotted(&path.into());
393    Self::new(move |p| {
394      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
395      let path_str = refs.join(".");
396      let s = match p.load_raw(&refs)? {
397        None => {
398          return Err(ConfigError::Missing {
399            path: path_str.clone(),
400          });
401        }
402        Some(s) => s,
403      };
404      match s.to_ascii_lowercase().as_str() {
405        "true" | "1" | "yes" => Ok(true),
406        "false" | "0" | "no" => Ok(false),
407        _ => Err(ConfigError::Invalid {
408          path: path_str,
409          value: s,
410          reason: "expected boolean string".into(),
411        }),
412      }
413    })
414  }
415}
416
417impl Config<Vec<String>> {
418  /// Load a delimiter-separated string list (Effect `Config.array(Config.string(…))`).
419  pub fn string_list(path: impl Into<String>) -> Self {
420    let segs = split_dotted(&path.into());
421    Self::new(move |p| {
422      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
423      let path_str = refs.join(".");
424      let s = match p.load_raw(&refs)? {
425        None => return Err(ConfigError::Missing { path: path_str }),
426        Some(s) => s,
427      };
428      let delim = p.seq_delim();
429      Ok(
430        s.split(delim)
431          .map(str::trim)
432          .filter(|x| !x.is_empty())
433          .map(str::to_string)
434          .collect(),
435      )
436    })
437  }
438
439  /// CSV list (comma-separated, trimmed); ignores [`ConfigProvider::seq_delim`] (Effect `Config.repeat`).
440  pub fn repeat(path: impl Into<String>) -> Self {
441    let segs = split_dotted(&path.into());
442    Self::new(move |p| {
443      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
444      let path_str = refs.join(".");
445      let s = match p.load_raw(&refs)? {
446        None => return Err(ConfigError::Missing { path: path_str }),
447        Some(s) => s,
448      };
449      Ok(split_csv_list(&s))
450    })
451  }
452}
453
454fn split_csv_list(s: &str) -> Vec<String> {
455  s.split(',')
456    .map(str::trim)
457    .filter(|x| !x.is_empty())
458    .map(str::to_string)
459    .collect()
460}
461
462impl Config<Duration> {
463  /// Load a [`Duration`] using [`id_effect::duration::duration::decode`] (Effect.ts `Duration` decode).
464  ///
465  /// ```rust
466  /// use std::time::Duration;
467  /// use effect_config::{Config, MapConfigProvider};
468  ///
469  /// let p = MapConfigProvider::from_pairs([("TIMEOUT", "5s")]);
470  /// let t = Config::duration("TIMEOUT").load(&p).unwrap();
471  /// assert_eq!(t, Duration::from_secs(5));
472  /// ```
473  pub fn duration(path: impl Into<String>) -> Self {
474    let segs = split_dotted(&path.into());
475    Self::new(move |p| {
476      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
477      let path_str = refs.join(".");
478      let s = match p.load_raw(&refs)? {
479        None => {
480          return Err(ConfigError::Missing {
481            path: path_str.clone(),
482          });
483        }
484        Some(s) => s,
485      };
486      duration::decode(s.trim()).map_err(|e| ConfigError::Invalid {
487        path: path_str,
488        value: e.input.clone(),
489        reason: e.to_string(),
490      })
491    })
492  }
493}
494
495impl Config<LogLevel> {
496  /// Load a [`LogLevel`] (case-insensitive; see [`LogLevel`](effect_logger::LogLevel)).
497  pub fn log_level(path: impl Into<String>) -> Self {
498    let segs = split_dotted(&path.into());
499    Self::new(move |p| {
500      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
501      let path_str = refs.join(".");
502      let s = match p.load_raw(&refs)? {
503        None => {
504          return Err(ConfigError::Missing {
505            path: path_str.clone(),
506          });
507        }
508        Some(s) => s,
509      };
510      LogLevel::from_str(s.trim()).map_err(|reason| ConfigError::Invalid {
511        path: path_str,
512        value: s,
513        reason,
514      })
515    })
516  }
517}
518
519impl Config<Url> {
520  /// Load a [`Url`] string.
521  pub fn url(path: impl Into<String>) -> Self {
522    let segs = split_dotted(&path.into());
523    Self::new(move |p| {
524      let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
525      let path_str = refs.join(".");
526      let s = match p.load_raw(&refs)? {
527        None => {
528          return Err(ConfigError::Missing {
529            path: path_str.clone(),
530          });
531        }
532        Some(s) => s,
533      };
534      s.parse::<Url>().map_err(|e| ConfigError::Invalid {
535        path: path_str,
536        value: s,
537        reason: e.to_string(),
538      })
539    })
540  }
541}
542
543// ── Free-function combinators ─────────────────────────────────────────────────
544
545/// Combine two configs into a tuple (Effect `Config.all` / `Config.zip`).
546///
547/// Both are loaded; the first error encountered is returned.
548///
549/// ```rust
550/// use effect_config::{config, Config, MapConfigProvider};
551///
552/// let p = MapConfigProvider::from_pairs([("HOST", "0.0.0.0"), ("PORT", "9000")]);
553/// let (host, port) = config::all(Config::string("HOST"), Config::integer("PORT"))
554///   .load(&p)
555///   .unwrap();
556/// assert_eq!(host, "0.0.0.0");
557/// assert_eq!(port, 9000);
558/// ```
559pub fn all<A, B>(a: Config<A>, b: Config<B>) -> Config<(A, B)>
560where
561  A: Send + Sync + 'static,
562  B: Send + Sync + 'static,
563{
564  Config {
565    loader: Arc::new(move |p| {
566      let av = (a.loader)(p)?;
567      let bv = (b.loader)(p)?;
568      Ok((av, bv))
569    }),
570  }
571}
572
573/// Combine three configs into a 3-tuple.
574pub fn all3<A, B, C>(a: Config<A>, b: Config<B>, c: Config<C>) -> Config<(A, B, C)>
575where
576  A: Send + Sync + 'static,
577  B: Send + Sync + 'static,
578  C: Send + Sync + 'static,
579{
580  Config {
581    loader: Arc::new(move |p| {
582      let av = (a.loader)(p)?;
583      let bv = (b.loader)(p)?;
584      let cv = (c.loader)(p)?;
585      Ok((av, bv, cv))
586    }),
587  }
588}
589
590/// Combine two configs and merge the results with `f` (Effect `Config.zipWith`).
591pub fn zip_with<A, B, C>(
592  a: Config<A>,
593  b: Config<B>,
594  f: impl Fn(A, B) -> C + Send + Sync + 'static,
595) -> Config<C>
596where
597  A: Send + Sync + 'static,
598  B: Send + Sync + 'static,
599  C: Send + Sync + 'static,
600{
601  let f = Arc::new(f);
602  Config {
603    loader: Arc::new(move |p| {
604      let av = (a.loader)(p)?;
605      let bv = (b.loader)(p)?;
606      Ok(f(av, bv))
607    }),
608  }
609}
610
611/// Try `primary`; fall back to `fallback` on a missing key (Effect `Config.orElse`).
612pub fn or_else<T: Send + Sync + 'static>(primary: Config<T>, fallback: Config<T>) -> Config<T> {
613  Config {
614    loader: Arc::new(move |p| match (primary.loader)(p) {
615      Ok(v) => Ok(v),
616      Err(ConfigError::Missing { .. }) => (fallback.loader)(p),
617      Err(e) => Err(e),
618    }),
619  }
620}
621
622/// Load multiple configs and collect results into a `Vec` (Effect `Config.all` on a slice).
623pub fn all_vec<T: Send + Sync + 'static>(configs: Vec<Config<T>>) -> Config<Vec<T>> {
624  let configs = Arc::new(configs);
625  Config {
626    loader: Arc::new(move |p| configs.iter().map(|c| (c.loader)(p)).collect()),
627  }
628}
629
630/// Namespace `inner` under `prefix` (same as [`Config::nested`]).
631#[inline]
632pub fn nest<T: Send + Sync + 'static>(prefix: impl Into<String>, inner: Config<T>) -> Config<T> {
633  inner.nested(prefix)
634}
635
636// ── Tests ─────────────────────────────────────────────────────────────────────
637
638#[cfg(test)]
639mod tests {
640  use std::time::Duration;
641
642  use crate::MapConfigProvider;
643  use effect_logger::LogLevel;
644
645  use super::*;
646
647  fn map_provider(pairs: &[(&str, &str)]) -> MapConfigProvider {
648    MapConfigProvider::from_pairs(pairs.iter().copied())
649  }
650
651  fn go<T: Send + Sync + 'static>(c: Config<T>, p: &dyn ConfigProvider) -> Result<T, ConfigError> {
652    c.load(p)
653  }
654
655  #[test]
656  fn string_required_found() {
657    let p = map_provider(&[("HOST", "localhost")]);
658    assert_eq!(go(Config::string("HOST"), &p).unwrap(), "localhost");
659  }
660
661  #[test]
662  fn string_required_missing_is_error() {
663    let p = map_provider(&[]);
664    assert!(go(Config::string("HOST"), &p).is_err());
665  }
666
667  #[test]
668  fn with_default_on_missing() {
669    let p = map_provider(&[]);
670    let port = go(Config::integer("PORT").with_default(3000), &p).unwrap();
671    assert_eq!(port, 3000);
672  }
673
674  #[test]
675  fn with_default_not_used_when_present() {
676    let p = map_provider(&[("PORT", "8080")]);
677    let port = go(Config::integer("PORT").with_default(3000), &p).unwrap();
678    assert_eq!(port, 8080);
679  }
680
681  #[test]
682  fn map_transforms_value() {
683    let p = map_provider(&[("PORT", "8080")]);
684    let port_u16: u16 = go(Config::integer("PORT").map(|n| n as u16), &p).unwrap();
685    assert_eq!(port_u16, 8080);
686  }
687
688  #[test]
689  fn map_attempt_success() {
690    let p = map_provider(&[("VAL", "42")]);
691    let v = go(
692      Config::string("VAL").map_attempt(|s| {
693        s.parse::<i32>().map_err(|e| ConfigError::Invalid {
694          path: "VAL".into(),
695          value: s,
696          reason: e.to_string(),
697        })
698      }),
699      &p,
700    )
701    .unwrap();
702    assert_eq!(v, 42i32);
703  }
704
705  #[test]
706  fn validate_passes() {
707    let p = map_provider(&[("PORT", "8080")]);
708    let port = go(
709      Config::integer("PORT").validate("must be > 0", |n| *n > 0),
710      &p,
711    )
712    .unwrap();
713    assert_eq!(port, 8080);
714  }
715
716  #[test]
717  fn validate_fails() {
718    let p = map_provider(&[("PORT", "99999")]);
719    let err = go(
720      Config::integer("PORT").validate("port must be 1–65535", |n| (1..=65535).contains(n)),
721      &p,
722    )
723    .unwrap_err();
724    assert!(err.to_string().contains("port must be 1–65535"));
725  }
726
727  #[test]
728  fn secret_wraps_and_redacts() {
729    let p = map_provider(&[("KEY", "s3cr3t")]);
730    let k = go(Config::string("KEY").secret(), &p).unwrap();
731    assert_eq!(format!("{k:?}"), "<redacted>");
732    assert_eq!(k.expose(), "s3cr3t");
733  }
734
735  #[test]
736  fn nested_prepends_prefix() {
737    let p = map_provider(&[("SERVER_HOST", "0.0.0.0")]);
738    let host = go(Config::string("HOST").nested("SERVER"), &p).unwrap();
739    assert_eq!(host, "0.0.0.0");
740  }
741
742  #[test]
743  fn all_combines_two() {
744    let p = map_provider(&[("HOST", "::1"), ("PORT", "9000")]);
745    let (host, port) = go(all(Config::string("HOST"), Config::integer("PORT")), &p).unwrap();
746    assert_eq!(host, "::1");
747    assert_eq!(port, 9000);
748  }
749
750  #[test]
751  fn all3_combines_three() {
752    let p = map_provider(&[("A", "1"), ("B", "2"), ("C", "3")]);
753    let (a, b, c) = go(
754      all3(
755        Config::integer("A"),
756        Config::integer("B"),
757        Config::integer("C"),
758      ),
759      &p,
760    )
761    .unwrap();
762    assert_eq!((a, b, c), (1, 2, 3));
763  }
764
765  #[test]
766  fn zip_with_merges() {
767    let p = map_provider(&[("HOST", "localhost"), ("PORT", "8080")]);
768    let addr = go(
769      zip_with(Config::string("HOST"), Config::integer("PORT"), |h, po| {
770        format!("{h}:{po}")
771      }),
772      &p,
773    )
774    .unwrap();
775    assert_eq!(addr, "localhost:8080");
776  }
777
778  #[test]
779  fn or_else_falls_back_on_missing() {
780    let p = map_provider(&[("FALLBACK_PORT", "4000")]);
781    let port = go(
782      or_else(Config::integer("PORT"), Config::integer("FALLBACK_PORT")),
783      &p,
784    )
785    .unwrap();
786    assert_eq!(port, 4000);
787  }
788
789  #[test]
790  fn or_else_uses_primary_when_present() {
791    let p = map_provider(&[("PORT", "8080"), ("FALLBACK_PORT", "4000")]);
792    let port = go(
793      or_else(Config::integer("PORT"), Config::integer("FALLBACK_PORT")),
794      &p,
795    )
796    .unwrap();
797    assert_eq!(port, 8080);
798  }
799
800  #[test]
801  fn all_vec_collects() {
802    let p = map_provider(&[("K0", "10"), ("K1", "20"), ("K2", "30")]);
803    let vals = go(
804      all_vec(vec![
805        Config::integer("K0"),
806        Config::integer("K1"),
807        Config::integer("K2"),
808      ]),
809      &p,
810    )
811    .unwrap();
812    assert_eq!(vals, vec![10, 20, 30]);
813  }
814
815  #[test]
816  fn boolean_true_and_false() {
817    let p = map_provider(&[("A", "true"), ("B", "false"), ("C", "1"), ("D", "0")]);
818    assert!(go(Config::boolean("A"), &p).unwrap());
819    assert!(!go(Config::boolean("B"), &p).unwrap());
820    assert!(go(Config::boolean("C"), &p).unwrap());
821    assert!(!go(Config::boolean("D"), &p).unwrap());
822  }
823
824  #[test]
825  fn string_list_splits() {
826    let p = map_provider(&[("TAGS", "a,b,c")]);
827    let tags = go(Config::string_list("TAGS"), &p).unwrap();
828    assert_eq!(tags, vec!["a", "b", "c"]);
829  }
830
831  mod duration {
832    use super::*;
833
834    fn dur(pairs: &[(&str, &str)], key: &'static str) -> Result<Duration, ConfigError> {
835      let p = map_provider(pairs);
836      go(Config::duration(key), &p)
837    }
838
839    #[test]
840    fn bare_integer_is_millis() {
841      assert_eq!(
842        dur(&[("T", "500")], "T").unwrap(),
843        Duration::from_millis(500)
844      );
845    }
846
847    #[test]
848    fn ms_suffix() {
849      assert_eq!(
850        dur(&[("T", "250ms")], "T").unwrap(),
851        Duration::from_millis(250)
852      );
853    }
854
855    #[test]
856    fn seconds_suffix() {
857      assert_eq!(dur(&[("T", "5s")], "T").unwrap(), Duration::from_secs(5));
858    }
859
860    #[test]
861    fn minutes_suffix() {
862      assert_eq!(
863        dur(&[("T", "2min")], "T").unwrap(),
864        Duration::from_secs(120)
865      );
866    }
867
868    #[test]
869    fn hours_suffix() {
870      assert_eq!(dur(&[("T", "1h")], "T").unwrap(), Duration::from_secs(3600));
871    }
872
873    #[test]
874    fn days_suffix() {
875      assert_eq!(
876        dur(&[("T", "1d")], "T").unwrap(),
877        Duration::from_secs(86400)
878      );
879    }
880
881    #[test]
882    fn unknown_unit_is_error() {
883      assert!(dur(&[("T", "5x")], "T").is_err());
884    }
885  }
886
887  #[test]
888  fn config_duration_parses_30_seconds() {
889    let p = map_provider(&[("D", "30 seconds")]);
890    assert_eq!(
891      go(Config::duration("D"), &p).unwrap(),
892      Duration::from_secs(30)
893    );
894  }
895
896  #[test]
897  fn config_log_level_parses_info() {
898    let p = map_provider(&[("LVL", "info")]);
899    assert_eq!(go(Config::log_level("LVL"), &p).unwrap(), LogLevel::Info);
900  }
901
902  #[test]
903  fn config_url_parses_valid_url() {
904    let p = map_provider(&[("U", "https://example.com/path?x=1")]);
905    let u = go(Config::url("U"), &p).unwrap();
906    assert_eq!(u.as_str(), "https://example.com/path?x=1");
907  }
908
909  #[test]
910  fn config_nested_prefixes_keys() {
911    let p = map_provider(&[("APP_SERVER_HOST", "h")]);
912    let host = go(nest("APP_SERVER", Config::string("HOST")), &p).unwrap();
913    assert_eq!(host, "h");
914  }
915
916  #[test]
917  fn config_repeat_splits_csv() {
918    let p = map_provider(&[("ITEMS", "a, b ,c")]);
919    let v = go(Config::repeat("ITEMS"), &p).unwrap();
920    assert_eq!(v, vec!["a", "b", "c"]);
921  }
922
923  // ── Debug / Clone ──────────────────────────────────────────────────────────
924
925  #[test]
926  fn config_debug_contains_type_name() {
927    let c = Config::<String>::string("X");
928    let s = format!("{c:?}");
929    assert!(s.contains("Config"));
930  }
931
932  #[test]
933  fn config_clone_independent_from_original() {
934    let p = map_provider(&[("X", "hello")]);
935    let c = Config::string("X");
936    let c2 = c.clone();
937    assert_eq!(go(c2, &p).unwrap(), "hello");
938  }
939
940  // ── Config::run ────────────────────────────────────────────────────────────
941
942  #[test]
943  fn config_run_via_effect() {
944    use crate::{MapConfigProvider, config_env};
945
946    let p = MapConfigProvider::from_pairs([("HOST", "localhost")]);
947    let env = config_env(p);
948    let host: String =
949      id_effect::run_blocking(Config::string("HOST").run::<String, ConfigError, _>(), env).unwrap();
950    assert_eq!(host, "localhost");
951  }
952
953  #[test]
954  fn config_run_error_propagated() {
955    use crate::{MapConfigProvider, config_env};
956
957    let p = MapConfigProvider::from_pairs::<[(&str, &str); 0], _, _>([]);
958    let env = config_env(p);
959    let result: Result<String, ConfigError> = id_effect::run_blocking(
960      Config::string("MISSING").run::<String, ConfigError, _>(),
961      env,
962    );
963    assert!(result.is_err());
964  }
965
966  // ── Config::load_current ───────────────────────────────────────────────────
967
968  #[test]
969  fn load_current_no_ambient_returns_missing() {
970    let err = Config::<String>::string("X").load_current().unwrap_err();
971    assert!(matches!(err, ConfigError::Missing { .. }));
972  }
973
974  #[test]
975  fn load_current_with_ambient_provider() {
976    use crate::ambient::with_config_provider;
977    use std::sync::Arc;
978
979    let p = Arc::new(map_provider(&[("X", "from-ambient")]));
980    let eff = with_config_provider(
981      id_effect::Effect::new(|_| Config::string("X").load_current()),
982      p,
983    );
984    assert_eq!(id_effect::run_blocking(eff, ()).unwrap(), "from-ambient");
985  }
986
987  // ── with_default non-missing error propagation ─────────────────────────────
988
989  #[test]
990  fn with_default_propagates_invalid_error() {
991    let p = map_provider(&[("PORT", "not_a_number")]);
992    let err = go(Config::integer("PORT").with_default(3000), &p).unwrap_err();
993    assert!(matches!(err, ConfigError::Invalid { .. }));
994  }
995
996  // ── optional_string ────────────────────────────────────────────────────────
997
998  #[test]
999  fn optional_string_present() {
1000    let p = map_provider(&[("OPT", "value")]);
1001    assert_eq!(
1002      go(Config::optional_string("OPT"), &p).unwrap(),
1003      Some("value".to_string())
1004    );
1005  }
1006
1007  #[test]
1008  fn optional_string_absent() {
1009    let p = map_provider(&[]);
1010    assert_eq!(go(Config::optional_string("OPT"), &p).unwrap(), None);
1011  }
1012
1013  #[test]
1014  fn optional_string_dotted_path() {
1015    let p = map_provider(&[("SERVER_HOST", "localhost")]);
1016    assert_eq!(
1017      go(Config::optional_string("SERVER.HOST"), &p).unwrap(),
1018      Some("localhost".to_string())
1019    );
1020  }
1021
1022  // ── boolean yes/no variants ────────────────────────────────────────────────
1023
1024  #[test]
1025  fn boolean_yes_no_variants() {
1026    let p = map_provider(&[("A", "yes"), ("B", "no"), ("C", "YES"), ("D", "NO")]);
1027    assert!(go(Config::boolean("A"), &p).unwrap());
1028    assert!(!go(Config::boolean("B"), &p).unwrap());
1029    assert!(go(Config::boolean("C"), &p).unwrap());
1030    assert!(!go(Config::boolean("D"), &p).unwrap());
1031  }
1032
1033  #[test]
1034  fn boolean_invalid_string_is_error() {
1035    let p = map_provider(&[("FLAG", "maybe")]);
1036    let err = go(Config::boolean("FLAG"), &p).unwrap_err();
1037    assert!(matches!(err, ConfigError::Invalid { .. }));
1038  }
1039
1040  #[test]
1041  fn boolean_missing_is_error() {
1042    let p = map_provider(&[]);
1043    let err = go(Config::boolean("FLAG"), &p).unwrap_err();
1044    assert!(matches!(err, ConfigError::Missing { .. }));
1045  }
1046
1047  // ── map_attempt failure ───────────────────────────────────────────────────
1048
1049  #[test]
1050  fn map_attempt_failure_propagates_error() {
1051    let p = map_provider(&[("VAL", "not-a-number")]);
1052    let err = go(
1053      Config::string("VAL").map_attempt(|s| {
1054        s.parse::<i32>().map_err(|e| ConfigError::Invalid {
1055          path: "VAL".into(),
1056          value: s,
1057          reason: e.to_string(),
1058        })
1059      }),
1060      &p,
1061    )
1062    .unwrap_err();
1063    assert!(matches!(err, ConfigError::Invalid { .. }));
1064  }
1065
1066  // ── number missing/invalid ────────────────────────────────────────────────
1067
1068  #[test]
1069  fn number_missing_is_error() {
1070    let p = map_provider(&[]);
1071    let err = go(Config::number("N"), &p).unwrap_err();
1072    assert!(matches!(err, ConfigError::Missing { .. }));
1073  }
1074
1075  #[test]
1076  fn number_invalid_is_error() {
1077    let p = map_provider(&[("N", "not_a_float")]);
1078    let err = go(Config::number("N"), &p).unwrap_err();
1079    assert!(matches!(err, ConfigError::Invalid { .. }));
1080  }
1081
1082  // ── integer missing/invalid ───────────────────────────────────────────────
1083
1084  #[test]
1085  fn integer_missing_is_error() {
1086    let p = map_provider(&[]);
1087    let err = go(Config::integer("N"), &p).unwrap_err();
1088    assert!(matches!(err, ConfigError::Missing { .. }));
1089  }
1090
1091  #[test]
1092  fn integer_invalid_is_error() {
1093    let p = map_provider(&[("N", "not_an_int")]);
1094    let err = go(Config::integer("N"), &p).unwrap_err();
1095    assert!(matches!(err, ConfigError::Invalid { .. }));
1096  }
1097
1098  // ── string_list missing ───────────────────────────────────────────────────
1099
1100  #[test]
1101  fn string_list_missing_is_error() {
1102    let p = map_provider(&[]);
1103    let err = go(Config::string_list("TAGS"), &p).unwrap_err();
1104    assert!(matches!(err, ConfigError::Missing { .. }));
1105  }
1106
1107  // ── repeat missing ────────────────────────────────────────────────────────
1108
1109  #[test]
1110  fn repeat_missing_is_error() {
1111    let p = map_provider(&[]);
1112    let err = go(Config::repeat("ITEMS"), &p).unwrap_err();
1113    assert!(matches!(err, ConfigError::Missing { .. }));
1114  }
1115
1116  // ── or_else propagates non-missing error ──────────────────────────────────
1117
1118  #[test]
1119  fn or_else_propagates_invalid_error() {
1120    let p = map_provider(&[("PORT", "bad")]);
1121    let err = go(
1122      or_else(Config::integer("PORT"), Config::integer("FALLBACK_PORT")),
1123      &p,
1124    )
1125    .unwrap_err();
1126    assert!(matches!(err, ConfigError::Invalid { .. }));
1127  }
1128
1129  // ── prefix provider seq_delim ─────────────────────────────────────────────
1130
1131  #[test]
1132  fn nested_prefix_provider_seq_delim_delegates() {
1133    use crate::MapConfigProvider;
1134    use crate::provider::ProviderOptions;
1135
1136    let opts = ProviderOptions {
1137      path_delim: "_",
1138      seq_delim: "|",
1139    };
1140    let map: std::collections::HashMap<String, String> =
1141      [("NS_ITEMS".to_string(), "a|b|c".to_string())]
1142        .into_iter()
1143        .collect();
1144    let p = MapConfigProvider::with_options(map, opts);
1145    let items = go(Config::string_list("ITEMS").nested("NS"), &p).unwrap();
1146    assert_eq!(items, vec!["a", "b", "c"]);
1147  }
1148}