Skip to main content

tier/
error.rs

1use std::fmt::{self, Display, Formatter};
2use std::path::PathBuf;
3
4use serde_json::Value;
5use thiserror::Error;
6
7use crate::loader::{FileFormat, SourceTrace};
8
9#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
10/// A single validation failure returned by a validator hook.
11pub struct ValidationError {
12    /// Dot-delimited configuration path associated with the failure.
13    pub path: String,
14    /// Additional paths related to the failure, used for cross-field validations.
15    pub related_paths: Vec<String>,
16    /// Human-readable failure message.
17    pub message: String,
18    /// Optional rule identifier for machine-readable consumers.
19    pub rule: Option<String>,
20    /// Optional expected value associated with the failed rule.
21    pub expected: Option<Value>,
22    /// Optional actual value observed during validation.
23    pub actual: Option<Value>,
24}
25
26impl ValidationError {
27    /// Creates a new validation error.
28    #[must_use]
29    pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
30        Self {
31            path: path.into(),
32            related_paths: Vec::new(),
33            message: message.into(),
34            rule: None,
35            expected: None,
36            actual: None,
37        }
38    }
39
40    /// Attaches a machine-readable rule identifier.
41    #[must_use]
42    pub fn with_rule(mut self, rule: impl Into<String>) -> Self {
43        self.rule = Some(rule.into());
44        self
45    }
46
47    /// Attaches related paths for cross-field validation failures.
48    #[must_use]
49    pub fn with_related_paths<I, S>(mut self, related_paths: I) -> Self
50    where
51        I: IntoIterator<Item = S>,
52        S: Into<String>,
53    {
54        self.related_paths = related_paths.into_iter().map(Into::into).collect();
55        self
56    }
57
58    /// Attaches the expected value for the failed rule.
59    #[must_use]
60    pub fn with_expected(mut self, expected: Value) -> Self {
61        self.expected = Some(expected);
62        self
63    }
64
65    /// Attaches the actual value observed during validation.
66    #[must_use]
67    pub fn with_actual(mut self, actual: Value) -> Self {
68        self.actual = Some(actual);
69        self
70    }
71}
72
73impl Display for ValidationError {
74    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
75        if self.path.is_empty() {
76            write!(f, "{}", self.message)
77        } else {
78            write!(f, "{}: {}", self.path, self.message)
79        }
80    }
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84/// A collection of validation failures returned by a validator hook.
85pub struct ValidationErrors {
86    errors: Vec<ValidationError>,
87}
88
89impl ValidationErrors {
90    /// Creates an empty validation error collection.
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Creates a collection containing a single validation error.
97    #[must_use]
98    pub fn from_error(error: ValidationError) -> Self {
99        Self {
100            errors: vec![error],
101        }
102    }
103
104    /// Creates a collection containing a single message-based validation error.
105    #[must_use]
106    pub fn from_message(path: impl Into<String>, message: impl Into<String>) -> Self {
107        Self::from_error(ValidationError::new(path, message))
108    }
109
110    /// Appends a validation error.
111    pub fn push(&mut self, error: ValidationError) {
112        self.errors.push(error);
113    }
114
115    /// Appends multiple validation errors.
116    pub fn extend<I>(&mut self, errors: I)
117    where
118        I: IntoIterator<Item = ValidationError>,
119    {
120        self.errors.extend(errors);
121    }
122
123    /// Returns `true` when the collection is empty.
124    #[must_use]
125    pub fn is_empty(&self) -> bool {
126        self.errors.is_empty()
127    }
128
129    /// Returns the number of validation errors.
130    #[must_use]
131    pub fn len(&self) -> usize {
132        self.errors.len()
133    }
134
135    /// Consumes the collection into a vector.
136    pub fn into_vec(self) -> Vec<ValidationError> {
137        self.errors
138    }
139
140    /// Returns an iterator over validation errors.
141    pub fn iter(&self) -> impl Iterator<Item = &ValidationError> {
142        self.errors.iter()
143    }
144}
145
146impl IntoIterator for ValidationErrors {
147    type Item = ValidationError;
148    type IntoIter = std::vec::IntoIter<ValidationError>;
149
150    fn into_iter(self) -> Self::IntoIter {
151        self.errors.into_iter()
152    }
153}
154
155impl Display for ValidationErrors {
156    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
157        for (index, error) in self.errors.iter().enumerate() {
158            if index > 0 {
159                writeln!(f)?;
160            }
161            write!(f, "- {error}")?;
162        }
163        Ok(())
164    }
165}
166
167impl std::error::Error for ValidationErrors {}
168
169#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
170/// Information about an unknown configuration path discovered during loading.
171pub struct UnknownField {
172    /// Dot-delimited path that was not recognized.
173    pub path: String,
174    /// Most recent source that contributed the unknown path, when known.
175    pub source: Option<SourceTrace>,
176    /// Best-effort suggestion for the intended path.
177    pub suggestion: Option<String>,
178}
179
180impl UnknownField {
181    /// Creates an unknown field description for a path.
182    #[must_use]
183    pub fn new(path: impl Into<String>) -> Self {
184        Self {
185            path: path.into(),
186            source: None,
187            suggestion: None,
188        }
189    }
190
191    /// Attaches source information.
192    #[must_use]
193    pub fn with_source(mut self, source: Option<SourceTrace>) -> Self {
194        self.source = source;
195        self
196    }
197
198    /// Attaches a best-effort suggestion.
199    #[must_use]
200    pub fn with_suggestion(mut self, suggestion: Option<String>) -> Self {
201        self.suggestion = suggestion;
202        self
203    }
204}
205
206impl Display for UnknownField {
207    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
208        write!(f, "unknown field `{}`", self.path)?;
209        if let Some(source) = &self.source {
210            write!(f, " from {source}")?;
211        }
212        if let Some(suggestion) = &self.suggestion {
213            write!(f, "; did you mean `{suggestion}`?")?;
214        }
215        Ok(())
216    }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220/// Line and column information for parse diagnostics.
221pub struct LineColumn {
222    /// One-based line number.
223    pub line: usize,
224    /// One-based column number.
225    pub column: usize,
226}
227
228impl Display for LineColumn {
229    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
230        write!(f, "line {}, column {}", self.line, self.column)
231    }
232}
233
234#[derive(Debug, Error)]
235/// Errors returned while building, loading, validating, or inspecting configuration.
236pub enum ConfigError {
237    /// The root serialized value was not a JSON-like object.
238    #[error("configuration root must serialize to a map-like object, got {actual}")]
239    RootMustBeObject {
240        /// Human-readable kind of the unexpected root value.
241        actual: &'static str,
242    },
243
244    /// Serializing configuration state into an intermediate value failed.
245    #[error("failed to serialize configuration state: {source}")]
246    Serialize {
247        /// Serialization error from the intermediate serde representation.
248        #[from]
249        source: serde_json::Error,
250    },
251
252    /// Starting or running a filesystem watcher failed.
253    #[error("failed to watch configuration files: {message}")]
254    Watch {
255        /// Human-readable watcher failure details.
256        message: String,
257    },
258
259    /// A required configuration file was not found.
260    #[error("required configuration file not found: {}", path.display())]
261    MissingFile {
262        /// Missing file path.
263        path: PathBuf,
264    },
265
266    /// None of the required candidate files were found.
267    #[error("none of the required configuration files were found:\n{paths}", paths = format_missing_paths(paths))]
268    MissingFiles {
269        /// Candidate paths that were checked.
270        paths: Vec<PathBuf>,
271    },
272
273    /// Reading a configuration file failed.
274    #[error("failed to read configuration file {}: {source}", path.display())]
275    ReadFile {
276        /// File path being read.
277        path: PathBuf,
278        /// Underlying I/O error.
279        #[source]
280        source: std::io::Error,
281    },
282
283    /// Parsing a configuration file failed.
284    #[error("failed to parse {format} configuration file {}{location}: {message}", path.display(), location = format_location(*location))]
285    ParseFile {
286        /// File path being parsed.
287        path: PathBuf,
288        /// Detected or configured file format.
289        format: FileFormat,
290        /// Optional line and column information for the parse error.
291        location: Option<LineColumn>,
292        /// Human-readable parse failure.
293        message: String,
294    },
295
296    /// An environment variable could not be converted into configuration data.
297    #[error("invalid environment variable {name} for path {path}: {message}")]
298    InvalidEnv {
299        /// Environment variable name.
300        name: String,
301        /// Derived configuration path.
302        path: String,
303        /// Human-readable conversion failure.
304        message: String,
305    },
306
307    /// A CLI override argument was invalid.
308    #[error("invalid CLI argument {arg}: {message}")]
309    InvalidArg {
310        /// Raw CLI fragment.
311        arg: String,
312        /// Human-readable validation failure.
313        message: String,
314    },
315
316    /// A typed sparse patch could not be converted into a configuration layer.
317    #[error("invalid patch {name} for path {path}: {message}")]
318    InvalidPatch {
319        /// Human-readable patch source name.
320        name: String,
321        /// Target configuration path.
322        path: String,
323        /// Human-readable validation failure.
324        message: String,
325    },
326
327    /// Multiple input paths resolved to the same canonical path.
328    #[error(
329        "configuration paths `{first_path}` and `{second_path}` both resolve to `{canonical_path}`"
330    )]
331    PathConflict {
332        /// First input path that mapped to the canonical path.
333        first_path: String,
334        /// Second conflicting input path.
335        second_path: String,
336        /// Canonical path both inputs resolved to.
337        canonical_path: String,
338    },
339
340    /// A source attempted to write to a field that restricts allowed source kinds.
341    #[error(
342        "source {trace} is not allowed to set `{path}`; allowed sources: {allowed}",
343        allowed = format_source_kind_list(allowed_sources)
344    )]
345    SourcePolicyViolation {
346        /// Concrete path rejected by the source policy.
347        path: String,
348        /// Actual source attempting to set the path.
349        trace: SourceTrace,
350        /// Allowed source kinds for the path.
351        allowed_sources: Vec<crate::loader::SourceKind>,
352    },
353
354    /// A serialized object key could not be represented in tier's dot-delimited path model.
355    #[error(
356        "configuration object key `{key}` under {location} cannot be represented in tier paths: {message}",
357        location = format_path_location(path)
358    )]
359    InvalidPathKey {
360        /// Parent path containing the unsupported key.
361        path: String,
362        /// Unsupported object key segment.
363        key: String,
364        /// Human-readable validation failure.
365        message: String,
366    },
367
368    /// Metadata declared the same alias or environment variable more than once.
369    #[error("metadata {kind} `{name}` is assigned to both `{first_path}` and `{second_path}`")]
370    MetadataConflict {
371        /// Human-readable conflict category such as `alias` or `environment variable`.
372        kind: &'static str,
373        /// Conflicting alias or environment variable name.
374        name: String,
375        /// First path using the name.
376        first_path: String,
377        /// Second path using the name.
378        second_path: String,
379    },
380
381    /// Metadata declared an unsupported or invalid field configuration.
382    #[error("invalid metadata for `{path}`: {message}")]
383    MetadataInvalid {
384        /// Metadata path that triggered the validation failure.
385        path: String,
386        /// Human-readable validation failure.
387        message: String,
388    },
389
390    /// A CLI flag requiring a value was missing one.
391    #[error("missing value for CLI flag {flag}")]
392    MissingArgValue {
393        /// Flag name missing a required value.
394        flag: String,
395    },
396
397    /// A file path template referenced `{profile}` without a profile being set.
398    #[error("path template {} contains {{profile}} but no profile was set", path.display())]
399    MissingProfile {
400        /// Path template containing `{profile}`.
401        path: PathBuf,
402    },
403
404    /// Deserializing the merged intermediate value into the target type failed.
405    #[error(
406        "failed to deserialize merged configuration at {path}: {message}{source_suffix}",
407        source_suffix = deserialize_source_suffix(provenance)
408    )]
409    Deserialize {
410        /// Configuration path reported by serde.
411        path: String,
412        /// Most recent source that contributed the failing value, when known.
413        provenance: Option<SourceTrace>,
414        /// Human-readable deserialization failure.
415        message: String,
416    },
417
418    /// The requested explain path did not exist in the final report.
419    #[error(
420        "cannot explain configuration path `{path}` because it does not exist in the final report"
421    )]
422    ExplainPathNotFound {
423        /// Requested explain path.
424        path: String,
425    },
426
427    /// Unknown configuration paths were found and the active policy rejected them.
428    #[error("unknown configuration fields:\n{fields}", fields = format_unknown_fields(fields))]
429    UnknownFields {
430        /// Unknown paths discovered during loading.
431        fields: Vec<UnknownField>,
432    },
433
434    /// A normalizer hook failed.
435    #[error("normalizer {name} failed: {message}")]
436    Normalize {
437        /// Normalizer name.
438        name: String,
439        /// Human-readable failure.
440        message: String,
441    },
442
443    /// A validator hook failed.
444    #[error("validator {name} failed:\n{errors}")]
445    Validation {
446        /// Validator name.
447        name: String,
448        /// Validation failures returned by the hook.
449        errors: ValidationErrors,
450    },
451
452    /// Built-in field validation rules failed.
453    #[error("declared validation failed:\n{errors}")]
454    DeclaredValidation {
455        /// Validation failures returned by metadata-driven rules.
456        errors: ValidationErrors,
457    },
458}
459
460fn format_location(location: Option<LineColumn>) -> String {
461    match location {
462        Some(location) => format!(" ({location})"),
463        None => String::new(),
464    }
465}
466
467fn format_missing_paths(paths: &[PathBuf]) -> String {
468    paths
469        .iter()
470        .map(|path| format!("- {}", path.display()))
471        .collect::<Vec<_>>()
472        .join("\n")
473}
474
475fn format_unknown_fields(fields: &[UnknownField]) -> String {
476    fields
477        .iter()
478        .map(|field| format!("- {field}"))
479        .collect::<Vec<_>>()
480        .join("\n")
481}
482
483fn format_path_location(path: &str) -> String {
484    if path.is_empty() {
485        "the configuration root".to_owned()
486    } else {
487        format!("`{path}`")
488    }
489}
490
491fn format_source_kind_list(kinds: &[crate::loader::SourceKind]) -> String {
492    kinds
493        .iter()
494        .map(ToString::to_string)
495        .collect::<Vec<_>>()
496        .join(", ")
497}
498
499fn deserialize_source_suffix(provenance: &Option<SourceTrace>) -> String {
500    provenance
501        .as_ref()
502        .map_or_else(String::new, |source| format!(" from {source}"))
503}