Skip to main content

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, explain, from_warning_all, guess, json,
17    lint, money, number, schema, string,
18    warning::{self, Caveat, IntoCaveat as _},
19};
20
21#[derive(Debug)]
22pub enum Warning {
23    /// The CDR location is not a valid `ISO 3166-1 alpha-3` code.
24    Country(country::Warning),
25    Currency(currency::Warning),
26    DateTime(datetime::Warning),
27    Decode(json::decode::Warning),
28    Duration(duration::Warning),
29    Enum(enumeration::Warning),
30
31    /// A field in the tariff doesn't have the expected type.
32    FieldInvalidType {
33        /// The type that the given field should have according to the schema.
34        expected_type: json::ValueKind,
35    },
36
37    /// A field in the tariff doesn't have the expected value.
38    FieldInvalidValue {
39        /// The value encountered.
40        value: String,
41
42        /// A message about what values are expected for this field.
43        message: Cow<'static, str>,
44    },
45
46    /// The given field is required.
47    FieldRequired {
48        field_name: Cow<'static, str>,
49    },
50
51    Money(money::Warning),
52
53    /// A tariff element has a `reservation` restriction (`RESERVATION` or `RESERVATION_EXPIRES`).
54    ///
55    /// Such elements apply only to reservation sessions, not to regular charging sessions. Because
56    /// reservation pricing is not supported, the element is treated as permanently inactive.
57    ///
58    /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#mod_tariffs_reservationrestrictiontype_enum>
59    ReservationElementSkipped,
60
61    /// The given tariff has a `min_price` set and the `total_cost` fell below it.
62    ///
63    /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
64    TotalCostClampedToMin,
65
66    /// The given tariff has a `max_price` set and the `total_cost` exceeded it.
67    ///
68    /// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>
69    TotalCostClampedToMax,
70
71    /// The tariff has no `Element`s.
72    NoElements,
73
74    /// The tariff is not active during the `Cdr::start_date_time`.
75    NotActive,
76    Number(number::Warning),
77
78    String(string::Warning),
79}
80
81impl Warning {
82    /// Create a new `Warning::FieldInvalidValue` where the field is built from the given `json::Element`.
83    fn field_invalid_value(
84        value: impl Into<String>,
85        message: impl Into<Cow<'static, str>>,
86    ) -> Self {
87        Warning::FieldInvalidValue {
88            value: value.into(),
89            message: message.into(),
90        }
91    }
92}
93
94impl fmt::Display for Warning {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::String(warning_kind) => write!(f, "{warning_kind}"),
98            Self::Country(warning_kind) => write!(f, "{warning_kind}"),
99            Self::Currency(warning_kind) => write!(f, "{warning_kind}"),
100            Self::DateTime(warning_kind) => write!(f, "{warning_kind}"),
101            Self::Decode(warning_kind) => write!(f, "{warning_kind}"),
102            Self::Duration(warning_kind) => write!(f, "{warning_kind}"),
103            Self::Enum(warning_kind) => write!(f, "{warning_kind}"),
104            Self::FieldInvalidType { expected_type } => {
105                write!(f, "Field has invalid type. Expected type `{expected_type}`")
106            }
107            Self::FieldInvalidValue { value, message } => {
108                write!(f, "Field has invalid value `{value}`: {message}")
109            }
110            Self::FieldRequired { field_name } => {
111                write!(f, "Field is required: `{field_name}`")
112            }
113            Self::Money(warning_kind) => write!(f, "{warning_kind}"),
114            Self::NoElements => f.write_str("The tariff has no `elements`"),
115            Self::NotActive => f.write_str("The tariff is not active for `Cdr::start_date_time`"),
116            Self::Number(warning_kind) => write!(f, "{warning_kind}"),
117            Self::ReservationElementSkipped => f.write_str(
118                "A tariff element has a `reservation` restriction and will not apply to regular \
119                 charging sessions. Reservation pricing is not supported.",
120            ),
121            Self::TotalCostClampedToMin => write!(
122                f,
123                "The given tariff has a `min_price` set and the `total_cost` fell below it."
124            ),
125            Self::TotalCostClampedToMax => write!(
126                f,
127                "The given tariff has a `max_price` set and the `total_cost` exceeded it."
128            ),
129        }
130    }
131}
132
133impl crate::Warning for Warning {
134    fn id(&self) -> warning::Id {
135        match self {
136            Self::String(warning) => warning.id(),
137            Self::Country(warning) => warning.id(),
138            Self::Currency(warning) => warning.id(),
139            Self::DateTime(warning) => warning.id(),
140            Self::Decode(warning) => warning.id(),
141            Self::Duration(warning) => warning.id(),
142            Self::Enum(warning) => warning.id(),
143            Self::FieldInvalidType { expected_type } => {
144                warning::Id::from_string(format!("field_invalid_type({expected_type})"))
145            }
146            Self::FieldInvalidValue { value, .. } => {
147                warning::Id::from_string(format!("field_invalid_value({value})"))
148            }
149            Self::FieldRequired { field_name } => {
150                warning::Id::from_string(format!("field_required({field_name})"))
151            }
152            Self::Money(warning) => warning.id(),
153            Self::NoElements => warning::Id::from_static("no_elements"),
154            Self::NotActive => warning::Id::from_static("not_active"),
155            Self::Number(warning) => warning.id(),
156            Self::ReservationElementSkipped => {
157                warning::Id::from_static("reservation_element_skipped")
158            }
159            Self::TotalCostClampedToMin => warning::Id::from_static("total_cost_clamped_to_min"),
160            Self::TotalCostClampedToMax => warning::Id::from_static("total_cost_clamped_to_max"),
161        }
162    }
163}
164
165from_warning_all!(
166    country::Warning => Warning::Country,
167    currency::Warning => Warning::Currency,
168    datetime::Warning => Warning::DateTime,
169    duration::Warning => Warning::Duration,
170    enumeration::Warning => Warning::Enum,
171    json::decode::Warning => Warning::Decode,
172    money::Warning => Warning::Money,
173    number::Warning => Warning::Number,
174    string::Warning => Warning::String
175);
176
177/// The five character ID of the CPO.
178///
179/// The first two characters are the ISO-3166 alpha-2 country code of the CPO.
180/// The remaining three characters are the ISO-15118 ID of the CPO.
181#[derive(Clone, Debug)]
182pub(crate) struct CpoId<'buf> {
183    /// The ISO-3166 alpha-2 country code.
184    pub country_code: country::Code,
185
186    /// The ISO-15118 ID.
187    pub id: string::CiExactLen<'buf, 3>,
188}
189
190/// Infer which OCPI [`Version`] a tariff [`json::Document`] is, without validating it.
191///
192/// Use this when the version of the tariff is not known up front. The [`json::Document`] is
193/// obtained by calling [`json::parse_object`]. The returned [`guess::TariffVersion`] is either
194/// [`Certain`](guess::Version::Certain) or [`Uncertain`](guess::Version::Uncertain) about the version.
195///
196/// To check the tariff against the OCPI schema for a known [`Version`], use [`build`].
197///
198/// # Example
199///
200/// ```rust
201/// # use ocpi_tariffs::{json, tariff, Version};
202/// #
203/// # const TARIFF_JSON: &str = include_str!("tariff.json");
204///
205/// let doc = json::parse_object(TARIFF_JSON)?;
206/// let tariff = tariff::infer_version(doc).certain_or(Version::V221);
207///
208/// # Ok::<(), json::ParseError>(())
209/// ```
210pub fn infer_version(json: json::Document<'_>) -> guess::TariffVersion<'_> {
211    guess::tariff_version(json)
212}
213
214/// Build and validate a [`json::Document`] against the OCPI tariff schema for the given [`Version`][^spec-v211][^spec-v221].
215///
216/// The [`json::Document`] is obtained by calling [`json::parse_object`]. Any unexpected, missing,
217/// or wrongly typed fields are reported as a [`warning::Set`] of [`schema::Warning`]s carried by
218/// the returned [`Caveat`].
219///
220/// # Example
221///
222/// ```rust
223/// # use ocpi_tariffs::{json, tariff, Version};
224/// #
225/// # const TARIFF_JSON: &str = include_str!("tariff.json");
226///
227/// let doc = json::parse_object(TARIFF_JSON)?;
228/// let (tariff, warnings) = tariff::build(doc, Version::V211).into_parts();
229///
230/// if !warnings.is_empty() {
231///     eprintln!("The tariff has `{}` schema warnings.", warnings.len_warnings());
232/// }
233///
234/// # Ok::<(), json::ParseError>(())
235/// ```
236///
237/// [^spec-v211]: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md>
238/// [^spec-v221]: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>
239pub fn build(
240    json: json::Document<'_>,
241    version: crate::Version,
242) -> Caveat<Versioned<'_>, schema::Warning> {
243    let (version, warnings) = match version {
244        crate::Version::V221 => {
245            let (tariff, warnings) = schema::v221::build_tariff(&json).into_parts();
246            (Version::V221(tariff), warnings)
247        }
248        crate::Version::V211 => {
249            let (tariff, warnings) = schema::v211::build_tariff(&json).into_parts();
250            (Version::V211(tariff), warnings)
251        }
252    };
253    let versioned = Versioned { doc: json, version };
254    versioned.into_caveat(warnings)
255}
256
257/// Validate a [`VersionedJson`] against the OCPI tariff schema for its known [`Version`].
258///
259/// Use this when the [`Version`] has already been resolved - for example a
260/// [`VersionedJson`] obtained from [`infer_version`] via [`certain_or`](guess::Version::certain_or).
261pub fn build_versioned(json: VersionedJson<'_>) -> Caveat<Versioned<'_>, schema::Warning> {
262    let VersionedJson { doc, version } = json;
263    build(doc, version)
264}
265
266/// A `json::Document` that has been processed by [`infer_version`] and has been identified
267/// as being a concrete [`Version`].
268#[derive(Clone)]
269pub struct VersionedJson<'buf> {
270    /// The parsed JSON.
271    doc: json::Document<'buf>,
272
273    /// The `Version` of the tariff, determined during parsing.
274    version: crate::Version,
275}
276
277/// A `json::Document` that has been processed by [`build`] or [`build_versioned`].
278#[derive(Clone)]
279pub struct Versioned<'buf> {
280    /// The parsed JSON.
281    doc: json::Document<'buf>,
282
283    /// The `Version` of the tariff, determined during parsing.
284    version: Version<'buf>,
285}
286
287impl fmt::Debug for Versioned<'_> {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        if f.alternate() {
290            match &self.version {
291                Version::V211(tariff) => fmt::Debug::fmt(&tariff, f),
292                Version::V221(tariff) => fmt::Debug::fmt(&tariff, f),
293            }
294        } else {
295            match &self.version {
296                Version::V211(_) => f.write_str("V211"),
297                Version::V221(_) => f.write_str("V221"),
298            }
299        }
300    }
301}
302
303impl crate::Versioned for Versioned<'_> {
304    fn version(&self) -> crate::Version {
305        match self.version {
306            Version::V211(_) => crate::Version::V211,
307            Version::V221(_) => crate::Version::V221,
308        }
309    }
310}
311
312impl<'buf> Versioned<'buf> {
313    /// Return the inner [`json::Document`] and discard the version info.
314    pub fn into_doc(self) -> json::Document<'buf> {
315        self.doc
316    }
317
318    /// Return the inner [`json::Element`] and discard the version info.
319    pub fn as_element(&self) -> &json::Element<'buf> {
320        self.doc.root()
321    }
322
323    /// Return the inner [`json::Document`] and discard the version info.
324    pub fn as_doc(&self) -> &json::Document<'buf> {
325        &self.doc
326    }
327
328    /// Return the inner JSON `str` and discard the version info.
329    pub fn as_json_str(&self) -> &'buf str {
330        self.doc.source()
331    }
332}
333
334#[expect(
335    clippy::large_enum_variant,
336    reason = "the v2.1.1 and v2.2.1 tariff IRs differ in size; this short-lived versioned \
337              value is not worth boxing"
338)]
339#[derive(Clone)]
340enum Version<'buf> {
341    V211(schema::v211::Tariff<'buf>),
342    V221(schema::v221::Tariff<'buf>),
343}
344
345impl fmt::Debug for VersionedJson<'_> {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        if f.alternate() {
348            fmt::Debug::fmt(&self.doc, f)
349        } else {
350            match self.version {
351                crate::Version::V211 => f.write_str("V211"),
352                crate::Version::V221 => f.write_str("V221"),
353            }
354        }
355    }
356}
357
358impl crate::Versioned for VersionedJson<'_> {
359    fn version(&self) -> crate::Version {
360        self.version
361    }
362}
363
364impl<'buf> VersionedJson<'buf> {
365    /// Create a new `Versioned` object.
366    pub(crate) fn new(doc: json::Document<'buf>, version: crate::Version) -> Self {
367        Self { doc, version }
368    }
369
370    /// Return the inner [`json::Document`] and discard the version info.
371    pub fn into_doc(self) -> json::Document<'buf> {
372        self.doc
373    }
374
375    /// Return the inner [`json::Element`] and discard the version info.
376    pub fn as_element(&self) -> &json::Element<'buf> {
377        self.doc.root()
378    }
379
380    /// Return the inner [`json::Document`] and discard the version info.
381    pub fn as_doc(&self) -> &json::Document<'buf> {
382        &self.doc
383    }
384
385    /// Return the inner JSON `str` and discard the version info.
386    pub fn as_json_str(&self) -> &'buf str {
387        self.doc.source()
388    }
389}
390
391/// A [`json::Document`] that has been processed by [`infer_version`]
392/// and was determined to not be one of the supported [`Version`]s.
393#[derive(Debug)]
394pub struct Unversioned<'buf> {
395    doc: json::Document<'buf>,
396}
397
398impl<'buf> Unversioned<'buf> {
399    /// Create an unversioned [`json::Element`].
400    pub(crate) fn new(elem: json::Document<'buf>) -> Self {
401        Self { doc: elem }
402    }
403
404    /// Return the inner [`json::Document`] and discard the version info.
405    pub fn into_doc(self) -> json::Document<'buf> {
406        self.doc
407    }
408
409    /// Return the inner [`json::Element`] and discard the version info.
410    pub fn as_element(&self) -> &json::Element<'buf> {
411        self.doc.root()
412    }
413}
414
415impl<'buf> crate::Unversioned for Unversioned<'buf> {
416    type Versioned = VersionedJson<'buf>;
417
418    fn force_into_versioned(self, version: crate::Version) -> VersionedJson<'buf> {
419        let Self { doc } = self;
420        VersionedJson { doc, version }
421    }
422}
423
424/// Lint the given tariff and return a [`lint::tariff::Report`] of any `Warning`s found.
425///
426/// # Example
427///
428/// ```rust
429/// # use ocpi_tariffs::{guess, json, tariff, warning};
430/// #
431/// # const TARIFF_JSON: &str = include_str!("tariff.json");
432///
433/// let doc = json::parse_object(TARIFF_JSON)?;
434/// let guess::Version::Certain(tariff) = tariff::infer_version(doc) else {
435///     return Err("Unable to guess the version of given tariff JSON.".into());
436/// };
437/// let tariff = tariff::build_versioned(tariff).ignore_warnings();
438///
439/// let report = tariff::lint(&tariff);
440///
441/// eprintln!("`{}` lint warnings found", report.warnings.len_warnings());
442///
443/// for group in report.warnings {
444///     let (element, warnings) = group.to_parts();
445///     eprintln!(
446///         "Warnings reported for `json::Element` at path: `{}`",
447///         element.path
448///     );
449///
450///     for warning in warnings {
451///         eprintln!("  * {warning}");
452///     }
453///
454///     eprintln!();
455/// }
456///
457/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
458/// ```
459pub fn lint(tariff: &Versioned<'_>) -> lint::tariff::Report {
460    lint::tariff(tariff)
461}
462
463/// Explain the given tariff in human language, returning the explanation as Markdown.
464///
465/// The tariff is parsed into the normalized `v2.2.1` form first, so a `v2.1.1` tariff is explained as
466/// its `v2.2.1` equivalent. Warnings raised while parsing are returned alongside the explanation; a
467/// hard parse failure returns an [`ErrorSet`](warning::ErrorSet) instead.
468///
469/// # Example
470///
471/// ```rust
472/// # use ocpi_tariffs::{guess, json, tariff};
473/// #
474/// # const TARIFF_JSON: &str = include_str!("tariff.json");
475///
476/// let json = json::parse_object(TARIFF_JSON).unwrap();
477/// let version = tariff::infer_version(json);
478/// let tariff = tariff::build_versioned(version.certain_or_none().unwrap()).ignore_warnings();
479///
480/// let Ok(explanation) = tariff::explain(&tariff) else {
481///     return Err("The tariff could not be parsed well enough to explain.".into());
482/// };
483///
484/// println!("{}", explanation.ignore_warnings());
485///
486/// # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
487/// ```
488pub fn explain(tariff: &Versioned<'_>) -> crate::Verdict<String, Warning> {
489    explain::tariff(tariff)
490}