Skip to main content

ocpi_tariffs/
tariff.rs

1//! Parse a tariff and lint the result.
2
3#[cfg(test)]
4pub(crate) mod test;
5
6#[cfg(test)]
7mod test_real_world;
8
9pub(crate) mod v211;
10pub(crate) mod v221;
11pub(crate) mod v2x;
12
13use std::{borrow::Cow, fmt};
14
15use crate::{
16    country, currency, datetime, duration, from_warning_all, guess, json, lint, money, number,
17    string, warning, weekday, ParseError, Version,
18};
19
20#[derive(Debug)]
21pub enum Warning {
22    /// The CDR location is not a valid ISO 3166-1 alpha-3 code.
23    Country(country::Warning),
24    Currency(currency::Warning),
25    DateTime(datetime::Warning),
26    Decode(json::decode::Warning),
27    Duration(duration::Warning),
28
29    /// A field in the tariff doesn't have the expected type.
30    FieldInvalidType {
31        /// The type that the given field should have according to the schema.
32        expected_type: json::ValueKind,
33    },
34
35    /// A field in the tariff doesn't have the expected value.
36    FieldInvalidValue {
37        /// The value encountered.
38        value: String,
39
40        /// A message about what values are expected for this field.
41        message: Cow<'static, str>,
42    },
43
44    /// The given field is required.
45    FieldRequired {
46        field_name: Cow<'static, str>,
47    },
48
49    Money(money::Warning),
50
51    /// The given tariff has a `min_price` set and the `total_cost` fell below it.
52    ///
53    /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
54    TotalCostClampedToMin,
55
56    /// The given tariff has a `max_price` set and the `total_cost` exceeded it.
57    ///
58    /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
59    TotalCostClampedToMax,
60
61    /// The tariff has no `Element`s.
62    NoElements,
63
64    /// The tariff is not active during the `Cdr::start_date_time`.
65    NotActive,
66    Number(number::Warning),
67
68    String(string::Warning),
69    Weekday(weekday::Warning),
70}
71
72impl Warning {
73    /// Create a new `Warning::FieldInvalidValue` where the field is built from the given `json::Element`.
74    fn field_invalid_value(
75        value: impl Into<String>,
76        message: impl Into<Cow<'static, str>>,
77    ) -> Self {
78        Warning::FieldInvalidValue {
79            value: value.into(),
80            message: message.into(),
81        }
82    }
83}
84
85impl fmt::Display for Warning {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::String(warning_kind) => write!(f, "{warning_kind}"),
89            Self::Country(warning_kind) => write!(f, "{warning_kind}"),
90            Self::Currency(warning_kind) => write!(f, "{warning_kind}"),
91            Self::DateTime(warning_kind) => write!(f, "{warning_kind}"),
92            Self::Decode(warning_kind) => write!(f, "{warning_kind}"),
93            Self::Duration(warning_kind) => write!(f, "{warning_kind}"),
94            Self::FieldInvalidType { expected_type } => {
95                write!(f, "Field has invalid type. Expected type `{expected_type}`")
96            }
97            Self::FieldInvalidValue { value, message } => {
98                write!(f, "Field has invalid value `{value}`: {message}")
99            }
100            Self::FieldRequired { field_name } => {
101                write!(f, "Field is required: `{field_name}`")
102            }
103            Self::Money(warning_kind) => write!(f, "{warning_kind}"),
104            Self::NoElements => f.write_str("The tariff has no `elements`"),
105            Self::NotActive => f.write_str("The tariff is not active for `Cdr::start_date_time`"),
106            Self::Number(warning_kind) => write!(f, "{warning_kind}"),
107            Self::TotalCostClampedToMin => write!(
108                f,
109                "The given tariff has a `min_price` set and the `total_cost` fell below it."
110            ),
111            Self::TotalCostClampedToMax => write!(
112                f,
113                "The given tariff has a `max_price` set and the `total_cost` exceeded it."
114            ),
115            Self::Weekday(warning_kind) => write!(f, "{warning_kind}"),
116        }
117    }
118}
119
120impl crate::Warning for Warning {
121    fn id(&self) -> warning::Id {
122        match self {
123            Self::String(kind) => kind.id(),
124            Self::Country(kind) => kind.id(),
125            Self::Currency(kind) => kind.id(),
126            Self::DateTime(kind) => kind.id(),
127            Self::Decode(kind) => kind.id(),
128            Self::Duration(kind) => kind.id(),
129            Self::FieldInvalidType { expected_type } => {
130                warning::Id::from_string(format!("field_invalid_type({expected_type})"))
131            }
132            Self::FieldInvalidValue { value, .. } => {
133                warning::Id::from_string(format!("field_invalid_value({value})"))
134            }
135            Self::FieldRequired { field_name } => {
136                warning::Id::from_string(format!("field_required({field_name})"))
137            }
138            Self::Money(kind) => kind.id(),
139            Self::NoElements => warning::Id::from_static("no_elements"),
140            Self::NotActive => warning::Id::from_static("not_active"),
141            Self::Number(kind) => kind.id(),
142            Self::TotalCostClampedToMin => warning::Id::from_static("total_cost_clamped_to_min"),
143            Self::TotalCostClampedToMax => warning::Id::from_static("total_cost_clamped_to_max"),
144            Self::Weekday(kind) => kind.id(),
145        }
146    }
147}
148
149from_warning_all!(
150    country::Warning => Warning::Country,
151    currency::Warning => Warning::Currency,
152    datetime::Warning => Warning::DateTime,
153    duration::Warning => Warning::Duration,
154    json::decode::Warning => Warning::Decode,
155    money::Warning => Warning::Money,
156    number::Warning => Warning::Number,
157    string::Warning => Warning::String,
158    weekday::Warning => Warning::Weekday
159);
160
161/// Parse a `&str` into a [`Versioned`] tariff using a schema for the given [`Version`][^spec-v211][^spec-v221] to check for
162/// any unexpected fields.
163///
164/// # Example
165///
166/// ```rust
167/// # use ocpi_tariffs::{tariff, Version, ParseError};
168/// #
169/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
170///
171/// let report = tariff::parse_with_version(TARIFF_JSON, Version::V211)?;
172/// let tariff::ParseReport {
173///     tariff,
174///     unexpected_fields,
175/// } = report;
176///
177/// if !unexpected_fields.is_empty() {
178///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
179///
180///     for path in &unexpected_fields {
181///         eprintln!("{path}");
182///     }
183/// }
184///
185/// # Ok::<(), ParseError>(())
186/// ```
187///
188/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
189/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
190pub fn parse_with_version(source: &str, version: Version) -> Result<ParseReport<'_>, ParseError> {
191    match version {
192        Version::V221 => {
193            let schema = &*crate::v221::TARIFF_SCHEMA;
194            let report =
195                json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
196            let json::ParseReport {
197                element,
198                unexpected_fields,
199            } = report;
200            Ok(ParseReport {
201                tariff: Versioned::new(source, element, Version::V221),
202                unexpected_fields,
203            })
204        }
205        Version::V211 => {
206            let schema = &*crate::v211::TARIFF_SCHEMA;
207            let report =
208                json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
209            let json::ParseReport {
210                element,
211                unexpected_fields,
212            } = report;
213            Ok(ParseReport {
214                tariff: Versioned::new(source, element, Version::V211),
215                unexpected_fields,
216            })
217        }
218    }
219}
220
221/// Parse the JSON and try to guess the [`Version`] based on fields defined in the
222/// OCPI v2.1.1[^spec-v211] and v2.2.1[^spec-v221] tariff spec.
223///
224/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
225/// The parser will also not complain if unexpected fields are present in the JSON.
226/// The [`Version`] guess is based on fields that exist.
227///
228/// # Example
229///
230/// ```rust
231/// # use ocpi_tariffs::{tariff, guess, ParseError, Version, Versioned as _};
232/// #
233/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
234/// let tariff = tariff::parse(TARIFF_JSON)?;
235///
236/// match tariff {
237///     guess::Version::Certain(tariff) => {
238///         println!("The tariff version is `{}`", tariff.version());
239///     },
240///     guess::Version::Uncertain(_tariff) => {
241///         eprintln!("Unable to guess the version of given tariff JSON.");
242///     }
243/// }
244///
245/// # Ok::<(), ParseError>(())
246/// ```
247///
248/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
249/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
250pub fn parse(tariff_json: &str) -> Result<guess::TariffVersion<'_>, ParseError> {
251    guess::tariff_version(tariff_json)
252}
253
254/// Guess the [`Version`][^spec-v211][^spec-v221] of the given tariff JSON and report on any unexpected fields.
255///
256/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
257/// The parser will also not complain if unexpected fields are present in the JSON.
258/// The [`Version`] guess is based on fields that exist.
259///
260/// # Example
261///
262/// ```rust
263/// # use ocpi_tariffs::{guess, tariff, warning};
264/// #
265/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
266///
267/// let report = tariff::parse_and_report(TARIFF_JSON)?;
268/// let guess::Report {
269///     unexpected_fields,
270///     version,
271/// } = report;
272///
273/// if !unexpected_fields.is_empty() {
274///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
275///
276///     for path in &unexpected_fields {
277///         eprintln!("  * {path}");
278///     }
279///
280///     eprintln!();
281/// }
282///
283/// let guess::Version::Certain(tariff) = version else {
284///     return Err("Unable to guess the version of given CDR JSON.".into());
285/// };
286///
287/// let report = tariff::lint(&tariff);
288///
289/// eprintln!("`{}` lint warnings found", report.warnings.len_warnings());
290///
291/// for group in report.warnings {
292///     let (element, warnings) = group.to_parts();
293///     eprintln!(
294///         "Warnings reported for `json::Element` at path: `{}`",
295///         element.path()
296///     );
297///
298///     for warning in warnings {
299///         eprintln!("  * {warning}");
300///     }
301///
302///     eprintln!();
303/// }
304///
305/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
306/// ```
307///
308/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
309/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
310pub fn parse_and_report(tariff_json: &str) -> Result<guess::TariffReport<'_>, ParseError> {
311    guess::tariff_version_with_report(tariff_json)
312}
313
314/// A [`Versioned`] tariff along with a set of unexpected fields.
315#[derive(Debug)]
316pub struct ParseReport<'buf> {
317    /// The root JSON `Element`.
318    pub tariff: Versioned<'buf>,
319
320    /// A list of fields that were not expected: The schema did not define them.
321    pub unexpected_fields: json::UnexpectedFields<'buf>,
322}
323
324/// A `json::Element` that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
325/// and has been identified as being a certain [`Version`].
326#[derive(Clone)]
327pub struct Versioned<'buf> {
328    /// The source JSON as string.
329    source: &'buf str,
330
331    /// The parsed JSON as structured [`Element`](crate::json::Element)s.
332    element: json::Element<'buf>,
333
334    /// The `Version` of the tariff, determined during parsing.
335    version: Version,
336}
337
338impl fmt::Debug for Versioned<'_> {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        if f.alternate() {
341            fmt::Debug::fmt(&self.element, f)
342        } else {
343            match self.version {
344                Version::V211 => f.write_str("V211"),
345                Version::V221 => f.write_str("V221"),
346            }
347        }
348    }
349}
350
351impl crate::Versioned for Versioned<'_> {
352    fn version(&self) -> Version {
353        self.version
354    }
355}
356
357impl<'buf> Versioned<'buf> {
358    pub(crate) fn new(source: &'buf str, element: json::Element<'buf>, version: Version) -> Self {
359        Self {
360            source,
361            element,
362            version,
363        }
364    }
365
366    /// Return the inner [`json::Element`] and discard the version info.
367    pub fn into_element(self) -> json::Element<'buf> {
368        self.element
369    }
370
371    /// Return the inner [`json::Element`] and discard the version info.
372    pub fn as_element(&self) -> &json::Element<'buf> {
373        &self.element
374    }
375
376    /// Return the inner JSON `str` and discard the version info.
377    pub fn as_json_str(&self) -> &'buf str {
378        self.source
379    }
380}
381
382/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
383/// and was determined to not be one of the supported [`Version`]s.
384#[derive(Debug)]
385pub struct Unversioned<'buf> {
386    /// The source JSON as string.
387    source: &'buf str,
388
389    /// A list of fields that were not expected: The schema did not define them.
390    element: json::Element<'buf>,
391}
392
393impl<'buf> Unversioned<'buf> {
394    /// Create an unversioned [`json::Element`].
395    pub(crate) fn new(source: &'buf str, elem: json::Element<'buf>) -> Self {
396        Self {
397            source,
398            element: elem,
399        }
400    }
401
402    /// Return the inner [`json::Element`] and discard the version info.
403    pub fn into_element(self) -> json::Element<'buf> {
404        self.element
405    }
406
407    /// Return the inner [`json::Element`] and discard the version info.
408    pub fn as_element(&self) -> &json::Element<'buf> {
409        &self.element
410    }
411
412    /// Return the inner JSON `&str` and discard the version info.
413    pub fn as_json_str(&self) -> &'buf str {
414        self.source
415    }
416}
417
418impl<'buf> crate::Unversioned for Unversioned<'buf> {
419    type Versioned = Versioned<'buf>;
420
421    fn force_into_versioned(self, version: Version) -> Versioned<'buf> {
422        let Self { source, element } = self;
423        Versioned {
424            source,
425            element,
426            version,
427        }
428    }
429}
430
431/// Lint the given tariff and return a [`lint::tariff::Report`] of any `Warning`s found.
432///
433/// # Example
434///
435/// ```rust
436/// # use ocpi_tariffs::{guess, tariff, warning};
437/// #
438/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
439///
440/// let report = tariff::parse_and_report(TARIFF_JSON)?;
441/// let guess::Report {
442///     unexpected_fields,
443///     version,
444/// } = report;
445///
446/// if !unexpected_fields.is_empty() {
447///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
448///
449///     for path in &unexpected_fields {
450///         eprintln!("  * {path}");
451///     }
452///
453///     eprintln!();
454/// }
455///
456/// let guess::Version::Certain(tariff) = version else {
457///     return Err("Unable to guess the version of given CDR JSON.".into());
458/// };
459///
460/// let report = tariff::lint(&tariff);
461///
462/// eprintln!("`{}` lint warnings found", report.warnings.len_warnings());
463///
464/// for group in report.warnings {
465///     let (element, warnings) = group.to_parts();
466///     eprintln!(
467///         "Warnings reported for `json::Element` at path: `{}`",
468///         element.path()
469///     );
470///
471///     for warning in warnings {
472///         eprintln!("  * {warning}");
473///     }
474///
475///     eprintln!();
476/// }
477///
478/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
479/// ```
480pub fn lint(tariff: &Versioned<'_>) -> lint::tariff::Report {
481    lint::tariff(tariff)
482}