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, ObjectType, ParseError, ReasonableStr, 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(
204    tariff_json: &str,
205    version: Version,
206) -> Result<ParseReport<'_>, ParseError> {
207    let tariff_json =
208        ReasonableStr::new(tariff_json).map_err(ParseError::from_kind(ObjectType::Tariff))?;
209    match version {
210        Version::V221 => {
211            let schema = &*crate::v221::TARIFF_SCHEMA;
212            let report =
213                json::parse_with_schema(tariff_json, schema).map_err(ParseError::from_cdr_err)?;
214            let json::ParseReport {
215                element,
216                unexpected_fields,
217            } = report;
218            Ok(ParseReport {
219                tariff: Versioned::new(tariff_json.into_inner(), element, Version::V221),
220                unexpected_fields,
221            })
222        }
223        Version::V211 => {
224            let schema = &*crate::v211::TARIFF_SCHEMA;
225            let report =
226                json::parse_with_schema(tariff_json, schema).map_err(ParseError::from_cdr_err)?;
227            let json::ParseReport {
228                element,
229                unexpected_fields,
230            } = report;
231            Ok(ParseReport {
232                tariff: Versioned::new(tariff_json.into_inner(), element, Version::V211),
233                unexpected_fields,
234            })
235        }
236    }
237}
238
239/// Parse the JSON and try to guess the [`Version`] based on fields defined in the
240/// OCPI v2.1.1[^spec-v211] and v2.2.1[^spec-v221] tariff spec.
241///
242/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
243/// The parser will also not complain if unexpected fields are present in the JSON.
244/// The [`Version`] guess is based on fields that exist.
245///
246/// # Example
247///
248/// ```rust
249/// # use ocpi_tariffs::{tariff, guess, ParseError, Version, Versioned as _};
250/// #
251/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
252/// let tariff = tariff::parse(TARIFF_JSON)?;
253///
254/// match tariff {
255///     guess::Version::Certain(tariff) => {
256///         println!("The tariff version is `{}`", tariff.version());
257///     },
258///     guess::Version::Uncertain(_tariff) => {
259///         eprintln!("Unable to guess the version of given tariff JSON.");
260///     }
261/// }
262///
263/// # Ok::<(), ParseError>(())
264/// ```
265///
266/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
267/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
268pub fn parse(tariff_json: &str) -> Result<guess::TariffVersion<'_>, ParseError> {
269    let tariff_json =
270        ReasonableStr::new(tariff_json).map_err(ParseError::from_kind(ObjectType::Tariff))?;
271    guess::tariff_version(tariff_json)
272}
273
274/// Guess the [`Version`][^spec-v211][^spec-v221] of the given tariff JSON and report on any unexpected fields.
275///
276/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
277/// The parser will also not complain if unexpected fields are present in the JSON.
278/// The [`Version`] guess is based on fields that exist.
279///
280/// # Example
281///
282/// ```rust
283/// # use ocpi_tariffs::{guess, tariff, warning};
284/// #
285/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
286///
287/// let report = tariff::parse_and_report(TARIFF_JSON)?;
288/// let guess::Report {
289///     unexpected_fields,
290///     version,
291/// } = report;
292///
293/// if !unexpected_fields.is_empty() {
294///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
295///
296///     for path in &unexpected_fields {
297///         eprintln!("  * {path}");
298///     }
299///
300///     eprintln!();
301/// }
302///
303/// let guess::Version::Certain(tariff) = version else {
304///     return Err("Unable to guess the version of given CDR JSON.".into());
305/// };
306///
307/// let report = tariff::lint(&tariff);
308///
309/// eprintln!("`{}` lint warnings found", report.warnings.len_warnings());
310///
311/// for group in report.warnings {
312///     let (element, warnings) = group.to_parts();
313///     eprintln!(
314///         "Warnings reported for `json::Element` at path: `{}`",
315///         element.path()
316///     );
317///
318///     for warning in warnings {
319///         eprintln!("  * {warning}");
320///     }
321///
322///     eprintln!();
323/// }
324///
325/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
326/// ```
327///
328/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
329/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
330pub fn parse_and_report(tariff_json: &str) -> Result<guess::TariffReport<'_>, ParseError> {
331    let tariff_json =
332        ReasonableStr::new(tariff_json).map_err(ParseError::from_kind(ObjectType::Tariff))?;
333    guess::tariff_version_with_report(tariff_json)
334}
335
336/// A [`Versioned`] tariff along with a set of unexpected fields.
337#[derive(Debug)]
338pub struct ParseReport<'buf> {
339    /// The root JSON `Element`.
340    pub tariff: Versioned<'buf>,
341
342    /// A list of fields that were not expected: The schema did not define them.
343    pub unexpected_fields: json::UnexpectedFields<'buf>,
344}
345
346/// A `json::Element` that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
347/// and has been identified as being a certain [`Version`].
348#[derive(Clone)]
349pub struct Versioned<'buf> {
350    /// The source JSON as string.
351    source: &'buf str,
352
353    /// The parsed JSON as structured [`Element`](crate::json::Element)s.
354    element: json::Element<'buf>,
355
356    /// The `Version` of the tariff, determined during parsing.
357    version: Version,
358}
359
360impl fmt::Debug for Versioned<'_> {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        if f.alternate() {
363            fmt::Debug::fmt(&self.element, f)
364        } else {
365            match self.version {
366                Version::V211 => f.write_str("V211"),
367                Version::V221 => f.write_str("V221"),
368            }
369        }
370    }
371}
372
373impl crate::Versioned for Versioned<'_> {
374    fn version(&self) -> Version {
375        self.version
376    }
377}
378
379impl<'buf> Versioned<'buf> {
380    pub(crate) fn new(source: &'buf str, element: json::Element<'buf>, version: Version) -> Self {
381        Self {
382            source,
383            element,
384            version,
385        }
386    }
387
388    /// Return the inner [`json::Element`] and discard the version info.
389    pub fn into_element(self) -> json::Element<'buf> {
390        self.element
391    }
392
393    /// Return the inner [`json::Element`] and discard the version info.
394    pub fn as_element(&self) -> &json::Element<'buf> {
395        &self.element
396    }
397
398    /// Return the inner JSON `str` and discard the version info.
399    pub fn as_json_str(&self) -> &'buf str {
400        self.source
401    }
402}
403
404/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
405/// and was determined to not be one of the supported [`Version`]s.
406#[derive(Debug)]
407pub struct Unversioned<'buf> {
408    /// The source JSON as string.
409    source: &'buf str,
410
411    /// A list of fields that were not expected: The schema did not define them.
412    element: json::Element<'buf>,
413}
414
415impl<'buf> Unversioned<'buf> {
416    /// Create an unversioned [`json::Element`].
417    pub(crate) fn new(source: &'buf str, elem: json::Element<'buf>) -> Self {
418        Self {
419            source,
420            element: elem,
421        }
422    }
423
424    /// Return the inner [`json::Element`] and discard the version info.
425    pub fn into_element(self) -> json::Element<'buf> {
426        self.element
427    }
428
429    /// Return the inner [`json::Element`] and discard the version info.
430    pub fn as_element(&self) -> &json::Element<'buf> {
431        &self.element
432    }
433
434    /// Return the inner JSON `&str` and discard the version info.
435    pub fn as_json_str(&self) -> &'buf str {
436        self.source
437    }
438}
439
440impl<'buf> crate::Unversioned for Unversioned<'buf> {
441    type Versioned = Versioned<'buf>;
442
443    fn force_into_versioned(self, version: Version) -> Versioned<'buf> {
444        let Self { source, element } = self;
445        Versioned {
446            source,
447            element,
448            version,
449        }
450    }
451}
452
453/// Lint the given tariff and return a [`lint::tariff::Report`] of any `Warning`s found.
454///
455/// # Example
456///
457/// ```rust
458/// # use ocpi_tariffs::{guess, tariff, warning};
459/// #
460/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
461///
462/// let report = tariff::parse_and_report(TARIFF_JSON)?;
463/// let guess::Report {
464///     unexpected_fields,
465///     version,
466/// } = report;
467///
468/// if !unexpected_fields.is_empty() {
469///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
470///
471///     for path in &unexpected_fields {
472///         eprintln!("  * {path}");
473///     }
474///
475///     eprintln!();
476/// }
477///
478/// let guess::Version::Certain(tariff) = version else {
479///     return Err("Unable to guess the version of given CDR JSON.".into());
480/// };
481///
482/// let report = tariff::lint(&tariff);
483///
484/// eprintln!("`{}` lint warnings found", report.warnings.len_warnings());
485///
486/// for group in report.warnings {
487///     let (element, warnings) = group.to_parts();
488///     eprintln!(
489///         "Warnings reported for `json::Element` at path: `{}`",
490///         element.path()
491///     );
492///
493///     for warning in warnings {
494///         eprintln!("  * {warning}");
495///     }
496///
497///     eprintln!();
498/// }
499///
500/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
501/// ```
502pub fn lint(tariff: &Versioned<'_>) -> lint::tariff::Report {
503    lint::tariff(tariff)
504}