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}