Skip to main content

ocpi_tariffs/lint/tariff/
v2x.rs

1//! The collection of lints that apply to all supported OCPI versions.
2
3pub(crate) mod currency {
4    use tracing::{debug, instrument};
5
6    use crate::{
7        currency,
8        json::{self, FromJson as _},
9        warning::{self, GatherWarnings as _, IntoCaveat as _},
10        Verdict,
11    };
12
13    /// Validate the `country_code` field.
14    #[instrument(skip_all)]
15    pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), currency::Warning> {
16        let mut warnings = warning::Set::<currency::Warning>::new();
17        let code = currency::Code::from_json(elem)?.gather_warnings_into(&mut warnings);
18
19        debug!("code: {code:?}");
20
21        Ok(().into_caveat(warnings))
22    }
23}
24
25pub(crate) mod datetime {
26    use chrono::{DateTime, Utc};
27    use tracing::instrument;
28
29    use crate::{
30        json::{self, FromJson as _},
31        lint::tariff::Warning,
32        warning::{self, GatherWarnings as _, IntoCaveat as _},
33        Verdict,
34    };
35
36    /// Lint both `start_date_time` and `end_date_time`.
37    ///
38    /// It's allowed for the `start_date_time` to be equal to the `end_date_time` but the
39    /// `start_date_time` should not be greater than the `end_date_time`.
40    #[instrument(skip_all)]
41    pub(crate) fn lint_start_end(
42        start_date_time_elem: Option<&json::Element<'_>>,
43        end_date_time_elem: Option<&json::Element<'_>>,
44    ) -> Verdict<(), Warning> {
45        let mut warnings = warning::Set::<Warning>::new();
46
47        let start_date = start_date_time_elem
48            .map(DateTime::<Utc>::from_json)
49            .transpose()?
50            .gather_warnings_into(&mut warnings);
51        let end_date = end_date_time_elem
52            .map(DateTime::<Utc>::from_json)
53            .transpose()?
54            .gather_warnings_into(&mut warnings);
55
56        if let Some(((start, start_elem), end)) = start_date.zip(start_date_time_elem).zip(end_date)
57        {
58            if start > end {
59                warnings.with_elem(Warning::StartDateTimeIsAfterEndDateTime, start_elem);
60            }
61        }
62
63        Ok(().into_caveat(warnings))
64    }
65}
66
67pub mod time {
68    //! Linting and warning infrastructure for the `start_time` and `end_time` fields.
69    //!
70    //! * See: [OCPI spec 2.2.1: Tariff Restrictions](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>)
71    //! * See: [OCPI spec 2.1.1: Tariff Restrictions](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#45-tariffrestrictions-class>)
72    use std::fmt;
73
74    use chrono::{NaiveTime, Timelike as _};
75
76    use crate::{
77        datetime, from_warning_all,
78        json::{self, FromJson as _},
79        warning::{self, GatherWarnings as _, IntoCaveat as _},
80        Verdict,
81    };
82
83    const DAY_BOUNDARY: HourMin = HourMin::new(0, 0);
84    const NEAR_END_OF_DAY: HourMin = HourMin::new(23, 59);
85
86    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
87    pub enum Warning {
88        /// Both `start_time` and `end_time` are defined and contain the entire day,
89        /// making the restriction superfluous.
90        ContainsEntireDay,
91
92        /// The `end_time` restriction is set to `23::59`.
93        ///
94        /// The spec states: "To stop at end of the day use: 00:00.".
95        ///
96        /// * See: [OCPI spec 2.2.1: Tariff Restrictions](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>)
97        EndTimeIsNearEndOfDay,
98
99        /// The `start_time` and `end_time` are equal and so the element is never valid.
100        NeverValid,
101
102        /// Each field needs to be a valid time.
103        DateTime(datetime::Warning),
104    }
105
106    impl fmt::Display for Warning {
107        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108            match self {
109                Warning::ContainsEntireDay => f.write_str("Both `start_time` and `end_time` are defined and contain the entire day."),
110                Warning::EndTimeIsNearEndOfDay => f.write_str(r#"
111The `end_time` restriction is set to `23::59`.
112
113The spec states: "To stop at end of the day use: 00:00.".
114
115See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>"#),
116                Warning::NeverValid => f.write_str("The `start_time` and `end_time` are equal and so the element is never valid."),
117                Warning::DateTime(kind) => fmt::Display::fmt(kind, f),
118            }
119        }
120    }
121
122    impl crate::Warning for Warning {
123        fn id(&self) -> crate::SmartString {
124            match self {
125                Warning::ContainsEntireDay => "contains_entire_day".into(),
126                Warning::EndTimeIsNearEndOfDay => "end_time_is_near_end_of_day".into(),
127                Warning::NeverValid => "never_valid".into(),
128                Warning::DateTime(kind) => kind.id(),
129            }
130        }
131    }
132
133    from_warning_all!(datetime::Warning => Warning::DateTime);
134
135    /// Lint the `start_time` and `end_time` field.
136    pub(crate) fn lint(
137        start_time_elem: Option<&json::Element<'_>>,
138        end_time_elem: Option<&json::Element<'_>>,
139    ) -> Verdict<(), Warning> {
140        let mut warnings = warning::Set::<Warning>::new();
141
142        let start = elem_to_time_hm(start_time_elem, &mut warnings)?;
143        let end = elem_to_time_hm(end_time_elem, &mut warnings)?;
144
145        // If both `start_time` and `end_time` are defined, then perform range linting.
146        if let Some(((start_time, start_elem), (end_time, end_elem))) = start.zip(end) {
147            if end_time == NEAR_END_OF_DAY {
148                warnings.with_elem(Warning::EndTimeIsNearEndOfDay, end_elem);
149            }
150
151            if start_time == DAY_BOUNDARY && is_day_end(end_time) {
152                warnings.with_elem(Warning::ContainsEntireDay, start_elem);
153            } else if start_time == end_time {
154                warnings.with_elem(Warning::NeverValid, start_elem);
155            }
156        } else if let Some((start_time, start_elem)) = start {
157            if start_time == DAY_BOUNDARY {
158                warnings.with_elem(Warning::ContainsEntireDay, start_elem);
159            }
160        } else if let Some((end_time, end_elem)) = end {
161            if is_day_end(end_time) {
162                warnings.with_elem(Warning::ContainsEntireDay, end_elem);
163            }
164        }
165
166        Ok(().into_caveat(warnings))
167    }
168
169    /// The time of day represented as hour and minute.
170    #[derive(Copy, Clone, Eq, PartialEq)]
171    struct HourMin {
172        /// Hour of the day. Stored as `u32` because that's what `chrono` returns from `NaiveTime::hour()`.
173        hour: u32,
174
175        /// Minute of the hour. Stored as `u32` because that's what `chrono` returns from `NaiveTime::minute()`.
176        min: u32,
177    }
178
179    impl HourMin {
180        /// Create a new `HourMin` time.
181        const fn new(hour: u32, min: u32) -> Self {
182            Self { hour, min }
183        }
184    }
185
186    /// Return true if the given time is close to or at the end of day.
187    fn is_day_end(time: HourMin) -> bool {
188        time == NEAR_END_OF_DAY || time == DAY_BOUNDARY
189    }
190
191    /// Return `Ok((HourMin, json::Element))` if the given [`json::Element`] is a valid [`NaiveTime`].
192    fn elem_to_time_hm<'a, 'bin>(
193        time_elem: Option<&'a json::Element<'bin>>,
194        warnings: &mut warning::Set<Warning>,
195    ) -> Result<Option<(HourMin, &'a json::Element<'bin>)>, warning::ErrorSet<Warning>> {
196        let v = time_elem.map(NaiveTime::from_json).transpose()?;
197
198        Ok(v.gather_warnings_into(warnings)
199            .map(|t| HourMin {
200                hour: t.hour(),
201                min: t.minute(),
202            })
203            .zip(time_elem))
204    }
205}
206
207pub mod elements {
208    //! The linting and Warning infrastructure for the `elements` field.
209    //!
210    //! * See: [OCPI spec 2.2.1: Tariff Element](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#144-tariffelement-class>)
211    //! * See: [OCPI spec 2.1.1: Tariff Element](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#43-tariffelement-class>)
212
213    use std::fmt;
214
215    use tracing::instrument;
216
217    use crate::{
218        from_warning_all,
219        json::{self, FieldsAsExt as _},
220        warning::{self, GatherWarnings as _, IntoCaveat as _},
221        Verdict,
222    };
223
224    use super::restrictions;
225
226    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
227    pub enum Warning {
228        /// The array exists but is empty. This means that no day is allowed.
229        Empty,
230
231        /// The JSON value given is not an array.
232        InvalidType,
233
234        /// There is no `elements` array and it's required.
235        RequiredField,
236
237        /// The `restriction` field is nested in the `elements` array.
238        Restrictions(restrictions::Warning),
239    }
240
241    impl fmt::Display for Warning {
242        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243            match self {
244                Warning::Empty => write!(
245                    f,
246                    "An empty list of days means that no day is allowed. Is this what you want?"
247                ),
248                Warning::InvalidType => write!(f, "The value should be an array."),
249                Warning::RequiredField => write!(f, "The `$.elements` field is required."),
250                Warning::Restrictions(kind) => fmt::Display::fmt(kind, f),
251            }
252        }
253    }
254
255    impl crate::Warning for Warning {
256        fn id(&self) -> crate::SmartString {
257            match self {
258                Warning::Empty => "empty".into(),
259                Warning::InvalidType => "invalid_type".into(),
260                Warning::RequiredField => "required".into(),
261                Warning::Restrictions(kind) => kind.id(),
262            }
263        }
264    }
265
266    from_warning_all!(restrictions::Warning => Warning::Restrictions);
267
268    /// Lint the `elements` field.
269    ///
270    /// * See: [OCPI v2.2.1 Tariff Element](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#mod_tariffs_tariffelement_class>)
271    #[instrument(skip_all)]
272    pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), Warning> {
273        let mut warnings = warning::Set::<Warning>::new();
274
275        // The `elements` field should be an array.
276        let Some(items) = elem.as_array() else {
277            return warnings.bail(Warning::InvalidType, elem);
278        };
279
280        // The `elements` array should contain at least one `Element`.
281        if items.is_empty() {
282            return warnings.bail(Warning::Empty, elem);
283        }
284
285        for ocpi_element in items {
286            let Some(fields) = ocpi_element.as_object_fields() else {
287                return warnings.bail(Warning::InvalidType, ocpi_element);
288            };
289
290            let restrictions = fields.find_field("restrictions");
291
292            // The `restrictions` field is optional
293            if let Some(field) = restrictions {
294                restrictions::lint(field.element()).gather_warnings_into(&mut warnings)?;
295            }
296        }
297
298        Ok(().into_caveat(warnings))
299    }
300}
301
302pub mod restrictions {
303    //! The linting and Warning infrastructure for the `restriction` field.
304    //!
305    //! * See: [OCPI spec 2.2.1: Tariff Restrictions](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#mod_tariffs_tariffrestrictions_class>)
306    //! * See: [OCPI spec 2.1.1: Tariff Restrictions](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#45-tariffrestrictions-class>)
307
308    use std::fmt;
309
310    use tracing::instrument;
311
312    use crate::{
313        from_warning_all,
314        json::{self, FieldsAsExt as _},
315        warning::{self, DeescalateError, GatherWarnings as _, IntoCaveat as _},
316        Verdict,
317    };
318
319    use super::{time, weekday};
320
321    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
322    pub enum Warning {
323        /// The `day_of_week` field is nested in the `restrictions` object.
324        Weekday(weekday::Warning),
325
326        /// The JSON value given is not an array.
327        InvalidType,
328
329        /// The `start_time` and `end_time` fields are nested in the `restrictions` object.
330        Time(time::Warning),
331    }
332
333    impl fmt::Display for Warning {
334        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335            match self {
336                Warning::Weekday(kind) => fmt::Display::fmt(kind, f),
337                Warning::InvalidType => write!(f, "The value should be an object."),
338                Warning::Time(kind) => fmt::Display::fmt(kind, f),
339            }
340        }
341    }
342
343    impl crate::Warning for Warning {
344        fn id(&self) -> crate::SmartString {
345            match self {
346                Warning::Weekday(kind) => kind.id(),
347                Warning::InvalidType => "invalid_type".into(),
348                Warning::Time(kind) => kind.id(),
349            }
350        }
351    }
352
353    from_warning_all!(
354        weekday::Warning => Warning::Weekday,
355        time::Warning => Warning::Time
356    );
357
358    /// Lint the `restrictions` field.
359    #[instrument(skip_all)]
360    pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), Warning> {
361        let mut warnings = warning::Set::<Warning>::new();
362
363        let Some(fields) = elem.as_object_fields() else {
364            return warnings.bail(Warning::InvalidType, elem);
365        };
366
367        let fields = fields.as_raw_map();
368
369        {
370            let start_time = fields.get("start_time").map(|e| &**e);
371            let end_time = fields.get("end_time").map(|e| &**e);
372
373            let _drop: Option<()> = time::lint(start_time, end_time)
374                .gather_warnings_into(&mut warnings)
375                .deescalate_error_into(&mut warnings);
376        }
377
378        {
379            let day_of_week = fields.get("day_of_week").map(|e| &**e);
380
381            let _drop: Option<()> = weekday::lint(day_of_week)
382                .gather_warnings_into(&mut warnings)
383                .deescalate_error_into(&mut warnings);
384        }
385
386        Ok(().into_caveat(warnings))
387    }
388}
389
390pub mod weekday {
391    //! Linting and warning infrastructure for the `day_of_week` field.
392    //!
393    //! * See: [OCPI spec 2.2.1: Tariff Restrictions](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>)
394    //! * See: [OCPI spec 2.2.1: Tariff DayOfWeek](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#mod_tariffs_dayofweek_enum>)
395    //! * See: [OCPI spec 2.1.1: Tariff Restrictions](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#45-tariffrestrictions-class>)
396    //! * See: [OCPI spec 2.1.1: Tariff DayOfWeek](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#41-dayofweek-enum>)
397
398    use std::{collections::BTreeSet, fmt, sync::LazyLock};
399
400    use crate::{
401        from_warning_all,
402        json::{self, FromJson},
403        warning::{self, GatherWarnings as _, IntoCaveat as _},
404        Verdict, Weekday,
405    };
406
407    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
408    pub enum Warning {
409        /// The list contains all days of the week.
410        ContainsEntireWeek,
411
412        /// Each field needs to be a valid weekday.
413        Weekday(crate::weekday::Warning),
414
415        /// There is at least one duplicate day.
416        Duplicates,
417
418        /// An empty array means that no day is allowed.
419        Empty,
420
421        /// The JSON value given is not an array.
422        InvalidType,
423
424        /// The days are unsorted.
425        Unsorted,
426    }
427
428    impl fmt::Display for Warning {
429        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430            match self {
431                Warning::ContainsEntireWeek => write!(f, "All days of the week are defined. You can simply leave out the `day_of_week` field."),
432                Warning::Weekday(kind) => fmt::Display::fmt(kind, f),
433                Warning::Duplicates => write!(f, "There's at least one duplicate day."),
434                Warning::Empty => write!(
435                    f,
436                    "An empty list of days means that no day is allowed. Is this what you want?"
437                ),
438                Warning::InvalidType => write!(f, "The value should be an array."),
439                Warning::Unsorted => write!(f, "The days are unsorted."),
440            }
441        }
442    }
443
444    impl crate::Warning for Warning {
445        fn id(&self) -> crate::SmartString {
446            match self {
447                Warning::ContainsEntireWeek => "contains_entire_week".into(),
448                Warning::Weekday(kind) => kind.id(),
449                Warning::Duplicates => "duplicates".into(),
450                Warning::Empty => "empty".into(),
451                Warning::InvalidType => "invalid_type".into(),
452                Warning::Unsorted => "unsorted".into(),
453            }
454        }
455    }
456
457    from_warning_all!(crate::weekday::Warning => Warning::Weekday);
458
459    /// Lint the `day_of_week` field.
460    pub(crate) fn lint(elem: Option<&json::Element<'_>>) -> Verdict<(), Warning> {
461        /// This is the correct order of the days of the week.
462        static ALL_DAYS_OF_WEEK: LazyLock<BTreeSet<Weekday>> = LazyLock::new(|| {
463            BTreeSet::from([
464                Weekday::Monday,
465                Weekday::Tuesday,
466                Weekday::Wednesday,
467                Weekday::Thursday,
468                Weekday::Friday,
469                Weekday::Saturday,
470                Weekday::Sunday,
471            ])
472        });
473
474        let mut warnings = warning::Set::<Warning>::new();
475
476        // The `day_of_week` field is optional.
477        let Some(elem) = elem else {
478            return Ok(().into_caveat(warnings));
479        };
480
481        // The `day_of_week` field should be an array.
482        let Some(items) = elem.as_array() else {
483            return warnings.bail(Warning::InvalidType, elem);
484        };
485
486        // Issue a warning if the `day_of_week` array is defined but empty.
487        // This can be a user misunderstanding.
488        if items.is_empty() {
489            warnings.with_elem(Warning::Empty, elem);
490            return Ok(().into_caveat(warnings));
491        }
492
493        // Convert each array item to a day and bail out on serious errors.
494        let days = items
495            .iter()
496            .map(Weekday::from_json)
497            .collect::<Result<Vec<_>, _>>()?;
498
499        // Collect warnings from the conversion of each array item to a day.
500        let days = days.gather_warnings_into(&mut warnings);
501
502        // Issue a warning if the days are not sorted.
503        if !days.is_sorted() {
504            warnings.with_elem(Warning::Unsorted, elem);
505        }
506
507        let day_set: BTreeSet<_> = days.iter().copied().collect();
508
509        // If the set length is less than the list, that means at least one duplicate was removed
510        // during the conversion.
511        if day_set.len() != days.len() {
512            warnings.with_elem(Warning::Duplicates, elem);
513        }
514
515        // Issue a warning of all days of the week are defined.
516        // This is equivalent to not defining the `day_of_week` array.
517        if day_set == *ALL_DAYS_OF_WEEK {
518            warnings.with_elem(Warning::ContainsEntireWeek, elem);
519        }
520
521        Ok(().into_caveat(warnings))
522    }
523}