ocpi-tariffs 0.20.0

OCPI tariff calculations
Documentation
use std::fmt;

use crate::{guess, json, lint, ParseError, Version};

/// Parse a `&str` into a [`Versioned`] tariff as a [`json::Element`] and return it as part of
/// [`Report`] that contains a set of unexpected fields.
pub fn parse_with_version(cdr_json: &str, version: Version) -> Result<Report<'_>, ParseError> {
    match version {
        Version::V221 => {
            let schema = &*crate::v221::TARIFF_SCHEMA;
            let report =
                json::parse_with_schema(cdr_json, schema).map_err(ParseError::from_cdr_err)?;
            let json::Report {
                element,
                unexpected_fields,
            } = report;
            Ok(Report {
                tariff: Versioned::V221(element),
                unexpected_fields,
            })
        }
        Version::V211 => {
            let schema = &*crate::v211::TARIFF_SCHEMA;
            let report =
                json::parse_with_schema(cdr_json, schema).map_err(ParseError::from_cdr_err)?;
            let json::Report {
                element,
                unexpected_fields,
            } = report;
            Ok(Report {
                tariff: Versioned::V211(element),
                unexpected_fields,
            })
        }
    }
}

/// Parse the JSON as a tariff and try guess the `Version`.
///
/// The parser is very forgiving and will not complain if the tariff JSON is missing required fields.
/// The version guess is based on fields that exist.
pub fn parse(tariff_json: &str) -> Result<guess::TariffVersion<'_>, ParseError> {
    guess::tariff_version(tariff_json)
}

/// Guess the `Version` of the given tariff JSON and report on any unexpected fields in the JSON.
///
/// The parser is very forgiving and will not complain if the tariff JSON is missing required fields.
/// The version guess is based on fields that exist.
pub fn parse_and_report(tariff_json: &str) -> Result<guess::TariffReport<'_>, ParseError> {
    guess::tariff_version_with_report(tariff_json)
}

/// A [`Versioned`] tariff along with a set of unexpected fields.
#[derive(Debug)]
pub struct Report<'buf> {
    /// The root JSON `Element`.
    pub tariff: Versioned<'buf>,

    /// A list of fields that were not expected for the given [`Version`].
    pub unexpected_fields: json::UnexpectedFields<'buf>,
}

/// A `json::Element` that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
/// and has been identified as being a certain [`Version`].
pub enum Versioned<'buf> {
    /// The parsing function determined that this is a v211 CDR.
    V211(json::Element<'buf>),

    /// The parsing function determined that this is a v221 CDR.
    V221(json::Element<'buf>),
}

impl fmt::Debug for Versioned<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if f.alternate() {
            // If the alternate sigil is used, print the normal `Debug` variant.
            // This is done to avoid printing large/complex `Element`s to stderr.
            match self {
                Versioned::V211(elem) | Versioned::V221(elem) => fmt::Debug::fmt(elem, f),
            }
        } else {
            // Just print the version and suppress the `Element` as printing large/complex `Element`s
            // to stderr during testing can make the test output unreadable.
            match self {
                Versioned::V211(_) => f.write_str("V211(...)"),
                Versioned::V221(_) => f.write_str("V221(...)"),
            }
        }
    }
}

impl crate::Versioned for Versioned<'_> {
    fn version(&self) -> Version {
        match self {
            Versioned::V211(_) => Version::V211,
            Versioned::V221(_) => Version::V221,
        }
    }
}

impl<'buf> Versioned<'buf> {
    /// Return the inner [`json::Element`] and discard the version info.
    pub fn into_element(self) -> json::Element<'buf> {
        match self {
            Versioned::V211(element) | Versioned::V221(element) => element,
        }
    }

    /// Return the inner [`json::Element`] and discard the version info.
    pub fn as_element(&self) -> &json::Element<'buf> {
        match self {
            Versioned::V211(element) | Versioned::V221(element) => element,
        }
    }
}

/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
/// and was determined to not be one of the supported [`Version`]s.
#[derive(Debug)]
pub struct Unversioned<'buf>(json::Element<'buf>);

