ocpi_tariffs/tariff.rs
1//! Parse a tariff and lint the result.
2
3pub(crate) mod v211;
4pub(crate) mod v221;
5pub(crate) mod v2x;
6
7use std::{borrow::Cow, fmt};
8
9use crate::{
10 country, currency, datetime, duration, from_warning_all, guess, json, lint, money, number,
11 string, warning, weekday, ParseError, Version,
12};
13
14#[derive(Debug)]
15pub enum WarningKind {
16 /// The CDR location is not a valid ISO 3166-1 alpha-3 code.
17 Country(country::WarningKind),
18 Currency(currency::WarningKind),
19 DateTime(datetime::WarningKind),
20 Decode(json::decode::WarningKind),
21 Duration(duration::WarningKind),
22
23 /// A field in the tariff doesn't have the expected type.
24 FieldInvalidType {
25 /// The type that the given field should have according to the schema.
26 expected_type: json::ValueKind,
27 },
28
29 /// A field in the tariff doesn't have the expected value.
30 FieldInvalidValue {
31 /// The value encountered.
32 value: String,
33
34 /// A message about what values are expected for this field.
35 message: Cow<'static, str>,
36 },
37
38 /// The given field is required.
39 FieldRequired {
40 field_name: Cow<'static, str>,
41 },
42
43 Money(money::WarningKind),
44
45 /// The given tariff has a `min_price` set and the `total_cost` fell below it.
46 ///
47 /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
48 TotalCostClampedToMin,
49
50 /// The given tariff has a `max_price` set and the `total_cost` exceeded it.
51 ///
52 /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
53 TotalCostClampedToMax,
54
55 /// The tariff has no `Element`s.
56 NoElements,
57
58 /// The tariff is not active during the `Cdr::start_date_time`.
59 NotActive,
60 Number(number::WarningKind),
61
62 String(string::WarningKind),
63 Weekday(weekday::WarningKind),
64}
65
66impl WarningKind {
67 /// Create a new `WarningKind::FieldInvalidValue` where the field is built from the given `json::Element`.
68 fn field_invalid_value(
69 value: impl Into<String>,
70 message: impl Into<Cow<'static, str>>,
71 ) -> Self {
72 WarningKind::FieldInvalidValue {
73 value: value.into(),
74 message: message.into(),
75 }
76 }
77}
78
79impl fmt::Display for WarningKind {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 Self::String(warning_kind) => write!(f, "{warning_kind}"),
83 Self::Country(warning_kind) => write!(f, "{warning_kind}"),
84 Self::Currency(warning_kind) => write!(f, "{warning_kind}"),
85 Self::DateTime(warning_kind) => write!(f, "{warning_kind}"),
86 Self::Decode(warning_kind) => write!(f, "{warning_kind}"),
87 Self::Duration(warning_kind) => write!(f, "{warning_kind}"),
88 Self::FieldInvalidType { expected_type } => {
89 write!(f, "Field has invalid type. Expected type `{expected_type}`")
90 }
91 Self::FieldInvalidValue { value, message } => {
92 write!(f, "Field has invalid value `{value}`: {message}")
93 }
94 Self::FieldRequired { field_name } => {
95 write!(f, "Field is required: {field_name}")
96 }
97 Self::Money(warning_kind) => write!(f, "{warning_kind}"),
98 Self::NoElements => f.write_str("The tariff has no `elements`"),
99 Self::NotActive => f.write_str("The tariff is not active for `Cdr::start_date_time`"),
100 Self::Number(warning_kind) => write!(f, "{warning_kind}"),
101 Self::TotalCostClampedToMin => write!(
102 f,
103 "The given tariff has a `min_price` set and the `total_cost` fell below it."
104 ),
105 Self::TotalCostClampedToMax => write!(
106 f,
107 "The given tariff has a `max_price` set and the `total_cost` exceeded it."
108 ),
109 Self::Weekday(warning_kind) => write!(f, "{warning_kind}"),
110 }
111 }
112}
113
114impl warning::Kind for WarningKind {
115 fn id(&self) -> Cow<'static, str> {
116 match self {
117 Self::String(kind) => kind.id(),
118 Self::Country(kind) => kind.id(),
119 Self::Currency(kind) => kind.id(),
120 Self::DateTime(kind) => kind.id(),
121 Self::Decode(kind) => kind.id(),
122 Self::Duration(kind) => kind.id(),
123 Self::FieldInvalidType { .. } => "field_invalid_type".into(),
124 Self::FieldInvalidValue { .. } => "field_invalid_value".into(),
125 Self::FieldRequired { .. } => "field_required".into(),
126 Self::Money(kind) => kind.id(),
127 Self::NoElements => "no_elements".into(),
128 Self::NotActive => "not_active".into(),
129 Self::Number(kind) => kind.id(),
130 Self::TotalCostClampedToMin => "total_cost_clamped_to_min".into(),
131 Self::TotalCostClampedToMax => "total_cost_clamped_to_max".into(),
132 Self::Weekday(kind) => kind.id(),
133 }
134 }
135}
136
137from_warning_all!(
138 country::WarningKind => WarningKind::Country,
139 currency::WarningKind => WarningKind::Currency,
140 datetime::WarningKind => WarningKind::DateTime,
141 duration::WarningKind => WarningKind::Duration,
142 json::decode::WarningKind => WarningKind::Decode,
143 money::WarningKind => WarningKind::Money,
144 number::WarningKind => WarningKind::Number,
145 string::WarningKind => WarningKind::String,
146 weekday::WarningKind => WarningKind::Weekday
147);
148
149/// Parse a `&str` into a [`Versioned`] tariff using a schema for the given [`Version`][^spec-v211][^spec-v221] to check for
150/// any unexpected fields.
151///
152/// # Example
153///
154/// ```rust
155/// # use ocpi_tariffs::{tariff, Version, ParseError};
156/// #
157/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
158///
159/// let report = tariff::parse_with_version(TARIFF_JSON, Version::V211)?;
160/// let tariff::ParseReport {
161/// tariff,
162/// unexpected_fields,
163/// } = report;
164///
165/// if !unexpected_fields.is_empty() {
166/// eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
167///
168/// for path in &unexpected_fields {
169/// eprintln!("{path}");
170/// }
171/// }
172///
173/// # Ok::<(), ParseError>(())
174/// ```
175///
176/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
177/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
178pub fn parse_with_version(source: &str, version: Version) -> Result<ParseReport<'_>, ParseError> {
179 match version {
180 Version::V221 => {
181 let schema = &*crate::v221::TARIFF_SCHEMA;
182 let report =
183 json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
184 let json::ParseReport {
185 element,
186 unexpected_fields,
187 } = report;
188 Ok(ParseReport {
189 tariff: Versioned::new(source, element, Version::V221),
190 unexpected_fields,
191 })
192 }
193 Version::V211 => {
194 let schema = &*crate::v211::TARIFF_SCHEMA;
195 let report =
196 json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
197 let json::ParseReport {
198 element,
199 unexpected_fields,
200 } = report;
201 Ok(ParseReport {
202 tariff: Versioned::new(source, element, Version::V211),
203 unexpected_fields,
204 })
205 }
206 }
207}
208
209/// Parse the JSON and try to guess the [`Version`] based on fields defined in the
210/// OCPI v2.1.1[^spec-v211] and v2.2.1[^spec-v221] tariff spec.
211///
212/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
213/// The parser will also not complain if unexpected fields are present in the JSON.
214/// The [`Version`] guess is based on fields that exist.
215///
216/// # Example
217///
218/// ```rust
219/// # use ocpi_tariffs::{tariff, guess, ParseError, Version, Versioned as _};
220/// #
221/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
222/// let tariff = tariff::parse(TARIFF_JSON)?;
223///
224/// match tariff {
225/// guess::Version::Certain(tariff) => {
226/// println!("The tariff version is `{}`", tariff.version());
227/// },
228/// guess::Version::Uncertain(_tariff) => {
229/// eprintln!("Unable to guess the version of given tariff JSON.");
230/// }
231/// }
232///
233/// # Ok::<(), ParseError>(())
234/// ```
235///
236/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
237/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
238pub fn parse(tariff_json: &str) -> Result<guess::TariffVersion<'_>, ParseError> {
239 guess::tariff_version(tariff_json)
240}
241
242/// Guess the [`Version`][^spec-v211][^spec-v221] of the given tariff JSON and report on any unexpected fields.
243///
244/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
245/// The parser will also not complain if unexpected fields are present in the JSON.
246/// The [`Version`] guess is based on fields that exist.
247///
248/// # Example
249///
250/// ```rust
251/// # use ocpi_tariffs::{guess, tariff, warning};
252/// #
253/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
254///
255/// let report = tariff::parse_and_report(TARIFF_JSON)?;
256/// let guess::Report {
257/// unexpected_fields,
258/// version,
259/// } = report;
260///
261/// if !unexpected_fields.is_empty() {
262/// eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
263///
264/// for path in &unexpected_fields {
265/// eprintln!(" * {path}");
266/// }
267///
268/// eprintln!();
269/// }
270///
271/// let guess::Version::Certain(tariff) = version else {
272/// return Err("Unable to guess the version of given CDR JSON.".into());
273/// };
274///
275/// let report = tariff::lint(&tariff)?;
276///
277/// eprintln!("`{}` lint warnings found", report.warnings.len());
278///
279/// for warning::Group { element, warnings } in report.warnings.group_by_elem(tariff.as_element()) {
280/// eprintln!(
281/// "Warnings reported for `json::Element` at path: `{}`",
282/// element.path()
283/// );
284///
285/// for warning in warnings {
286/// eprintln!(" * {warning}");
287/// }
288///
289/// eprintln!();
290/// }
291///
292/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
293/// ```
294///
295/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
296/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
297pub fn parse_and_report(tariff_json: &str) -> Result<guess::TariffReport<'_>, ParseError> {
298 guess::tariff_version_with_report(tariff_json)
299}
300
301/// A [`Versioned`] tariff along with a set of unexpected fields.
302#[derive(Debug)]
303pub struct ParseReport<'buf> {
304 /// The root JSON `Element`.
305 pub tariff: Versioned<'buf>,
306
307 /// A list of fields that were not expected: The schema did not define them.
308 pub unexpected_fields: json::UnexpectedFields<'buf>,
309}
310
311/// A `json::Element` that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
312/// and has been identified as being a certain [`Version`].
313#[derive(Clone)]
314pub struct Versioned<'buf> {
315 /// The source JSON as string.
316 source: &'buf str,
317
318 /// The parsed JSON as structured [`Element`](crate::json::Element)s.
319 element: json::Element<'buf>,
320
321 /// The `Version` of the tariff, determined during parsing.
322 version: Version,
323}
324
325impl fmt::Debug for Versioned<'_> {
326 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327 if f.alternate() {
328 fmt::Debug::fmt(&self.element, f)
329 } else {
330 match self.version {
331 Version::V211 => f.write_str("V211"),
332 Version::V221 => f.write_str("V221"),
333 }
334 }
335 }
336}
337
338impl crate::Versioned for Versioned<'_> {
339 fn version(&self) -> Version {
340 self.version
341 }
342}
343
344impl<'buf> Versioned<'buf> {
345 pub(crate) fn new(source: &'buf str, element: json::Element<'buf>, version: Version) -> Self {
346 Self {
347 source,
348 element,
349 version,
350 }
351 }
352
353 /// Return the inner [`json::Element`] and discard the version info.
354 pub fn into_element(self) -> json::Element<'buf> {
355 self.element
356 }
357
358 /// Return the inner [`json::Element`] and discard the version info.
359 pub fn as_element(&self) -> &json::Element<'buf> {
360 &self.element
361 }
362
363 /// Return the inner JSON `str` and discard the version info.
364 pub fn as_json_str(&self) -> &'buf str {
365 self.source
366 }
367}
368
369/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
370/// and was determined to not be one of the supported [`Version`]s.
371#[derive(Debug)]
372pub struct Unversioned<'buf> {
373 /// The source JSON as string.
374 source: &'buf str,
375
376 /// A list of fields that were not expected: The schema did not define them.
377 element: json::Element<'buf>,
378}
379
380impl<'buf> Unversioned<'buf> {
381 /// Create an unversioned [`json::Element`].
382 pub(crate) fn new(source: &'buf str, elem: json::Element<'buf>) -> Self {
383 Self {
384 source,
385 element: elem,
386 }
387 }
388
389 /// Return the inner [`json::Element`] and discard the version info.
390 pub fn into_element(self) -> json::Element<'buf> {
391 self.element
392 }
393
394 /// Return the inner [`json::Element`] and discard the version info.
395 pub fn as_element(&self) -> &json::Element<'buf> {
396 &self.element
397 }
398
399 /// Return the inner JSON `&str` and discard the version info.
400 pub fn as_json_str(&self) -> &'buf str {
401 self.source
402 }
403}
404
405impl<'buf> crate::Unversioned for Unversioned<'buf> {
406 type Versioned = Versioned<'buf>;
407
408 fn force_into_versioned(self, version: Version) -> Versioned<'buf> {
409 let Self { source, element } = self;
410 Versioned {
411 source,
412 element,
413 version,
414 }
415 }
416}
417
418/// Lint the given tariff and return a [`lint::tariff::Report`] of any `Warning`s found.
419///
420/// # Example
421///
422/// ```rust
423/// # use ocpi_tariffs::{guess, tariff, warning};
424/// #
425/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
426///
427/// let report = tariff::parse_and_report(TARIFF_JSON)?;
428/// let guess::Report {
429/// unexpected_fields,
430/// version,
431/// } = report;
432///
433/// if !unexpected_fields.is_empty() {
434/// eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
435///
436/// for path in &unexpected_fields {
437/// eprintln!(" * {path}");
438/// }
439///
440/// eprintln!();
441/// }
442///
443/// let guess::Version::Certain(tariff) = version else {
444/// return Err("Unable to guess the version of given CDR JSON.".into());
445/// };
446///
447/// let report = tariff::lint(&tariff)?;
448///
449/// eprintln!("`{}` lint warnings found", report.warnings.len());
450///
451/// for warning::Group { element, warnings } in report.warnings.group_by_elem(tariff.as_element()) {
452/// eprintln!(
453/// "Warnings reported for `json::Element` at path: `{}`",
454/// element.path()
455/// );
456///
457/// for warning in warnings {
458/// eprintln!(" * {warning}");
459/// }
460///
461/// eprintln!();
462/// }
463///
464/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
465/// ```
466pub fn lint(tariff: &Versioned<'_>) -> Result<lint::tariff::Report, lint::Error> {
467 lint::tariff(tariff)
468}
469
470#[cfg(test)]
471mod test_real_world {
472 use std::path::Path;
473
474 use assert_matches::assert_matches;
475
476 use crate::{guess, test, Version, Versioned as _};
477
478 use super::{parse_and_report, test::assert_parse_guess_report};
479
480 #[test_each::file(
481 glob = "ocpi-tariffs/test_data/v211/real_world/*/tariff*.json",
482 name(segments = 2)
483 )]
484 fn test_parse_v211(tariff_json: &str, path: &Path) {
485 test::setup();
486 expect_version(tariff_json, path, Version::V211);
487 }
488
489 #[test_each::file(
490 glob = "ocpi-tariffs/test_data/v221/real_world/*/tariff*.json",
491 name(segments = 2)
492 )]
493 fn test_parse_v221(tariff_json: &str, path: &Path) {
494 test::setup();
495 expect_version(tariff_json, path, Version::V221);
496 }
497
498 /// Parse the given JSON as a tariff and generate a report on the unexpected fields.
499 fn expect_version(tariff_json: &str, path: &Path, expected_version: Version) {
500 let report = parse_and_report(tariff_json).unwrap();
501
502 let expect_json = test::read_expect_json(path, "parse");
503 let parse_expect = test::parse_expect_json(expect_json.as_deref());
504
505 let tariff = assert_matches!(&report.version, guess::Version::Certain(tariff) => tariff);
506 assert_eq!(tariff.version(), expected_version);
507
508 assert_parse_guess_report(report, parse_expect);
509 }
510}
511
512#[cfg(test)]
513pub mod test {
514 #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
515 #![allow(clippy::panic, reason = "tests are allowed panic")]
516
517 use std::collections::BTreeMap;
518
519 use crate::{
520 guess, json,
521 test::{ExpectFile, Expectation},
522 warning,
523 };
524
525 /// Expectations for the result of calling `json::parse_and_report`.
526 #[derive(Debug, serde::Deserialize)]
527 pub struct ParseExpect {
528 #[serde(default)]
529 unexpected_fields: Expectation<Vec<json::test::PathGlob>>,
530 }
531
532 /// Expectations for the result of calling `json::parse_and_report`.
533 #[derive(Debug, serde::Deserialize)]
534 pub struct FromJsonExpect {
535 #[serde(default)]
536 warnings: Expectation<BTreeMap<String, Vec<String>>>,
537 }
538
539 /// Assert that the `TariffReport` resulting from the call to `tariff::parse_and_report`
540 /// matches the expectations of the `ParseExpect` file.
541 #[track_caller]
542 pub(crate) fn assert_parse_guess_report(
543 report: guess::TariffReport<'_>,
544 expect: ExpectFile<ParseExpect>,
545 ) -> guess::TariffVersion<'_> {
546 let guess::Report {
547 version,
548 mut unexpected_fields,
549 } = report;
550
551 let ExpectFile {
552 value: expect,
553 expect_file_name,
554 } = expect;
555
556 let Some(expect) = expect else {
557 json::test::expect_no_unexpected_fields(&expect_file_name, &unexpected_fields);
558 return version;
559 };
560
561 let ParseExpect {
562 unexpected_fields: expected,
563 } = expect;
564
565 json::test::expect_unexpected_fields(&expect_file_name, &mut unexpected_fields, expected);
566
567 version
568 }
569
570 /// Assert that the unexpected fields resulting from the call to [`tariff::parse_with_version`](crate::tariff::parse_with_version)
571 /// match the expectations of the `ParseExpect` file.
572 #[track_caller]
573 pub(crate) fn assert_parse_report(
574 mut unexpected_fields: json::UnexpectedFields<'_>,
575 expect: ExpectFile<ParseExpect>,
576 ) {
577 let ExpectFile {
578 value,
579 expect_file_name,
580 } = expect;
581
582 let Some(ParseExpect {
583 unexpected_fields: expected,
584 }) = value
585 else {
586 json::test::expect_no_unexpected_fields(&expect_file_name, &unexpected_fields);
587 return;
588 };
589
590 json::test::expect_unexpected_fields(&expect_file_name, &mut unexpected_fields, expected);
591 }
592
593 pub(crate) fn assert_from_json_warnings(
594 root: &json::Element<'_>,
595 warnings: &warning::Set<super::WarningKind>,
596 expect: ExpectFile<FromJsonExpect>,
597 ) {
598 let ExpectFile {
599 value,
600 expect_file_name,
601 } = expect;
602
603 // If there are warnings reported and there is no `expect` file
604 // then panic printing the fields of the expect JSON object that would silence these warnings.
605 // These can be copied into an `output_lint__*.json` file.
606 let Some(expect) = value else {
607 assert!(
608 warnings.is_empty(),
609 "There is no expectation file at `{expect_file_name}` but the tariff has warnings;\n{:?}",
610 warnings.group_by_elem(root).into_id_map()
611 );
612 return;
613 };
614
615 warning::test::assert_warnings(&expect_file_name, root, warnings, expect.warnings);
616 }
617}