1use std::path::PathBuf;
2
3use crate::field::FieldMeta;
4use crate::provenance::Source;
5
6#[derive(Debug)]
11#[non_exhaustive]
12pub enum ConfigError {
13 ParseError {
15 field: String,
16 raw_display: String,
18 expected_type: String,
19 source: Box<dyn std::error::Error + Send + Sync>,
20 },
21
22 FileError {
24 path: PathBuf,
25 source: Box<dyn std::error::Error + Send + Sync>,
26 },
27
28 EnvVar { key: String, cause: String },
30
31 FieldConflict { field: String, a: Source, b: Source },
33
34 MissingRequired { field: String },
36
37 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 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 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 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 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 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 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}