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