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