Skip to main content

nautilus_common/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Path-aware configuration validation errors and checks.
17
18use std::{error::Error, fmt::Display};
19
20/// Result type for configuration validation.
21pub type ConfigResult<T> = Result<T, ConfigError>;
22
23/// A typed configuration validation error with owned field paths.
24///
25/// Variants store owned field paths and explanatory text. Callers should avoid placing
26/// secrets in reason strings, reference names, or duplicate labels.
27#[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    /// A field is not supported by the current runtime.
34    UnsupportedField { field: String, reason: String },
35    /// A field value is not supported by the current runtime.
36    UnsupportedValue { field: String, reason: String },
37    /// A required field is missing.
38    MissingField { field: String },
39    /// A required field is present but empty.
40    EmptyField { field: String },
41    /// A field value is invalid.
42    InvalidValue { field: String, reason: String },
43    /// A field value has an invalid format.
44    InvalidFormat { field: String, expected: String },
45    /// A field value is outside the accepted range.
46    Range { field: String, reason: String },
47    /// Fields were set together but are mutually exclusive.
48    MutuallyExclusiveFields { fields: Vec<String> },
49    /// At least one of the listed fields is required.
50    RequiredOneOf { fields: Vec<String> },
51    /// A field requires another field.
52    Dependency {
53        field: String,
54        depends_on: String,
55        reason: String,
56    },
57    /// A field contains a duplicate entry.
58    Duplicate {
59        field: String,
60        value: Option<String>,
61    },
62    /// A field requires a disabled feature.
63    FeatureDisabled { field: String, feature: String },
64    /// A field references an invalid object.
65    InvalidReference {
66        field: String,
67        reference: String,
68        reason: String,
69    },
70    /// Multiple config validation errors were collected.
71    Multiple { errors: Vec<Self> },
72}
73
74impl ConfigError {
75    /// Creates an unsupported-field error.
76    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    /// Creates an unsupported-value error.
84    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    /// Creates a missing-field error.
92    pub fn missing_field(field: impl Into<String>) -> Self {
93        Self::MissingField {
94            field: field.into(),
95        }
96    }
97
98    /// Creates an empty-field error.
99    pub fn empty_field(field: impl Into<String>) -> Self {
100        Self::EmptyField {
101            field: field.into(),
102        }
103    }
104
105    /// Creates an invalid-value error.
106    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    /// Creates an invalid-format error.
114    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    /// Creates a range error.
122    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    /// Creates a mutually-exclusive-fields error.
130    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    /// Creates a required-one-of error.
137    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    /// Creates a dependency error.
144    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    /// Creates a duplicate-entry error.
157    pub fn duplicate(field: impl Into<String>, value: Option<String>) -> Self {
158        Self::Duplicate {
159            field: field.into(),
160            value,
161        }
162    }
163
164    /// Creates a feature-disabled error.
165    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    /// Creates an invalid-reference error.
173    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    /// Creates a multiple-errors error.
186    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/// Collects configuration validation errors.
260#[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    /// Creates an empty collector.
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    /// Creates an empty collector with capacity for `capacity` errors.
276    pub fn with_capacity(capacity: usize) -> Self {
277        Self {
278            errors: Vec::with_capacity(capacity),
279        }
280    }
281
282    /// Returns `true` if no errors have been collected.
283    pub fn is_empty(&self) -> bool {
284        self.errors.is_empty()
285    }
286
287    /// Returns the number of collected errors.
288    pub fn len(&self) -> usize {
289        self.errors.len()
290    }
291
292    /// Returns the collected errors.
293    pub fn errors(&self) -> &[ConfigError] {
294        &self.errors
295    }
296
297    /// Adds an error to the collection.
298    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    /// Adds `error` when `condition` is false.
306    pub fn check(&mut self, condition: bool, error: ConfigError) {
307        if !condition {
308            self.push(error);
309        }
310    }
311
312    /// Adds the error from a validation result.
313    pub fn collect(&mut self, result: ConfigResult<()>) {
314        if let Err(e) = result {
315            self.push(e);
316        }
317    }
318
319    /// Converts the collection to a validation result.
320    ///
321    /// # Errors
322    ///
323    /// Returns the collected error, or a [`ConfigError::Multiple`] when more than one error
324    /// was collected.
325    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
337/// Checks a boolean validation condition.
338///
339/// # Errors
340///
341/// Returns `error` when `condition` is false.
342pub fn check(condition: bool, config_error: ConfigError) -> ConfigResult<()> {
343    if condition { Ok(()) } else { Err(config_error) }
344}
345
346/// Checks that a field is supported.
347///
348/// # Errors
349///
350/// Returns [`ConfigError::UnsupportedField`] when `supported` is false.
351pub 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
359/// Checks that a field value is supported.
360///
361/// # Errors
362///
363/// Returns [`ConfigError::UnsupportedValue`] when `supported` is false.
364pub 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
372/// Checks that a string field is present and non-empty after trimming.
373///
374/// # Errors
375///
376/// Returns [`ConfigError::EmptyField`] when `value` is empty after trimming.
377pub fn check_non_empty_field(field: impl Into<String>, value: &str) -> ConfigResult<()> {
378    check(!value.trim().is_empty(), ConfigError::empty_field(field))
379}
380
381/// Checks that a field value is valid.
382///
383/// # Errors
384///
385/// Returns [`ConfigError::InvalidValue`] when `valid` is false.
386pub 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
394/// Checks that a field value has the expected format.
395///
396/// # Errors
397///
398/// Returns [`ConfigError::InvalidFormat`] when `valid` is false.
399pub 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
407/// Checks that a field value is in range.
408///
409/// # Errors
410///
411/// Returns [`ConfigError::Range`] when `in_range` is false.
412pub 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
420/// Checks that a field's feature is enabled.
421///
422/// # Errors
423///
424/// Returns [`ConfigError::FeatureDisabled`] when `enabled` is false.
425pub 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}