ocpi_tariffs/
guess.rs

1//! Guess the `Version` of the given `CDR` or tariff.
2use std::fmt;
3
4use tracing::debug;
5
6use crate::{cdr, json, tariff, v211, v221, ParseError, Unversioned, Versioned};
7
8/// The result of calling `cdr::parse`.
9pub type CdrVersion<'buf> = Version<cdr::Versioned<'buf>, cdr::Unversioned<'buf>>;
10
11/// The result of calling `cdr::parse_and_report`.
12pub type CdrReport<'buf> = Report<'buf, cdr::Versioned<'buf>, cdr::Unversioned<'buf>>;
13
14/// Guess the `Version` of the given CDR.
15///
16/// See: `cdr::guess_version`.
17pub(crate) fn cdr_version(cdr_json: &str) -> Result<CdrVersion<'_>, ParseError> {
18    guess_cdr_version(cdr_json)
19}
20
21/// Guess the `Version` of the given CDR and report on any unexpected fields in the JSON.
22///
23/// See: `cdr::guess_version_with_report`.
24pub(crate) fn cdr_version_and_report(cdr_json: &str) -> Result<CdrReport<'_>, ParseError> {
25    let version = guess_cdr_version(cdr_json)?;
26
27    let Version::Certain(cdr) = version else {
28        return Ok(Report {
29            version,
30            unexpected_fields: json::UnexpectedFields::empty(),
31        });
32    };
33
34    let schema = match cdr.version() {
35        crate::Version::V211 => &v211::CDR_SCHEMA,
36        crate::Version::V221 => &v221::CDR_SCHEMA,
37    };
38
39    let report = json::parse_with_schema(cdr_json, schema).map_err(ParseError::from_cdr_err)?;
40    let json::ParseReport {
41        element: _,
42        unexpected_fields,
43    } = report;
44
45    Ok(CdrReport {
46        unexpected_fields,
47        version: Version::Certain(cdr),
48    })
49}
50
51/// Try guess a CDR's [`Version`] and return a [`CdrVersion`] with the outcome.
52fn guess_cdr_version(source: &str) -> Result<CdrVersion<'_>, ParseError> {
53    /// The list of field names exclusively defined in the `v221` spec.
54    const V211_EXCLUSIVE_FIELDS: &[&str] = &["stop_date_time"];
55
56    /// The list of field names shared between the `v211` and `v221` specs.
57    const V221_EXCLUSIVE_FIELDS: &[&str] = &["end_date_time", "cdr_location", "cdr_token"];
58
59    let element = json::parse(source).map_err(ParseError::from_cdr_err)?;
60    let value = element.value();
61    let json::Value::Object(fields) = value else {
62        return Err(ParseError::cdr_should_be_object());
63    };
64
65    for field in fields {
66        let key = field.key().as_raw();
67
68        if V211_EXCLUSIVE_FIELDS.contains(&key) {
69            return Ok(Version::Certain(cdr::Versioned::new(
70                source,
71                element,
72                crate::Version::V211,
73            )));
74        } else if V221_EXCLUSIVE_FIELDS.contains(&key) {
75            return Ok(Version::Certain(cdr::Versioned::new(
76                source,
77                element,
78                crate::Version::V221,
79            )));
80        }
81    }
82
83    Ok(Version::Uncertain(cdr::Unversioned::new(source, element)))
84}
85
86/// The result of calling `tariff::parse`.
87pub type TariffVersion<'buf> = Version<tariff::Versioned<'buf>, tariff::Unversioned<'buf>>;
88
89/// The result of calling `tariff::parse_and_report`.
90pub type TariffReport<'buf> = Report<'buf, tariff::Versioned<'buf>, tariff::Unversioned<'buf>>;
91
92/// Guess the `Version` of the given tariff.
93///
94/// See: [`tariff::parse`]
95pub(super) fn tariff_version(tariff_json: &str) -> Result<TariffVersion<'_>, ParseError> {
96    guess_tariff_version(tariff_json)
97}
98
99/// Guess the `Version` of the given tariff and report on any unexpected fields in the JSON.
100///
101/// See: [`tariff::parse_and_report`]
102pub(super) fn tariff_version_with_report(
103    tariff_json: &str,
104) -> Result<TariffReport<'_>, ParseError> {
105    let version = guess_tariff_version(tariff_json)?;
106
107    let Version::Certain(object) = version else {
108        return Ok(Report {
109            version,
110            unexpected_fields: json::UnexpectedFields::empty(),
111        });
112    };
113
114    let schema = match object.version() {
115        crate::Version::V211 => &v211::TARIFF_SCHEMA,
116        crate::Version::V221 => &v221::TARIFF_SCHEMA,
117    };
118
119    let report =
120        json::parse_with_schema(tariff_json, schema).map_err(ParseError::from_tariff_err)?;
121    let json::ParseReport {
122        element: _,
123        unexpected_fields,
124    } = report;
125
126    Ok(TariffReport {
127        unexpected_fields,
128        version: Version::Certain(object),
129    })
130}
131
132/// The private impl for detecting a tariff's version
133fn guess_tariff_version(source: &str) -> Result<TariffVersion<'_>, ParseError> {
134    /// The list of field names exclusively defined in the `v221` spec.
135    const V221_EXCLUSIVE_FIELDS: &[&str] = &[
136        "country_code",
137        "party_id",
138        "type",
139        "min_price",
140        "max_price",
141        "start_date_time",
142        "end_date_time",
143    ];
144
145    /// The list of field names shared between the `v211` and `v221` specs.
146    const V211_V221_SHARED_FIELDS: &[&str] = &[
147        "id",
148        "currency",
149        "tariff_alt_text",
150        "tariff_alt_url",
151        "elements",
152        "energy_mix",
153        "last_updated",
154    ];
155
156    // Parse the tariff without a schema first to determine the version.
157    let element = json::parse(source).map_err(ParseError::from_tariff_err)?;
158    let value = element.value();
159    let json::Value::Object(fields) = value else {
160        return Err(ParseError::tariff_should_be_object());
161    };
162
163    for field in fields {
164        let key = field.key().as_raw();
165
166        // If the field is in the v221 exclusive list we know without a doubt that it's a v221 tariff.
167        if V221_EXCLUSIVE_FIELDS.contains(&key) {
168            debug!("Tariff is v221 because of field: `{key}`");
169            return Ok(TariffVersion::Certain(tariff::Versioned::new(
170                source,
171                element,
172                crate::Version::V221,
173            )));
174        }
175    }
176
177    for field in fields {
178        let key = field.key().as_raw();
179
180        if V211_V221_SHARED_FIELDS.contains(&key) {
181            return Ok(TariffVersion::Certain(tariff::Versioned::new(
182                source,
183                element,
184                crate::Version::V211,
185            )));
186        }
187    }
188
189    Ok(TariffVersion::Uncertain(tariff::Unversioned::new(
190        source, element,
191    )))
192}
193
194/// An OCPI object with a certain or uncertain version.
195#[derive(Debug)]
196pub enum Version<V, U>
197where
198    V: Versioned,
199    U: fmt::Debug,
200{
201    /// The version of the object `V` is certain.
202    Certain(V),
203
204    /// The version of the object `U` is uncertain.
205    Uncertain(U),
206}
207
208impl<V, U> Version<V, U>
209where
210    V: Versioned,
211    U: Unversioned<Versioned = V>,
212{
213    /// Convert the OCPI object into it's [`Version`](crate::Version) if it's [`Versioned`].
214    /// Otherwise return `Uncertain(())`.
215    pub fn into_version(self) -> Version<crate::Version, ()> {
216        match self {
217            Version::Certain(v) => Version::Certain(v.version()),
218            Version::Uncertain(_) => Version::Uncertain(()),
219        }
220    }
221
222    /// Convert a reference to the an OCPI object into it's [`Version`](crate::Version) if it's [`Versioned`].
223    /// Otherwise return `Uncertain(())`.
224    pub fn as_version(&self) -> Version<crate::Version, ()> {
225        match self {
226            Version::Certain(v) => Version::Certain(v.version()),
227            Version::Uncertain(_) => Version::Uncertain(()),
228        }
229    }
230
231    /// Return the contained OCPI object if it's [`Versioned`]. Otherwise force convert the object
232    /// to the given [`Version`](crate::Version).
233    pub fn certain_or(self, fallback: crate::Version) -> V {
234        match self {
235            Version::Certain(v) => v,
236            Version::Uncertain(u) => u.force_into_versioned(fallback),
237        }
238    }
239
240    /// Return `Some` OCPI object if it's [`Versioned`]. Otherwise return None if the object's version is uncertain.
241    pub fn certain_or_none(self) -> Option<V> {
242        match self {
243            Version::Certain(v) => Some(v),
244            Version::Uncertain(_) => None,
245        }
246    }
247}
248
249/// The guess version report.
250///
251/// This contains the guessed version of the given JSON object and a list of unexpected fields
252/// if the JSON contains fields not specified in the guessed version of the OCPI spec.
253///
254/// The `unexpected_fields` list will always be empty if the guessed `Version` is `Uncertain`.
255#[derive(Debug)]
256pub struct Report<'buf, V, U>
257where
258    V: Versioned,
259    U: fmt::Debug,
260{
261    /// A list of fields that were not expected: The schema did not define them.
262    ///
263    /// This list will always be empty if the guessed `Version` is `Uncertain`.
264    pub unexpected_fields: json::UnexpectedFields<'buf>,
265
266    /// The guessed version.
267    ///
268    /// The `unexpected_fields` list will always be empty if the guessed `Version` is `Uncertain`.
269    pub version: Version<V, U>,
270}
271
272#[cfg(test)]
273mod test {
274    use super::{Unversioned, Version, Versioned};
275
276    impl<V, U> Version<V, U>
277    where
278        V: Versioned,
279        U: Unversioned<Versioned = V>,
280    {
281        /// Return the `Versioned` object if the `Version` is `Certain`.
282        ///
283        /// # Panics
284        ///
285        /// Will panic if the `Version` is `Uncertain`.
286        pub fn unwrap_certain(self) -> V {
287            match self {
288                Version::Certain(v) => v,
289                Version::Uncertain(_) => panic!("The version of the object is unknown"),
290            }
291        }
292    }
293}
294
295#[cfg(test)]
296mod test_guess_cdr {
297    use assert_matches::assert_matches;
298
299    use crate::{test, Versioned as _};
300
301    use super::{cdr_version_and_report, Report, Version};
302
303    #[test]
304    fn should_guess_cdr_version_v211() {
305        const JSON: &str = include_str!("../test_data/v211/lint/every_field_set/cdr.json");
306
307        test::setup();
308
309        let Report {
310            version,
311            unexpected_fields,
312        } = cdr_version_and_report(JSON).unwrap();
313
314        let cdr = assert_matches!(version, Version::Certain ( cdr ) => cdr );
315        assert_matches!(cdr.version(), crate::Version::V211);
316
317        test::assert_no_unexpected_fields(&unexpected_fields);
318    }
319
320    #[test]
321    fn should_guess_cdr_version_v221() {
322        const JSON: &str = include_str!("../test_data/v221/lint/every_field_set/cdr.json");
323
324        test::setup();
325
326        let Report {
327            version,
328            unexpected_fields,
329        } = cdr_version_and_report(JSON).unwrap();
330
331        let cdr = assert_matches!(version, Version::Certain ( cdr ) => cdr );
332        assert_matches!(cdr.version(), crate::Version::V221);
333
334        test::assert_no_unexpected_fields(&unexpected_fields);
335    }
336}
337
338#[cfg(test)]
339mod test_guess_tariff {
340    use assert_matches::assert_matches;
341
342    use crate::{test, Versioned as _};
343
344    use super::{tariff_version_with_report, Report, Version};
345
346    #[test]
347    fn should_guess_tariff_version_v211() {
348        const JSON: &str = include_str!("../test_data/v211/lint/every_field_set/tariff.json");
349
350        test::setup();
351
352        let Report {
353            version,
354            unexpected_fields,
355        } = tariff_version_with_report(JSON).unwrap();
356
357        let tariff = assert_matches!(version, Version::Certain ( tariff ) => tariff );
358        assert_matches!(tariff.version(), crate::Version::V211);
359
360        test::assert_no_unexpected_fields(&unexpected_fields);
361    }
362
363    #[test]
364    fn should_guess_tariff_version_v221() {
365        const JSON: &str = include_str!("../test_data/v221/lint/every_field_set/tariff.json");
366
367        test::setup();
368
369        let Report {
370            version,
371            unexpected_fields,
372        } = tariff_version_with_report(JSON).unwrap();
373
374        let tariff = assert_matches!(version, Version::Certain ( tariff ) => tariff );
375        assert_matches!(tariff.version(), crate::Version::V221);
376
377        test::assert_no_unexpected_fields(&unexpected_fields);
378    }
379}
380
381#[cfg(test)]
382mod test_real_world {
383    use std::path::Path;
384
385    use assert_matches::assert_matches;
386
387    use crate::{test, Versioned as _};
388
389    use super::{cdr_version_and_report, tariff_version_with_report, Report, Version};
390
391    #[test_each::file(
392        glob = "ocpi-tariffs/test_data/v221/real_world/*/cdr*.json",
393        name(segments = 2)
394    )]
395    fn should_guess_version_v221(cdr_json: &str, path: &Path) {
396        test::setup();
397
398        {
399            let Report {
400                version,
401                unexpected_fields,
402            } = cdr_version_and_report(cdr_json).unwrap();
403
404            let tariff = assert_matches!(version, Version::Certain ( tariff ) => tariff );
405            assert_matches!(tariff.version(), crate::Version::V221);
406
407            test::assert_no_unexpected_fields(&unexpected_fields);
408        }
409
410        {
411            let tariff = std::fs::read_to_string(path.parent().unwrap().join("tariff.json")).ok();
412
413            if let Some(tariff) = tariff {
414                let Report {
415                    version: guess,
416                    unexpected_fields,
417                } = tariff_version_with_report(&tariff).unwrap();
418
419                let tariff = assert_matches!(guess, Version::Certain ( tariff ) => tariff);
420                assert_matches!(tariff.version(), crate::Version::V221);
421
422                test::assert_no_unexpected_fields(&unexpected_fields);
423            }
424        }
425    }
426
427    #[test_each::file(
428        glob = "ocpi-tariffs/test_data/v211/lint/*/cdr*.json",
429        name(segments = 2)
430    )]
431    fn should_guess_version_v211(cdr_json: &str, path: &Path) {
432        test::setup();
433
434        {
435            let Report {
436                version: guess,
437                unexpected_fields,
438            } = cdr_version_and_report(cdr_json).unwrap();
439
440            let cdr = assert_matches!(guess, Version::Certain ( cdr ) => cdr);
441            assert_matches!(cdr.version(), crate::Version::V211);
442
443            test::assert_no_unexpected_fields(&unexpected_fields);
444        }
445
446        {
447            let tariff_json =
448                std::fs::read_to_string(path.parent().unwrap().join("tariff.json")).ok();
449
450            if let Some(tariff) = tariff_json {
451                let Report {
452                    version: guess,
453                    unexpected_fields,
454                } = tariff_version_with_report(&tariff).unwrap();
455
456                let tariff = assert_matches!(guess, Version::Certain ( tariff ) => tariff);
457                assert_matches!(tariff.version(), crate::Version::V211);
458
459                test::assert_no_unexpected_fields(&unexpected_fields);
460            }
461        }
462    }
463}