1use std::{error::Error, fmt::Display};
19
20pub type ConfigResult<T> = Result<T, ConfigError>;
22
23#[allow(
28 clippy::module_name_repetitions,
29 reason = "public name states the error domain when imported outside the module"
30)]
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ConfigError {
33 UnsupportedField { field: String, reason: String },
35 UnsupportedValue { field: String, reason: String },
37 MissingField { field: String },
39 EmptyField { field: String },
41 InvalidValue { field: String, reason: String },
43 InvalidFormat { field: String, expected: String },
45 Range { field: String, reason: String },
47 MutuallyExclusiveFields { fields: Vec<String> },
49 RequiredOneOf { fields: Vec<String> },
51 Dependency {
53 field: String,
54 depends_on: String,
55 reason: String,
56 },
57 Duplicate {
59 field: String,
60 value: Option<String>,
61 },
62 FeatureDisabled { field: String, feature: String },
64 InvalidReference {
66 field: String,
67 reference: String,
68 reason: String,
69 },
70 Multiple { errors: Vec<Self> },
72}
73
74impl ConfigError {
75 pub fn unsupported_field(field: impl Into<String>, reason: impl Into<String>) -> Self {
77 Self::UnsupportedField {
78 field: field.into(),
79 reason: reason.into(),
80 }
81 }
82
83 pub fn unsupported_value(field: impl Into<String>, reason: impl Into<String>) -> Self {
85 Self::UnsupportedValue {
86 field: field.into(),
87 reason: reason.into(),
88 }
89 }
90
91 pub fn missing_field(field: impl Into<String>) -> Self {
93 Self::MissingField {
94 field: field.into(),
95 }
96 }
97
98 pub fn empty_field(field: impl Into<String>) -> Self {
100 Self::EmptyField {
101 field: field.into(),
102 }
103 }
104
105 pub fn invalid_value(field: impl Into<String>, reason: impl Into<String>) -> Self {
107 Self::InvalidValue {
108 field: field.into(),
109 reason: reason.into(),
110 }
111 }
112
113 pub fn invalid_format(field: impl Into<String>, expected: impl Into<String>) -> Self {
115 Self::InvalidFormat {
116 field: field.into(),
117 expected: expected.into(),
118 }
119 }
120
121 pub fn range(field: impl Into<String>, reason: impl Into<String>) -> Self {
123 Self::Range {
124 field: field.into(),
125 reason: reason.into(),
126 }
127 }
128
129 pub fn mutually_exclusive_fields(fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
131 Self::MutuallyExclusiveFields {
132 fields: fields.into_iter().map(Into::into).collect(),
133 }
134 }
135
136 pub fn required_one_of(fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
138 Self::RequiredOneOf {
139 fields: fields.into_iter().map(Into::into).collect(),
140 }
141 }
142
143 pub fn dependency(
145 field: impl Into<String>,
146 depends_on: impl Into<String>,
147 reason: impl Into<String>,
148 ) -> Self {
149 Self::Dependency {
150 field: field.into(),
151 depends_on: depends_on.into(),
152 reason: reason.into(),
153 }
154 }
155
156 pub fn duplicate(field: impl Into<String>, value: Option<String>) -> Self {
158 Self::Duplicate {
159 field: field.into(),
160 value,
161 }
162 }
163
164 pub fn feature_disabled(field: impl Into<String>, feature: impl Into<String>) -> Self {
166 Self::FeatureDisabled {
167 field: field.into(),
168 feature: feature.into(),
169 }
170 }
171
172 pub fn invalid_reference(
174 field: impl Into<String>,
175 reference: impl Into<String>,
176 reason: impl Into<String>,
177 ) -> Self {
178 Self::InvalidReference {
179 field: field.into(),
180 reference: reference.into(),
181 reason: reason.into(),
182 }
183 }
184
185 pub fn multiple(errors: Vec<Self>) -> Self {
187 Self::Multiple { errors }
188 }
189}
190
191impl Display for ConfigError {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 match self {
194 Self::UnsupportedField { field, reason } => write!(f, "{field} is {reason}"),
195 Self::UnsupportedValue { field, reason } => {
196 write!(f, "{field} has unsupported value: {reason}")
197 }
198 Self::MissingField { field } => write!(f, "{field} is required"),
199 Self::EmptyField { field } => write!(f, "{field} must not be empty"),
200 Self::InvalidValue { field, reason } | Self::Range { field, reason } => {
201 write!(f, "invalid {field}: {reason}")
202 }
203 Self::InvalidFormat { field, expected } => write!(f, "invalid {field}: {expected}"),
204 Self::MutuallyExclusiveFields { fields } => {
205 write!(f, "mutually exclusive fields: ")?;
206 write_fields(f, fields)
207 }
208 Self::RequiredOneOf { fields } => {
209 write!(f, "one of these fields is required: ")?;
210 write_fields(f, fields)
211 }
212 Self::Dependency {
213 field,
214 depends_on,
215 reason,
216 } => write!(f, "{field} requires {depends_on}: {reason}"),
217 Self::Duplicate { field, value } => match value {
218 Some(value) => write!(f, "duplicate {field}: {value}"),
219 None => write!(f, "duplicate {field}"),
220 },
221 Self::FeatureDisabled { field, feature } => {
222 write!(f, "{field} requires feature `{feature}`")
223 }
224 Self::InvalidReference {
225 field,
226 reference,
227 reason,
228 } => write!(f, "invalid {field} reference {reference}: {reason}"),
229 Self::Multiple { errors } => {
230 write!(f, "multiple config validation errors")?;
231 if !errors.is_empty() {
232 write!(f, ": ")?;
233
234 for (index, error) in errors.iter().enumerate() {
235 if index > 0 {
236 write!(f, "; ")?;
237 }
238 write!(f, "{}. {error}", index + 1)?;
239 }
240 }
241 Ok(())
242 }
243 }
244 }
245}
246
247impl Error for ConfigError {}
248
249fn write_fields(f: &mut std::fmt::Formatter<'_>, fields: &[String]) -> std::fmt::Result {
250 for (index, field) in fields.iter().enumerate() {
251 if index > 0 {
252 write!(f, ", ")?;
253 }
254 write!(f, "{field}")?;
255 }
256 Ok(())
257}
258
259#[allow(
261 clippy::module_name_repetitions,
262 reason = "public name states the error domain when imported outside the module"
263)]
264#[derive(Debug, Clone, Default, PartialEq, Eq)]
265pub struct ConfigErrorCollector {
266 errors: Vec<ConfigError>,
267}
268
269impl ConfigErrorCollector {
270 pub fn new() -> Self {
272 Self::default()
273 }
274
275 pub fn with_capacity(capacity: usize) -> Self {
277 Self {
278 errors: Vec::with_capacity(capacity),
279 }
280 }
281
282 pub fn is_empty(&self) -> bool {
284 self.errors.is_empty()
285 }
286
287 pub fn len(&self) -> usize {
289 self.errors.len()
290 }
291
292 pub fn errors(&self) -> &[ConfigError] {
294 &self.errors
295 }
296
297 pub fn push(&mut self, error: ConfigError) {
299 match error {
300 ConfigError::Multiple { errors } => self.errors.extend(errors),
301 error => self.errors.push(error),
302 }
303 }
304
305 pub fn check(&mut self, condition: bool, error: ConfigError) {
307 if !condition {
308 self.push(error);
309 }
310 }
311
312 pub fn collect(&mut self, result: ConfigResult<()>) {
314 if let Err(e) = result {
315 self.push(e);
316 }
317 }
318
319 pub fn into_result(self) -> ConfigResult<()> {
326 let mut errors = self.errors;
327 if errors.is_empty() {
328 Ok(())
329 } else if errors.len() == 1 {
330 Err(errors.remove(0))
331 } else {
332 Err(ConfigError::Multiple { errors })
333 }
334 }
335}
336
337pub fn check(condition: bool, config_error: ConfigError) -> ConfigResult<()> {
343 if condition { Ok(()) } else { Err(config_error) }
344}
345
346pub fn check_supported_field(
352 field: impl Into<String>,
353 supported: bool,
354 reason: impl Into<String>,
355) -> ConfigResult<()> {
356 check(supported, ConfigError::unsupported_field(field, reason))
357}
358
359pub fn check_supported_value(
365 field: impl Into<String>,
366 supported: bool,
367 reason: impl Into<String>,
368) -> ConfigResult<()> {
369 check(supported, ConfigError::unsupported_value(field, reason))
370}
371
372pub fn check_non_empty_field(field: impl Into<String>, value: &str) -> ConfigResult<()> {
378 check(!value.trim().is_empty(), ConfigError::empty_field(field))
379}
380
381pub fn check_valid_value(
387 field: impl Into<String>,
388 valid: bool,
389 reason: impl Into<String>,
390) -> ConfigResult<()> {
391 check(valid, ConfigError::invalid_value(field, reason))
392}
393
394pub fn check_valid_format(
400 field: impl Into<String>,
401 valid: bool,
402 expected: impl Into<String>,
403) -> ConfigResult<()> {
404 check(valid, ConfigError::invalid_format(field, expected))
405}
406
407pub fn check_range(
413 field: impl Into<String>,
414 in_range: bool,
415 reason: impl Into<String>,
416) -> ConfigResult<()> {
417 check(in_range, ConfigError::range(field, reason))
418}
419
420pub fn check_feature_enabled(
426 field: impl Into<String>,
427 feature: impl Into<String>,
428 enabled: bool,
429) -> ConfigResult<()> {
430 check(enabled, ConfigError::feature_disabled(field, feature))
431}
432
433#[cfg(test)]
434mod tests {
435 use rstest::rstest;
436
437 use super::*;
438
439 #[rstest]
440 fn test_config_error_display_uses_field_path() {
441 let error = ConfigError::invalid_format(
442 "LiveNodeConfig.plugins[0].sha256",
443 "must be a 64-character hex digest",
444 );
445
446 assert_eq!(
447 error.to_string(),
448 "invalid LiveNodeConfig.plugins[0].sha256: must be a 64-character hex digest",
449 );
450 }
451
452 #[rstest]
453 #[case(
454 ConfigError::unsupported_field("field_a", "disabled"),
455 "field_a is disabled"
456 )]
457 #[case(
458 ConfigError::unsupported_value("field_a", "mode is disabled"),
459 "field_a has unsupported value: mode is disabled"
460 )]
461 #[case(ConfigError::missing_field("field_a"), "field_a is required")]
462 #[case(ConfigError::empty_field("field_a"), "field_a must not be empty")]
463 #[case(
464 ConfigError::invalid_value("field_a", "must be positive"),
465 "invalid field_a: must be positive"
466 )]
467 #[case(
468 ConfigError::invalid_format("field_a", "expected kind/name"),
469 "invalid field_a: expected kind/name"
470 )]
471 #[case(
472 ConfigError::range("field_a", "must be <= 10"),
473 "invalid field_a: must be <= 10"
474 )]
475 #[case(
476 ConfigError::mutually_exclusive_fields(["field_a", "field_b"]),
477 "mutually exclusive fields: field_a, field_b"
478 )]
479 #[case(
480 ConfigError::required_one_of(["field_a", "field_b"]),
481 "one of these fields is required: field_a, field_b"
482 )]
483 #[case(
484 ConfigError::dependency("field_a", "field_b", "field_b must be set first"),
485 "field_a requires field_b: field_b must be set first"
486 )]
487 #[case(
488 ConfigError::duplicate("field_a", Some("entry_a".to_string())),
489 "duplicate field_a: entry_a"
490 )]
491 #[case(ConfigError::duplicate("field_a", None), "duplicate field_a")]
492 #[case(
493 ConfigError::feature_disabled("field_a", "live"),
494 "field_a requires feature `live`"
495 )]
496 #[case(
497 ConfigError::invalid_reference("field_a", "instrument ID", "not found"),
498 "invalid field_a reference instrument ID: not found"
499 )]
500 fn test_config_error_display_covers_public_vocabulary(
501 #[case] error: ConfigError,
502 #[case] expected: &str,
503 ) {
504 assert_eq!(error.to_string(), expected);
505 }
506
507 #[rstest]
508 fn test_check_non_empty_field_rejects_blank_values() {
509 let error = check_non_empty_field("LiveNodeConfig.plugins[0].path", " ").unwrap_err();
510
511 assert_eq!(
512 error,
513 ConfigError::EmptyField {
514 field: "LiveNodeConfig.plugins[0].path".to_string(),
515 },
516 );
517 }
518
519 #[rstest]
520 #[case(
521 check_supported_field("field_a", false, "unsupported"),
522 ConfigError::unsupported_field("field_a", "unsupported")
523 )]
524 #[case(
525 check_supported_value("field_a", false, "unsupported"),
526 ConfigError::unsupported_value("field_a", "unsupported")
527 )]
528 #[case(
529 check_valid_value("field_a", false, "must be positive"),
530 ConfigError::invalid_value("field_a", "must be positive")
531 )]
532 #[case(
533 check_valid_format("field_a", false, "expected kind/name"),
534 ConfigError::invalid_format("field_a", "expected kind/name")
535 )]
536 #[case(
537 check_range("field_a", false, "must be <= 10"),
538 ConfigError::range("field_a", "must be <= 10")
539 )]
540 #[case(
541 check_feature_enabled("field_a", "live", false),
542 ConfigError::feature_disabled("field_a", "live")
543 )]
544 fn test_check_functions_return_expected_errors(
545 #[case] result: ConfigResult<()>,
546 #[case] expected: ConfigError,
547 ) {
548 assert_eq!(result.unwrap_err(), expected);
549 }
550
551 #[rstest]
552 fn test_collector_returns_single_error_without_wrapping() {
553 let mut collector = ConfigErrorCollector::new();
554 collector.push(ConfigError::empty_field("LiveNodeConfig.plugins[0].path"));
555
556 let error = collector.into_result().unwrap_err();
557
558 assert_eq!(
559 error,
560 ConfigError::EmptyField {
561 field: "LiveNodeConfig.plugins[0].path".to_string(),
562 },
563 );
564 }
565
566 #[rstest]
567 fn test_collector_flattens_multiple_errors() {
568 let mut collector = ConfigErrorCollector::new();
569 collector.push(ConfigError::multiple(vec![
570 ConfigError::empty_field("field_a"),
571 ConfigError::empty_field("field_b"),
572 ]));
573 collector.push(ConfigError::empty_field("field_c"));
574
575 let error = collector.into_result().unwrap_err();
576
577 match error {
578 ConfigError::Multiple { errors } => assert_eq!(errors.len(), 3),
579 _ => panic!("Expected multiple config errors, received {error:?}"),
580 }
581 }
582}