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