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::{borrow::Cow, collections::HashSet, fmt};
216
217    use tracing::instrument;
218
219    use crate::{
220        from_warning_all,
221        json::{self, FieldsAsExt as _},
222        lint::Item,
223        required_field,
224        tariff::v2x::DimensionType,
225        warning::{self, DeescalateError as _, GatherWarnings as _, IntoCaveat as _},
226        Verdict,
227    };
228
229    use super::{price_components, restrictions};
230
231    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
232    pub enum Warning {
233        /// The array exists but is empty.
234        Empty,
235
236        /// The given field is required.
237        FieldRequired { field_name: Cow<'static, str> },
238
239        /// The JSON value given is not an array.
240        InvalidType { type_found: json::ValueKind },
241
242        /// The last element should have no restrictions so that it acts as a catch-all case.
243        MissingCatchAll,
244
245        /// The `price_components` field is nested in the `elements` array.
246        PriceComponents(price_components::Warning),
247
248        /// The `restriction` field is nested in the `elements` array.
249        Restrictions(restrictions::Warning),
250    }
251
252    impl Warning {
253        fn invalid_type(elem: &json::Element<'_>) -> Self {
254            Self::InvalidType {
255                type_found: elem.value().kind(),
256            }
257        }
258    }
259
260    impl fmt::Display for Warning {
261        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262            match self {
263                Self::Empty => write!(
264                    f,
265                    "An empty list of days means that no day is allowed. Is this what you want?"
266                ),
267                Self::FieldRequired { field_name } => {
268                    write!(f, "Field is required: `{field_name}`")
269                }
270                Self::InvalidType { type_found } => {
271                    write!(f, "The value should be an array but is `{type_found}`")
272                }
273                Self::MissingCatchAll => write!(
274                    f,
275                    "The last element should have no restrictions so that it catches all cases."
276                ),
277                Self::PriceComponents(warning) => fmt::Display::fmt(warning, f),
278                Self::Restrictions(warning) => fmt::Display::fmt(warning, f),
279            }
280        }
281    }
282
283    impl crate::Warning for Warning {
284        fn id(&self) -> warning::Id {
285            match self {
286                Self::Empty => warning::Id::from_static("empty"),
287                Self::FieldRequired { field_name } => {
288                    warning::Id::from_string(format!("field_required({field_name})"))
289                }
290                Self::InvalidType { type_found } => {
291                    warning::Id::from_string(format!("invalid_type({type_found})"))
292                }
293                Self::MissingCatchAll => warning::Id::from_static("missing_catch_all"),
294                Self::PriceComponents(warning) => warning.id(),
295                Self::Restrictions(warning) => warning.id(),
296            }
297        }
298    }
299
300    from_warning_all!(
301        price_components::Warning => Warning::PriceComponents,
302        restrictions::Warning => Warning::Restrictions
303    );
304
305    /// Lint the `elements` field.
306    ///
307    /// * 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>).
308    #[instrument(skip_all)]
309    pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), Warning> {
310        /// A summary of the `Element` after parsing and linting has taken place.
311        #[expect(
312            dead_code,
313            reason = "The `ElementSummary` will be used in an upcoming analysis PR."
314        )]
315        struct ElementSummary {
316            dimensions: HashSet<DimensionType>,
317            has_restrictions: bool,
318        }
319
320        let mut warnings = warning::Set::<Warning>::new();
321
322        // The `elements` field should be an array.
323        let Some(elements) = elem.as_array() else {
324            return warnings.bail(Warning::invalid_type(elem), elem);
325        };
326
327        // The `elements` array should contain at least one `Element`.
328        if elements.is_empty() {
329            return warnings.bail(Warning::Empty, elem);
330        }
331
332        // The elements `Vec` will be used in an upcoming analysis PR.
333        let _elements = elements
334            .iter()
335            .map(|elem| {
336                let Some(fields) = elem.as_object_fields() else {
337                    warnings.insert(Warning::invalid_type(elem), elem);
338                    return Item::Invalid;
339                };
340                let restrictions = fields.find_field("restrictions");
341                let mut has_restrictions = false;
342
343                // The `restrictions` field is optional
344                if let Some(field) = restrictions {
345                    let report = restrictions::lint(field.element())
346                        .deescalate_error_into(&mut warnings)
347                        .gather_warnings_into(&mut warnings);
348
349                    if let Some(report) = report {
350                        let restrictions::Report { is_empty } = report;
351                        has_restrictions = is_empty;
352                    }
353                }
354
355                let fields = fields.as_raw_map();
356                // The `price_components` field is required and should contain at least one item.
357                let dimensions = required_field!(elem, fields, "price_components", warnings)
358                    .and_then(|elem| {
359                        price_components::lint(elem)
360                            .deescalate_error_into(&mut warnings)
361                            .gather_warnings_into(&mut warnings)
362                    })
363                    .unwrap_or_default();
364
365                // Filter off the invalid dimension types.
366                let dimensions = dimensions.into_iter().filter_map(Option::from).collect();
367
368                Item::Valid(ElementSummary {
369                    dimensions,
370                    has_restrictions,
371                })
372            })
373            .collect::<Vec<_>>();
374
375        Ok(().into_caveat(warnings))
376    }
377}
378
379pub mod price_components {
380    use std::{borrow::Cow, fmt};
381
382    use tracing::instrument;
383
384    use crate::{
385        enumeration, from_warning_all,
386        json::{self, FieldsAsExt as _, FromJson as _},
387        lint::Item,
388        number, required_field,
389        tariff::v2x::DimensionType,
390        warning::{self, DeescalateError as _, GatherWarnings as _, IntoCaveat as _},
391        Money, Verdict,
392    };
393
394    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
395    pub enum Warning {
396        /// The array exists but is empty.
397        Empty,
398
399        /// The given field is required.
400        FieldRequired {
401            field_name: Cow<'static, str>,
402        },
403
404        /// The JSON value given is not an array.
405        InvalidType {
406            type_found: json::ValueKind,
407        },
408
409        Money(number::Warning),
410
411        Type(enumeration::Warning),
412    }
413
414    impl Warning {
415        fn invalid_type(elem: &json::Element<'_>) -> Self {
416            Self::InvalidType {
417                type_found: elem.value().kind(),
418            }
419        }
420    }
421
422    from_warning_all!(
423        enumeration::Warning => Warning::Type,
424        number::Warning => Warning::Money
425    );
426
427    impl fmt::Display for Warning {
428        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429            match self {
430                Self::Empty => write!(
431                    f,
432                    "An empty list of days means that no day is allowed. Is this what you want?"
433                ),
434                Self::FieldRequired { field_name } => {
435                    write!(f, "Field is required: `{field_name}`")
436                }
437                Self::InvalidType { type_found } => write!(
438                    f,
439                    "The value should be an object but a `{type_found}` was found."
440                ),
441                Self::Money(w) => fmt::Display::fmt(w, f),
442                Self::Type(w) => fmt::Display::fmt(w, f),
443            }
444        }
445    }
446
447    impl crate::Warning for Warning {
448        fn id(&self) -> warning::Id {
449            match self {
450                Self::Empty => warning::Id::from_static("empty"),
451                Self::FieldRequired { field_name } => {
452                    warning::Id::from_string(format!("field_required({field_name})"))
453                }
454                Self::InvalidType { type_found } => {
455                    warning::Id::from_string(format!("invalid_type({type_found})"))
456                }
457                Self::Money(w) => w.id(),
458                Self::Type(w) => w.id(),
459            }
460        }
461    }
462
463    /// Lint the `price_components` field.
464    #[instrument(skip_all)]
465    pub(super) fn lint(elem: &json::Element<'_>) -> Verdict<Vec<Item<DimensionType>>, Warning> {
466        let mut warnings = warning::Set::<Warning>::new();
467
468        // The `elements` field should be an array.
469        let Some(items) = elem.as_array() else {
470            return warnings.bail(Warning::invalid_type(elem), elem);
471        };
472
473        // The `price_components` array should contain at least one `PriceComponent` object.
474        if items.is_empty() {
475            return warnings.bail(Warning::Empty, elem);
476        }
477
478        let dimensions: Vec<Item<DimensionType>> = items
479            .iter()
480            .map(|elem| {
481                let Some(fields) = elem.as_object_fields() else {
482                    warnings.insert(Warning::invalid_type(elem), elem);
483                    return Item::Invalid;
484                };
485
486                let fields = fields.as_raw_map();
487
488                {
489                    let price_elem = fields.get("price");
490
491                    if let Some(elem) = price_elem {
492                        let _money = Money::from_json(elem)
493                            .deescalate_error_into(&mut warnings)
494                            .gather_warnings_into(&mut warnings);
495                    }
496                }
497
498                {
499                    let Some(type_elem) = required_field!(elem, fields, "type", warnings) else {
500                        // The `type` field is required.
501                        return Item::Invalid;
502                    };
503
504                    let dimension = DimensionType::from_json(type_elem)
505                        .deescalate_error_into(&mut warnings)
506                        .gather_warnings_into(&mut warnings);
507
508                    Item::from(dimension)
509                }
510            })
511            .collect();
512
513        Ok(dimensions.into_caveat(warnings))
514    }
515}
516
517pub mod restrictions {
518    //! The linting and Warning infrastructure for the `restriction` field.
519    //!
520    //! * 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>)
521    //! * 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>)
522
523    use std::fmt;
524
525    use tracing::instrument;
526
527    use crate::{
528        duration::{self, Seconds},
529        from_warning_all,
530        json::{self, FieldsAsExt as _},
531        number,
532        warning::{self, DeescalateError as _, GatherWarnings as _, IntoCaveat as _},
533        Ampere, Kw, Kwh, Verdict,
534    };
535
536    use super::{time, weekday};
537
538    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
539    pub enum Warning {
540        Duration(duration::Warning),
541
542        /// The JSON value given is not an object.
543        InvalidType {
544            type_found: json::ValueKind,
545        },
546
547        Number(number::Warning),
548
549        MaxZeroNeverMatch,
550
551        /// The `start_time` and `end_time` fields are nested in the `restrictions` object.
552        Time(time::Warning),
553
554        /// The `day_of_week` field is nested in the `restrictions` object.
555        Weekday(weekday::Warning),
556    }
557
558    impl Warning {
559        fn invalid_type(elem: &json::Element<'_>) -> Self {
560            Self::InvalidType {
561                type_found: elem.value().kind(),
562            }
563        }
564    }
565
566    impl fmt::Display for Warning {
567        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
568            match self {
569                Self::Duration(warning) => fmt::Display::fmt(warning, f),
570                Self::InvalidType { type_found } => {
571                    write!(f, "The value should be an object but is `{type_found}`")
572                }
573                Self::MaxZeroNeverMatch => write!(f, "This element contains a `max_*` restriction and so will never match. This element can be removed"),
574                Self::Number(warning) => fmt::Display::fmt(warning, f),
575                Self::Time(warning) => fmt::Display::fmt(warning, f),
576                Self::Weekday(warning) => fmt::Display::fmt(warning, f),
577            }
578        }
579    }
580
581    impl crate::Warning for Warning {
582        fn id(&self) -> warning::Id {
583            match self {
584                Self::Duration(warning) => warning.id(),
585                Self::InvalidType { type_found } => {
586                    warning::Id::from_string(format!("invalid_type({type_found})"))
587                }
588                Self::MaxZeroNeverMatch => warning::Id::from_static("max_zero_will_never_match"),
589                Self::Number(warning) => warning.id(),
590                Self::Time(warning) => warning.id(),
591                Self::Weekday(warning) => warning.id(),
592            }
593        }
594    }
595
596    from_warning_all!(
597        duration::Warning => Warning::Duration,
598        number::Warning => Warning::Number,
599        time::Warning => Warning::Time,
600        weekday::Warning => Warning::Weekday
601    );
602
603    /// Observations from linting the `restrictions` field.
604    pub struct Report {
605        /// True the object has no fields.
606        pub is_empty: bool,
607    }
608
609    /// Lint the `restrictions` field.
610    #[instrument(skip_all)]
611    pub(super) fn lint(elem: &json::Element<'_>) -> Verdict<Report, Warning> {
612        let mut warnings = warning::Set::<Warning>::new();
613
614        let Some(fields) = elem.as_object_fields() else {
615            return warnings.bail(Warning::invalid_type(elem), elem);
616        };
617
618        let fields = fields.as_raw_map();
619
620        {
621            let start_time = fields.get("start_time").map(|e| &**e);
622            let end_time = fields.get("end_time").map(|e| &**e);
623
624            let _drop: Option<()> = time::lint(start_time, end_time)
625                .deescalate_error_into(&mut warnings)
626                .gather_warnings_into(&mut warnings);
627        }
628
629        {
630            let day_of_week = fields.get("day_of_week").map(|e| &**e);
631
632            let _drop: Option<()> = weekday::lint(day_of_week)
633                .deescalate_error_into(&mut warnings)
634                .gather_warnings_into(&mut warnings);
635        }
636
637        {
638            fields
639                .get("max_current")
640                .map(|elem| from_json_lint_zero::<Ampere>(elem, &mut warnings));
641
642            fields
643                .get("max_duration")
644                .map(|elem| from_json_lint_zero::<Seconds>(elem, &mut warnings));
645
646            fields
647                .get("max_kwh")
648                .map(|elem| from_json_lint_zero::<Kwh>(elem, &mut warnings));
649
650            fields
651                .get("max_power")
652                .map(|elem| from_json_lint_zero::<Kw>(elem, &mut warnings));
653        }
654
655        Ok(Report {
656            is_empty: fields.is_empty(),
657        }
658        .into_caveat(warnings))
659    }
660
661    /// Parse a given `Element` as a `max_*` restriction and raise a `Warning::MaxZeroNeverMatch`
662    /// if the value is zero.
663    fn from_json_lint_zero<'elem, 'buf, T>(
664        element: &'elem json::Element<'buf>,
665        warnings: &mut warning::Set<Warning>,
666    ) -> Option<T>
667    where
668        T: json::FromJson<'buf, Warning: Into<Warning>> + number::IsZero,
669    {
670        let value = T::from_json(element)
671            .deescalate_error_into(warnings)
672            .gather_warnings_into(warnings);
673
674        if value.as_ref().is_some_and(number::IsZero::is_zero) {
675            warnings.insert(Warning::MaxZeroNeverMatch, element);
676        }
677
678        value
679    }
680}
681
682pub mod weekday {
683    //! Linting and warning infrastructure for the `day_of_week` field.
684    //!
685    //! * 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>)
686    //! * 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>)
687    //! * 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>)
688    //! * 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>)
689
690    use std::{collections::BTreeSet, fmt, sync::LazyLock};
691
692    use crate::{
693        from_warning_all,
694        json::{self, FromJson as _},
695        warning::{self, GatherWarnings as _, IntoCaveat as _},
696        Verdict, Weekday,
697    };
698
699    #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
700    pub enum Warning {
701        /// The list contains all days of the week.
702        ContainsEntireWeek,
703
704        /// Each field needs to be a valid weekday.
705        Enum(crate::enumeration::Warning),
706
707        /// There is at least one duplicate day.
708        Duplicates,
709
710        /// An empty array means that no day is allowed.
711        Empty,
712
713        /// The JSON value given is not an array.
714        InvalidType { type_found: json::ValueKind },
715
716        /// The days are unsorted.
717        Unsorted,
718    }
719
720    impl Warning {
721        fn invalid_type(elem: &json::Element<'_>) -> Self {
722            Self::InvalidType {
723                type_found: elem.value().kind(),
724            }
725        }
726    }
727
728    impl fmt::Display for Warning {
729        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
730            match self {
731                Self::ContainsEntireWeek => write!(f, "All days of the week are defined. You can simply leave out the `day_of_week` field."),
732                Self::Enum(warning) => fmt::Display::fmt(warning, f),
733                Self::Duplicates => write!(f, "There's at least one duplicate day."),
734                Self::Empty => write!(
735                    f,
736                    "An empty list of days means that no day is allowed. Is this what you want?"
737                ),
738                Self::InvalidType { type_found } => {
739                    write!(f, "The value should be an array but is `{type_found}`")
740                }
741                Self::Unsorted => write!(f, "The days are unsorted."),
742            }
743        }
744    }
745
746    impl crate::Warning for Warning {
747        fn id(&self) -> warning::Id {
748            match self {
749                Self::ContainsEntireWeek => warning::Id::from_static("contains_entire_week"),
750                Self::Enum(warning) => warning.id(),
751                Self::Duplicates => warning::Id::from_static("duplicates"),
752                Self::Empty => warning::Id::from_static("empty"),
753                Self::InvalidType { type_found } => {
754                    warning::Id::from_string(format!("invalid_type({type_found})"))
755                }
756                Self::Unsorted => warning::Id::from_static("unsorted"),
757            }
758        }
759    }
760
761    from_warning_all!(crate::enumeration::Warning => Warning::Enum);
762
763    /// Lint the `day_of_week` field.
764    pub(super) fn lint(elem: Option<&json::Element<'_>>) -> Verdict<(), Warning> {
765        /// This is the correct order of the days of the week.
766        static ALL_DAYS_OF_WEEK: LazyLock<BTreeSet<Weekday>> = LazyLock::new(|| {
767            BTreeSet::from([
768                Weekday::Monday,
769                Weekday::Tuesday,
770                Weekday::Wednesday,
771                Weekday::Thursday,
772                Weekday::Friday,
773                Weekday::Saturday,
774                Weekday::Sunday,
775            ])
776        });
777
778        let mut warnings = warning::Set::<Warning>::new();
779
780        // The `day_of_week` field is optional.
781        let Some(elem) = elem else {
782            return Ok(().into_caveat(warnings));
783        };
784
785        // The `day_of_week` field should be an array.
786        let Some(items) = elem.as_array() else {
787            return warnings.bail(Warning::invalid_type(elem), elem);
788        };
789
790        // Issue a warning if the `day_of_week` array is defined but empty.
791        // This can be a user misunderstanding.
792        if items.is_empty() {
793            warnings.insert(Warning::Empty, elem);
794            return Ok(().into_caveat(warnings));
795        }
796
797        // Convert each array item to a day and bail out on serious errors.
798        let days = items
799            .iter()
800            .map(Weekday::from_json)
801            .collect::<Result<Vec<_>, _>>()?;
802
803        // Collect warnings from the conversion of each array item to a day.
804        let days = days.gather_warnings_into(&mut warnings);
805
806        // Issue a warning if the days are not sorted.
807        if !days.is_sorted() {
808            warnings.insert(Warning::Unsorted, elem);
809        }
810
811        let day_set: BTreeSet<_> = days.iter().copied().collect();
812
813        // If the set length is less than the list, that means at least one duplicate was removed
814        // during the conversion.
815        if day_set.len() != days.len() {
816            warnings.insert(Warning::Duplicates, elem);
817        }
818
819        // Issue a warning of all days of the week are defined.
820        // This is equivalent to not defining the `day_of_week` array.
821        if day_set == *ALL_DAYS_OF_WEEK {
822            warnings.insert(Warning::ContainsEntireWeek, elem);
823        }
824
825        Ok(().into_caveat(warnings))
826    }
827}