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