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