Skip to main content

effect_config/
load.rs

1//! Low-level reads against the injected [`crate::ConfigProvider`] service.
2//!
3//! Every public function returns `Effect<A, E, R>` where `R: NeedsConfigProvider`.
4//! The provider is extracted synchronously from the environment via
5//! `Get::<ConfigProviderKey, Here>::get(r)` so all effects stay non-async and
6//! the `EFFECT_PREFER_FROM_ASYNC_OVER_NEW_ASYNC` lint is never triggered.
7
8use ::id_effect::{Effect, Get, Here, effect};
9
10use crate::error::ConfigError;
11use crate::provider::{ConfigProviderKey, NeedsConfigProvider};
12
13// ── helpers ──────────────────────────────────────────────────────────────────
14
15/// `Config.withDefault` as a method — only [`ConfigError::Missing`] is swapped.
16///
17/// This is a trait-impl method so the `effect!` lint is not required here.
18pub trait WithConfigDefault<A, R>: Sized {
19  /// Return `default` in place of the effect value when the error is [`ConfigError::Missing`].
20  fn with_default(self, default: A) -> Effect<A, ConfigError, R>;
21}
22
23impl<A, R> WithConfigDefault<A, R> for Effect<A, ConfigError, R>
24where
25  A: Clone + 'static,
26  R: 'static,
27{
28  fn with_default(self, default: A) -> Effect<A, ConfigError, R> {
29    self.catch(move |e| match e {
30      ConfigError::Missing { .. } => ::id_effect::succeed(default.clone()),
31      other => ::id_effect::fail(other),
32    })
33  }
34}
35
36// ── path helpers ──────────────────────────────────────────────────────────────
37
38/// Build a multi-segment path, e.g. `nested_path("SERVER", &["HOST"])` → `["SERVER", "HOST"]`.
39#[inline]
40pub fn nested_path(namespace: &str, leaf: &[&str]) -> Vec<String> {
41  std::iter::once(namespace.to_string())
42    .chain(leaf.iter().map(|s| (*s).to_string()))
43    .collect()
44}
45
46// ── primitive reads ───────────────────────────────────────────────────────────
47
48/// Required string.
49pub fn read_string<A, E, R>(path: &[&str]) -> Effect<A, E, R>
50where
51  A: From<String> + 'static,
52  E: From<ConfigError> + 'static,
53  R: NeedsConfigProvider + 'static,
54{
55  let path_owned: Vec<String> = path.iter().map(|s| s.to_string()).collect();
56  effect!(|r: &mut R| {
57    let provider = Get::<ConfigProviderKey, Here>::get(r);
58    let refs: Vec<&str> = path_owned.iter().map(String::as_str).collect();
59    let path_str = refs.join(".");
60    match provider.0.load_raw(&refs) {
61      Err(e) => return Err(E::from(e)),
62      Ok(None) => return Err(E::from(ConfigError::Missing { path: path_str })),
63      Ok(Some(s)) => A::from(s),
64    }
65  })
66}
67
68/// Optional string — missing key yields `None`.
69pub fn read_string_opt<A, E, R>(path: &[&str]) -> Effect<A, E, R>
70where
71  A: From<Option<String>> + 'static,
72  E: From<ConfigError> + 'static,
73  R: NeedsConfigProvider + 'static,
74{
75  let path_owned: Vec<String> = path.iter().map(|s| s.to_string()).collect();
76  effect!(|r: &mut R| {
77    let provider = Get::<ConfigProviderKey, Here>::get(r);
78    let refs: Vec<&str> = path_owned.iter().map(String::as_str).collect();
79    match provider.0.load_raw(&refs) {
80      Err(e) => return Err(E::from(e)),
81      Ok(raw) => A::from(raw),
82    }
83  })
84}
85
86/// Floating-point number parsed from a string scalar.
87pub fn read_number<A, E, R>(path: &[&str]) -> Effect<A, E, R>
88where
89  A: From<f64> + 'static,
90  E: From<ConfigError> + 'static,
91  R: NeedsConfigProvider + 'static,
92{
93  let path_owned: Vec<String> = path.iter().map(|s| s.to_string()).collect();
94  effect!(|r: &mut R| {
95    let provider = Get::<ConfigProviderKey, Here>::get(r);
96    let refs: Vec<&str> = path_owned.iter().map(String::as_str).collect();
97    let path_str = refs.join(".");
98    let s = match provider.0.load_raw(&refs) {
99      Err(e) => return Err(E::from(e)),
100      Ok(None) => {
101        return Err(E::from(ConfigError::Missing {
102          path: path_str.clone(),
103        }));
104      }
105      Ok(Some(s)) => s,
106    };
107    let n = s.parse::<f64>().map_err(|e| {
108      E::from(ConfigError::Invalid {
109        path: path_str,
110        value: s,
111        reason: e.to_string(),
112      })
113    })?;
114    A::from(n)
115  })
116}
117
118/// Signed 64-bit integer parsed from a string scalar.
119pub fn read_i64<A, E, R>(path: &[&str]) -> Effect<A, E, R>
120where
121  A: From<i64> + 'static,
122  E: From<ConfigError> + 'static,
123  R: NeedsConfigProvider + 'static,
124{
125  let path_owned: Vec<String> = path.iter().map(|s| s.to_string()).collect();
126  effect!(|r: &mut R| {
127    let provider = Get::<ConfigProviderKey, Here>::get(r);
128    let refs: Vec<&str> = path_owned.iter().map(String::as_str).collect();
129    let path_str = refs.join(".");
130    let s = match provider.0.load_raw(&refs) {
131      Err(e) => return Err(E::from(e)),
132      Ok(None) => {
133        return Err(E::from(ConfigError::Missing {
134          path: path_str.clone(),
135        }));
136      }
137      Ok(Some(s)) => s,
138    };
139    let n = s.parse::<i64>().map_err(|e| {
140      E::from(ConfigError::Invalid {
141        path: path_str,
142        value: s,
143        reason: e.to_string(),
144      })
145    })?;
146    A::from(n)
147  })
148}
149
150/// Boolean parsed from `"true"` / `"false"` / `"1"` / `"0"` / `"yes"` / `"no"`.
151pub fn read_bool<A, E, R>(path: &[&str]) -> Effect<A, E, R>
152where
153  A: From<bool> + 'static,
154  E: From<ConfigError> + 'static,
155  R: NeedsConfigProvider + 'static,
156{
157  let path_owned: Vec<String> = path.iter().map(|s| s.to_string()).collect();
158  effect!(|r: &mut R| {
159    let provider = Get::<ConfigProviderKey, Here>::get(r);
160    let refs: Vec<&str> = path_owned.iter().map(String::as_str).collect();
161    let path_str = refs.join(".");
162    let s = match provider.0.load_raw(&refs) {
163      Err(e) => return Err(E::from(e)),
164      Ok(None) => {
165        return Err(E::from(ConfigError::Missing {
166          path: path_str.clone(),
167        }));
168      }
169      Ok(Some(s)) => s,
170    };
171    let b = match s.to_ascii_lowercase().as_str() {
172      "true" | "1" | "yes" => true,
173      "false" | "0" | "no" => false,
174      _ => {
175        return Err(E::from(ConfigError::Invalid {
176          path: path_str,
177          value: s,
178          reason: "expected boolean string".into(),
179        }));
180      }
181    };
182    A::from(b)
183  })
184}
185
186/// Sequence of strings split by [`ConfigProvider::seq_delim`](crate::provider::ConfigProvider::seq_delim).
187pub fn read_string_list<A, E, R>(path: &[&str]) -> Effect<A, E, R>
188where
189  A: From<Vec<String>> + 'static,
190  E: From<ConfigError> + 'static,
191  R: NeedsConfigProvider + 'static,
192{
193  let path_owned: Vec<String> = path.iter().map(|s| s.to_string()).collect();
194  effect!(|r: &mut R| {
195    let provider = Get::<ConfigProviderKey, Here>::get(r);
196    let refs: Vec<&str> = path_owned.iter().map(String::as_str).collect();
197    let path_str = refs.join(".");
198    let s = match provider.0.load_raw(&refs) {
199      Err(e) => return Err(E::from(e)),
200      Ok(None) => return Err(E::from(ConfigError::Missing { path: path_str })),
201      Ok(Some(s)) => s,
202    };
203    let delim = provider.0.seq_delim();
204    let list: Vec<String> = s
205      .split(delim)
206      .map(str::trim)
207      .filter(|x| !x.is_empty())
208      .map(str::to_string)
209      .collect();
210    A::from(list)
211  })
212}
213
214// ── nested convenience ────────────────────────────────────────────────────────
215
216/// [`nested_path`] then [`read_string`].
217pub fn read_nested_string<A, E, R>(namespace: &str, leaf: &[&str]) -> Effect<A, E, R>
218where
219  A: From<String> + 'static,
220  E: From<ConfigError> + 'static,
221  R: NeedsConfigProvider + 'static,
222{
223  let path_owned = nested_path(namespace, leaf);
224  effect!(|r: &mut R| {
225    let provider = Get::<ConfigProviderKey, Here>::get(r);
226    let refs: Vec<&str> = path_owned.iter().map(String::as_str).collect();
227    let path_str = refs.join(".");
228    match provider.0.load_raw(&refs) {
229      Err(e) => return Err(E::from(e)),
230      Ok(None) => return Err(E::from(ConfigError::Missing { path: path_str })),
231      Ok(Some(s)) => A::from(s),
232    }
233  })
234}
235
236/// [`nested_path`] then [`read_string_list`].
237pub fn read_nested_string_list<A, E, R>(namespace: &str, leaf: &[&str]) -> Effect<A, E, R>
238where
239  A: From<Vec<String>> + 'static,
240  E: From<ConfigError> + 'static,
241  R: NeedsConfigProvider + 'static,
242{
243  let path_owned = nested_path(namespace, leaf);
244  effect!(|r: &mut R| {
245    let provider = Get::<ConfigProviderKey, Here>::get(r);
246    let refs: Vec<&str> = path_owned.iter().map(String::as_str).collect();
247    let path_str = refs.join(".");
248    let s = match provider.0.load_raw(&refs) {
249      Err(e) => return Err(E::from(e)),
250      Ok(None) => return Err(E::from(ConfigError::Missing { path: path_str })),
251      Ok(Some(s)) => s,
252    };
253    let delim = provider.0.seq_delim();
254    let list: Vec<String> = s
255      .split(delim)
256      .map(str::trim)
257      .filter(|x| !x.is_empty())
258      .map(str::to_string)
259      .collect();
260    A::from(list)
261  })
262}
263
264#[cfg(test)]
265mod tests {
266  use super::*;
267  use crate::ConfigEnv;
268  use crate::MapConfigProvider;
269  use crate::ProviderOptions;
270  use crate::config_env;
271  use ::id_effect::run_blocking;
272
273  fn env_map(pairs: &[(&str, &str)]) -> ConfigEnv {
274    config_env(MapConfigProvider::from_pairs(pairs.iter().copied()))
275  }
276
277  #[test]
278  fn nested_path_builds_segments() {
279    assert_eq!(
280      nested_path("SERVER", &["HOST", "PORT"]),
281      vec!["SERVER", "HOST", "PORT"]
282    );
283  }
284
285  #[test]
286  fn read_string_ok_and_missing() {
287    let v: String = run_blocking(
288      read_string::<String, ConfigError, _>(&["K"]),
289      env_map(&[("K", "hello")]),
290    )
291    .unwrap();
292    assert_eq!(v, "hello");
293
294    let err = run_blocking(
295      read_string::<String, ConfigError, _>(&["MISSING"]),
296      env_map(&[]),
297    )
298    .unwrap_err();
299    assert!(matches!(err, ConfigError::Missing { .. }));
300  }
301
302  #[test]
303  fn read_string_opt_some_and_none() {
304    let v: Option<String> = run_blocking(
305      read_string_opt::<Option<String>, ConfigError, _>(&["K"]),
306      env_map(&[("K", "x")]),
307    )
308    .unwrap();
309    assert_eq!(v, Some("x".into()));
310
311    let none: Option<String> = run_blocking(
312      read_string_opt::<Option<String>, ConfigError, _>(&["MISSING"]),
313      env_map(&[]),
314    )
315    .unwrap();
316    assert_eq!(none, None);
317  }
318
319  #[test]
320  fn read_number_ok_missing_invalid() {
321    let n: f64 = run_blocking(
322      read_number::<f64, ConfigError, _>(&["N"]),
323      env_map(&[("N", "3.5")]),
324    )
325    .unwrap();
326    assert!((n - 3.5).abs() < f64::EPSILON);
327
328    let err = run_blocking(
329      read_number::<f64, ConfigError, _>(&["MISSING"]),
330      env_map(&[]),
331    )
332    .unwrap_err();
333    assert!(matches!(err, ConfigError::Missing { .. }));
334
335    let err = run_blocking(
336      read_number::<f64, ConfigError, _>(&["N"]),
337      env_map(&[("N", "not-a-number")]),
338    )
339    .unwrap_err();
340    assert!(matches!(err, ConfigError::Invalid { .. }));
341  }
342
343  #[test]
344  fn read_i64_ok_missing_invalid() {
345    let n: i64 = run_blocking(
346      read_i64::<i64, ConfigError, _>(&["N"]),
347      env_map(&[("N", "-42")]),
348    )
349    .unwrap();
350    assert_eq!(n, -42);
351
352    let err =
353      run_blocking(read_i64::<i64, ConfigError, _>(&["MISSING"]), env_map(&[])).unwrap_err();
354    assert!(matches!(err, ConfigError::Missing { .. }));
355
356    let err = run_blocking(
357      read_i64::<i64, ConfigError, _>(&["N"]),
358      env_map(&[("N", "1.2")]),
359    )
360    .unwrap_err();
361    assert!(matches!(err, ConfigError::Invalid { .. }));
362  }
363
364  #[test]
365  fn read_bool_variants_and_invalid() {
366    for (raw, expected) in [
367      ("true", true),
368      ("TRUE", true),
369      ("1", true),
370      ("yes", true),
371      ("false", false),
372      ("0", false),
373      ("no", false),
374    ] {
375      let b: bool = run_blocking(
376        read_bool::<bool, ConfigError, _>(&["B"]),
377        env_map(&[("B", raw)]),
378      )
379      .unwrap();
380      assert_eq!(b, expected, "raw={raw}");
381    }
382
383    let err = run_blocking(
384      read_bool::<bool, ConfigError, _>(&["MISSING"]),
385      env_map(&[]),
386    )
387    .unwrap_err();
388    assert!(matches!(err, ConfigError::Missing { .. }));
389
390    let err = run_blocking(
391      read_bool::<bool, ConfigError, _>(&["B"]),
392      env_map(&[("B", "maybe")]),
393    )
394    .unwrap_err();
395    assert!(matches!(err, ConfigError::Invalid { .. }));
396  }
397
398  #[test]
399  fn read_string_list_splits_on_seq_delim() {
400    let mut m = std::collections::HashMap::new();
401    m.insert("TAGS".into(), "a, b ,c".into());
402    let p = MapConfigProvider::with_options(
403      m,
404      ProviderOptions {
405        path_delim: "_",
406        seq_delim: ",",
407      },
408    );
409    let env = config_env(p);
410    let tags: Vec<String> = run_blocking(
411      read_string_list::<Vec<String>, ConfigError, _>(&["TAGS"]),
412      env,
413    )
414    .unwrap();
415    assert_eq!(tags, vec!["a", "b", "c"]);
416  }
417
418  #[test]
419  fn read_nested_string_and_list() {
420    let v: String = run_blocking(
421      read_nested_string::<String, ConfigError, _>("SERVER", &["HOST"]),
422      env_map(&[("SERVER_HOST", "z")]),
423    )
424    .unwrap();
425    assert_eq!(v, "z");
426
427    let list: Vec<String> = run_blocking(
428      read_nested_string_list::<Vec<String>, ConfigError, _>("APP", &["IDS"]),
429      env_map(&[("APP_IDS", "1,2")]),
430    )
431    .unwrap();
432    assert_eq!(list, vec!["1", "2"]);
433  }
434
435  #[test]
436  fn read_with_empty_path_is_invalid() {
437    let err = run_blocking(read_string::<String, ConfigError, _>(&[]), env_map(&[])).unwrap_err();
438    assert!(matches!(err, ConfigError::Invalid { .. }));
439  }
440
441  #[test]
442  fn with_default_replaces_only_missing() {
443    let def: String = run_blocking(
444      read_string::<String, ConfigError, _>(&["K"]).with_default("default".into()),
445      env_map(&[]),
446    )
447    .unwrap();
448    assert_eq!(def, "default");
449
450    let v: String = run_blocking(
451      read_string::<String, ConfigError, _>(&["K"]).with_default("default".into()),
452      env_map(&[("K", "real")]),
453    )
454    .unwrap();
455    assert_eq!(v, "real");
456
457    let err = run_blocking(
458      read_number::<f64, ConfigError, _>(&["N"]).with_default(0.0),
459      env_map(&[("N", "bad")]),
460    )
461    .unwrap_err();
462    assert!(matches!(err, ConfigError::Invalid { .. }));
463  }
464}