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}