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