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}