Skip to main content

cistell_core/
error.rs

1use std::path::PathBuf;
2
3use crate::field::FieldMeta;
4use crate::provenance::Source;
5
6/// All errors that cistell-core can produce.
7///
8/// `#[non_exhaustive]` so we can add variants in minor versions
9/// without breaking downstream `match` arms.
10#[derive(Debug)]
11#[non_exhaustive]
12pub enum ConfigError {
13    /// A value could not be parsed into the expected type.
14    ParseError {
15        field: String,
16        /// The raw value (or `"<secret>"` if the field is secret).
17        raw_display: String,
18        expected_type: String,
19        source: Box<dyn std::error::Error + Send + Sync>,
20    },
21
22    /// A config file could not be read or parsed.
23    FileError {
24        path: PathBuf,
25        source: Box<dyn std::error::Error + Send + Sync>,
26    },
27
28    /// An environment variable exists but is not valid UTF-8.
29    EnvVar { key: String, cause: String },
30
31    /// Two sources at the same rank provide conflicting values.
32    FieldConflict { field: String, a: Source, b: Source },
33
34    /// A required field has no default and no source provided a value.
35    MissingRequired { field: String },
36
37    /// An internal invariant was violated (should never happen in normal operation).
38    Internal { message: String },
39}
40
41impl std::fmt::Display for ConfigError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            ConfigError::ParseError {
45                field,
46                raw_display,
47                expected_type,
48                source,
49            } => write!(
50                f,
51                "field '{field}': cannot parse '{raw_display}' as {expected_type}: {source}"
52            ),
53            ConfigError::FileError { path, source } => {
54                write!(f, "config file '{}': {source}", path.display())
55            }
56            ConfigError::EnvVar { key, cause } => {
57                write!(f, "env var '{key}': {cause}")
58            }
59            ConfigError::FieldConflict { field, a, b } => {
60                write!(f, "field '{field}': conflicting values from {a} and {b}")
61            }
62            ConfigError::MissingRequired { field } => {
63                write!(f, "field '{field}': required but no value provided")
64            }
65            ConfigError::Internal { message } => {
66                write!(f, "internal error: {message}")
67            }
68        }
69    }
70}
71
72impl std::error::Error for ConfigError {
73    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
74        match self {
75            ConfigError::ParseError { source, .. } => Some(source.as_ref()),
76            ConfigError::FileError { source, .. } => Some(source.as_ref()),
77            _ => None,
78        }
79    }
80}
81
82impl ConfigError {
83    /// Create a `ParseError`, automatically redacting the raw value when `meta.is_secret`.
84    pub fn parse<E>(meta: &FieldMeta, raw: &str, source: E) -> Self
85    where
86        E: std::error::Error + Send + Sync + 'static,
87    {
88        ConfigError::ParseError {
89            field: meta.name.to_owned(),
90            raw_display: if meta.is_secret {
91                "<secret>".to_owned()
92            } else {
93                raw.to_owned()
94            },
95            expected_type: meta.expected_type.to_owned(),
96            source: Box::new(source),
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use std::error::Error;
105    use std::num::ParseIntError;
106
107    fn test_meta(is_secret: bool) -> FieldMeta {
108        FieldMeta {
109            name: "port",
110            config_key: "redis.port",
111            env_key: "REDIS__PORT",
112            generic_env_key: None,
113            is_secret,
114            expected_type: "u16",
115            has_default: true,
116            deserialize_fn: None,
117        }
118    }
119
120    #[test]
121    fn test_error_parse_redacts_secret() {
122        let meta = test_meta(true);
123        let parse_err: ParseIntError = "abc".parse::<u16>().unwrap_err();
124        let err = ConfigError::parse(&meta, "hunter2", parse_err);
125        let msg = err.to_string();
126        assert!(msg.contains("<secret>"), "should contain <secret>: {msg}");
127        assert!(!msg.contains("hunter2"), "should NOT leak raw: {msg}");
128    }
129
130    #[test]
131    fn test_error_parse_shows_raw_for_non_secret() {
132        let meta = test_meta(false);
133        let parse_err: ParseIntError = "abc".parse::<u16>().unwrap_err();
134        let err = ConfigError::parse(&meta, "bad_value", parse_err);
135        let msg = err.to_string();
136        assert!(msg.contains("bad_value"), "should show raw: {msg}");
137    }
138
139    #[test]
140    fn test_error_display_messages() {
141        let meta = test_meta(false);
142        let parse_err: ParseIntError = "abc".parse::<u16>().unwrap_err();
143
144        // ParseError
145        let err = ConfigError::parse(&meta, "abc", parse_err);
146        let msg = err.to_string();
147        assert!(msg.contains("port"));
148        assert!(msg.contains("abc"));
149        assert!(msg.contains("u16"));
150
151        // FileError
152        let err = ConfigError::FileError {
153            path: PathBuf::from("/etc/config.toml"),
154            source: Box::new(std::io::Error::new(
155                std::io::ErrorKind::NotFound,
156                "not found",
157            )),
158        };
159        assert!(err.to_string().contains("/etc/config.toml"));
160
161        // EnvVar
162        let err = ConfigError::EnvVar {
163            key: "MY_VAR".into(),
164            cause: "not valid unicode".into(),
165        };
166        assert!(err.to_string().contains("MY_VAR"));
167
168        // FieldConflict
169        let err = ConfigError::FieldConflict {
170            field: "host".into(),
171            a: Source::Default,
172            b: Source::EnvVar {
173                name: "HOST".into(),
174            },
175        };
176        assert!(err.to_string().contains("host"));
177
178        // MissingRequired
179        let err = ConfigError::MissingRequired {
180            field: "port".into(),
181        };
182        assert!(err.to_string().contains("port"));
183        assert!(err.to_string().contains("required"));
184    }
185
186    #[test]
187    fn test_error_source_chain() {
188        let meta = test_meta(false);
189        let parse_err: ParseIntError = "abc".parse::<u16>().unwrap_err();
190        let err = ConfigError::parse(&meta, "abc", parse_err);
191        assert!(err.source().is_some());
192
193        let err = ConfigError::MissingRequired {
194            field: "port".into(),
195        };
196        assert!(err.source().is_none());
197    }
198}