Skip to main content

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