1use std::fmt::{Display, Formatter};
2
3use crate::config::SchemaValueType;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ConfigError {
8 FileRead {
10 path: String,
12 reason: String,
14 },
15 FileWrite {
17 path: String,
19 reason: String,
21 },
22 LayerLoad {
24 path: String,
26 source: Box<ConfigError>,
28 },
29 InsecureSecretsPermissions {
31 path: String,
33 mode: u32,
35 },
36 TomlParse(String),
38 TomlRootMustBeTable,
40 UnknownTopLevelSection(String),
42 InvalidSection {
44 section: String,
46 expected: String,
48 },
49 UnsupportedTomlValue {
51 path: String,
53 kind: String,
55 },
56 InvalidEnvOverride {
58 key: String,
60 reason: String,
62 },
63 InvalidConfigKey {
65 key: String,
67 reason: String,
69 },
70 ReadOnlyConfigKey {
72 key: String,
74 reason: String,
76 },
77 InvalidBootstrapScope {
79 key: String,
81 profile: Option<String>,
83 terminal: Option<String>,
85 },
86 MissingDefaultProfile,
91 InvalidBootstrapValue {
93 key: String,
95 reason: String,
97 },
98 UnknownProfile {
100 profile: String,
102 known: Vec<String>,
104 },
105 InvalidPlaceholderSyntax {
107 key: String,
109 template: String,
111 },
112 UnresolvedPlaceholder {
114 key: String,
116 placeholder: String,
118 },
119 PlaceholderCycle {
121 cycle: Vec<String>,
123 },
124 NonScalarPlaceholder {
126 key: String,
128 placeholder: String,
130 },
131 UnknownConfigKeys {
133 keys: Vec<String>,
135 },
136 MissingRequiredKey {
138 key: String,
140 },
141 InvalidValueType {
143 key: String,
145 expected: SchemaValueType,
147 actual: String,
149 },
150 InvalidEnumValue {
152 key: String,
154 value: String,
156 allowed: Vec<String>,
158 },
159}
160
161impl Display for ConfigError {
162 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
163 match self {
164 ConfigError::FileRead { path, reason } => {
165 write!(f, "failed to read config file {path}: {reason}")
166 }
167 ConfigError::FileWrite { path, reason } => {
168 write!(f, "failed to write config file {path}: {reason}")
169 }
170 ConfigError::LayerLoad { path, source } => {
171 write!(f, "{source} (path: {path})")
172 }
173 ConfigError::InsecureSecretsPermissions { path, mode } => {
174 write!(
175 f,
176 "insecure permissions on secrets file {path}: mode {:o}, expected 600",
177 mode
178 )
179 }
180 ConfigError::TomlParse(message) => write!(f, "failed to parse TOML: {message}"),
181 ConfigError::TomlRootMustBeTable => {
182 write!(f, "config root must be a TOML table")
183 }
184 ConfigError::UnknownTopLevelSection(section) => {
185 write!(f, "unknown top-level config section: {section}")
186 }
187 ConfigError::InvalidSection { section, expected } => {
188 write!(f, "invalid section {section}: expected {expected}")
189 }
190 ConfigError::UnsupportedTomlValue { path, kind } => {
191 write!(f, "unsupported TOML value at {path}: {kind}")
192 }
193 ConfigError::InvalidEnvOverride { key, reason } => {
194 write!(f, "invalid env override {key}: {reason}")
195 }
196 ConfigError::InvalidConfigKey { key, reason } => {
197 write!(f, "invalid config key {key}: {reason}")
198 }
199 ConfigError::ReadOnlyConfigKey { key, reason } => {
200 write!(f, "config key {key} is read-only: {reason}")
201 }
202 ConfigError::InvalidBootstrapScope {
203 key,
204 profile,
205 terminal,
206 } => {
207 let scope = match (profile.as_deref(), terminal.as_deref()) {
208 (Some(profile), Some(terminal)) => {
209 format!("profile={profile}, terminal={terminal}")
210 }
211 (Some(profile), None) => format!("profile={profile}"),
212 (None, Some(terminal)) => format!("terminal={terminal}"),
213 (None, None) => "global".to_string(),
214 };
215 write!(
216 f,
217 "bootstrap-only key {key} is not allowed in scope {scope}; allowed scopes: global or terminal-only"
218 )
219 }
220 ConfigError::MissingDefaultProfile => {
221 write!(f, "missing profile.default and no fallback profile")
222 }
223 ConfigError::InvalidBootstrapValue { key, reason } => {
224 write!(f, "invalid bootstrap value for {key}: {reason}")
225 }
226 ConfigError::UnknownProfile { profile, known } => {
227 write!(
228 f,
229 "unknown profile '{profile}'. known profiles: {}",
230 known.join(",")
231 )
232 }
233 ConfigError::InvalidPlaceholderSyntax { key, template } => {
234 write!(f, "invalid placeholder syntax in key {key}: {template}")
235 }
236 ConfigError::UnresolvedPlaceholder { key, placeholder } => {
237 write!(f, "unresolved placeholder in key {key}: {placeholder}")
238 }
239 ConfigError::PlaceholderCycle { cycle } => {
240 write!(f, "placeholder cycle detected: {}", cycle.join(" -> "))
241 }
242 ConfigError::NonScalarPlaceholder { key, placeholder } => {
243 write!(
244 f,
245 "placeholder {placeholder} in key {key} points to a non-scalar value"
246 )
247 }
248 ConfigError::UnknownConfigKeys { keys } => {
249 write!(f, "unknown config keys: {}", keys.join(", "))
250 }
251 ConfigError::MissingRequiredKey { key } => {
252 write!(f, "missing required config key: {key}")
253 }
254 ConfigError::InvalidValueType {
255 key,
256 expected,
257 actual,
258 } => {
259 write!(
260 f,
261 "invalid type for key {key}: expected {expected}, got {actual}"
262 )
263 }
264 ConfigError::InvalidEnumValue {
265 key,
266 value,
267 allowed,
268 } => {
269 write!(
270 f,
271 "invalid value for key {key}: {value}. allowed: {}",
272 allowed.join(", ")
273 )
274 }
275 }
276 }
277}
278
279impl std::error::Error for ConfigError {}
280
281pub(crate) fn with_path_context(path: String, error: ConfigError) -> ConfigError {
282 ConfigError::LayerLoad {
283 path,
284 source: Box::new(error),
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::{ConfigError, with_path_context};
291 use crate::config::SchemaValueType;
292
293 #[test]
294 fn config_error_display_and_path_context_cover_user_facing_variants_unit() {
295 let cases = [
296 (
297 ConfigError::FileRead {
298 path: "/tmp/config.toml".to_string(),
299 reason: "permission denied".to_string(),
300 },
301 "failed to read config file /tmp/config.toml: permission denied",
302 ),
303 (
304 ConfigError::FileWrite {
305 path: "/tmp/config.toml".to_string(),
306 reason: "disk full".to_string(),
307 },
308 "failed to write config file /tmp/config.toml: disk full",
309 ),
310 (
311 ConfigError::InsecureSecretsPermissions {
312 path: "/tmp/secrets.toml".to_string(),
313 mode: 0o644,
314 },
315 "expected 600",
316 ),
317 (
318 ConfigError::TomlParse("unexpected token".to_string()),
319 "failed to parse TOML: unexpected token",
320 ),
321 (
322 ConfigError::TomlRootMustBeTable,
323 "config root must be a TOML table",
324 ),
325 (
326 ConfigError::UnknownTopLevelSection("wat".to_string()),
327 "unknown top-level config section: wat",
328 ),
329 (
330 ConfigError::InvalidSection {
331 section: "profile.default".to_string(),
332 expected: "table".to_string(),
333 },
334 "invalid section profile.default: expected table",
335 ),
336 (
337 ConfigError::UnsupportedTomlValue {
338 path: "ui.format".to_string(),
339 kind: "array".to_string(),
340 },
341 "unsupported TOML value at ui.format: array",
342 ),
343 (
344 ConfigError::InvalidEnvOverride {
345 key: "OSP_UI_FORMAT".to_string(),
346 reason: "unknown enum".to_string(),
347 },
348 "invalid env override OSP_UI_FORMAT: unknown enum",
349 ),
350 (
351 ConfigError::InvalidConfigKey {
352 key: "ui.wat".to_string(),
353 reason: "unknown key".to_string(),
354 },
355 "invalid config key ui.wat: unknown key",
356 ),
357 (
358 ConfigError::ReadOnlyConfigKey {
359 key: "profile.active".to_string(),
360 reason: "derived at runtime".to_string(),
361 },
362 "config key profile.active is read-only: derived at runtime",
363 ),
364 (
365 ConfigError::InvalidBootstrapScope {
366 key: "profile.default".to_string(),
367 profile: Some("prod".to_string()),
368 terminal: Some("repl".to_string()),
369 },
370 "profile=prod, terminal=repl",
371 ),
372 (
373 ConfigError::InvalidBootstrapScope {
374 key: "profile.default".to_string(),
375 profile: Some("prod".to_string()),
376 terminal: None,
377 },
378 "scope profile=prod",
379 ),
380 (
381 ConfigError::InvalidBootstrapScope {
382 key: "profile.default".to_string(),
383 profile: None,
384 terminal: Some("repl".to_string()),
385 },
386 "scope terminal=repl",
387 ),
388 (
389 ConfigError::InvalidBootstrapScope {
390 key: "profile.default".to_string(),
391 profile: None,
392 terminal: None,
393 },
394 "scope global",
395 ),
396 (
397 ConfigError::MissingDefaultProfile,
398 "missing profile.default and no fallback profile",
399 ),
400 (
401 ConfigError::InvalidBootstrapValue {
402 key: "profile.default".to_string(),
403 reason: "cannot be empty".to_string(),
404 },
405 "invalid bootstrap value for profile.default: cannot be empty",
406 ),
407 (
408 ConfigError::UnknownProfile {
409 profile: "prod".to_string(),
410 known: vec!["default".to_string(), "dev".to_string()],
411 },
412 "unknown profile 'prod'. known profiles: default,dev",
413 ),
414 (
415 ConfigError::InvalidPlaceholderSyntax {
416 key: "ui.format".to_string(),
417 template: "${oops".to_string(),
418 },
419 "invalid placeholder syntax in key ui.format: ${oops",
420 ),
421 (
422 ConfigError::UnresolvedPlaceholder {
423 key: "ldap.uri".to_string(),
424 placeholder: "profile.current".to_string(),
425 },
426 "unresolved placeholder in key ldap.uri: profile.current",
427 ),
428 (
429 ConfigError::PlaceholderCycle {
430 cycle: vec!["a".to_string(), "b".to_string(), "a".to_string()],
431 },
432 "placeholder cycle detected: a -> b -> a",
433 ),
434 (
435 ConfigError::NonScalarPlaceholder {
436 key: "ldap.uri".to_string(),
437 placeholder: "profiles".to_string(),
438 },
439 "placeholder profiles in key ldap.uri points to a non-scalar value",
440 ),
441 (
442 ConfigError::UnknownConfigKeys {
443 keys: vec!["ui.wat".to_string(), "ldap.nope".to_string()],
444 },
445 "unknown config keys: ui.wat, ldap.nope",
446 ),
447 (
448 ConfigError::MissingRequiredKey {
449 key: "ldap.uri".to_string(),
450 },
451 "missing required config key: ldap.uri",
452 ),
453 (
454 ConfigError::InvalidValueType {
455 key: "ui.debug".to_string(),
456 expected: SchemaValueType::Bool,
457 actual: "string".to_string(),
458 },
459 "invalid type for key ui.debug: expected bool, got string",
460 ),
461 (
462 ConfigError::InvalidEnumValue {
463 key: "ui.format".to_string(),
464 value: "yaml".to_string(),
465 allowed: vec!["json".to_string(), "table".to_string()],
466 },
467 "invalid value for key ui.format: yaml. allowed: json, table",
468 ),
469 ];
470
471 assert!(
472 cases
473 .into_iter()
474 .all(|(error, expected)| error.to_string().contains(expected))
475 );
476
477 let wrapped = with_path_context(
478 "/tmp/config.toml".to_string(),
479 ConfigError::TomlParse("bad value".to_string()),
480 );
481
482 assert_eq!(
483 wrapped.to_string(),
484 "failed to parse TOML: bad value (path: /tmp/config.toml)"
485 );
486
487 if let ConfigError::LayerLoad { path, source } = wrapped {
488 assert_eq!(path, "/tmp/config.toml");
489 assert!(matches!(*source, ConfigError::TomlParse(_)));
490 }
491 }
492}