impl<'buf> Unversioned<'buf> {
    /// Create an unversioned [`json::Element`].
    pub(crate) fn new(elem: json::Element<'buf>) -> Self {
        Self(elem)
    }

    /// Return the inner `json::Element` and discard the version info.
    pub fn into_element(self) -> json::Element<'buf> {
        self.0
    }
}

impl<'buf> crate::Unversioned for Unversioned<'buf> {
    type Versioned = Versioned<'buf>;

    fn force_into_versioned(self, version: Version) -> Self::Versioned {
        match version {
            Version::V221 => Versioned::V221(self.0),
            Version::V211 => Versioned::V211(self.0),
        }
    }
}

/// Lint the given tariff and return a [`Report`] of any `Warning`s found.
///
/// * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>)
/// * See: [OCPI spec 2.1.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#31-tariff-object>)
pub fn lint(tariff: &Versioned<'_>) -> Result<lint::tariff::Report, lint::Error> {
    lint::tariff(tariff)
}

#[cfg(test)]
mod test_real_world {
    use std::path::Path;

    use assert_matches::assert_matches;

    use crate::{guess, test, Version, Versioned as _};

    use super::{
        parse_and_report,
        test::{assert_parse_report, parse_expect_json},
    };

    #[test_each::file(
        glob = "ocpi-tariffs/test_data/v211/real_world/*/tariff*.json",
        name(segments = 2)
    )]
    fn test_parse_v211(tariff_json: &str, path: &Path) {
        test::setup();
        expect_version(tariff_json, path, Version::V211);
    }

    #[test_each::file(
        glob = "ocpi-tariffs/test_data/v221/real_world/*/tariff*.json",
        name(segments = 2)
    )]
    fn test_parse_v221(tariff_json: &str, path: &Path) {
        test::setup();
        expect_version(tariff_json, path, Version::V221);
    }

    /// Parse the given JSON as a tariff and generate a report on the unexpected fields.
    fn expect_version(tariff_json: &str, path: &Path, expected_version: Version) {
        let report = parse_and_report(tariff_json).unwrap();

        let expect_json = test::read_expect_json(path, "parse");
        let parse_expect = parse_expect_json(expect_json.as_deref());

        let tariff = assert_matches!(report.version(), guess::Version::Certain(tariff) => tariff);
        assert_eq!(tariff.version(), expected_version);

        assert_parse_report(report, parse_expect);
    }
}

#[cfg(test)]
pub mod test {
    #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
    #![allow(clippy::panic, reason = "tests are allowed panic")]

    use crate::{
        guess, json,
        test::{assert_no_unexpected_fields, Expectation},
    };

    /// Expectations for the result of calling `json::parse_and_report`.
    #[derive(Debug, serde::Deserialize)]
    pub struct ParseExpect<'buf> {
        #[serde(borrow, default)]
        unexpected_fields: Expectation<Vec<json::test::PathGlob<'buf>>>,
    }

    #[track_caller]
    pub fn parse_expect_json(expect_json: Option<&str>) -> Option<ParseExpect<'_>> {
        expect_json.map(|json| serde_json::from_str(json).expect("Unable to parse expect JSON"))
    }

    /// Assert that the `TariffReport` resulting from the call to `tariff::parse_and_report`
    /// matches the expectations of the `ParseExpect` file.
    #[track_caller]
    pub fn assert_parse_report<'bin>(
        report: guess::TariffReport<'bin>,
        expect: Option<ParseExpect<'_>>,
    ) -> guess::TariffVersion<'bin> {
        let (version, mut unexpected_fields) = report.into_parts();
        let Some(expect) = expect else {
            assert_no_unexpected_fields(&unexpected_fields);
            return version;
        };

        let ParseExpect {
            unexpected_fields: unexpected_fields_expect,
        } = expect;

        if let Expectation::Present(expectation) = unexpected_fields_expect {
            let unexpected_fields_expect = expectation.expect_value();

            for expect_glob in unexpected_fields_expect {
                unexpected_fields.filter_matches(&expect_glob);
            }

            assert_no_unexpected_fields(&unexpected_fields);
        } else {
            assert_no_unexpected_fields(&unexpected_fields);
        }

        version
    }
}