tier 0.1.17

Rust configuration library for layered TOML, env, and CLI settings
Documentation
use std::cmp::Ordering;
use std::collections::BTreeSet;

use serde_json::{Number, Value};

use crate::error::ValidationError;
use crate::metadata::{ValidationNumber, ValidationRule};
use crate::number::{compare_json_numbers, json_number_is_multiple_of};

use super::super::error::validation_error;

pub(super) fn validate_min(
    path: &str,
    actual: &Value,
    rule: &ValidationRule,
    secret_paths: &BTreeSet<String>,
    min: &ValidationNumber,
) -> Option<ValidationError> {
    validate_numeric_bound(NumericBound::Min, path, actual, rule, secret_paths, min)
}

pub(super) fn validate_max(
    path: &str,
    actual: &Value,
    rule: &ValidationRule,
    secret_paths: &BTreeSet<String>,
    max: &ValidationNumber,
) -> Option<ValidationError> {
    validate_numeric_bound(NumericBound::Max, path, actual, rule, secret_paths, max)
}

pub(super) fn validate_multiple_of(
    path: &str,
    actual: &Value,
    rule: &ValidationRule,
    secret_paths: &BTreeSet<String>,
    factor: &ValidationNumber,
) -> Option<ValidationError> {
    let Some(divisor) = factor.as_f64().filter(|value| value.is_finite()) else {
        return Some(validation_error(
            path,
            actual,
            rule,
            secret_paths,
            &format!("declared multiple_of factor must be finite, got {factor}"),
            Some(factor.as_json_value()),
        ));
    };
    if divisor <= 0.0 {
        return Some(validation_error(
            path,
            actual,
            rule,
            secret_paths,
            "declared multiple_of factor must be > 0",
            Some(factor.as_json_value()),
        ));
    }

    match actual.as_number() {
        Some(value) if number_is_multiple_of(value, factor) => None,
        Some(_) => Some(validation_error(
            path,
            actual,
            rule,
            secret_paths,
            &format!("must be a multiple of {factor}"),
            Some(factor.as_json_value()),
        )),
        None => Some(validation_error(
            path,
            actual,
            rule,
            secret_paths,
            "must be a numeric value",
            Some(factor.as_json_value()),
        )),
    }
}

fn compare_value_to_bound(value: &Value, bound: &ValidationNumber) -> Option<Ordering> {
    compare_json_numbers(value.as_number()?, validation_number(bound)?)
}

#[derive(Clone, Copy)]
enum NumericBound {
    Min,
    Max,
}

impl NumericBound {
    fn accepts(self, ordering: Ordering) -> bool {
        match self {
            Self::Min => matches!(ordering, Ordering::Greater | Ordering::Equal),
            Self::Max => matches!(ordering, Ordering::Less | Ordering::Equal),
        }
    }

    fn name(self) -> &'static str {
        match self {
            Self::Min => "minimum",
            Self::Max => "maximum",
        }
    }

    fn operator(self) -> &'static str {
        match self {
            Self::Min => ">=",
            Self::Max => "<=",
        }
    }
}

fn validate_numeric_bound(
    bound: NumericBound,
    path: &str,
    actual: &Value,
    rule: &ValidationRule,
    secret_paths: &BTreeSet<String>,
    limit: &ValidationNumber,
) -> Option<ValidationError> {
    if !limit.is_finite() {
        return Some(validation_error(
            path,
            actual,
            rule,
            secret_paths,
            &format!("declared {} must be finite, got {limit}", bound.name()),
            Some(limit.as_json_value()),
        ));
    }

    match compare_value_to_bound(actual, limit) {
        Some(ordering) if bound.accepts(ordering) => None,
        Some(_) => Some(validation_error(
            path,
            actual,
            rule,
            secret_paths,
            &format!("must be {} {limit}", bound.operator()),
            Some(limit.as_json_value()),
        )),
        None => Some(validation_error(
            path,
            actual,
            rule,
            secret_paths,
            "must be a numeric value",
            Some(limit.as_json_value()),
        )),
    }
}

fn number_is_multiple_of(value: &Number, factor: &ValidationNumber) -> bool {
    let Some(factor_number) = validation_number(factor) else {
        return false;
    };

    json_number_is_multiple_of(value, factor_number).unwrap_or(false)
}

fn validation_number(value: &ValidationNumber) -> Option<&Number> {
    match value {
        ValidationNumber::Finite(value) => Some(value),
        ValidationNumber::Invalid(_) => None,
    }
}