1use std::fmt::{Display, Formatter};
2
3use crate::config::SchemaValueType;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum ConfigError {
7 FileRead {
8 path: String,
9 reason: String,
10 },
11 FileWrite {
12 path: String,
13 reason: String,
14 },
15 LayerLoad {
16 path: String,
17 source: Box<ConfigError>,
18 },
19 InsecureSecretsPermissions {
20 path: String,
21 mode: u32,
22 },
23 TomlParse(String),
24 TomlRootMustBeTable,
25 UnknownTopLevelSection(String),
26 InvalidSection {
27 section: String,
28 expected: String,
29 },
30 UnsupportedTomlValue {
31 path: String,
32 kind: String,
33 },
34 InvalidEnvOverride {
35 key: String,
36 reason: String,
37 },
38 InvalidConfigKey {
39 key: String,
40 reason: String,
41 },
42 InvalidBootstrapScope {
43 key: String,
44 profile: Option<String>,
45 terminal: Option<String>,
46 },
47 MissingDefaultProfile,
48 InvalidBootstrapValue {
49 key: String,
50 reason: String,
51 },
52 UnknownProfile {
53 profile: String,
54 known: Vec<String>,
55 },
56 InvalidPlaceholderSyntax {
57 key: String,
58 template: String,
59 },
60 UnresolvedPlaceholder {
61 key: String,
62 placeholder: String,
63 },
64 PlaceholderCycle {
65 cycle: Vec<String>,
66 },
67 NonScalarPlaceholder {
68 key: String,
69 placeholder: String,
70 },
71 UnknownConfigKeys {
72 keys: Vec<String>,
73 },
74 MissingRequiredKey {
75 key: String,
76 },
77 InvalidValueType {
78 key: String,
79 expected: SchemaValueType,
80 actual: String,
81 },
82 InvalidEnumValue {
83 key: String,
84 value: String,
85 allowed: Vec<String>,
86 },
87}
88
89impl Display for ConfigError {
90 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
91 match self {
92 ConfigError::FileRead { path, reason } => {
93 write!(f, "failed to read config file {path}: {reason}")
94 }
95 ConfigError::FileWrite { path, reason } => {
96 write!(f, "failed to write config file {path}: {reason}")
97 }
98 ConfigError::LayerLoad { path, source } => {
99 write!(f, "{source} (path: {path})")
100 }
101 ConfigError::InsecureSecretsPermissions { path, mode } => {
102 write!(
103 f,
104 "insecure permissions on secrets file {path}: mode {:o}, expected 600",
105 mode
106 )
107 }
108 ConfigError::TomlParse(message) => write!(f, "failed to parse TOML: {message}"),
109 ConfigError::TomlRootMustBeTable => {
110 write!(f, "config root must be a TOML table")
111 }
112 ConfigError::UnknownTopLevelSection(section) => {
113 write!(f, "unknown top-level config section: {section}")
114 }
115 ConfigError::InvalidSection { section, expected } => {
116 write!(f, "invalid section {section}: expected {expected}")
117 }
118 ConfigError::UnsupportedTomlValue { path, kind } => {
119 write!(f, "unsupported TOML value at {path}: {kind}")
120 }
121 ConfigError::InvalidEnvOverride { key, reason } => {
122 write!(f, "invalid env override {key}: {reason}")
123 }
124 ConfigError::InvalidConfigKey { key, reason } => {
125 write!(f, "invalid config key {key}: {reason}")
126 }
127 ConfigError::InvalidBootstrapScope {
128 key,
129 profile,
130 terminal,
131 } => {
132 let scope = match (profile.as_deref(), terminal.as_deref()) {
133 (Some(profile), Some(terminal)) => {
134 format!("profile={profile}, terminal={terminal}")
135 }
136 (Some(profile), None) => format!("profile={profile}"),
137 (None, Some(terminal)) => format!("terminal={terminal}"),
138 (None, None) => "global".to_string(),
139 };
140 write!(
141 f,
142 "bootstrap-only key {key} is not allowed in scope {scope}; allowed scopes: global or terminal-only"
143 )
144 }
145 ConfigError::MissingDefaultProfile => {
146 write!(f, "missing profile.default and no fallback profile")
147 }
148 ConfigError::InvalidBootstrapValue { key, reason } => {
149 write!(f, "invalid bootstrap value for {key}: {reason}")
150 }
151 ConfigError::UnknownProfile { profile, known } => {
152 write!(
153 f,
154 "unknown profile '{profile}'. known profiles: {}",
155 known.join(",")
156 )
157 }
158 ConfigError::InvalidPlaceholderSyntax { key, template } => {
159 write!(f, "invalid placeholder syntax in key {key}: {template}")
160 }
161 ConfigError::UnresolvedPlaceholder { key, placeholder } => {
162 write!(f, "unresolved placeholder in key {key}: {placeholder}")
163 }
164 ConfigError::PlaceholderCycle { cycle } => {
165 write!(f, "placeholder cycle detected: {}", cycle.join(" -> "))
166 }
167 ConfigError::NonScalarPlaceholder { key, placeholder } => {
168 write!(
169 f,
170 "placeholder {placeholder} in key {key} points to a non-scalar value"
171 )
172 }
173 ConfigError::UnknownConfigKeys { keys } => {
174 write!(f, "unknown config keys: {}", keys.join(", "))
175 }
176 ConfigError::MissingRequiredKey { key } => {
177 write!(f, "missing required config key: {key}")
178 }
179 ConfigError::InvalidValueType {
180 key,
181 expected,
182 actual,
183 } => {
184 write!(
185 f,
186 "invalid type for key {key}: expected {expected}, got {actual}"
187 )
188 }
189 ConfigError::InvalidEnumValue {
190 key,
191 value,
192 allowed,
193 } => {
194 write!(
195 f,
196 "invalid value for key {key}: {value}. allowed: {}",
197 allowed.join(", ")
198 )
199 }
200 }
201 }
202}
203
204impl std::error::Error for ConfigError {}
205
206pub(crate) fn with_path_context(path: String, error: ConfigError) -> ConfigError {
207 ConfigError::LayerLoad {
208 path,
209 source: Box::new(error),
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::{ConfigError, with_path_context};
216 use crate::config::SchemaValueType;
217
218 #[test]
219 fn config_error_display_covers_user_facing_variants() {
220 let cases = [
221 (
222 ConfigError::FileRead {
223 path: "/tmp/config.toml".to_string(),
224 reason: "permission denied".to_string(),
225 },
226 "failed to read config file /tmp/config.toml: permission denied",
227 ),
228 (
229 ConfigError::FileWrite {
230 path: "/tmp/config.toml".to_string(),
231 reason: "disk full".to_string(),
232 },
233 "failed to write config file /tmp/config.toml: disk full",
234 ),
235 (
236 ConfigError::InsecureSecretsPermissions {
237 path: "/tmp/secrets.toml".to_string(),
238 mode: 0o644,
239 },
240 "expected 600",
241 ),
242 (
243 ConfigError::TomlParse("unexpected token".to_string()),
244 "failed to parse TOML: unexpected token",
245 ),
246 (
247 ConfigError::TomlRootMustBeTable,
248 "config root must be a TOML table",
249 ),
250 (
251 ConfigError::UnknownTopLevelSection("wat".to_string()),
252 "unknown top-level config section: wat",
253 ),
254 (
255 ConfigError::InvalidSection {
256 section: "profile.default".to_string(),
257 expected: "table".to_string(),
258 },
259 "invalid section profile.default: expected table",
260 ),
261 (
262 ConfigError::UnsupportedTomlValue {
263 path: "ui.format".to_string(),
264 kind: "array".to_string(),
265 },
266 "unsupported TOML value at ui.format: array",
267 ),
268 (
269 ConfigError::InvalidEnvOverride {
270 key: "OSP_UI_FORMAT".to_string(),
271 reason: "unknown enum".to_string(),
272 },
273 "invalid env override OSP_UI_FORMAT: unknown enum",
274 ),
275 (
276 ConfigError::InvalidConfigKey {
277 key: "ui.wat".to_string(),
278 reason: "unknown key".to_string(),
279 },
280 "invalid config key ui.wat: unknown key",
281 ),
282 (
283 ConfigError::InvalidBootstrapScope {
284 key: "profile.default".to_string(),
285 profile: Some("prod".to_string()),
286 terminal: Some("repl".to_string()),
287 },
288 "profile=prod, terminal=repl",
289 ),
290 (
291 ConfigError::InvalidBootstrapScope {
292 key: "profile.default".to_string(),
293 profile: Some("prod".to_string()),
294 terminal: None,
295 },
296 "scope profile=prod",
297 ),
298 (
299 ConfigError::InvalidBootstrapScope {
300 key: "profile.default".to_string(),
301 profile: None,
302 terminal: Some("repl".to_string()),
303 },
304 "scope terminal=repl",
305 ),
306 (
307 ConfigError::InvalidBootstrapScope {
308 key: "profile.default".to_string(),
309 profile: None,
310 terminal: None,
311 },
312 "scope global",
313 ),
314 (
315 ConfigError::MissingDefaultProfile,
316 "missing profile.default and no fallback profile",
317 ),
318 (
319 ConfigError::InvalidBootstrapValue {
320 key: "profile.default".to_string(),
321 reason: "cannot be empty".to_string(),
322 },
323 "invalid bootstrap value for profile.default: cannot be empty",
324 ),
325 (
326 ConfigError::UnknownProfile {
327 profile: "prod".to_string(),
328 known: vec!["default".to_string(), "dev".to_string()],
329 },
330 "unknown profile 'prod'. known profiles: default,dev",
331 ),
332 (
333 ConfigError::InvalidPlaceholderSyntax {
334 key: "ui.format".to_string(),
335 template: "${oops".to_string(),
336 },
337 "invalid placeholder syntax in key ui.format: ${oops",
338 ),
339 (
340 ConfigError::UnresolvedPlaceholder {
341 key: "ldap.uri".to_string(),
342 placeholder: "profile.current".to_string(),
343 },
344 "unresolved placeholder in key ldap.uri: profile.current",
345 ),
346 (
347 ConfigError::PlaceholderCycle {
348 cycle: vec!["a".to_string(), "b".to_string(), "a".to_string()],
349 },
350 "placeholder cycle detected: a -> b -> a",
351 ),
352 (
353 ConfigError::NonScalarPlaceholder {
354 key: "ldap.uri".to_string(),
355 placeholder: "profiles".to_string(),
356 },
357 "placeholder profiles in key ldap.uri points to a non-scalar value",
358 ),
359 (
360 ConfigError::UnknownConfigKeys {
361 keys: vec!["ui.wat".to_string(), "ldap.nope".to_string()],
362 },
363 "unknown config keys: ui.wat, ldap.nope",
364 ),
365 (
366 ConfigError::MissingRequiredKey {
367 key: "ldap.uri".to_string(),
368 },
369 "missing required config key: ldap.uri",
370 ),
371 (
372 ConfigError::InvalidValueType {
373 key: "ui.debug".to_string(),
374 expected: SchemaValueType::Bool,
375 actual: "string".to_string(),
376 },
377 "invalid type for key ui.debug: expected bool, got string",
378 ),
379 (
380 ConfigError::InvalidEnumValue {
381 key: "ui.format".to_string(),
382 value: "yaml".to_string(),
383 allowed: vec!["json".to_string(), "table".to_string()],
384 },
385 "invalid value for key ui.format: yaml. allowed: json, table",
386 ),
387 ];
388
389 assert!(
390 cases
391 .into_iter()
392 .all(|(error, expected)| error.to_string().contains(expected))
393 );
394 }
395
396 #[test]
397 fn with_path_context_wraps_source_error() {
398 let wrapped = with_path_context(
399 "/tmp/config.toml".to_string(),
400 ConfigError::TomlParse("bad value".to_string()),
401 );
402
403 assert_eq!(
404 wrapped.to_string(),
405 "failed to parse TOML: bad value (path: /tmp/config.toml)"
406 );
407
408 if let ConfigError::LayerLoad { path, source } = wrapped {
409 assert_eq!(path, "/tmp/config.toml");
410 assert!(matches!(*source, ConfigError::TomlParse(_)));
411 }
412 }
413}