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