pub trait Validate {
fn validate(&self) -> Result<(), ConfigError>;
fn validate_all(&self) -> Result<(), Vec<ConfigError>> {
self.validate().map_err(|e| vec![e])
}
}
#[derive(Debug, Default)]
pub struct Violations {
errors: Vec<ConfigError>,
}
impl Violations {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn check(&mut self, check: Result<(), ConfigError>) {
if let Err(e) = check {
self.errors.push(e);
}
}
pub fn into_result(self) -> Result<(), Vec<ConfigError>> {
if self.errors.is_empty() {
Ok(())
} else {
Err(self.errors)
}
}
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[error("{config}.{field}: {kind}")]
pub struct ConfigError {
pub config: &'static str,
pub field: &'static str,
pub kind: ConstraintKind,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ConstraintKind {
#[error("value {got} is out of range [{lo}, {hi}]")]
OutOfRange {
lo: f64,
hi: f64,
got: f64,
},
#[error("value {got} must be strictly positive")]
NotPositive {
got: f64,
},
#[error("{low} must be strictly less than {high}")]
NotOrdered {
low: f64,
high: f64,
},
#[error("value {value} must differ from its pair")]
DegenerateInterval {
value: f64,
},
#[error("count {got} must be at least {min}")]
TooSmall {
min: u64,
got: u64,
},
#[error("count/size must be non-zero")]
Zero,
#[error("{0}")]
Custom(&'static str),
}
pub fn positive(config: &'static str, field: &'static str, got: f64) -> Result<(), ConfigError> {
if got > 0.0 {
Ok(())
} else {
Err(ConfigError {
config,
field,
kind: ConstraintKind::NotPositive { got },
})
}
}
pub fn in_range(
config: &'static str,
field: &'static str,
lo: f64,
hi: f64,
got: f64,
) -> Result<(), ConfigError> {
if got >= lo && got <= hi {
Ok(())
} else {
Err(ConfigError {
config,
field,
kind: ConstraintKind::OutOfRange { lo, hi, got },
})
}
}
pub fn ordered(
config: &'static str,
field: &'static str,
low: f64,
high: f64,
) -> Result<(), ConfigError> {
if low < high {
Ok(())
} else {
Err(ConfigError {
config,
field,
kind: ConstraintKind::NotOrdered { low, high },
})
}
}
pub fn distinct(
config: &'static str,
field: &'static str,
a: f64,
b: f64,
) -> Result<(), ConfigError> {
if (a - b).abs() > 0.0 {
Ok(())
} else {
Err(ConfigError {
config,
field,
kind: ConstraintKind::DegenerateInterval { value: a },
})
}
}
pub fn nonzero(config: &'static str, field: &'static str, n: usize) -> Result<(), ConfigError> {
if n == 0 {
Err(ConfigError {
config,
field,
kind: ConstraintKind::Zero,
})
} else {
Ok(())
}
}
pub fn at_least(
config: &'static str,
field: &'static str,
got: usize,
min: usize,
) -> Result<(), ConfigError> {
if got >= min {
Ok(())
} else {
Err(ConfigError {
config,
field,
kind: ConstraintKind::TooSmall {
min: min as u64,
got: got as u64,
},
})
}
}
#[cfg(test)]
mod tests {
use super::{
ConfigError, ConstraintKind, Validate, Violations, at_least, distinct, in_range, nonzero,
ordered, positive,
};
const C: &str = "TestConfig";
#[test]
fn positive_accepts_and_rejects() {
assert!(positive(C, "dt", 0.5).is_ok());
let err = positive(C, "dt", 0.0).unwrap_err();
assert_eq!(err.field, "dt");
assert_eq!(err.kind, ConstraintKind::NotPositive { got: 0.0 });
assert!(positive(C, "dt", -1.0).is_err());
}
#[test]
fn positive_rejects_nan() {
assert!(positive(C, "dt", f64::NAN).is_err());
}
#[test]
fn in_range_boundaries_are_inclusive() {
assert!(in_range(C, "gamma", 0.0, 1.0, 0.0).is_ok());
assert!(in_range(C, "gamma", 0.0, 1.0, 1.0).is_ok());
assert!(in_range(C, "gamma", 0.0, 1.0, 0.5).is_ok());
let err = in_range(C, "gamma", 0.0, 1.0, 1.5).unwrap_err();
assert_eq!(
err.kind,
ConstraintKind::OutOfRange {
lo: 0.0,
hi: 1.0,
got: 1.5
}
);
assert!(in_range(C, "gamma", 0.0, 1.0, f64::NAN).is_err());
}
#[test]
fn ordered_requires_strict() {
assert!(ordered(C, "clip", -1.0, 1.0).is_ok());
assert!(ordered(C, "clip", 1.0, 1.0).is_err());
let err = ordered(C, "clip", 2.0, 1.0).unwrap_err();
assert_eq!(
err.kind,
ConstraintKind::NotOrdered {
low: 2.0,
high: 1.0
}
);
}
#[test]
fn distinct_rejects_equal() {
assert!(distinct(C, "v_max", -10.0, 10.0).is_ok());
let err = distinct(C, "v_max", 5.0, 5.0).unwrap_err();
assert_eq!(err.kind, ConstraintKind::DegenerateInterval { value: 5.0 });
}
#[test]
fn nonzero_and_at_least() {
assert!(nonzero(C, "max_steps", 1).is_ok());
assert_eq!(
nonzero(C, "max_steps", 0).unwrap_err().kind,
ConstraintKind::Zero
);
assert!(at_least(C, "pop_size", 2, 2).is_ok());
assert_eq!(
at_least(C, "pop_size", 1, 2).unwrap_err().kind,
ConstraintKind::TooSmall { min: 2, got: 1 }
);
}
#[test]
fn violations_accumulate_every_failure() {
let mut v = Violations::new();
v.check(positive(C, "a", -1.0));
v.check(nonzero(C, "b", 4));
v.check(in_range(C, "c", 0.0, 1.0, 2.0));
let errs = v.into_result().unwrap_err();
assert_eq!(errs.len(), 2);
assert_eq!(errs[0].field, "a");
assert_eq!(errs[1].field, "c");
}
#[test]
fn violations_all_ok_is_ok() {
let mut v = Violations::new();
v.check(positive(C, "a", 1.0));
v.check(nonzero(C, "b", 4));
assert!(v.into_result().is_ok());
}
#[test]
fn validate_all_default_wraps_first_error() {
struct One;
impl Validate for One {
fn validate(&self) -> Result<(), ConfigError> {
positive(C, "x", 0.0)
}
}
let errs = One.validate_all().unwrap_err();
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].field, "x");
}
#[test]
fn error_display_format() {
let err = ConfigError {
config: "C51TrainingConfig",
field: "v_max",
kind: ConstraintKind::DegenerateInterval { value: 0.0 },
};
let s = err.to_string();
assert!(s.contains("C51TrainingConfig.v_max"));
assert!(s.contains("must differ"));
}
}