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}