Skip to main content

osp_cli/config/
error.rs

1use std::fmt::{Display, Formatter};
2
3use crate::config::SchemaValueType;
4
5/// Error type returned by config parsing, validation, and resolution code.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ConfigError {
8    /// Reading a config file from disk failed.
9    FileRead {
10        /// Path that could not be read.
11        path: String,
12        /// Lower-level failure description.
13        reason: String,
14    },
15    /// Writing a config file to disk failed.
16    FileWrite {
17        /// Path that could not be written.
18        path: String,
19        /// Lower-level failure description.
20        reason: String,
21    },
22    /// Adds path context while propagating another config error.
23    LayerLoad {
24        /// Path of the layer being loaded.
25        path: String,
26        /// Underlying config error.
27        source: Box<ConfigError>,
28    },
29    /// Secrets file permissions are broader than allowed.
30    InsecureSecretsPermissions {
31        /// Path of the secrets file.
32        path: String,
33        /// Observed Unix file mode.
34        mode: u32,
35    },
36    /// TOML parsing failed before semantic validation.
37    TomlParse(String),
38    /// The parsed TOML document root was not a table.
39    TomlRootMustBeTable,
40    /// Encountered an unsupported top-level section name.
41    UnknownTopLevelSection(String),
42    /// A named section had the wrong TOML type.
43    InvalidSection {
44        /// Fully qualified section name.
45        section: String,
46        /// Expected TOML kind for the section.
47        expected: String,
48    },
49    /// Encountered a TOML value kind that the config model does not accept.
50    UnsupportedTomlValue {
51        /// Dotted config path at which the value was found.
52        path: String,
53        /// TOML value kind that was rejected.
54        kind: String,
55    },
56    /// An `OSP__...` environment override used invalid syntax or scope.
57    InvalidEnvOverride {
58        /// Environment variable name or derived config key.
59        key: String,
60        /// Validation failure description.
61        reason: String,
62    },
63    /// A config key failed syntactic or semantic validation.
64    InvalidConfigKey {
65        /// Key that failed validation.
66        key: String,
67        /// Validation failure description.
68        reason: String,
69    },
70    /// A caller attempted to write a read-only config key.
71    ReadOnlyConfigKey {
72        /// Key that is read-only.
73        key: String,
74        /// Explanation of why the key is immutable.
75        reason: String,
76    },
77    /// A bootstrap-only key was used in a disallowed scope.
78    InvalidBootstrapScope {
79        /// Bootstrap-only key being validated.
80        key: String,
81        /// Profile selector present on the rejected scope, if any.
82        profile: Option<String>,
83        /// Terminal selector present on the rejected scope, if any.
84        terminal: Option<String>,
85    },
86    /// No default profile could be determined during bootstrap.
87    MissingDefaultProfile,
88    /// A bootstrap-only key failed its value validation rule.
89    InvalidBootstrapValue {
90        /// Bootstrap-only key being validated.
91        key: String,
92        /// Validation failure description.
93        reason: String,
94    },
95    /// Resolution requested a profile that is not known.
96    UnknownProfile {
97        /// Requested profile name.
98        profile: String,
99        /// Known profile names at the time of resolution.
100        known: Vec<String>,
101    },
102    /// Placeholder syntax inside a template string is malformed.
103    InvalidPlaceholderSyntax {
104        /// Key containing the invalid template.
105        key: String,
106        /// Original template string.
107        template: String,
108    },
109    /// Placeholder expansion referenced an unknown key.
110    UnresolvedPlaceholder {
111        /// Key whose template is being resolved.
112        key: String,
113        /// Placeholder that could not be resolved.
114        placeholder: String,
115    },
116    /// Placeholder expansion formed a dependency cycle.
117    PlaceholderCycle {
118        /// Ordered key path describing the detected cycle.
119        cycle: Vec<String>,
120    },
121    /// Placeholder expansion referenced a non-scalar value.
122    NonScalarPlaceholder {
123        /// Key whose template is being resolved.
124        key: String,
125        /// Placeholder that resolved to a non-scalar value.
126        placeholder: String,
127    },
128    /// One or more config keys are unknown to the schema.
129    UnknownConfigKeys {
130        /// Unknown canonical keys.
131        keys: Vec<String>,
132    },
133    /// A required runtime-visible key was missing after resolution.
134    MissingRequiredKey {
135        /// Missing canonical key.
136        key: String,
137    },
138    /// A value could not be adapted to the schema's declared type.
139    InvalidValueType {
140        /// Key whose value had the wrong type.
141        key: String,
142        /// Schema type expected for the key.
143        expected: SchemaValueType,
144        /// Actual type name observed during adaptation.
145        actual: String,
146    },
147    /// A string value was outside the schema allow-list.
148    InvalidEnumValue {
149        /// Key whose value was rejected.
150        key: String,
151        /// Rejected value.
152        value: String,
153        /// Allowed normalized values.
154        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}