ocpi-tariffs 0.20.0

OCPI tariff calculations
Documentation
use std::fmt;

use serde::{Deserialize, Serialize};

use crate::{
    into_caveat, json,
    warning::{self, GatherWarnings as _, IntoCaveat},
    Caveat, OutOfRange, Verdict,
};

/// The types that we want to be deserialized by the [`obj_from_json_str`](crate::pricer::obj_from_json_str) fn
/// only define the fields we use in their struct definition. By implementing `SpecFieldNames` they can
/// provide the rest of field names that the spec defines so that the unexpected field system doesn't report
/// false positives.
///
/// The [`v221::Cdr`](crate::v221::Cdr) and [`v211::Cdr`](crate::v211::Cdr) types don't list all fields in the
/// respective `OCPI` specs. These types only list the fields that this crates uses. The same applies to the
/// [`v221::Tariff`](crate::v221::Tariff) and [`v211::Tariff`](crate::v211::Tariff) types.
pub trait SpecFieldNames {
    const SPEC_FIELD_NAMES: &[&'static str];
}

pub mod cistring {
    use std::{borrow::Cow, fmt};

    use crate::warning;

    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
    pub enum WarningKind {
        /// There should be no escape codes in a `CiString`.
        ContainsEscapeCodes,

        /// There should only be printable ASCII bytes in a `CiString`.
        ContainsNonPrintableASCII,

        /// The JSON value given is not a string.
        InvalidType,

        /// The length of the string exceeds the specs constraint.
        ExceedsMaxLength,
    }

    impl fmt::Display for WarningKind {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                WarningKind::ContainsEscapeCodes => {
                    f.write_str("The string contains escape codes.")
                }
                WarningKind::ContainsNonPrintableASCII => {
                    f.write_str("The string contains non-printable bytes.")
                }
                WarningKind::ExceedsMaxLength => {
                    f.write_str("The string is longer than the max length defined in the spec.")
                }
                WarningKind::InvalidType => f.write_str("The value should be a string."),
            }
        }
    }

    impl warning::Kind for WarningKind {
        fn id(&self) -> Cow<'static, str> {
            match self {
                WarningKind::ContainsEscapeCodes => "contains_escape_codes".into(),
                WarningKind::ContainsNonPrintableASCII => "contains_non_printable_ascii".into(),
                WarningKind::ExceedsMaxLength => "exceeds_max_len".into(),
                WarningKind::InvalidType => "invalid_type".into(),
            }
        }
    }
}

#[derive(Copy, Clone, Debug)]
pub struct CiString<'buf, const BYTE_LEN: usize>(&'buf str);

impl<const BYTE_LEN: usize> fmt::Display for CiString<'_, BYTE_LEN> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl<const BYTE_LEN: usize> IntoCaveat for CiString<'_, BYTE_LEN> {
    fn into_caveat<K: warning::Kind>(self, warnings: warning::Set<K>) -> Caveat<Self, K> {
        Caveat::new(self, warnings)
    }
}

impl<'buf, 'elem: 'buf, const BYTE_LEN: usize> json::FromJson<'elem, 'buf>
    for CiString<'buf, BYTE_LEN>
{
    type WarningKind = cistring::WarningKind;

    fn from_json(elem: &'elem json::Element<'buf>) -> Verdict<Self, Self::WarningKind> {
        let mut warnings = warning::Set::new();
        let mut check_len_and_printable = |s: &str| {
            if s.len() > BYTE_LEN {
                warnings.with_elem(cistring::WarningKind::ExceedsMaxLength, elem);
            }

            if s.chars()
                .any(|c| c.is_ascii_whitespace() || c.is_ascii_control())
            {
                warnings.with_elem(cistring::WarningKind::ContainsNonPrintableASCII, elem);
            }
        };
        let Some(id) = elem.as_raw_str() else {
            warnings.with_elem(cistring::WarningKind::InvalidType, elem);
            return Err(warnings);
        };

        // We don't care about the details of any warnings the escapes in the Id may have.
        // The Id should simply not have any escapes.
        let id = id.has_escapes(elem).ignore_warnings();
        let id = match id {
            json::decode::PendingStr::NoEscapes(s) => {
                check_len_and_printable(s);
                s
            }
            json::decode::PendingStr::HasEscapes(escape_str) => {
                // We decode the escapes to check if any of the escapes result in non-printable ASCII.
                let decoded = escape_str.decode_escapes(elem).ignore_warnings();
                check_len_and_printable(&decoded);
                escape_str.into_raw()
            }
        };

        Ok(CiString(id).into_caveat(warnings))
    }
}

