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}