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)]
10pub struct ValidationError {
12 pub path: String,
14 pub related_paths: Vec<String>,
16 pub message: String,
18 pub rule: Option<String>,
20 pub expected: Option<Value>,
22 pub actual: Option<Value>,
24 pub tags: Vec<String>,
26}
27
28impl ValidationError {
29 #[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 #[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 #[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 #[must_use]
63 pub fn with_expected(mut self, expected: Value) -> Self {
64 self.expected = Some(expected);
65 self
66 }
67
68 #[must_use]
70 pub fn with_actual(mut self, actual: Value) -> Self {
71 self.actual = Some(actual);
72 self
73 }
74
75 #[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)]
98pub struct ValidationErrors {
100 errors: Vec<ValidationError>,
101}
102
103impl ValidationErrors {
104 #[must_use]
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 #[must_use]
112 pub fn from_error(error: ValidationError) -> Self {
113 Self {
114 errors: vec![error],
115 }
116 }
117
118 #[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 pub fn push(&mut self, error: ValidationError) {
126 self.errors.push(error);
127 }
128
129 pub fn extend<I>(&mut self, errors: I)
131 where
132 I: IntoIterator<Item = ValidationError>,
133 {
134 self.errors.extend(errors);
135 }
136
137 #[must_use]
139 pub fn is_empty(&self) -> bool {
140 self.errors.is_empty()
141 }
142
143 #[must_use]
145 pub fn len(&self) -> usize {
146 self.errors.len()
147 }
148
149 pub fn into_vec(self) -> Vec<ValidationError> {
151 self.errors
152 }
153
154 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)]
184pub struct UnknownField {
186 pub path: String,
188 pub source: Option<SourceTrace>,
190 pub suggestion: Option<String>,
192}
193
194impl UnknownField {
195 #[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 #[must_use]
207 pub fn with_source(mut self, source: Option<SourceTrace>) -> Self {
208 self.source = source;
209 self
210 }
211
212 #[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)]
234pub struct LineColumn {
236 pub line: usize,
238 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)]
249pub enum ConfigError {
251 #[error("configuration root must serialize to a map-like object, got {actual}")]
253 RootMustBeObject {
254 actual: &'static str,
256 },
257
258 #[error("failed to serialize configuration state: {source}")]
260 Serialize {
261 #[from]
263 source: serde_json::Error,
264 },
265
266 #[error("failed to watch configuration files: {message}")]
268 Watch {
269 message: String,
271 },
272
273 #[error("required configuration file not found: {}", path.display())]
275 MissingFile {
276 path: PathBuf,
278 },
279
280 #[error("none of the required configuration files were found:\n{paths}", paths = format_missing_paths(paths))]
282 MissingFiles {
283 paths: Vec<PathBuf>,
285 },
286
287 #[error("failed to read configuration file {}: {source}", path.display())]
289 ReadFile {
290 path: PathBuf,
292 #[source]
294 source: std::io::Error,
295 },
296
297 #[error("failed to parse {format} configuration file {}{location}: {message}", path.display(), location = format_location(*location))]
299 ParseFile {
300 path: PathBuf,
302 format: FileFormat,
304 location: Option<LineColumn>,
306 message: String,
308 },
309
310 #[error("invalid environment variable {name} for path {path}: {message}")]
312 InvalidEnv {
313 name: String,
315 path: String,
317 message: String,
319 },
320
321 #[error("invalid CLI argument {arg}: {message}")]
323 InvalidArg {
324 arg: String,
326 message: String,
328 },
329
330 #[error("invalid patch {name} for path {path}: {message}")]
332 InvalidPatch {
333 name: String,
335 path: String,
337 message: String,
339 },
340
341 #[error(
343 "configuration paths `{first_path}` and `{second_path}` both resolve to `{canonical_path}`"
344 )]
345 PathConflict {
346 first_path: String,
348 second_path: String,
350 canonical_path: String,
352 },
353
354 #[error(
356 "source {trace} is not allowed to set `{path}`; {policy}",
357 policy = format_source_policy(allowed_sources, denied_sources)
358 )]
359 SourcePolicyViolation {
360 path: String,
362 trace: SourceTrace,
364 allowed_sources: Box<[crate::loader::SourceKind]>,
366 denied_sources: Box<[crate::loader::SourceKind]>,
368 },
369
370 #[error(
372 "configuration version at `{path}` is {found}, but this binary only supports up to {supported}"
373 )]
374 UnsupportedConfigVersion {
375 path: String,
377 found: u32,
379 supported: u32,
381 },
382
383 #[error("configuration version at `{path}` must be an unsigned integer: {message}")]
385 InvalidConfigVersion {
386 path: String,
388 message: String,
390 },
391
392 #[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 path: String,
400 key: String,
402 message: String,
404 },
405
406 #[error("metadata {kind} `{name}` is assigned to both `{first_path}` and `{second_path}`")]
408 MetadataConflict {
409 kind: &'static str,
411 name: String,
413 first_path: String,
415 second_path: String,
417 },
418
419 #[error("invalid metadata for `{path}`: {message}")]
421 MetadataInvalid {
422 path: String,
424 message: String,
426 },
427
428 #[error("missing value for CLI flag {flag}")]
430 MissingArgValue {
431 flag: String,
433 },
434
435 #[error("path template {} contains {{profile}} but no profile was set", path.display())]
437 MissingProfile {
438 path: PathBuf,
440 },
441
442 #[error(
444 "failed to deserialize merged configuration at {path}: {message}{source_suffix}",
445 source_suffix = deserialize_source_suffix(provenance)
446 )]
447 Deserialize {
448 path: String,
450 provenance: Option<SourceTrace>,
452 message: String,
454 },
455
456 #[error(
458 "cannot explain configuration path `{path}` because it does not exist in the final report"
459 )]
460 ExplainPathNotFound {
461 path: String,
463 },
464
465 #[error("unknown configuration fields:\n{fields}", fields = format_unknown_fields(fields))]
467 UnknownFields {
468 fields: Vec<UnknownField>,
470 },
471
472 #[error("normalizer {name} failed: {message}")]
474 Normalize {
475 name: String,
477 message: String,
479 },
480
481 #[error("validator {name} failed:\n{errors}")]
483 Validation {
484 name: String,
486 errors: ValidationErrors,
488 },
489
490 #[error("declared validation failed:\n{errors}")]
492 DeclaredValidation {
493 errors: ValidationErrors,
495 },
496}
497
498impl ConfigError {
499 #[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}