pub mod day_of_week {
    //! The Warning infrastructure for the `DayOfWeek` type.
    //!
    //! * See: [OCPI spec 2.2.1: Tariff DayOfWeek](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#mod_tariffs_dayofweek_enum>)
    //! * See: [OCPI spec 2.1.1: Tariff DayOfWeek](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#41-dayofweek-enum>)

    use std::{borrow::Cow, fmt};

    use crate::{json, warning};

    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
    pub enum WarningKind {
        /// Neither the day of the week does not need escape codes.
        ContainsEscapeCodes,

        /// The field at the path could not be decoded.
        Decode(json::decode::WarningKind),

        /// Each day of the week should be uppercase.
        InvalidCase,

        /// The value is not a valid day.
        InvalidDay,

        /// The JSON value given is not a string.
        InvalidType,
    }

    impl fmt::Display for WarningKind {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                WarningKind::ContainsEscapeCodes => write!(
                    f,
                    "The value contains escape codes but it does not need them."
                ),
                WarningKind::Decode(warning) => fmt::Display::fmt(warning, f),
                WarningKind::InvalidCase => write!(f, "The day should be uppercase."),
                WarningKind::InvalidDay => {
                    write!(f, "The value is not a valid day.")
                }
                WarningKind::InvalidType => write!(f, "The value should be a string."),
            }
        }
    }

    impl warning::Kind for WarningKind {
        fn id(&self) -> Cow<'static, str> {
            match self {
                WarningKind::ContainsEscapeCodes => "contains_escape_codes".into(),
                WarningKind::Decode(kind) => format!("decode.{}", kind.id()).into(),
                WarningKind::InvalidCase => "invalid_case".into(),
                WarningKind::InvalidDay => "invalid_day".into(),
                WarningKind::InvalidType => "invalid_type".into(),
            }
        }
    }

    impl From<json::decode::WarningKind> for WarningKind {
        fn from(warn_kind: json::decode::WarningKind) -> Self {
            Self::Decode(warn_kind)
        }
    }
}

/// A single day of the week.
#[derive(Copy, Debug, PartialEq, Eq, Ord, PartialOrd, Clone, Hash, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum DayOfWeek {
    /// Monday
    Monday,
    /// Tuesday
    Tuesday,
    /// Wednesday
    Wednesday,
    /// Thursday
    Thursday,
    /// Friday
    Friday,
    /// Saturday
    Saturday,
    /// Sunday
    Sunday,
}

into_caveat!(DayOfWeek);

/// Convert a `chrono::Weekday` into an OCPI `DayOfWeek`.
impl From<chrono::Weekday> for DayOfWeek {
    fn from(value: chrono::Weekday) -> Self {
        match value {
            chrono::Weekday::Mon => DayOfWeek::Monday,
            chrono::Weekday::Tue => DayOfWeek::Tuesday,
            chrono::Weekday::Wed => DayOfWeek::Wednesday,
            chrono::Weekday::Thu => DayOfWeek::Thursday,
            chrono::Weekday::Fri => DayOfWeek::Friday,
            chrono::Weekday::Sat => DayOfWeek::Saturday,
            chrono::Weekday::Sun => DayOfWeek::Sunday,
        }
    }
}

/// Convert a `DayOfWeek` into a index.
impl From<DayOfWeek> for usize {
    fn from(value: DayOfWeek) -> Self {
        match value {
            DayOfWeek::Monday => 0,
            DayOfWeek::Tuesday => 1,
            DayOfWeek::Wednesday => 2,
            DayOfWeek::Thursday => 3,
            DayOfWeek::Friday => 4,
            DayOfWeek::Saturday => 5,
            DayOfWeek::Sunday => 6,
        }
    }
}

/// Convert an index into a `DayOfWeek`.
impl TryFrom<usize> for DayOfWeek {
    type Error = OutOfRange;

