Skip to main content

ocpi_tariffs/
tariff.rs

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