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 an error if any validation error was collected. Returns the collected error for a
324    /// single failure, or combines multiple failures into one error.
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 an error if `condition` is false. Returns the supplied `config_error` unchanged.
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 an error if `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 an error if `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 an error if `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 an error if `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 an error if `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 an error if `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 an error if `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/// Checks that a list contains no repeated values.
434///
435/// Empty lists are valid. Duplicate values are omitted from errors because configuration
436/// values may contain secrets.
437///
438/// # Errors
439///
440/// Returns an error if an entry equals an earlier entry.
441pub 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}