ocpi-tariffs 0.42.0

OCPI tariff calculations
Documentation
//! Guess the `Version` of the given `CDR` or tariff.
#[cfg(test)]
mod test;

#[cfg(test)]
mod test_guess_cdr;

#[cfg(test)]
mod test_guess_tariff;

#[cfg(test)]
mod test_real_world;

use std::fmt;

use tracing::debug;

use crate::{cdr, json, tariff, v211, v221, ParseError, Unversioned, Versioned};

/// The result of calling `cdr::parse`.
pub type CdrVersion<'buf> = Version<cdr::Versioned<'buf>, cdr::Unversioned<'buf>>;

/// The result of calling `cdr::parse_and_report`.
pub type CdrReport<'buf> = Report<'buf, cdr::Versioned<'buf>, cdr::Unversioned<'buf>>;

/// Guess the `Version` of the given CDR.
///
/// See: `cdr::guess_version`.
pub(crate) fn cdr_version(cdr_json: &str) -> Result<CdrVersion<'_>, ParseError> {
    guess_cdr_version(cdr_json)
}

/// Guess the `Version` of the given CDR and report on any unexpected fields in the JSON.
///
/// See: `cdr::guess_version_with_report`.
pub(crate) fn cdr_version_and_report(cdr_json: &str) -> Result<CdrReport<'_>, ParseError> {
    let version = guess_cdr_version(cdr_json)?;

    let Version::Certain(cdr) = version else {
        return Ok(Report {
            version,
            unexpected_fields: json::UnexpectedFields::empty(),
        });
    };

    let schema = match cdr.version() {
        crate::Version::V211 => &v211::CDR_SCHEMA,
        crate::Version::V221 => &v221::CDR_SCHEMA,
    };

    let report = json::parse_with_schema(cdr_json, schema).map_err(ParseError::from_cdr_err)?;
    let json::ParseReport {
        element: _,
        unexpected_fields,
    } = report;

    Ok(CdrReport {
        unexpected_fields,
        version: Version::Certain(cdr),
    })
}

/// Try to guess a CDR's [`Version`] and return a [`CdrVersion`] with the outcome.
fn guess_cdr_version(source: &str) -> Result<CdrVersion<'_>, ParseError> {
    /// The list of field names exclusively defined in the `v221` spec.
    const V211_EXCLUSIVE_FIELDS: &[&str] = &["stop_date_time"];

    /// The list of field names shared between the `v211` and `v221` specs.
    const V221_EXCLUSIVE_FIELDS: &[&str] = &["end_date_time", "cdr_location", "cdr_token"];

    let element = json::parse(source).map_err(ParseError::from_cdr_err)?;
    let value = element.value();
    let json::Value::Object(fields) = value else {
        return Err(ParseError::cdr_should_be_object());
    };

    for field in fields {
        let key = field.key().as_raw();

        if V211_EXCLUSIVE_FIELDS.contains(&key) {
            return Ok(Version::Certain(cdr::Versioned::new(
                source,
                element,
                crate::Version::V211,
            )));
        } else if V221_EXCLUSIVE_FIELDS.contains(&key) {
            return Ok(Version::Certain(cdr::Versioned::new(
                source,
                element,
                crate::Version::V221,
            )));
        }
    }

    Ok(Version::Uncertain(cdr::Unversioned::new(source, element)))
}

/// The result of calling `tariff::parse`.
pub type TariffVersion<'buf> = Version<tariff::Versioned<'buf>, tariff::Unversioned<'buf>>;

/// The result of calling `tariff::parse_and_report`.
pub type TariffReport<'buf> = Report<'buf, tariff::Versioned<'buf>, tariff::Unversioned<'buf>>;

/// Guess the `Version` of the given tariff.
///
/// See: [`tariff::parse`]
pub(super) fn tariff_version(tariff_json: &str) -> Result<TariffVersion<'_>, ParseError> {
    guess_tariff_version(tariff_json)
}

/// Guess the `Version` of the given tariff and report on any unexpected fields in the JSON.
///
/// See: [`tariff::parse_and_report`]
pub(super) fn tariff_version_with_report(
    tariff_json: &str,
) -> Result<TariffReport<'_>, ParseError> {
    let version = guess_tariff_version(tariff_json)?;

    let Version::Certain(object) = version else {
        return Ok(Report {
            version,
            unexpected_fields: json::UnexpectedFields::empty(),
        });
    };

    let schema = match object.version() {
        crate::Version::V211 => &v211::TARIFF_SCHEMA,
        crate::Version::V221 => &v221::TARIFF_SCHEMA,
    };

    let report =
        json::parse_with_schema(tariff_json, schema).map_err(ParseError::from_tariff_err)?;
    let json::ParseReport {
        element: _,
        unexpected_fields,
    } = report;

    Ok(TariffReport {
        unexpected_fields,
        version: Version::Certain(object),
    })
}

