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