ocpi_tariffs/
tariff.rs

1//! Parse a tariff and lint the result.
2use std::fmt;
3
4use crate::{guess, json, lint, ParseError, Version};
5
6/// Parse a `&str` into a [`Versioned`] tariff using a schema for the given [`Version`][^spec-v211][^spec-v221] to check for
7/// any unexpected fields.
8///
9/// # Example
10///
11/// ```rust
12/// # use ocpi_tariffs::{tariff, Version, ParseError};
13/// #
14/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
15///
16/// let report = tariff::parse_with_version(TARIFF_JSON, Version::V211)?;
17/// let tariff::ParseReport {
18///     tariff,
19///     unexpected_fields,
20/// } = report;
21///
22/// if !unexpected_fields.is_empty() {
23///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
24///
25///     for path in &unexpected_fields {
26///         eprintln!("{path}");
27///     }
28/// }
29///
30/// # Ok::<(), ParseError>(())
31/// ```
32///
33/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
34/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
35pub fn parse_with_version(source: &str, version: Version) -> Result<ParseReport<'_>, ParseError> {
36    match version {
37        Version::V221 => {
38            let schema = &*crate::v221::TARIFF_SCHEMA;
39            let report =
40                json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
41            let json::ParseReport {
42                element,
43                unexpected_fields,
44            } = report;
45            Ok(ParseReport {
46                tariff: Versioned::new(source, element, Version::V221),
47                unexpected_fields,
48            })
49        }
50        Version::V211 => {
51            let schema = &*crate::v211::TARIFF_SCHEMA;
52            let report =
53                json::parse_with_schema(source, schema).map_err(ParseError::from_cdr_err)?;
54            let json::ParseReport {
55                element,
56                unexpected_fields,
57            } = report;
58            Ok(ParseReport {
59                tariff: Versioned::new(source, element, Version::V211),
60                unexpected_fields,
61            })
62        }
63    }
64}
65
66/// Parse the JSON and try guess the [`Version`] based on fields defined in the OCPI v2.1.1[^spec-v211] and v2.2.1[^spec-v221] tariff spec.
67///
68/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
69/// The parser will also not complain if unexpected fields are present in the JSON.
70/// The [`Version`] guess is based on fields that exist.
71///
72/// # Example
73///
74/// ```rust
75/// # use ocpi_tariffs::{tariff, guess, ParseError, Version, Versioned as _};
76/// #
77/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
78/// let tariff = tariff::parse(TARIFF_JSON)?;
79///
80/// match tariff {
81///     guess::Version::Certain(tariff) => {
82///         println!("The tariff version is `{}`", tariff.version());
83///     },
84///     guess::Version::Uncertain(_tariff) => {
85///         eprintln!("Unable to guess the version of given tariff JSON.");
86///     }
87/// }
88///
89/// # Ok::<(), ParseError>(())
90/// ```
91///
92/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
93/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
94pub fn parse(tariff_json: &str) -> Result<guess::TariffVersion<'_>, ParseError> {
95    guess::tariff_version(tariff_json)
96}
97
98/// Guess the [`Version`][^spec-v211][^spec-v221] of the given tariff JSON and report on any unexpected fields.
99///
100/// The parser is forgiving and will not complain if the tariff JSON is missing required fields.
101/// The parser will also not complain if unexpected fields are present in the JSON.
102/// The [`Version`] guess is based on fields that exist.
103///
104/// # Example
105///
106/// ```rust
107/// # use ocpi_tariffs::{guess, tariff, warning};
108/// #
109/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
110///
111/// let report = tariff::parse_and_report(TARIFF_JSON)?;
112/// let guess::Report {
113///     unexpected_fields,
114///     version,
115/// } = report;
116///
117/// if !unexpected_fields.is_empty() {
118///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
119///
120///     for path in &unexpected_fields {
121///         eprintln!("  * {path}");
122///     }
123///
124///     eprintln!();
125/// }
126///
127/// let guess::Version::Certain(tariff) = version else {
128///     return Err("Unable to guess the version of given CDR JSON.".into());
129/// };
130///
131/// let report = tariff::lint(&tariff)?;
132/// let warnings = report.into_warning_report();
133///
134/// eprintln!("`{}` lint warnings found", warnings.len());
135///
136/// for warning::ElementReport { element, warnings } in warnings.iter(tariff.as_element()) {
137///     eprintln!(
138///         "Warnings reported for `json::Element` at path: `{}`",
139///         element.path()
140///     );
141///
142///     for warning in warnings {
143///         eprintln!("  * {warning}");
144///     }
145///
146///     eprintln!();
147/// }
148///
149/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
150/// ```
151///
152/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
153/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
154pub fn parse_and_report(tariff_json: &str) -> Result<guess::TariffReport<'_>, ParseError> {
155    guess::tariff_version_with_report(tariff_json)
156}
157
158/// A [`Versioned`] tariff along with a set of unexpected fields.
159#[derive(Debug)]
160pub struct ParseReport<'buf> {
161    /// The root JSON `Element`.
162    pub tariff: Versioned<'buf>,
163
164    /// A list of fields that were not expected: The schema did not define them.
165    pub unexpected_fields: json::UnexpectedFields<'buf>,
166}
167
168/// A `json::Element` that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
169/// and has been identified as being a certain [`Version`].
170pub struct Versioned<'buf> {
171    /// The source JSON as string.
172    source: &'buf str,
173
174    /// The parsed JSON as structured [`Element`](crate::json::Element)s.
175    element: json::Element<'buf>,
176
177    /// The `Version` of the tariff, determined during parsing.
178    version: Version,
179}
180
181impl fmt::Debug for Versioned<'_> {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        if f.alternate() {
184            fmt::Debug::fmt(&self.element, f)
185        } else {
186            match self.version {
187                Version::V211 => f.write_str("V211"),
188                Version::V221 => f.write_str("V221"),
189            }
190        }
191    }
192}
193
194impl crate::Versioned for Versioned<'_> {
195    fn version(&self) -> Version {
196        self.version
197    }
198}
199
200impl<'buf> Versioned<'buf> {
201    pub(crate) fn new(source: &'buf str, element: json::Element<'buf>, version: Version) -> Self {
202        Self {
203            source,
204            element,
205            version,
206        }
207    }
208
209    /// Return the inner [`json::Element`] and discard the version info.
210    pub fn into_element(self) -> json::Element<'buf> {
211        self.element
212    }
213
214    /// Return the inner [`json::Element`] and discard the version info.
215    pub fn as_element(&self) -> &json::Element<'buf> {
216        &self.element
217    }
218
219    /// Return the inner JSON `str` and discard the version info.
220    pub fn as_json_str(&self) -> &'buf str {
221        self.source
222    }
223}
224
225/// A [`json::Element`] that has been parsed by the either the [`parse_with_version`] or [`parse`] functions
226/// and was determined to not be one of the supported [`Version`]s.
227#[derive(Debug)]
228pub struct Unversioned<'buf> {
229    /// The source JSON as string.
230    source: &'buf str,
231
232    /// A list of fields that were not expected: The schema did not define them.
233    element: json::Element<'buf>,
234}
235
236impl<'buf> Unversioned<'buf> {
237    /// Create an unversioned [`json::Element`].
238    pub(crate) fn new(source: &'buf str, elem: json::Element<'buf>) -> Self {
239        Self {
240            source,
241            element: elem,
242        }
243    }
244
245    /// Return the inner [`json::Element`] and discard the version info.
246    pub fn into_element(self) -> json::Element<'buf> {
247        self.element
248    }
249
250    /// Return the inner [`json::Element`] and discard the version info.
251    pub fn as_element(&self) -> &json::Element<'buf> {
252        &self.element
253    }
254
255    /// Return the inner JSON `&str` and discard the version info.
256    pub fn as_json_str(&self) -> &'buf str {
257        self.source
258    }
259}
260
261impl<'buf> crate::Unversioned for Unversioned<'buf> {
262    type Versioned = Versioned<'buf>;
263
264    fn force_into_versioned(self, version: Version) -> Versioned<'buf> {
265        let Self { source, element } = self;
266        Versioned {
267            source,
268            element,
269            version,
270        }
271    }
272}
273
274/// Lint the given tariff and return a [`lint::tariff::Report`] of any `Warning`s found.
275///
276/// # Example
277///
278/// ```rust
279/// # use ocpi_tariffs::{guess, tariff, warning};
280/// #
281/// # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
282///
283/// let report = tariff::parse_and_report(TARIFF_JSON)?;
284/// let guess::Report {
285///     unexpected_fields,
286///     version,
287/// } = report;
288///
289/// if !unexpected_fields.is_empty() {
290///     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
291///
292///     for path in &unexpected_fields {
293///         eprintln!("  * {path}");
294///     }
295///
296///     eprintln!();
297/// }
298///
299/// let guess::Version::Certain(tariff) = version else {
300///     return Err("Unable to guess the version of given CDR JSON.".into());
301/// };
302///
303/// let report = tariff::lint(&tariff)?;
304/// let warnings = report.into_warning_report();
305///
306/// eprintln!("`{}` lint warnings found", warnings.len());
307///
308/// for warning::ElementReport { element, warnings } in warnings.iter(tariff.as_element()) {
309///     eprintln!(
310///         "Warnings reported for `json::Element` at path: `{}`",
311///         element.path()
312///     );
313///
314///     for warning in warnings {
315///         eprintln!("  * {warning}");
316///     }
317///
318///     eprintln!();
319/// }
320///
321/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
322/// ```
323pub fn lint(tariff: &Versioned<'_>) -> Result<lint::tariff::Report, lint::Error> {
324    lint::tariff(tariff)
325}
326
327#[cfg(test)]
328mod test_real_world {
329    use std::path::Path;
330
331    use assert_matches::assert_matches;
332
333    use crate::{guess, test, Version, Versioned as _};
334
335    use super::{
336        parse_and_report,
337        test::{assert_parse_report, parse_expect_json},
338    };
339
340    #[test_each::file(
341        glob = "ocpi-tariffs/test_data/v211/real_world/*/tariff*.json",
342        name(segments = 2)
343    )]
344    fn test_parse_v211(tariff_json: &str, path: &Path) {
345        test::setup();
346        expect_version(tariff_json, path, Version::V211);
347    }
348
349    #[test_each::file(
350        glob = "ocpi-tariffs/test_data/v221/real_world/*/tariff*.json",
351        name(segments = 2)
352    )]
353    fn test_parse_v221(tariff_json: &str, path: &Path) {
354        test::setup();
355        expect_version(tariff_json, path, Version::V221);
356    }
357
358    /// Parse the given JSON as a tariff and generate a report on the unexpected fields.
359    fn expect_version(tariff_json: &str, path: &Path, expected_version: Version) {
360        let report = parse_and_report(tariff_json).unwrap();
361
362        let expect_json = test::read_expect_json(path, "parse");
363        let parse_expect = parse_expect_json(expect_json.as_deref());
364
365        let tariff = assert_matches!(&report.version, guess::Version::Certain(tariff) => tariff);
366        assert_eq!(tariff.version(), expected_version);
367
368        assert_parse_report(report, parse_expect);
369    }
370}
371
372#[cfg(test)]
373pub mod test {
374    #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
375    #![allow(clippy::panic, reason = "tests are allowed panic")]
376
377    use crate::{
378        guess, json,
379        test::{assert_no_unexpected_fields, Expectation},
380    };
381
382    /// Expectations for the result of calling `json::parse_and_report`.
383    #[derive(Debug, serde::Deserialize)]
384    pub struct ParseExpect<'buf> {
385        #[serde(borrow, default)]
386        unexpected_fields: Expectation<Vec<json::test::PathGlob<'buf>>>,
387    }
388
389    #[track_caller]
390    pub fn parse_expect_json(expect_json: Option<&str>) -> Option<ParseExpect<'_>> {
391        expect_json.map(|json| serde_json::from_str(json).expect("Unable to parse expect JSON"))
392    }
393
394    /// Assert that the `TariffReport` resulting from the call to `tariff::parse_and_report`
395    /// matches the expectations of the `ParseExpect` file.
396    #[track_caller]
397    pub fn assert_parse_report<'bin>(
398        report: guess::TariffReport<'bin>,
399        expect: Option<ParseExpect<'_>>,
400    ) -> guess::TariffVersion<'bin> {
401        let guess::Report {
402            version,
403            mut unexpected_fields,
404        } = report;
405        let Some(expect) = expect else {
406            assert_no_unexpected_fields(&unexpected_fields);
407            return version;
408        };
409
410        let ParseExpect {
411            unexpected_fields: unexpected_fields_expect,
412        } = expect;
413
414        if let Expectation::Present(expectation) = unexpected_fields_expect {
415            let unexpected_fields_expect = expectation.expect_value();
416
417            for expect_glob in unexpected_fields_expect {
418                unexpected_fields.filter_matches(&expect_glob);
419            }
420
421            assert_no_unexpected_fields(&unexpected_fields);
422        } else {
423            assert_no_unexpected_fields(&unexpected_fields);
424        }
425
426        version
427    }
428}