ocpi_tariffs/
tariff.rs

1//! Parse a tariff and lint the result.
2
3pub(crate) mod v211;
4pub(crate) mod v221;
5pub(crate) mod v2x;
6
7use std::{borrow::Cow, fmt};
8
9use crate::{
10    country, currency, datetime, duration, from_warning_all, guess, json, lint, money, number,
11    string, warning, weekday, ParseError, Version,
12};
13
14#[derive(Debug)]
15pub enum WarningKind {
16    /// The CDR location is not a valid ISO 3166-1 alpha-3 code.
17    Country(country::WarningKind),
18    Currency(currency::WarningKind),
19    DateTime(datetime::WarningKind),
20    Decode(json::decode::WarningKind),
21    Duration(duration::WarningKind),
22
23    /// A field in the tariff doesn't have the expected type.
24    FieldInvalidType {
25        /// The type that the given field should have according to the schema.
26        expected_type: json::ValueKind,
27    },
28
29    /// A field in the tariff doesn't have the expected value.
30    FieldInvalidValue {
31        /// The value encountered.
32        value: String,
33
34        /// A message about what values are expected for this field.
35        message: Cow<'static, str>,
36    },
37
38    /// The given field is required.
39    FieldRequired {
40        field_name: Cow<'static, str>,
41    },
42
43    Money(money::WarningKind),
44
45    /// The given tariff has a `min_price` set and the `total_cost` fell below it.
46    ///
47    /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
48    TotalCostClampedToMin,
49
50    /// The given tariff has a `max_price` set and the `total_cost` exceeded it.
51    ///
52    /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
53    TotalCostClampedToMax,
54
55    /// The tariff has no `Element`s.
56    NoElements,
57
58    /// The tariff is not active during the `Cdr::start_date_time`.
59    NotActive,
60    Number(number::WarningKind),
61
62    String(string::WarningKind),
63    Weekday(weekday::WarningKind),
64}
65
66impl WarningKind {
67    /// Create a new `WarningKind::FieldInvalidValue` where the field is built from the given `json::Element`.
68    fn field_invalid_value(
69        value: impl Into<String>,
70        message: impl Into<Cow<'static, str>>,
71    ) -> Self {
72        WarningKind::FieldInvalidValue {
73            value: value.into(),
74            message: message.into(),
75        }
76    }
77}
78
79impl fmt::Display for WarningKind {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::String(warning_kind) => write!(f, "{warning_kind}"),
83            Self::Country(warning_kind) => write!(f, "{warning_kind}"),
84            Self::Currency(warning_kind) => write!(f, "{warning_kind}"),
85            Self::DateTime(warning_kind) => write!(f, "{warning_kind}"),
86            Self::Decode(warning_kind) => write!(f, "{warning_kind}"),
87            Self::Duration(warning_kind) => write!(f, "{warning_kind}"),
88            Self::FieldInvalidType { expected_type } => {
89                write!(f, "Field has invalid type. Expected type `{expected_type}`")
90            }
91            Self::FieldInvalidValue { value, message } => {
92                write!(f, "Field has invalid value `{value}`: {message}")
93            }
94            Self::FieldRequired { field_name } => {
95                write!(f, "Field is required: {field_name}")
96            }
97            Self::Money(warning_kind) => write!(f, "{warning_kind}"),
98            Self::NoElements => f.write_str("The tariff has no `elements`"),
99            Self::NotActive => f.write_str("The tariff is not active for `Cdr::start_date_time`"),
100            Self::Number(warning_kind) => write!(f, "{warning_kind}"),
101            Self::TotalCostClampedToMin => write!(
102                f,
103                "The given tariff has a `min_price` set and the `total_cost` fell below it."
104            ),
105            Self::TotalCostClampedToMax => write!(
106                f,
107                "The given tariff has a `max_price` set and the `total_cost` exceeded it."
108            ),
109            Self::Weekday(warning_kind) => write!(f, "{warning_kind}"),
110        }
111    }
112}
113
114impl warning::Kind for WarningKind {
115    fn id(&self) -> Cow<'static, str> {
116        match self {
117            Self::String(kind) => kind.id(),
118            Self::Country(kind) => kind.id(),
119            Self::Currency(kind) => kind.id(),
120            Self::DateTime(kind) => kind.id(),
121            Self::Decode(kind) => kind.id(),
122            Self::Duration(kind) => kind.id(),
123            Self::FieldInvalidType { .. } => "field_invalid_type".into(),
124            Self::FieldInvalidValue { .. } => "field_invalid_value".into(),
125            Self::FieldRequired { .. } => "field_required".into(),
126            Self::Money(kind) => kind.id(),
127            Self::NoElements => "no_elements".into(),
128            Self::NotActive => "not_active".into(),
129            Self::Number(kind) => kind.id(),
130            Self::TotalCostClampedToMin => "total_cost_clamped_to_min".into(),
131            Self::TotalCostClampedToMax => "total_cost_clamped_to_max".into(),
132            Self::Weekday(kind) => kind.id(),
133        }
134    }
135}
136
137from_warning_all!(
138    country::WarningKind => WarningKind::Country,
139    currency::WarningKind => WarningKind::Currency,
140    datetime::WarningKind => WarningKind::DateTime,
141    duration::WarningKind => WarningKind::Duration,
142    json::decode::WarningKind => WarningKind::Decode,
143    money::WarningKind => WarningKind::Money,
144    number::WarningKind => WarningKind::Number,
145    string::WarningKind => WarningKind::String,
146    weekday::WarningKind => WarningKind::Weekday
147);
148
149/// Parse a `&str` into a [`Versioned`] tariff using a schema for the given [`Version`][^spec-v211][^spec-v221] to check for
150/// any unexpected fields.
151///
152/// # Example
153///
154/// ```rust
155/// # use ocpi_tariffs::{tariff, Version, ParseError};
156/// #
157/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
158///
159/// let report = tariff::parse_with_version(TARIFF_JSON, Version::V211)?;
160/// let tariff::ParseReport {
161///     tariff,
162///     unexpected_fields,
163/// } = report;
164///
165/// if !unexpected_fields.is_empty() {
166///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
167///
168///     for path in &unexpected_fields {
169///         eprintln!("{path}");
170///     }
171/// }
172///
173/// # Ok::<(), ParseError>(())
174/// ```
175///
176/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
177/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
178pub fn parse_with_version(source: &str, version: Version) -> Result<ParseReport<'_>, ParseError> {
179    match version {
180        Version::V221 => {
181            let schema = &*crate::v221::TARIFF_SCHEMA;
182            let report =
183                json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
184            let json::ParseReport {
185                element,
186                unexpected_fields,
187            } = report;
188            Ok(ParseReport {
189                tariff: Versioned::new(source, element, Version::V221),
190                unexpected_fields,
191            })
192        }
193        Version::V211 => {
194            let schema = &*crate::v211::TARIFF_SCHEMA;
195            let report =
196                json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
197            let json::ParseReport {
198                element,
199                unexpected_fields,
200            } = report;
201            Ok(ParseReport {
202                tariff: Versioned::new(source, element, Version::V211),
203                unexpected_fields,
204            })
205        }
206    }
207}
208
209/// Parse the JSON and try to guess the [`Version`] based on fields defined in the
210/// OCPI v2.1.1[^spec-v211] and v2.2.1[^spec-v221] tariff spec.
211///
212/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
213/// The parser will also not complain if unexpected fields are present in the JSON.
214/// The [`Version`] guess is based on fields that exist.
215///
216/// # Example
217///
218/// ```rust
219/// # use ocpi_tariffs::{tariff, guess, ParseError, Version, Versioned as _};
220/// #
221/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
222/// let tariff = tariff::parse(TARIFF_JSON)?;
223///
224/// match tariff {
225///     guess::Version::Certain(tariff) => {
226///         println!("The tariff version is `{}`", tariff.version());
227///     },
228///     guess::Version::Uncertain(_tariff) => {
229///         eprintln!("Unable to guess the version of given tariff JSON.");
230///     }
231/// }
232///
233/// # Ok::<(), ParseError>(())
234/// ```
235///
236/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
237/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
238pub fn parse(tariff_json: &str) -> Result<guess::TariffVersion<'_>, ParseError> {
239    guess::tariff_version(tariff_json)
240}
241
242/// Guess the [`Version`][^spec-v211][^spec-v221] of the given tariff JSON and report on any unexpected fields.
243///
244/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
245/// The parser will also not complain if unexpected fields are present in the JSON.
246/// The [`Version`] guess is based on fields that exist.
247///
248/// # Example
249///
250/// ```rust
251/// # use ocpi_tariffs::{guess, tariff, warning};
252/// #
253/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
254///
255/// let report = tariff::parse_and_report(TARIFF_JSON)?;
256/// let guess::Report {
257///     unexpected_fields,
258///     version,
259/// } = report;
260///
261/// if !unexpected_fields.is_empty() {
262///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
263///
264///     for path in &unexpected_fields {
265///         eprintln!("  * {path}");
266///     }
267///
268///     eprintln!();
269/// }
270///
271/// let guess::Version::Certain(tariff) = version else {
272///     return Err("Unable to guess the version of given CDR JSON.".into());
273/// };
274///
275/// let report = tariff::lint(&tariff)?;
276///
277/// eprintln!("`{}` lint warnings found", report.warnings.len());
278///
279/// for warning::Group { element, warnings } in report.warnings.group_by_elem(tariff.as_element()) {
280///     eprintln!(
281///         "Warnings reported for `json::Element` at path: `{}`",
282///         element.path()
283///     );
284///
285///     for warning in warnings {
286///         eprintln!("  * {warning}");
287///     }
288///
289///     eprintln!();
290/// }
291///
292/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
293/// ```
294///
295/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
296/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
297pub fn parse_and_report(tariff_json: &str) -> Result<guess::TariffReport<'_>, ParseError> {
298    guess::tariff_version_with_report(tariff_json)
299}
300
301/// A [`Versioned`] tariff along with a set of unexpected fields.
302#[derive(Debug)]
303pub struct ParseReport<'buf> {
304    /// The root JSON `Element`.
305    pub tariff: Versioned<'buf>,
306
307    /// A list of fields that were not expected: The schema did not define them.
308    pub unexpected_fields: json::UnexpectedFields<'buf>,
309}
310
311/// A `json::Element` that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
312/// and has been identified as being a certain [`Version`].
313#[derive(Clone)]
314pub struct Versioned<'buf> {
315    /// The source JSON as string.
316    source: &'buf str,
317
318    /// The parsed JSON as structured [`Element`](crate::json::Element)s.
319    element: json::Element<'buf>,
320
321    /// The `Version` of the tariff, determined during parsing.
322    version: Version,
323}
324
325impl fmt::Debug for Versioned<'_> {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        if f.alternate() {
328            fmt::Debug::fmt(&self.element, f)
329        } else {
330            match self.version {
331                Version::V211 => f.write_str("V211"),
332                Version::V221 => f.write_str("V221"),
333            }
334        }
335    }
336}
337
338impl crate::Versioned for Versioned<'_> {
339    fn version(&self) -> Version {
340        self.version
341    }
342}
343
344impl<'buf> Versioned<'buf> {
345    pub(crate) fn new(source: &'buf str, element: json::Element<'buf>, version: Version) -> Self {
346        Self {
347            source,
348            element,
349            version,
350        }
351    }
352
353    /// Return the inner [`json::Element`] and discard the version info.
354    pub fn into_element(self) -> json::Element<'buf> {
355        self.element
356    }
357
358    /// Return the inner [`json::Element`] and discard the version info.
359    pub fn as_element(&self) -> &json::Element<'buf> {
360        &self.element
361    }
362
363    /// Return the inner JSON `str` and discard the version info.
364    pub fn as_json_str(&self) -> &'buf str {
365        self.source
366    }
367}
368
369/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
370/// and was determined to not be one of the supported [`Version`]s.
371#[derive(Debug)]
372pub struct Unversioned<'buf> {
373    /// The source JSON as string.
374    source: &'buf str,
375
376    /// A list of fields that were not expected: The schema did not define them.
377    element: json::Element<'buf>,
378}
379
380impl<'buf> Unversioned<'buf> {
381    /// Create an unversioned [`json::Element`].
382    pub(crate) fn new(source: &'buf str, elem: json::Element<'buf>) -> Self {
383        Self {
384            source,
385            element: elem,
386        }
387    }
388
389    /// Return the inner [`json::Element`] and discard the version info.
390    pub fn into_element(self) -> json::Element<'buf> {
391        self.element
392    }
393
394    /// Return the inner [`json::Element`] and discard the version info.
395    pub fn as_element(&self) -> &json::Element<'buf> {
396        &self.element
397    }
398
399    /// Return the inner JSON `&str` and discard the version info.
400    pub fn as_json_str(&self) -> &'buf str {
401        self.source
402    }
403}
404
405impl<'buf> crate::Unversioned for Unversioned<'buf> {
406    type Versioned = Versioned<'buf>;
407
408    fn force_into_versioned(self, version: Version) -> Versioned<'buf> {
409        let Self { source, element } = self;
410        Versioned {
411            source,
412            element,
413            version,
414        }
415    }
416}
417
418/// Lint the given tariff and return a [`lint::tariff::Report`] of any `Warning`s found.
419///
420/// # Example
421///
422/// ```rust
423/// # use ocpi_tariffs::{guess, tariff, warning};
424/// #
425/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
426///
427/// let report = tariff::parse_and_report(TARIFF_JSON)?;
428/// let guess::Report {
429///     unexpected_fields,
430///     version,
431/// } = report;
432///
433/// if !unexpected_fields.is_empty() {
434///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
435///
436///     for path in &unexpected_fields {
437///         eprintln!("  * {path}");
438///     }
439///
440///     eprintln!();
441/// }
442///
443/// let guess::Version::Certain(tariff) = version else {
444///     return Err("Unable to guess the version of given CDR JSON.".into());
445/// };
446///
447/// let report = tariff::lint(&tariff)?;
448///
449/// eprintln!("`{}` lint warnings found", report.warnings.len());
450///
451/// for warning::Group { element, warnings } in report.warnings.group_by_elem(tariff.as_element()) {
452///     eprintln!(
453///         "Warnings reported for `json::Element` at path: `{}`",
454///         element.path()
455///     );
456///
457///     for warning in warnings {
458///         eprintln!("  * {warning}");
459///     }
460///
461///     eprintln!();
462/// }
463///
464/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
465/// ```
466pub fn lint(tariff: &Versioned<'_>) -> Result<lint::tariff::Report, lint::Error> {
467    lint::tariff(tariff)
468}
469
470#[cfg(test)]
471mod test_real_world {
472    use std::path::Path;
473
474    use assert_matches::assert_matches;
475
476    use crate::{guess, test, Version, Versioned as _};
477
478    use super::{parse_and_report, test::assert_parse_guess_report};
479
480    #[test_each::file(
481        glob = "ocpi-tariffs/test_data/v211/real_world/*/tariff*.json",
482        name(segments = 2)
483    )]
484    fn test_parse_v211(tariff_json: &str, path: &Path) {
485        test::setup();
486        expect_version(tariff_json, path, Version::V211);
487    }
488
489    #[test_each::file(
490        glob = "ocpi-tariffs/test_data/v221/real_world/*/tariff*.json",
491        name(segments = 2)
492    )]
493    fn test_parse_v221(tariff_json: &str, path: &Path) {
494        test::setup();
495        expect_version(tariff_json, path, Version::V221);
496    }
497
498    /// Parse the given JSON as a tariff and generate a report on the unexpected fields.
499    fn expect_version(tariff_json: &str, path: &Path, expected_version: Version) {
500        let report = parse_and_report(tariff_json).unwrap();
501
502        let expect_json = test::read_expect_json(path, "parse");
503        let parse_expect = test::parse_expect_json(expect_json.as_deref());
504
505        let tariff = assert_matches!(&report.version, guess::Version::Certain(tariff) => tariff);
506        assert_eq!(tariff.version(), expected_version);
507
508        assert_parse_guess_report(report, parse_expect);
509    }
510}
511
512#[cfg(test)]
513pub mod test {
514    #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
515    #![allow(clippy::panic, reason = "tests are allowed panic")]
516
517    use std::collections::BTreeMap;
518
519    use crate::{
520        guess, json,
521        test::{ExpectFile, Expectation},
522        warning,
523    };
524
525    /// Expectations for the result of calling `json::parse_and_report`.
526    #[derive(Debug, serde::Deserialize)]
527    pub struct ParseExpect {
528        #[serde(default)]
529        unexpected_fields: Expectation<Vec<json::test::PathGlob>>,
530    }
531
532    /// Expectations for the result of calling `json::parse_and_report`.
533    #[derive(Debug, serde::Deserialize)]
534    pub struct FromJsonExpect {
535        #[serde(default)]
536        warnings: Expectation<BTreeMap<String, Vec<String>>>,
537    }
538
539    /// Assert that the `TariffReport` resulting from the call to `tariff::parse_and_report`
540    /// matches the expectations of the `ParseExpect` file.
541    #[track_caller]
542    pub(crate) fn assert_parse_guess_report(
543        report: guess::TariffReport<'_>,
544        expect: ExpectFile<ParseExpect>,
545    ) -> guess::TariffVersion<'_> {
546        let guess::Report {
547            version,
548            mut unexpected_fields,
549        } = report;
550
551        let ExpectFile {
552            value: expect,
553            expect_file_name,
554        } = expect;
555
556        let Some(expect) = expect else {
557            json::test::expect_no_unexpected_fields(&expect_file_name, &unexpected_fields);
558            return version;
559        };
560
561        let ParseExpect {
562            unexpected_fields: expected,
563        } = expect;
564
565        json::test::expect_unexpected_fields(&expect_file_name, &mut unexpected_fields, expected);
566
567        version
568    }
569
570    /// Assert that the unexpected fields resulting from the call to [`tariff::parse_with_version`](crate::tariff::parse_with_version)
571    /// match the expectations of the `ParseExpect` file.
572    #[track_caller]
573    pub(crate) fn assert_parse_report(
574        mut unexpected_fields: json::UnexpectedFields<'_>,
575        expect: ExpectFile<ParseExpect>,
576    ) {
577        let ExpectFile {
578            value,
579            expect_file_name,
580        } = expect;
581
582        let Some(ParseExpect {
583            unexpected_fields: expected,
584        }) = value
585        else {
586            json::test::expect_no_unexpected_fields(&expect_file_name, &unexpected_fields);
587            return;
588        };
589
590        json::test::expect_unexpected_fields(&expect_file_name, &mut unexpected_fields, expected);
591    }
592
593    pub(crate) fn assert_from_json_warnings(
594        root: &json::Element<'_>,
595        warnings: &warning::Set<super::WarningKind>,
596        expect: ExpectFile<FromJsonExpect>,
597    ) {
598        let ExpectFile {
599            value,
600            expect_file_name,
601        } = expect;
602
603        // If there are warnings reported and there is no `expect` file
604        // then panic printing the fields of the expect JSON object that would silence these warnings.
605        // These can be copied into an `output_lint__*.json` file.
606        let Some(expect) = value else {
607            assert!(
608                warnings.is_empty(),
609                "There is no expectation file at `{expect_file_name}` but the tariff has warnings;\n{:?}",
610                warnings.group_by_elem(root).into_id_map()
611            );
612            return;
613        };
614
615        warning::test::assert_warnings(&expect_file_name, root, warnings, expect.warnings);
616    }
617}