Skip to main content

osp_cli/config/
error.rs

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}