/// The private impl for detecting a tariff's version
fn guess_tariff_version(source: &str) -> Result<TariffVersion<'_>, ParseError> {
    /// The list of field names exclusively defined in the `v221` spec.
    const V221_EXCLUSIVE_FIELDS: &[&str] = &[
        "country_code",
        "party_id",
        "type",
        "min_price",
        "max_price",
        "start_date_time",
        "end_date_time",
    ];

    /// The list of field names shared between the `v211` and `v221` specs.
    const V211_V221_SHARED_FIELDS: &[&str] = &[
        "id",
        "currency",
        "tariff_alt_text",
        "tariff_alt_url",
        "elements",
        "energy_mix",
        "last_updated",
    ];

    // Parse the tariff without a schema first to determine the version.
    let element = json::parse(source).map_err(ParseError::from_tariff_err)?;
    let value = element.value();
    let json::Value::Object(fields) = value else {
        return Err(ParseError::tariff_should_be_object());
    };

    for field in fields {
        let key = field.key().as_raw();

        // If the field is in the v221 exclusive list we know without a doubt that it's a v221 tariff.
        if V221_EXCLUSIVE_FIELDS.contains(&key) {
            debug!("Tariff is v221 because of field: `{key}`");
            return Ok(TariffVersion::Certain(tariff::Versioned::new(
                source,
                element,
                crate::Version::V221,
            )));
        }
    }

    for field in fields {
        let key = field.key().as_raw();

        if V211_V221_SHARED_FIELDS.contains(&key) {
            return Ok(TariffVersion::Certain(tariff::Versioned::new(
                source,
                element,
                crate::Version::V211,
            )));
        }
    }

    Ok(TariffVersion::Uncertain(tariff::Unversioned::new(
        source, element,
    )))
}

/// An OCPI object with a certain or uncertain version.
#[derive(Debug)]
pub enum Version<V, U>
where
    V: Versioned,
    U: fmt::Debug,
{
    /// The version of the object `V` is certain.
    Certain(V),

    /// The version of the object `U` is uncertain.
    Uncertain(U),
}

impl<V, U> Version<V, U>
where
    V: Versioned,
    U: Unversioned<Versioned = V>,
{
    /// Convert the OCPI object into it's [`Version`](crate::Version) if it's [`Versioned`].
    /// Otherwise, return `Uncertain(())`.
    pub fn into_version(self) -> Version<crate::Version, ()> {
        match self {
            Version::Certain(v) => Version::Certain(v.version()),
            Version::Uncertain(_) => Version::Uncertain(()),
        }
    }

    /// Convert a reference to the an OCPI object into it's [`Version`](crate::Version) if it's [`Versioned`].
    /// Otherwise, return `Uncertain(())`.
    pub fn as_version(&self) -> Version<crate::Version, ()> {
        match self {
            Version::Certain(v) => Version::Certain(v.version()),
            Version::Uncertain(_) => Version::Uncertain(()),
        }
    }

    /// Return the contained OCPI object if it's [`Versioned`]. Otherwise, force convert the object
    /// to the given [`Version`](crate::Version).
    pub fn certain_or(self, fallback: crate::Version) -> V {
        match self {
            Version::Certain(v) => v,
            Version::Uncertain(u) => u.force_into_versioned(fallback),
        }
    }

    /// Return `Some` OCPI object if it's [`Versioned`]. Otherwise, return None if the object's version is uncertain.
    pub fn certain_or_none(self) -> Option<V> {
        match self {
            Version::Certain(v) => Some(v),
            Version::Uncertain(_) => None,
        }
    }
}

/// The guess version report.
///
/// This contains the guessed version of the given JSON object and a list of unexpected fields
/// if the JSON contains fields not specified in the guessed version of the OCPI spec.
///
/// The `unexpected_fields` list will always be empty if the guessed `Version` is `Uncertain`.
#[derive(Debug)]
pub struct Report<'buf, V, U>
where
    V: Versioned,
    U: fmt::Debug,
{
    /// A list of fields that were not expected: The schema did not define them.
    ///
    /// This list will always be empty if the guessed `Version` is `Uncertain`.
    pub unexpected_fields: json::UnexpectedFields<'buf>,

    /// The guessed version.
    ///
    /// The `unexpected_fields` list will always be empty if the guessed `Version` is `Uncertain`.
    pub version: Version<V, U>,
}