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
433pub fn check_unique_values<T: PartialEq>(
442 field: impl Into<String>,
443 values: &[T],
444) -> ConfigResult<()> {
445 for (index, value) in values.iter().enumerate() {
446 if values[..index].contains(value) {
447 return Err(ConfigError::duplicate(field, None));
448 }
449 }
450
451 Ok(())
452}
453
454#[cfg(test)]
455mod tests {
456 use rstest::rstest;
457
458 use super::*;
459
460 #[rstest]
461 #[case::empty(vec![], Ok(()))]
462 #[case::single(vec!["a"], Ok(()))]
463 #[case::distinct(vec!["b", "a"], Ok(()))]
464 #[case::adjacent(vec!["a", "a"], Err(ConfigError::duplicate("selections", None)))]
465 #[case::separated(vec!["a", "b", "a"], Err(ConfigError::duplicate("selections", None)))]
466 fn test_check_unique_values(#[case] values: Vec<&str>, #[case] expected: ConfigResult<()>) {
467 let original = values.clone();
468 let result = check_unique_values("selections", &values);
469
470 assert_eq!(result, expected);
471 assert_eq!(values, original);
472 }
473
474 #[rstest]
475 fn test_config_error_display_uses_field_path() {
476 let error = ConfigError::invalid_format(
477 "LiveNodeConfig.plugins[0].sha256",
478 "must be a 64-character hex digest",
479 );
480
481 assert_eq!(
482 error.to_string(),
483 "invalid LiveNodeConfig.plugins[0].sha256: must be a 64-character hex digest",
484 );
485 }
486
487 #[rstest]
488 #[case(
489 ConfigError::unsupported_field("field_a", "disabled"),
490 "field_a is disabled"
491 )]
492 #[case(
493 ConfigError::unsupported_value("field_a", "mode is disabled"),
494 "field_a has unsupported value: mode is disabled"
495 )]
496 #[case(ConfigError::missing_field("field_a"), "field_a is required")]
497 #[case(ConfigError::empty_field("field_a"), "field_a must not be empty")]
498 #[case(
499 ConfigError::invalid_value("field_a", "must be positive"),
500 "invalid field_a: must be positive"
501 )]
502 #[case(
503 ConfigError::invalid_format("field_a", "expected kind/name"),
504 "invalid field_a: expected kind/name"
505 )]
506 #[case(
507 ConfigError::range("field_a", "must be <= 10"),
508 "invalid field_a: must be <= 10"
509 )]
510 #[case(
511 ConfigError::mutually_exclusive_fields(["field_a", "field_b"]),
512 "mutually exclusive fields: field_a, field_b"
513 )]
514 #[case(
515 ConfigError::required_one_of(["field_a", "field_b"]),
516 "one of these fields is required: field_a, field_b"
517 )]
518 #[case(
519 ConfigError::dependency("field_a", "field_b", "field_b must be set first"),
520 "field_a requires field_b: field_b must be set first"
521 )]
522 #[case(
523 ConfigError::duplicate("field_a", Some("entry_a".to_string())),
524 "duplicate field_a: entry_a"
525 )]
526 #[case(ConfigError::duplicate("field_a", None), "duplicate field_a")]
527 #[case(
528 ConfigError::feature_disabled("field_a", "live"),
529 "field_a requires feature `live`"
530 )]
531 #[case(
532 ConfigError::invalid_reference("field_a", "instrument ID", "not found"),
533 "invalid field_a reference instrument ID: not found"
534 )]
535 fn test_config_error_display_covers_public_vocabulary(
536 #[case] error: ConfigError,
537 #[case] expected: &str,
538 ) {
539 assert_eq!(error.to_string(), expected);
540 }
541
542 #[rstest]
543 fn test_check_non_empty_field_rejects_blank_values() {
544 let error = check_non_empty_field("LiveNodeConfig.plugins[0].path", " ").unwrap_err();
545
546 assert_eq!(
547 error,
548 ConfigError::EmptyField {
549 field: "LiveNodeConfig.plugins[0].path".to_string(),
550 },
551 );
552 }
553
554 #[rstest]
555 #[case(
556 check_supported_field("field_a", false, "unsupported"),
557 ConfigError::unsupported_field("field_a", "unsupported")
558 )]
559 #[case(
560 check_supported_value("field_a", false, "unsupported"),
561 ConfigError::unsupported_value("field_a", "unsupported")
562 )]
563 #[case(
564 check_valid_value("field_a", false, "must be positive"),
565 ConfigError::invalid_value("field_a", "must be positive")
566 )]
567 #[case(
568 check_valid_format("field_a", false, "expected kind/name"),
569 ConfigError::invalid_format("field_a", "expected kind/name")
570 )]
571 #[case(
572 check_range("field_a", false, "must be <= 10"),
573 ConfigError::range("field_a", "must be <= 10")
574 )]
575 #[case(
576 check_feature_enabled("field_a", "live", false),
577 ConfigError::feature_disabled("field_a", "live")
578 )]
579 fn test_check_functions_return_expected_errors(
580 #[case] result: ConfigResult<()>,
581 #[case] expected: ConfigError,
582 ) {
583 assert_eq!(result.unwrap_err(), expected);
584 }
585
586 #[rstest]
587 fn test_collector_returns_single_error_without_wrapping() {
588 let mut collector = ConfigErrorCollector::new();
589 collector.push(ConfigError::empty_field("LiveNodeConfig.plugins[0].path"));
590
591 let error = collector.into_result().unwrap_err();
592
593 assert_eq!(
594 error,
595 ConfigError::EmptyField {
596 field: "LiveNodeConfig.plugins[0].path".to_string(),
597 },
598 );
599 }
600
601 #[rstest]
602 fn test_collector_flattens_multiple_errors() {
603 let mut collector = ConfigErrorCollector::new();
604 collector.push(ConfigError::multiple(vec![
605 ConfigError::empty_field("field_a"),
606 ConfigError::empty_field("field_b"),
607 ]));
608 collector.push(ConfigError::empty_field("field_c"));
609
610 let error = collector.into_result().unwrap_err();
611
612 match error {
613 ConfigError::Multiple { errors } => assert_eq!(errors.len(), 3),
614 _ => panic!("Expected multiple config errors, received {error:?}"),
615 }
616 }
617}