Skip to main content

ocpi_tariffs/lint/
tariff.rs

1//! Lint a tariff by reading the schema intermediate representation.
2//!
3//! The walk deconstructs the IR itself rather than going through a whole-object lowering. Such a
4//! lowering abandons the object as soon as a required field is missing or invalid, which is right
5//! for a feature that needs a usable value but wrong here: a linter has to keep inspecting every
6//! remaining field and report everything it finds. Leaf lowerings
7//! (a `Str` to a `NaiveTime`, a `Number` to a `Decimal`) are used as-is - they are pure, they
8//! anchor their warnings at the right element, and reimplementing them would duplicate parsing
9//! that belongs in one place.
10//!
11//! Fields that were absent from the document, or that the schema walk could not build, are
12//! skipped without comment. Validating the document against the OCPI schema is a separate job
13//! with its own warning set, which [`tariff::from_json`] already returned to the caller.
14
15#[cfg(test)]
16mod test;
17
18#[cfg(test)]
19mod test_rejected;
20
21use std::fmt;
22
23use tracing::{debug, instrument};
24
25use chrono::{DateTime, Utc};
26
27use crate::{
28    country, currency, datetime,
29    duration::{self, Seconds},
30    from_warning_all, json, money, number,
31    schema::{self, HasElement as _, Integrity, OcpiEnum},
32    string, tariff,
33    warning::{self, DeescalateError as _},
34    Ampere, FromSchema, Kw, Kwh, Price, Weekday,
35};
36
37/// Lint the given tariff and return a report of any [`Warning`]s found.
38///
39/// A [`tariff::Versioned`] has already been validated against the OCPI schema, and that walk's
40/// warnings were returned to whoever called [`tariff::from_json`]. This reports only what
41/// linting adds.
42///
43/// * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>)
44/// * See: [OCPI spec 2.1.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#31-tariff-object>)
45pub(crate) fn lint(tariff: &tariff::Versioned<'_>) -> Report {
46    let warnings = warning::Set::new();
47
48    match tariff.schema() {
49        tariff::Version::V221(tariff) => lint_v221(tariff, warnings),
50        tariff::Version::V211(tariff) => lint_v211(tariff, warnings),
51    }
52}
53
54/// A tariff linting report.
55#[derive(Debug)]
56pub struct Report {
57    /// What the linter found: the judgments a schema cannot express, and the warnings raised
58    /// while lowering a leaf it wanted to inspect.
59    pub warnings: warning::Set<Warning>,
60}
61
62/// The warnings the tariff linter can raise.
63///
64/// The variants that wrap another module's warning are raised by the leaf lowering the
65/// linter called to read a field, not by the linter itself.
66#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
67pub enum Warning {
68    /// Both `start_time` and `end_time` are defined and contain the entire day, making the
69    /// restriction superfluous.
70    ContainsEntireDay,
71
72    /// The `day_of_week` list holds all seven days, which is the same as leaving it out.
73    ContainsEntireWeek,
74
75    /// Raised while reading a country code.
76    Country(country::Warning),
77
78    /// Both the CDR and tariff have a `country_code` that should be an alpha-2.
79    CpoCountryCodeShouldBeAlpha2,
80
81    /// Raised while reading a currency code.
82    Currency(currency::Warning),
83
84    /// Raised while reading a timestamp.
85    DateTime(datetime::Warning),
86
87    /// The `day_of_week` list names the same day more than once.
88    DayOfWeekDuplicates,
89
90    /// The `day_of_week` list is present but empty, so no day matches.
91    DayOfWeekEmpty,
92
93    /// The `day_of_week` list is not in Monday-to-Sunday order.
94    DayOfWeekUnsorted,
95
96    /// Raised while reading a duration.
97    Duration(duration::Warning),
98
99    /// The `end_time` restriction is set to `23:59`.
100    ///
101    /// The spec states: "To stop at end of the day use: 00:00.".
102    ///
103    /// * 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>).
104    EndTimeIsNearEndOfDay,
105
106    /// A `max_*` restriction is zero, so the element can never match.
107    MaxZeroNeverMatch,
108
109    /// The `min_price` is greater than `max_price`.
110    MinPriceIsGreaterThanMax,
111
112    /// The `start_time` and `end_time` are equal, so the element is never valid.
113    NeverValid,
114
115    /// Raised while reading a price.
116    Money(money::Warning),
117
118    /// Raised while reading a number.
119    Number(number::Warning),
120
121    /// The `restrictions` object is present but holds no restriction.
122    RestrictionsEmpty,
123
124    /// The `start_date_time` is after the `end_date_time`.
125    StartDateTimeIsAfterEndDateTime,
126
127    /// Raised while reading a string.
128    String(string::Warning),
129}
130
131from_warning_all!(
132    country::Warning => Warning::Country,
133    currency::Warning => Warning::Currency,
134    datetime::Warning => Warning::DateTime,
135    duration::Warning => Warning::Duration,
136    money::Warning => Warning::Money,
137    number::Warning => Warning::Number,
138    string::Warning => Warning::String
139);
140
141impl crate::Warning for Warning {
142    fn id(&self) -> warning::Id {
143        match self {
144            Self::ContainsEntireDay => warning::Id::from_static("contains_entire_day"),
145            Self::ContainsEntireWeek => warning::Id::from_static("contains_entire_week"),
146            Self::Country(kind) => kind.id(),
147            Self::CpoCountryCodeShouldBeAlpha2 => {
148                warning::Id::from_static("cpo_country_code_should_be_alpha2")
149            }
150            Self::Currency(kind) => kind.id(),
151            Self::DateTime(kind) => kind.id(),
152            Self::DayOfWeekDuplicates => warning::Id::from_static("duplicates"),
153            Self::DayOfWeekEmpty => warning::Id::from_static("empty"),
154            Self::DayOfWeekUnsorted => warning::Id::from_static("unsorted"),
155            Self::Duration(kind) => kind.id(),
156            Self::EndTimeIsNearEndOfDay => warning::Id::from_static("end_time_is_near_end_of_day"),
157            Self::MaxZeroNeverMatch => warning::Id::from_static("max_zero_will_never_match"),
158            Self::MinPriceIsGreaterThanMax => {
159                warning::Id::from_static("min_price_is_greater_than_max")
160            }
161            Self::NeverValid => warning::Id::from_static("never_valid"),
162            Self::Money(kind) => kind.id(),
163            Self::Number(kind) => kind.id(),
164            Self::RestrictionsEmpty => warning::Id::from_static("restrictions_empty"),
165            Self::StartDateTimeIsAfterEndDateTime => {
166                warning::Id::from_static("start_date_time_is_after_end_date_time")
167            }
168            Self::String(kind) => kind.id(),
169        }
170    }
171
172    /// A leaf lowering that could not build a value seeds this marker rather than restating
173    /// the structural cause, which the schema walk already located.
174    fn is_rejected(&self) -> bool {
175        match self {
176            Self::Country(kind) => kind.is_rejected(),
177            Self::Currency(kind) => kind.is_rejected(),
178            Self::DateTime(kind) => kind.is_rejected(),
179            Self::Duration(kind) => kind.is_rejected(),
180            Self::Money(kind) => kind.is_rejected(),
181            Self::Number(kind) => kind.is_rejected(),
182            Self::String(kind) => kind.is_rejected(),
183            Self::ContainsEntireDay
184            | Self::ContainsEntireWeek
185            | Self::CpoCountryCodeShouldBeAlpha2
186            | Self::DayOfWeekDuplicates
187            | Self::DayOfWeekEmpty
188            | Self::DayOfWeekUnsorted
189            | Self::EndTimeIsNearEndOfDay
190            | Self::MaxZeroNeverMatch
191            | Self::MinPriceIsGreaterThanMax
192            | Self::NeverValid
193            | Self::RestrictionsEmpty
194            | Self::StartDateTimeIsAfterEndDateTime => false,
195        }
196    }
197}
198
199impl fmt::Display for Warning {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            Self::ContainsEntireDay => f.write_str(
203                "Both `start_time` and `end_time` are defined and contain the entire day.",
204            ),
205            Self::ContainsEntireWeek => f.write_str(
206                "All days of the week are defined. You can simply leave out the \
207                 `day_of_week` field.",
208            ),
209            Self::Country(kind) => fmt::Display::fmt(kind, f),
210            Self::CpoCountryCodeShouldBeAlpha2 => {
211                f.write_str("The value should be an alpha-2 ISO 3166-1 country code")
212            }
213            Self::Currency(kind) => fmt::Display::fmt(kind, f),
214            Self::DateTime(kind) => fmt::Display::fmt(kind, f),
215            Self::DayOfWeekDuplicates => f.write_str("There's at least one duplicate day."),
216            Self::DayOfWeekEmpty => f.write_str(
217                "An empty list of days means that no day is allowed. Is this what you want?",
218            ),
219            Self::DayOfWeekUnsorted => f.write_str("The days are unsorted."),
220            Self::Duration(kind) => fmt::Display::fmt(kind, f),
221            Self::EndTimeIsNearEndOfDay => f.write_str(
222                "The `end_time` restriction is set to `23:59`. The spec states: \"To stop at \
223                 end of the day use: 00:00.\".",
224            ),
225            Self::MaxZeroNeverMatch => f.write_str(
226                "This element contains a zero `max_*` restriction and so will never match. \
227                 This element can be removed.",
228            ),
229            Self::MinPriceIsGreaterThanMax => {
230                f.write_str("The `min_price` is greater than `max_price`.")
231            }
232            Self::NeverValid => f.write_str(
233                "The `start_time` and `end_time` are equal and so the element is never valid.",
234            ),
235            Self::Money(kind) => fmt::Display::fmt(kind, f),
236            Self::Number(kind) => fmt::Display::fmt(kind, f),
237            Self::RestrictionsEmpty => f.write_str(
238                "The `restrictions` object holds no restriction, which is what leaving it out \
239                 means.",
240            ),
241            Self::StartDateTimeIsAfterEndDateTime => {
242                f.write_str("The `start_date_time` is after the `end_date_time`.")
243            }
244            Self::String(kind) => fmt::Display::fmt(kind, f),
245        }
246    }
247}
248
249/// Lint a `v2.2.1` tariff.
250///
251/// * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>)
252#[instrument(skip_all)]
253fn lint_v221(tariff: &schema::v221::Tariff<'_>, mut warnings: warning::Set<Warning>) -> Report {
254    if let Integrity::Ok(country_code) = &tariff.country_code {
255        lint_country_code(country_code, &mut warnings);
256    }
257
258    if let Integrity::Ok(party_id) = &tariff.party_id {
259        lint_party_id(party_id, &mut warnings);
260    }
261
262    if let Integrity::Ok(currency) = &tariff.currency {
263        lint_currency(currency, &mut warnings);
264    }
265
266    lint_min_max_price(&tariff.min_price, &tariff.max_price, &mut warnings);
267    lint_start_end_date_time(
268        &tariff.start_date_time,
269        &tariff.end_date_time,
270        &mut warnings,
271    );
272
273    if let Integrity::Ok(elements) = &tariff.elements {
274        for element in elements {
275            let Integrity::Ok(element) = element else {
276                continue;
277            };
278            let Integrity::Ok(Some(restrictions)) = &element.restrictions else {
279                continue;
280            };
281
282            lint_restrictions_empty(restrictions, &mut warnings);
283            lint_times(
284                &restrictions.start_time,
285                &restrictions.end_time,
286                &mut warnings,
287            );
288            lint_day_of_week(&restrictions.day_of_week, &mut warnings);
289            lint_max_zero::<Ampere>(&restrictions.max_current, &mut warnings);
290            lint_max_zero::<Seconds>(&restrictions.max_duration, &mut warnings);
291            lint_max_zero::<Kwh>(&restrictions.max_kwh, &mut warnings);
292            lint_max_zero::<Kw>(&restrictions.max_power, &mut warnings);
293        }
294    }
295
296    Report { warnings }
297}
298
299/// Lint a `v2.1.1` tariff.
300///
301/// A `v2.1.1` tariff carries only `currency`, `id` and `elements`, so the lints that read
302/// `country_code`, `party_id`, the price bounds or the active window do not apply to it.
303///
304/// * See: [OCPI spec 2.1.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#31-tariff-object>)
305#[instrument(skip_all)]
306fn lint_v211(tariff: &schema::v211::Tariff<'_>, mut warnings: warning::Set<Warning>) -> Report {
307    if let Integrity::Ok(currency) = &tariff.currency {
308        lint_currency(currency, &mut warnings);
309    }
310
311    if let Integrity::Ok(elements) = &tariff.elements {
312        for element in elements {
313            let Integrity::Ok(element) = element else {
314                continue;
315            };
316            let Integrity::Ok(Some(restrictions)) = &element.restrictions else {
317                continue;
318            };
319
320            lint_restrictions_empty(restrictions, &mut warnings);
321
322            // `v2.1.1` restrictions have no `max_current`.
323            lint_times(
324                &restrictions.start_time,
325                &restrictions.end_time,
326                &mut warnings,
327            );
328            lint_day_of_week(&restrictions.day_of_week, &mut warnings);
329            lint_max_zero::<Seconds>(&restrictions.max_duration, &mut warnings);
330            lint_max_zero::<Kwh>(&restrictions.max_kwh, &mut warnings);
331            lint_max_zero::<Kw>(&restrictions.max_power, &mut warnings);
332        }
333    }
334
335    Report { warnings }
336}
337
338/// Lint a `restrictions` object that holds no restriction.
339///
340/// Every field of a `restrictions` object is optional, so an object written empty restricts
341/// nothing. This has the same semantic meaning as leaving the object out.
342#[instrument(skip_all)]
343fn lint_restrictions_empty<'buf>(
344    restrictions: &impl schema::HasElement<'buf>,
345    warnings: &mut warning::Set<Warning>,
346) {
347    let elem = restrictions.element();
348
349    let Some(fields) = elem.value().as_object_fields() else {
350        return;
351    };
352
353    if fields.is_empty() {
354        warnings.insert(elem, Warning::RestrictionsEmpty);
355    }
356}
357
358/// Lint the `country_code` field.
359///
360/// An alpha-3 code is accepted and converted, but the caller is told to use alpha-2. The
361/// remaining findings - a bad code, an unusable length, escapes, lower case - come from the
362/// lowering rather than from this function.
363#[instrument(skip_all)]
364fn lint_country_code(source: &schema::Str<'_>, warnings: &mut warning::Set<Warning>) {
365    let Some(code_set) = country::CodeSet::from_schema(source).deescalate_error_into(warnings)
366    else {
367        return;
368    };
369
370    debug!("code_set: {code_set:?}");
371
372    if let country::CodeSet::Alpha3(_) = code_set {
373        warnings.insert(source.element(), Warning::CpoCountryCodeShouldBeAlpha2);
374    }
375}
376
377/// The `party_id` is the three character ISO-15118 ID of the CPO.
378type PartyId<'buf> = string::CiExactLen<'buf, 3>;
379
380/// Lint the `party_id` field.
381///
382/// The length and lexical checks come from the lowering. The case advice is this layer's
383/// own: `CiExactLen` is case-insensitive by definition, so it has no opinion on it.
384#[instrument(skip_all)]
385fn lint_party_id(source: &schema::Str<'_>, warnings: &mut warning::Set<Warning>) {
386    let party_id: Option<PartyId<'_>> =
387        PartyId::from_schema(source).deescalate_error_into(warnings);
388
389    let Some(party_id) = party_id else {
390        return;
391    };
392
393    // The value is held as written, so an escape sequence still reads as its own letters -
394    // the `n` of a `\n` would otherwise be taken for lower case. A string carrying escapes
395    // is already reported by the lowering, and its casing is not the useful thing to say
396    // about it.
397    if source.value().lexical_issues().escapes {
398        return;
399    }
400
401    if party_id.chars().any(char::is_lowercase) {
402        warnings.insert(
403            source.element(),
404            Warning::String(string::Warning::IncorrectCase),
405        );
406    }
407}
408
409/// Lint the `currency` field.
410///
411/// Every finding comes from the lowering: an unknown code, a code the ISO standard reserves,
412/// and the case advice.
413#[instrument(skip_all)]
414fn lint_currency(source: &schema::Str<'_>, warnings: &mut warning::Set<Warning>) {
415    let code: Option<currency::Code> =
416        currency::Code::from_schema(source).deescalate_error_into(warnings);
417
418    debug!("code: {code:?}");
419}
420
421/// The `min_price` should not be greater than the `max_price`.
422///
423/// Both bounds are lowered even when only one is present, so a problem with the value itself
424/// is still reported; the comparison only happens when both are usable.
425#[instrument(skip_all)]
426fn lint_min_max_price(
427    min_price: &Integrity<Option<schema::v221::Price<'_>>>,
428    max_price: &Integrity<Option<schema::v221::Price<'_>>>,
429    warnings: &mut warning::Set<Warning>,
430) {
431    let min = lower_price(min_price, warnings);
432    let max = lower_price(max_price, warnings);
433
434    let (Some((min, min_elem)), Some((max, _))) = (min, max) else {
435        return;
436    };
437
438    if min > max {
439        warnings.insert(min_elem, Warning::MinPriceIsGreaterThanMax);
440    }
441}
442
443/// Lower an optional `Price` field, keeping the element its warnings anchor to.
444fn lower_price<'a, 'buf>(
445    price: &'a Integrity<Option<schema::v221::Price<'buf>>>,
446    warnings: &mut warning::Set<Warning>,
447) -> Option<(Price, &'a json::Element<'buf>)> {
448    let Integrity::Ok(Some(price)) = price else {
449        return None;
450    };
451
452    let lowered = Price::from_schema(price).deescalate_error_into(warnings)?;
453
454    Some((lowered, price.element()))
455}
456
457/// Lint both `start_date_time` and `end_date_time`.
458///
459/// The two may be equal - a tariff active for an instant is odd but not wrong - so only a
460/// `start_date_time` strictly after the `end_date_time` is reported.
461#[instrument(skip_all)]
462fn lint_start_end_date_time(
463    start_date_time: &Integrity<Option<schema::Str<'_>>>,
464    end_date_time: &Integrity<Option<schema::Str<'_>>>,
465    warnings: &mut warning::Set<Warning>,
466) {
467    let start = lower_date_time(start_date_time, warnings);
468    let end = lower_date_time(end_date_time, warnings);
469
470    let (Some((start, start_elem)), Some((end, _))) = (start, end) else {
471        return;
472    };
473
474    if start > end {
475        warnings.insert(start_elem, Warning::StartDateTimeIsAfterEndDateTime);
476    }
477}
478
479/// Lower an optional `DateTime` field, keeping the element its warnings anchor to.
480fn lower_date_time<'a, 'buf>(
481    date_time: &'a Integrity<Option<schema::Str<'buf>>>,
482    warnings: &mut warning::Set<Warning>,
483) -> Option<(DateTime<Utc>, &'a json::Element<'buf>)> {
484    let Integrity::Ok(Some(date_time)) = date_time else {
485        return None;
486    };
487
488    let lowered = DateTime::<Utc>::from_schema(date_time).deescalate_error_into(warnings)?;
489
490    Some((lowered, date_time.element()))
491}
492
493/// The time of day as hour and minute.
494///
495/// Seconds are deliberately not compared: OCPI writes these restrictions as `HH:MM`, and the
496/// spec's advice about the end of the day is phrased in those terms.
497#[derive(Copy, Clone, Eq, PartialEq)]
498struct HourMin {
499    hour: u32,
500    min: u32,
501}
502
503/// Midnight, which OCPI uses to mean both the start and the end of a day.
504const DAY_BOUNDARY: HourMin = HourMin { hour: 0, min: 0 };
505
506/// The spec asks for `00:00` to end a day, so `23:59` is a near miss worth flagging.
507const NEAR_END_OF_DAY: HourMin = HourMin { hour: 23, min: 59 };
508
509/// True if the time is at, or as good as at, the end of the day.
510fn is_day_end(time: HourMin) -> bool {
511    time == NEAR_END_OF_DAY || time == DAY_BOUNDARY
512}
513
514/// Lint the `start_time` and `end_time` restrictions.
515///
516/// * 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>)
517#[instrument(skip_all)]
518fn lint_times(
519    start_time: &Integrity<Option<schema::Str<'_>>>,
520    end_time: &Integrity<Option<schema::Str<'_>>>,
521    warnings: &mut warning::Set<Warning>,
522) {
523    let start = lower_time(start_time, warnings);
524    let end = lower_time(end_time, warnings);
525
526    // With both bounds present the pair is judged together; with only one, that bound alone
527    // can still describe the whole day.
528    if let (Some((start, start_elem)), Some((end, end_elem))) = (start, end) {
529        if end == NEAR_END_OF_DAY {
530            warnings.insert(end_elem, Warning::EndTimeIsNearEndOfDay);
531        }
532
533        if start == DAY_BOUNDARY && is_day_end(end) {
534            warnings.insert(start_elem, Warning::ContainsEntireDay);
535        } else if start == end {
536            warnings.insert(start_elem, Warning::NeverValid);
537        }
538
539        return;
540    }
541
542    if let Some((start, start_elem)) = start {
543        if start == DAY_BOUNDARY {
544            warnings.insert(start_elem, Warning::ContainsEntireDay);
545        }
546    } else if let Some((end, end_elem)) = end {
547        if is_day_end(end) {
548            warnings.insert(end_elem, Warning::ContainsEntireDay);
549        }
550    }
551}
552
553/// Lower an optional `HH:MM` field, keeping the element its warnings anchor to.
554fn lower_time<'a, 'buf>(
555    time: &'a Integrity<Option<schema::Str<'buf>>>,
556    warnings: &mut warning::Set<Warning>,
557) -> Option<(HourMin, &'a json::Element<'buf>)> {
558    let Integrity::Ok(Some(time)) = time else {
559        return None;
560    };
561
562    let lowered: Option<chrono::NaiveTime> =
563        chrono::NaiveTime::from_schema(time).deescalate_error_into(warnings);
564    let lowered = lowered?;
565
566    let hour_min = HourMin {
567        hour: chrono::Timelike::hour(&lowered),
568        min: chrono::Timelike::minute(&lowered),
569    };
570
571    Some((hour_min, time.element()))
572}
573
574/// Every day of the week; a list holding all of them says the same as no list at all.
575const ALL_DAYS: usize = 7;
576
577/// Lint the `day_of_week` restriction.
578///
579/// A day the schema could not read is skipped rather than abandoning the list, so the
580/// ordering and duplicate checks still describe the days that are readable. Every warning
581/// here is about the list as a whole, so all of them anchor to the array.
582///
583/// * 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>)
584#[instrument(skip_all)]
585fn lint_day_of_week<T>(
586    day_of_week: &Integrity<Option<schema::List<'_, schema::Enum<'_, T>>>>,
587    warnings: &mut warning::Set<Warning>,
588) where
589    T: OcpiEnum,
590    Weekday: for<'a> FromSchema<'a, T, Warning = std::convert::Infallible>,
591{
592    let Integrity::Ok(Some(days)) = day_of_week else {
593        return;
594    };
595
596    let elem = days.element();
597
598    // An empty list matches no day at all, which is rarely what the author meant.
599    if days.is_empty() {
600        warnings.insert(elem, Warning::DayOfWeekEmpty);
601        return;
602    }
603
604    let mut lowered: Vec<Weekday> = Vec::with_capacity(days.len());
605
606    for day in days {
607        let Integrity::Ok(day) = day else {
608            continue;
609        };
610
611        // The schema already proved the value is one of the enum's variants, so mapping it
612        // to a `Weekday` cannot fail and cannot warn. `Infallible` makes the `Err` arm
613        // uninhabited, but `let` still needs it spelled out.
614        let Ok(day) = Weekday::from_schema(&day.value()) else {
615            continue;
616        };
617        lowered.push(day.ignore_warnings());
618    }
619
620    if !lowered.is_sorted() {
621        warnings.insert(elem, Warning::DayOfWeekUnsorted);
622    }
623
624    let unique: std::collections::BTreeSet<_> = lowered.iter().copied().collect();
625
626    if unique.len() != lowered.len() {
627        warnings.insert(elem, Warning::DayOfWeekDuplicates);
628    }
629
630    if unique.len() == ALL_DAYS {
631        warnings.insert(elem, Warning::ContainsEntireWeek);
632    }
633}
634
635/// Lint a `max_*` restriction, which never matches anything when it is zero.
636#[instrument(skip_all)]
637fn lint_max_zero<T>(
638    max: &Integrity<Option<schema::Number<'_>>>,
639    warnings: &mut warning::Set<Warning>,
640) where
641    T: for<'a> FromSchema<'a, schema::Number<'a>> + number::IsZero,
642    for<'a> <T as FromSchema<'a, schema::Number<'a>>>::Warning: Into<Warning>,
643{
644    let Integrity::Ok(Some(max)) = max else {
645        return;
646    };
647
648    let value: Option<T> = T::from_schema(max).deescalate_error_into(warnings);
649
650    if value.is_some_and(|v| v.is_zero()) {
651        warnings.insert(max.element(), Warning::MaxZeroNeverMatch);
652    }
653}