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.insert(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                Self::ContainsEntireDay => f.write_str("Both `start_time` and `end_time` are defined and contain the entire day."),
110                Self::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                Self::NeverValid => f.write_str("The `start_time` and `end_time` are equal and so the element is never valid."),
117                Self::DateTime(kind) => fmt::Display::fmt(kind, f),
118            }
119        }
120    }
121
122    impl crate::Warning for Warning {
123        fn id(&self) -> warning::Id {
124            match self {
125                Self::ContainsEntireDay => warning::Id::from_static("contains_entire_day"),
126                Self::EndTimeIsNearEndOfDay => {
127                    warning::Id::from_static("end_time_is_near_end_of_day")
128                }
129                Self::NeverValid => warning::Id::from_static("never_valid"),
130                Self::DateTime(kind) => kind.id(),
131            }
132        }
133    }
134
135    from_warning_all!(datetime::Warning => Warning::DateTime);
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<(), Warning> {
142        let mut warnings = warning::Set::<Warning>::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.insert(Warning::EndTimeIsNearEndOfDay, end_elem);
151            }
152
153            if start_time == DAY_BOUNDARY && is_day_end(end_time) {
154                warnings.insert(Warning::ContainsEntireDay, start_elem);
155            } else if start_time == end_time {
156                warnings.insert(Warning::NeverValid, start_elem);
157            }
158        } else if let Some((start_time, start_elem)) = start {
159            if start_time == DAY_BOUNDARY {
160                warnings.insert(Warning::ContainsEntireDay, start_elem);
161            }
162        } else if let Some((end_time, end_elem)) = end {
163            if is_day_end(end_time) {
164                warnings.insert(Warning::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, 'buf>(
195        time_elem: Option<&'a json::Element<'buf>>,
196        warnings: &mut warning::Set<Warning>,
197    ) -> Result<Option<(HourMin, &'a json::Element<'buf>)>, warning::ErrorSet<Warning>> {
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::fmt;
216
217    use tracing::instrument;
218
219    use crate::{
220        from_warning_all,
221        json::{self, FieldsAsExt as _},
222        warning::{self, GatherWarnings as _, IntoCaveat as _},
223        Verdict,
224    };
225
226    use super::restrictions;
227
228    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
229    pub enum Warning {
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 { type_found: json::ValueKind },
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::Warning),
241    }
242
243    impl Warning {
244        fn invalid_type(elem: &json::Element<'_>) -> Self {
245            Self::InvalidType {
246                type_found: elem.value().kind(),
247            }
248        }
249    }
250
251    impl fmt::Display for Warning {
252        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253            match self {
254                Self::Empty => write!(
255                    f,
256                    "An empty list of days means that no day is allowed. Is this what you want?"
257                ),
258                Self::InvalidType { type_found } => {
259                    write!(f, "The value should be an array but is `{type_found}`")
260                }
261                Self::RequiredField => write!(f, "The `$.elements` field is required."),
262                Self::Restrictions(kind) => fmt::Display::fmt(kind, f),
263            }
264        }
265    }
266
267    impl crate::Warning for Warning {
268        fn id(&self) -> warning::Id {
269            match self {
270                Self::Empty => warning::Id::from_static("empty"),
271                Self::InvalidType { .. } => warning::Id::from_static("invalid_type"),
272                Self::RequiredField => warning::Id::from_static("required"),
273                Self::Restrictions(kind) => kind.id(),
274            }
275        }
276    }
277
278    from_warning_all!(restrictions::Warning => Warning::Restrictions);
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<(), Warning> {
285        let mut warnings = warning::Set::<Warning>::new();
286
287        // The `elements` field should be an array.
288        let Some(items) = elem.as_array() else {
289            return warnings.bail(Warning::invalid_type(elem), elem);
290        };
291
292        // The `elements` array should contain at least one `Element`.
293        if items.is_empty() {
294            return warnings.bail(Warning::Empty, elem);
295        }
296
297        for ocpi_element in items {
298            let Some(fields) = ocpi_element.as_object_fields() else {
299                return warnings.bail(Warning::invalid_type(elem), ocpi_element);
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()).gather_warnings_into(&mut warnings)?;
307            }
308        }
309
310        Ok(().into_caveat(warnings))
311    }
312}
313
314pub mod restrictions {
315    //! The linting and Warning infrastructure for the `restriction` field.
316    //!
317    //! * 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>)
318    //! * 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>)
319
320    use std::fmt;
321
322    use tracing::instrument;
323
324    use crate::{
325        from_warning_all,
326        json::{self, FieldsAsExt as _},
327        warning::{self, DeescalateError, GatherWarnings as _, IntoCaveat as _},
328        Verdict,
329    };
330
331    use super::{time, weekday};
332
333    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
334    pub enum Warning {
335        /// The `day_of_week` field is nested in the `restrictions` object.
336        Weekday(weekday::Warning),
337
338        /// The JSON value given is not an object.
339        InvalidType { type_found: json::ValueKind },
340
341        /// The `start_time` and `end_time` fields are nested in the `restrictions` object.
342        Time(time::Warning),
343    }
344
345    impl Warning {
346        fn invalid_type(elem: &json::Element<'_>) -> Self {
347            Self::InvalidType {
348                type_found: elem.value().kind(),
349            }
350        }
351    }
352
353    impl fmt::Display for Warning {
354        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355            match self {
356                Self::Weekday(kind) => fmt::Display::fmt(kind, f),
357                Self::InvalidType { type_found } => {
358                    write!(f, "The value should be an object but is `{type_found}`")
359                }
360                Self::Time(kind) => fmt::Display::fmt(kind, f),
361            }
362        }
363    }
364
365    impl crate::Warning for Warning {
366        fn id(&self) -> warning::Id {
367            match self {
368                Self::Weekday(kind) => kind.id(),
369                Self::InvalidType { .. } => warning::Id::from_static("invalid_type"),
370                Self::Time(kind) => kind.id(),
371            }
372        }
373    }
374
375    from_warning_all!(
376        weekday::Warning => Warning::Weekday,
377        time::Warning => Warning::Time
378    );
379
380    /// Lint the `restrictions` field.
381    #[instrument(skip_all)]
382    pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), Warning> {
383        let mut warnings = warning::Set::<Warning>::new();
384
385        let Some(fields) = elem.as_object_fields() else {
386            return warnings.bail(Warning::invalid_type(elem), elem);
387        };
388
389        let fields = fields.as_raw_map();
390
391        {
392            let start_time = fields.get("start_time").map(|e| &**e);
393            let end_time = fields.get("end_time").map(|e| &**e);
394
395            let _drop: Option<()> = time::lint(start_time, end_time)
396                .gather_warnings_into(&mut warnings)
397                .deescalate_error_into(&mut warnings);
398        }
399
400        {
401            let day_of_week = fields.get("day_of_week").map(|e| &**e);
402
403            let _drop: Option<()> = weekday::lint(day_of_week)
404                .gather_warnings_into(&mut warnings)
405                .deescalate_error_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::{collections::BTreeSet, fmt, sync::LazyLock};
421
422    use crate::{
423        from_warning_all,
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 Warning {
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::Warning),
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 { type_found: json::ValueKind },
445
446        /// The days are unsorted.
447        Unsorted,
448    }
449
450    impl Warning {
451        fn invalid_type(elem: &json::Element<'_>) -> Self {
452            Self::InvalidType {
453                type_found: elem.value().kind(),
454            }
455        }
456    }
457
458    impl fmt::Display for Warning {
459        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
460            match self {
461                Self::ContainsEntireWeek => write!(f, "All days of the week are defined. You can simply leave out the `day_of_week` field."),
462                Self::Weekday(kind) => fmt::Display::fmt(kind, f),
463                Self::Duplicates => write!(f, "There's at least one duplicate day."),
464                Self::Empty => write!(
465                    f,
466                    "An empty list of days means that no day is allowed. Is this what you want?"
467                ),
468                Self::InvalidType { type_found } => {
469                    write!(f, "The value should be an array but is `{type_found}`")
470                }
471                Self::Unsorted => write!(f, "The days are unsorted."),
472            }
473        }
474    }
475
476    impl crate::Warning for Warning {
477        fn id(&self) -> warning::Id {
478            match self {
479                Self::ContainsEntireWeek => warning::Id::from_static("contains_entire_week"),
480                Self::Weekday(kind) => kind.id(),
481                Self::Duplicates => warning::Id::from_static("duplicates"),
482                Self::Empty => warning::Id::from_static("empty"),
483                Self::InvalidType { .. } => warning::Id::from_static("invalid_type"),
484                Self::Unsorted => warning::Id::from_static("unsorted"),
485            }
486        }
487    }
488
489    from_warning_all!(crate::weekday::Warning => Warning::Weekday);
490
491    /// Lint the `day_of_week` field.
492    pub(crate) fn lint(elem: Option<&json::Element<'_>>) -> Verdict<(), Warning> {
493        /// This is the correct order of the days of the week.
494        static ALL_DAYS_OF_WEEK: LazyLock<BTreeSet<Weekday>> = LazyLock::new(|| {
495            BTreeSet::from([
496                Weekday::Monday,
497                Weekday::Tuesday,
498                Weekday::Wednesday,
499                Weekday::Thursday,
500                Weekday::Friday,
501                Weekday::Saturday,
502                Weekday::Sunday,
503            ])
504        });
505
506        let mut warnings = warning::Set::<Warning>::new();
507
508        // The `day_of_week` field is optional.
509        let Some(elem) = elem else {
510            return Ok(().into_caveat(warnings));
511        };
512
513        // The `day_of_week` field should be an array.
514        let Some(items) = elem.as_array() else {
515            return warnings.bail(Warning::invalid_type(elem), elem);
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.insert(Warning::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.insert(Warning::Unsorted, elem);
537        }
538
539        let day_set: BTreeSet<_> = days.iter().copied().collect();
540
541        // If the set length 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.insert(Warning::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.insert(Warning::ContainsEntireWeek, elem);
551        }
552
553        Ok(().into_caveat(warnings))
554    }
555}