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