1use 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
55type LoadFn<T> = Arc<dyn Fn(&dyn ConfigProvider) -> Result<T, ConfigError> + Send + Sync>;
57
58struct 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
78pub 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
103impl<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 #[inline]
116 pub fn load(&self, provider: &dyn ConfigProvider) -> Result<T, ConfigError> {
117 (self.loader)(provider)
118 }
119
120 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 #[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 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 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 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 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
240impl<T: Clone + Send + Sync + 'static> Config<T> {
243 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 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
301fn 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 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 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 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 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 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 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 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 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 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 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
543pub 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
573pub 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
590pub 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
611pub 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
622pub 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#[inline]
632pub fn nest<T: Send + Sync + 'static>(prefix: impl Into<String>, inner: Config<T>) -> Config<T> {
633 inner.nested(prefix)
634}
635
636#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}