    fn try_from(value: usize) -> Result<Self, Self::Error> {
        let day = match value {
            0 => DayOfWeek::Monday,
            1 => DayOfWeek::Tuesday,
            2 => DayOfWeek::Wednesday,
            3 => DayOfWeek::Thursday,
            4 => DayOfWeek::Friday,
            5 => DayOfWeek::Saturday,
            6 => DayOfWeek::Sunday,
            _ => return Err(OutOfRange::new()),
        };

        Ok(day)
    }
}

impl json::FromJson<'_, '_> for DayOfWeek {
    type WarningKind = day_of_week::WarningKind;

    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
        const NUM_DAYS: usize = 7;
        const DAYS: [&str; NUM_DAYS] = [
            "MONDAY",
            "TUESDAY",
            "WEDNESDAY",
            "THURSDAY",
            "FRIDAY",
            "SATURDAY",
            "SUNDAY",
        ];

        let mut warnings = warning::Set::new();
        let value = elem.as_value();

        let Some(s) = value.as_raw_str() else {
            warnings.with_elem(day_of_week::WarningKind::InvalidType, elem);
            return Err(warnings);
        };

        let pending_str = s.has_escapes(elem).gather_warnings_into(&mut warnings);

        let s = match pending_str {
            json::decode::PendingStr::NoEscapes(s) => s,
            json::decode::PendingStr::HasEscapes(_) => {
                warnings.with_elem(day_of_week::WarningKind::ContainsEscapeCodes, elem);
                return Err(warnings);
            }
        };

        if !s.chars().all(char::is_uppercase) {
            warnings.with_elem(day_of_week::WarningKind::InvalidCase, elem);
        }

        let Some(index) = DAYS.iter().position(|day| day.eq_ignore_ascii_case(s)) else {
            warnings.with_elem(day_of_week::WarningKind::InvalidDay, elem);
            return Err(warnings);
        };

        let Ok(day) = DayOfWeek::try_from(index) else {
            warnings.with_elem(day_of_week::WarningKind::InvalidDay, elem);
            return Err(warnings);
        };

        Ok(day.into_caveat(warnings))
    }
}

impl From<DayOfWeek> for chrono::Weekday {
    fn from(day: DayOfWeek) -> Self {
        match day {
            DayOfWeek::Monday => Self::Mon,
            DayOfWeek::Tuesday => Self::Tue,
            DayOfWeek::Wednesday => Self::Wed,
            DayOfWeek::Thursday => Self::Thu,
            DayOfWeek::Friday => Self::Fri,
            DayOfWeek::Saturday => Self::Sat,
            DayOfWeek::Sunday => Self::Sun,
        }
    }
}

#[cfg(test)]
mod test_day_of_week {
    use assert_matches::assert_matches;

    use crate::{
        json::{self, FromJson as _},
        test,
    };

    use super::{day_of_week::WarningKind, DayOfWeek};

    #[test]
    fn should_create_from_json() {
        const JSON: &str = r#""MONDAY""#;

        test::setup();

        let elem = json::parse(JSON).unwrap();
        let day = DayOfWeek::from_json(&elem).unwrap().unwrap();
        assert_matches!(day, DayOfWeek::Monday);
    }

    #[test]
    fn should_fail_on_type_from_json() {
        const JSON: &str = "[]";

        test::setup();

        let elem = json::parse(JSON).unwrap();
        let warnings = DayOfWeek::from_json(&elem).unwrap_err().into_kind_vec();
        assert_matches!(*warnings, [WarningKind::InvalidType]);
    }

    #[test]
    fn should_fail_on_value_from_json() {
        const JSON: &str = r#""MOONDAY""#;

        test::setup();

        let elem = json::parse(JSON).unwrap();
        let warnings = DayOfWeek::from_json(&elem).unwrap_err().into_kind_vec();
        assert_matches!(*warnings, [WarningKind::InvalidDay]);
    }

    #[test]
    fn should_warn_about_case_from_json() {
        const JSON: &str = r#""sunday""#;

        test::setup();

        let elem = json::parse(JSON).unwrap();
        let (day, warnings) = DayOfWeek::from_json(&elem).unwrap().into_parts();
        let warnings = warnings.into_kind_vec();

        assert_matches!(day, DayOfWeek::Sunday);
        assert_matches!(*warnings, [WarningKind::InvalidCase]);
    }
}