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