ocpi_tariffs/lint/tariff/
v2x.rs

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