Skip to main content

effect_config/
error.rs

1//! Unified errors for Figment extraction and [`crate::ConfigProvider`] reads.
2
3/// Configuration load / parse failure (Figment + Effect.ts-style provider reads).
4#[derive(Debug)]
5#[non_exhaustive]
6pub enum ConfigError {
7  /// Figment merge / deserialize failure (see [`extract`](crate::extract)).
8  Figment(
9    /// Underlying Figment error.
10    figment::Error,
11  ),
12  /// No value at the flattened lookup key (Effect `ConfigError.MissingData`).
13  Missing {
14    /// Flattened lookup path where no value was found.
15    path: String,
16  },
17  /// Value present but not usable for the requested type (Effect `ConfigError.InvalidData`).
18  Invalid {
19    /// Dot- or delimiter-joined path for the invalid value.
20    path: String,
21    /// Raw string form of the value that could not be coerced.
22    value: String,
23    /// Parser or validation explanation.
24    reason: String,
25  },
26  /// Environment variable is not valid UTF-8.
27  InvalidUtf8 {
28    /// Name of the environment variable that was not valid UTF-8.
29    var: String,
30  },
31}
32
33impl From<figment::Error> for ConfigError {
34  fn from(value: figment::Error) -> Self {
35    Self::Figment(value)
36  }
37}
38
39impl From<core::convert::Infallible> for ConfigError {
40  fn from(e: core::convert::Infallible) -> Self {
41    match e {}
42  }
43}
44
45/// Owned, `'static` summary for [`ConfigError`] display via [`id_effect::Matcher`] (avoids matching on
46/// `&ConfigError`, which is not `'static`).
47#[derive(Clone)]
48enum ConfigErrorDisp {
49  Figment(String),
50  Missing {
51    path: String,
52  },
53  Invalid {
54    path: String,
55    value: String,
56    reason: String,
57  },
58  InvalidUtf8 {
59    var: String,
60  },
61}
62
63impl From<&ConfigError> for ConfigErrorDisp {
64  fn from(e: &ConfigError) -> Self {
65    match e {
66      ConfigError::Figment(err) => Self::Figment(format!("{err}")),
67      ConfigError::Missing { path } => Self::Missing { path: path.clone() },
68      ConfigError::Invalid {
69        path,
70        value,
71        reason,
72      } => Self::Invalid {
73        path: path.clone(),
74        value: value.clone(),
75        reason: reason.clone(),
76      },
77      ConfigError::InvalidUtf8 { var } => Self::InvalidUtf8 { var: var.clone() },
78    }
79  }
80}
81
82impl std::fmt::Display for ConfigError {
83  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84    use id_effect::Matcher;
85
86    let d = ConfigErrorDisp::from(self);
87    let s = Matcher::<ConfigErrorDisp, String>::new()
88      .when(
89        Box::new(|c: &ConfigErrorDisp| matches!(c, ConfigErrorDisp::Figment(_))),
90        |c| match c {
91          ConfigErrorDisp::Figment(msg) => msg,
92          _ => unreachable!(),
93        },
94      )
95      .when(
96        Box::new(|c: &ConfigErrorDisp| matches!(c, ConfigErrorDisp::Missing { .. })),
97        |c| match c {
98          ConfigErrorDisp::Missing { path } => format!("missing configuration at path {path:?}"),
99          _ => unreachable!(),
100        },
101      )
102      .when(
103        Box::new(|c: &ConfigErrorDisp| matches!(c, ConfigErrorDisp::Invalid { .. })),
104        |c| match c {
105          ConfigErrorDisp::Invalid {
106            path,
107            value,
108            reason,
109          } => format!("invalid configuration at path {path:?} (value={value:?}): {reason}"),
110          _ => unreachable!(),
111        },
112      )
113      .when(
114        Box::new(|c: &ConfigErrorDisp| matches!(c, ConfigErrorDisp::InvalidUtf8 { .. })),
115        |c| match c {
116          ConfigErrorDisp::InvalidUtf8 { var } => {
117            format!("environment variable {var:?} is not valid UTF-8")
118          }
119          _ => unreachable!(),
120        },
121      )
122      .run_exhaustive(d);
123    f.write_str(&s)
124  }
125}
126
127impl std::error::Error for ConfigError {
128  fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
129    match self {
130      ConfigError::Figment(e) => Some(e),
131      _ => None,
132    }
133  }
134}
135
136#[cfg(test)]
137mod tests {
138  use super::*;
139  use std::error::Error;
140
141  #[test]
142  fn config_error_matcher_all_variants_covered() {
143    use id_effect::Matcher;
144
145    let fig_display =
146      ConfigError::from(figment::Figment::new().extract::<i32>().unwrap_err()).to_string();
147    let fig_for_match = ConfigError::from(figment::Figment::new().extract::<i32>().unwrap_err());
148    let via_matcher = Matcher::<ConfigError, String>::new()
149      .when(
150        Box::new(|c: &ConfigError| matches!(c, ConfigError::Figment(_))),
151        |c: ConfigError| match c {
152          ConfigError::Figment(e) => format!("{e}"),
153          _ => unreachable!(),
154        },
155      )
156      .when(
157        Box::new(|c: &ConfigError| matches!(c, ConfigError::Missing { .. })),
158        |c: ConfigError| match c {
159          ConfigError::Missing { path } => format!("missing configuration at path {path:?}"),
160          _ => unreachable!(),
161        },
162      )
163      .when(
164        Box::new(|c: &ConfigError| matches!(c, ConfigError::Invalid { .. })),
165        |c: ConfigError| match c {
166          ConfigError::Invalid {
167            path,
168            value,
169            reason,
170          } => format!("invalid configuration at path {path:?} (value={value:?}): {reason}"),
171          _ => unreachable!(),
172        },
173      )
174      .when(
175        Box::new(|c: &ConfigError| matches!(c, ConfigError::InvalidUtf8 { .. })),
176        |c: ConfigError| match c {
177          ConfigError::InvalidUtf8 { var } => {
178            format!("environment variable {var:?} is not valid UTF-8")
179          }
180          _ => unreachable!(),
181        },
182      )
183      .or_else(|c| format!("configuration error: {c:?}"))
184      .run_exhaustive(fig_for_match);
185    assert!(!via_matcher.is_empty());
186    assert_eq!(via_matcher, fig_display);
187
188    let missing = ConfigError::Missing {
189      path: "db.url".into(),
190    };
191    assert_eq!(
192      missing.to_string(),
193      "missing configuration at path \"db.url\""
194    );
195
196    let invalid = ConfigError::Invalid {
197      path: "port".into(),
198      value: "x".into(),
199      reason: "not a number".into(),
200    };
201    assert_eq!(
202      invalid.to_string(),
203      "invalid configuration at path \"port\" (value=\"x\"): not a number"
204    );
205
206    let utf8 = ConfigError::InvalidUtf8 {
207      var: "WEIRD".into(),
208    };
209    assert_eq!(
210      utf8.to_string(),
211      "environment variable \"WEIRD\" is not valid UTF-8"
212    );
213  }
214
215  #[test]
216  fn config_error_source_figment_variant_has_source() {
217    let fig_err = figment::Figment::new().extract::<i32>().unwrap_err();
218    let e = ConfigError::Figment(fig_err);
219    assert!(e.source().is_some());
220  }
221
222  #[test]
223  fn config_error_source_non_figment_variants_return_none() {
224    let missing = ConfigError::Missing { path: "x".into() };
225    assert!(missing.source().is_none());
226
227    let invalid = ConfigError::Invalid {
228      path: "x".into(),
229      value: "v".into(),
230      reason: "r".into(),
231    };
232    assert!(invalid.source().is_none());
233
234    let utf8 = ConfigError::InvalidUtf8 { var: "VAR".into() };
235    assert!(utf8.source().is_none());
236  }
237}