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}