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 usable bootstrap profile name could be determined during bootstrap.
87    ///
88    /// This includes both "no default profile exists" and "the selected
89    /// bootstrap profile normalized to empty".
90    MissingDefaultProfile,
91    /// A bootstrap-only key failed its value validation rule.
92    InvalidBootstrapValue {
93        /// Bootstrap-only key being validated.
94        key: String,
95        /// Validation failure description.
96        reason: String,
97    },
98    /// Resolution requested a profile that is not known.
99    UnknownProfile {
100        /// Requested profile name.
101        profile: String,
102        /// Known profile names at the time of resolution.
103        known: Vec<String>,
104    },
105    /// Placeholder syntax inside a template string is malformed.
106    InvalidPlaceholderSyntax {
107        /// Key containing the invalid template.
108        key: String,
109        /// Original template string.
110        template: String,
111    },
112    /// Placeholder expansion referenced an unknown key.
113    UnresolvedPlaceholder {
114        /// Key whose template is being resolved.
115        key: String,
116        /// Placeholder that could not be resolved.
117        placeholder: String,
118    },
119    /// Placeholder expansion formed a dependency cycle.
120    PlaceholderCycle {
121        /// Ordered key path describing the detected cycle.
122        cycle: Vec<String>,
123    },
124    /// Placeholder expansion referenced a non-scalar value.
125    NonScalarPlaceholder {
126        /// Key whose template is being resolved.
127        key: String,
128        /// Placeholder that resolved to a non-scalar value.
129        placeholder: String,
130    },
131    /// One or more config keys are unknown to the schema.
132    UnknownConfigKeys {
133        /// Unknown canonical keys.
134        keys: Vec<String>,
135    },
136    /// A required runtime-visible key was missing after resolution.
137    MissingRequiredKey {
138        /// Missing canonical key.
139        key: String,
140    },
141    /// A value could not be adapted to the schema's declared type.
142    InvalidValueType {
143        /// Key whose value had the wrong type.
144        key: String,
145        /// Schema type expected for the key.
146        expected: SchemaValueType,
147        /// Actual type name observed during adaptation.
148        actual: String,
149    },
150    /// A string value was outside the schema allow-list.
151    InvalidEnumValue {
152        /// Key whose value was rejected.
153        key: String,
154        /// Rejected value.
155        value: String,
156        /// Allowed normalized values.
157        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}