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 { .. } => warning::Id::from_static("field_invalid_type"),
130 Self::FieldInvalidValue { .. } => warning::Id::from_static("field_invalid_value"),
131 Self::FieldRequired { field_name } => {
132 warning::Id::from_string(format!("field_required({field_name})"))
133 }
134 Self::Money(kind) => kind.id(),
135 Self::NoElements => warning::Id::from_static("no_elements"),
136 Self::NotActive => warning::Id::from_static("not_active"),
137 Self::Number(kind) => kind.id(),
138 Self::TotalCostClampedToMin => warning::Id::from_static("total_cost_clamped_to_min"),
139 Self::TotalCostClampedToMax => warning::Id::from_static("total_cost_clamped_to_max"),
140 Self::Weekday(kind) => kind.id(),
141 }
142 }
143}
144
145from_warning_all!(
146 country::Warning => Warning::Country,
147 currency::Warning => Warning::Currency,
148 datetime::Warning => Warning::DateTime,
149 duration::Warning => Warning::Duration,
150 json::decode::Warning => Warning::Decode,
151 money::Warning => Warning::Money,
152 number::Warning => Warning::Number,
153 string::Warning => Warning::String,
154 weekday::Warning => Warning::Weekday
155);
156
157/// Parse a `&str` into a [`Versioned`] tariff using a schema for the given [`Version`][^spec-v211][^spec-v221] to check for
158/// any unexpected fields.
159///
160/// # Example
161///
162/// ```rust
163/// # use ocpi_tariffs::{tariff, Version, ParseError};
164/// #
165/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
166///
167/// let report = tariff::parse_with_version(TARIFF_JSON, Version::V211)?;
168/// let tariff::ParseReport {
169/// tariff,
170/// unexpected_fields,
171/// } = report;
172///
173/// if !unexpected_fields.is_empty() {
174/// eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
175///
176/// for path in &unexpected_fields {
177/// eprintln!("{path}");
178/// }
179/// }
180///
181/// # Ok::<(), ParseError>(())
182/// ```
183///
184/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
185/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
186pub fn parse_with_version(source: &str, version: Version) -> Result<ParseReport<'_>, ParseError> {
187 match version {
188 Version::V221 => {
189 let schema = &*crate::v221::TARIFF_SCHEMA;
190 let report =
191 json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
192 let json::ParseReport {
193 element,
194 unexpected_fields,
195 } = report;
196 Ok(ParseReport {
197 tariff: Versioned::new(source, element, Version::V221),
198 unexpected_fields,
199 })
200 }
201 Version::V211 => {
202 let schema = &*crate::v211::TARIFF_SCHEMA;
203 let report =
204 json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
205 let json::ParseReport {
206 element,
207 unexpected_fields,
208 } = report;
209 Ok(ParseReport {
210 tariff: Versioned::new(source, element, Version::V211),
211 unexpected_fields,
212 })
213 }
214 }
215}
216
217/// Parse the JSON and try to guess the [`Version`] based on fields defined in the
218/// OCPI v2.1.1[^spec-v211] and v2.2.1[^spec-v221] tariff spec.
219///
220/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
221/// The parser will also not complain if unexpected fields are present in the JSON.
222/// The [`Version`] guess is based on fields that exist.
223///
224/// # Example
225///
226/// ```rust
227/// # use ocpi_tariffs::{tariff, guess, ParseError, Version, Versioned as _};
228/// #
229/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
230/// let tariff = tariff::parse(TARIFF_JSON)?;
231///
232/// match tariff {
233/// guess::Version::Certain(tariff) => {
234/// println!("The tariff version is `{}`", tariff.version());
235/// },
236/// guess::Version::Uncertain(_tariff) => {
237/// eprintln!("Unable to guess the version of given tariff JSON.");
238/// }
239/// }
240///
241/// # Ok::<(), ParseError>(())
242/// ```
243///
244/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
245/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
246pub fn parse(tariff_json: &str) -> Result<guess::TariffVersion<'_>, ParseError> {
247 guess::tariff_version(tariff_json)
248}
249
250/// Guess the [`Version`][^spec-v211][^spec-v221] of the given tariff JSON and report on any unexpected fields.
251///
252/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
253/// The parser will also not complain if unexpected fields are present in the JSON.
254/// The [`Version`] guess is based on fields that exist.
255///
256/// # Example
257///
258/// ```rust
259/// # use ocpi_tariffs::{guess, tariff, warning};
260/// #
261/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
262///
263/// let report = tariff::parse_and_report(TARIFF_JSON)?;
264/// let guess::Report {
265/// unexpected_fields,
266/// version,
267/// } = report;
268///
269/// if !unexpected_fields.is_empty() {
270/// eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
271///
272/// for path in &unexpected_fields {
273/// eprintln!(" * {path}");
274/// }
275///
276/// eprintln!();
277/// }
278///
279/// let guess::Version::Certain(tariff) = version else {
280/// return Err("Unable to guess the version of given CDR JSON.".into());
281/// };
282///
283/// let report = tariff::lint(&tariff);
284///
285/// eprintln!("`{}` lint warnings found", report.warnings.len_warnings());
286///
287/// for group in report.warnings {
288/// let (element, warnings) = group.to_parts();
289/// eprintln!(
290/// "Warnings reported for `json::Element` at path: `{}`",
291/// element.path()
292/// );
293///
294/// for warning in warnings {
295/// eprintln!(" * {warning}");
296/// }
297///
298/// eprintln!();
299/// }
300///
301/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
302/// ```
303///
304/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
305/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
306pub fn parse_and_report(tariff_json: &str) -> Result<guess::TariffReport<'_>, ParseError> {
307 guess::tariff_version_with_report(tariff_json)
308}
309
310/// A [`Versioned`] tariff along with a set of unexpected fields.
311#[derive(Debug)]
312pub struct ParseReport<'buf> {
313 /// The root JSON `Element`.
314 pub tariff: Versioned<'buf>,
315
316 /// A list of fields that were not expected: The schema did not define them.
317 pub unexpected_fields: json::UnexpectedFields<'buf>,
318}
319
320/// A `json::Element` that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
321/// and has been identified as being a certain [`Version`].
322#[derive(Clone)]
323pub struct Versioned<'buf> {
324 /// The source JSON as string.
325 source: &'buf str,
326
327 /// The parsed JSON as structured [`Element`](crate::json::Element)s.
328 element: json::Element<'buf>,
329
330 /// The `Version` of the tariff, determined during parsing.
331 version: Version,
332}
333
334impl fmt::Debug for Versioned<'_> {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 if f.alternate() {
337 fmt::Debug::fmt(&self.element, f)
338 } else {
339 match self.version {
340 Version::V211 => f.write_str("V211"),
341 Version::V221 => f.write_str("V221"),
342 }
343 }
344 }
345}
346
347impl crate::Versioned for Versioned<'_> {
348 fn version(&self) -> Version {
349 self.version
350 }
351}
352
353impl<'buf> Versioned<'buf> {
354 pub(crate) fn new(source: &'buf str, element: json::Element<'buf>, version: Version) -> Self {
355 Self {
356 source,
357 element,
358 version,
359 }
360 }
361
362 /// Return the inner [`json::Element`] and discard the version info.
363 pub fn into_element(self) -> json::Element<'buf> {
364 self.element
365 }
366
367 /// Return the inner [`json::Element`] and discard the version info.
368 pub fn as_element(&self) -> &json::Element<'buf> {
369 &self.element
370 }
371
372 /// Return the inner JSON `str` and discard the version info.
373 pub fn as_json_str(&self) -> &'buf str {
374 self.source
375 }
376}
377
378/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
379/// and was determined to not be one of the supported [`Version`]s.
380#[derive(Debug)]
381pub struct Unversioned<'buf> {
382 /// The source JSON as string.
383 source: &'buf str,
384
385 /// A list of fields that were not expected: The schema did not define them.
386 element: json::Element<'buf>,
387}
388
389impl<'buf> Unversioned<'buf> {
390 /// Create an unversioned [`json::Element`].
391 pub(crate) fn new(source: &'buf str, elem: json::Element<'buf>) -> Self {
392 Self {
393 source,
394 element: elem,
395 }
396 }
397
398 /// Return the inner [`json::Element`] and discard the version info.
399 pub fn into_element(self) -> json::Element<'buf> {
400 self.element
401 }
402
403 /// Return the inner [`json::Element`] and discard the version info.
404 pub fn as_element(&self) -> &json::Element<'buf> {
405 &self.element
406 }
407
408 /// Return the inner JSON `&str` and discard the version info.
409 pub fn as_json_str(&self) -> &'buf str {
410 self.source
411 }
412}
413
414impl<'buf> crate::Unversioned for Unversioned<'buf> {
415 type Versioned = Versioned<'buf>;
416
417 fn force_into_versioned(self, version: Version) -> Versioned<'buf> {
418 let Self { source, element } = self;
419 Versioned {
420 source,
421 element,
422 version,
423 }
424 }
425}
426
427/// Lint the given tariff and return a [`lint::tariff::Report`] of any `Warning`s found.
428///
429/// # Example
430///
431/// ```rust
432/// # use ocpi_tariffs::{guess, tariff, warning};
433/// #
434/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
435///
436/// let report = tariff::parse_and_report(TARIFF_JSON)?;
437/// let guess::Report {
438/// unexpected_fields,
439/// version,
440/// } = report;
441///
442/// if !unexpected_fields.is_empty() {
443/// eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
444///
445/// for path in &unexpected_fields {
446/// eprintln!(" * {path}");
447/// }
448///
449/// eprintln!();
450/// }
451///
452/// let guess::Version::Certain(tariff) = version else {
453/// return Err("Unable to guess the version of given CDR JSON.".into());
454/// };
455///
456/// let report = tariff::lint(&tariff);
457///
458/// eprintln!("`{}` lint warnings found", report.warnings.len_warnings());
459///
460/// for group in report.warnings {
461/// let (element, warnings) = group.to_parts();
462/// eprintln!(
463/// "Warnings reported for `json::Element` at path: `{}`",
464/// element.path()
465/// );
466///
467/// for warning in warnings {
468/// eprintln!(" * {warning}");
469/// }
470///
471/// eprintln!();
472/// }
473///
474/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
475/// ```
476pub fn lint(tariff: &Versioned<'_>) -> lint::tariff::Report {
477 lint::tariff(tariff)
478}