ocpi-tariffs 0.52.0

OCPI tariff calculations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
//! Lint a tariff by reading the schema intermediate representation.
//!
//! The walk deconstructs the IR itself rather than going through a
//! [`FromSchema`](crate::FromSchema) lowering of a whole object. An object lowering abandons
//! the object as soon as a required field is missing or invalid, which is right for a feature
//! that needs a usable value but wrong here: a linter has to keep inspecting every remaining
//! field and report everything it finds. Leaf lowerings (a `Str` to a `NaiveTime`, a `Number`
//! to a `Decimal`) are used as-is - they are pure, they anchor their warnings at the right
//! element, and reimplementing them would duplicate parsing that belongs in one place.
//!
//! Fields that arrive as [`Integrity::Missing`](crate::schema::Integrity::Missing) or
//! [`Integrity::Err`](crate::schema::Integrity::Err) are skipped without comment. Validating
//! the document against the OCPI schema is a separate job with its own warning set, which
//! [`tariff::from_json`](crate::tariff::from_json) already returned to the caller.

#[cfg(test)]
mod test;

#[cfg(test)]
mod test_rejected;

use std::fmt;

use tracing::{debug, instrument};

use chrono::{DateTime, Utc};

use crate::{
    country, currency, datetime,
    duration::{self, Seconds},
    from_warning_all, json, money, number,
    schema::{self, HasElement as _, Integrity, OcpiEnum},
    string, tariff,
    warning::{self, DeescalateError as _},
    Ampere, FromSchema, Kw, Kwh, Price, Weekday,
};

/// Lint the given tariff and return a report of any [`Warning`]s found.
///
/// A [`tariff::Versioned`](crate::tariff::Versioned) has already been validated against the
/// OCPI schema, and that walk's warnings were returned to whoever called
/// [`tariff::from_json`](crate::tariff::from_json). This reports only what linting adds.
///
/// * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>)
/// * See: [OCPI spec 2.1.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#31-tariff-object>)
pub(crate) fn lint(tariff: &tariff::Versioned<'_>) -> Report {
    let warnings = warning::Set::new();

    match tariff.schema() {
        tariff::Version::V221(tariff) => lint_v221(tariff, warnings),
        tariff::Version::V211(tariff) => lint_v211(tariff, warnings),
    }
}

/// A tariff linting report.
#[derive(Debug)]
pub struct Report {
    /// What the linter found: the judgments a schema cannot express, and the warnings raised
    /// while lowering a leaf it wanted to inspect.
    pub warnings: warning::Set<Warning>,
}

/// The warnings the tariff linter can raise.
///
/// The variants that wrap another module's warning are raised by the leaf lowering the
/// linter called to read a field, not by the linter itself. `docs/lint-catalogue.md` lists
/// the lints still to be reintroduced.
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum Warning {
    /// Both `start_time` and `end_time` are defined and contain the entire day, making the
    /// restriction superfluous.
    ContainsEntireDay,

    /// The `day_of_week` list holds all seven days, which is the same as leaving it out.
    ContainsEntireWeek,

    Country(country::Warning),

    /// Both the CDR and tariff have a `country_code` that should be an alpha-2.
    CpoCountryCodeShouldBeAlpha2,

    Currency(currency::Warning),

    DateTime(datetime::Warning),

    /// The `day_of_week` list names the same day more than once.
    DayOfWeekDuplicates,

    /// The `day_of_week` list is present but empty, so no day matches.
    DayOfWeekEmpty,

    /// The `day_of_week` list is not in Monday-to-Sunday order.
    DayOfWeekUnsorted,

    Duration(duration::Warning),

    /// The `end_time` restriction is set to `23:59`.
    ///
    /// The spec states: "To stop at end of the day use: 00:00.".
    ///
    /// * 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>).
    EndTimeIsNearEndOfDay,

    /// A `max_*` restriction is zero, so the element can never match.
    MaxZeroNeverMatch,

    /// The `min_price` is greater than `max_price`.
    MinPriceIsGreaterThanMax,

    /// The `start_time` and `end_time` are equal, so the element is never valid.
    NeverValid,

    Money(money::Warning),

    Number(number::Warning),

    /// The `start_date_time` is after the `end_date_time`.
    StartDateTimeIsAfterEndDateTime,

    String(string::Warning),
}

from_warning_all!(
    country::Warning => Warning::Country,
    currency::Warning => Warning::Currency,
    datetime::Warning => Warning::DateTime,
    duration::Warning => Warning::Duration,
    money::Warning => Warning::Money,
    number::Warning => Warning::Number,
    string::Warning => Warning::String
);

impl crate::Warning for Warning {
    fn id(&self) -> warning::Id {
        match self {
            Self::ContainsEntireDay => warning::Id::from_static("contains_entire_day"),
            Self::ContainsEntireWeek => warning::Id::from_static("contains_entire_week"),
            Self::Country(kind) => kind.id(),
            Self::CpoCountryCodeShouldBeAlpha2 => {
                warning::Id::from_static("cpo_country_code_should_be_alpha2")
            }
            Self::Currency(kind) => kind.id(),
            Self::DateTime(kind) => kind.id(),
            Self::DayOfWeekDuplicates => warning::Id::from_static("duplicates"),
            Self::DayOfWeekEmpty => warning::Id::from_static("empty"),
            Self::DayOfWeekUnsorted => warning::Id::from_static("unsorted"),
            Self::Duration(kind) => kind.id(),
            Self::EndTimeIsNearEndOfDay => warning::Id::from_static("end_time_is_near_end_of_day"),
            Self::MaxZeroNeverMatch => warning::Id::from_static("max_zero_will_never_match"),
            Self::MinPriceIsGreaterThanMax => {
                warning::Id::from_static("min_price_is_greater_than_max")
            }
            Self::NeverValid => warning::Id::from_static("never_valid"),
            Self::Money(kind) => kind.id(),
            Self::Number(kind) => kind.id(),
            Self::StartDateTimeIsAfterEndDateTime => {
                warning::Id::from_static("start_date_time_is_after_end_date_time")
            }
            Self::String(kind) => kind.id(),
        }
    }

    /// A leaf lowering that could not build a value seeds this marker rather than restating
    /// the structural cause, which the schema walk already located.
    fn is_rejected(&self) -> bool {
        match self {
            Self::Country(kind) => kind.is_rejected(),
            Self::Currency(kind) => kind.is_rejected(),
            Self::DateTime(kind) => kind.is_rejected(),
            Self::Duration(kind) => kind.is_rejected(),
            Self::Money(kind) => kind.is_rejected(),
            Self::Number(kind) => kind.is_rejected(),
            Self::String(kind) => kind.is_rejected(),
            Self::ContainsEntireDay
            | Self::ContainsEntireWeek
            | Self::CpoCountryCodeShouldBeAlpha2
            | Self::DayOfWeekDuplicates
            | Self::DayOfWeekEmpty
            | Self::DayOfWeekUnsorted
            | Self::EndTimeIsNearEndOfDay
            | Self::MaxZeroNeverMatch
            | Self::MinPriceIsGreaterThanMax
            | Self::NeverValid
            | Self::StartDateTimeIsAfterEndDateTime => false,
        }
    }
}

impl fmt::Display for Warning {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ContainsEntireDay => f.write_str(
                "Both `start_time` and `end_time` are defined and contain the entire day.",
            ),
            Self::ContainsEntireWeek => f.write_str(
                "All days of the week are defined. You can simply leave out the \
                 `day_of_week` field.",
            ),
            Self::Country(kind) => fmt::Display::fmt(kind, f),
            Self::CpoCountryCodeShouldBeAlpha2 => {
                f.write_str("The value should be an alpha-2 ISO 3166-1 country code")
            }
            Self::Currency(kind) => fmt::Display::fmt(kind, f),
            Self::DateTime(kind) => fmt::Display::fmt(kind, f),
            Self::DayOfWeekDuplicates => f.write_str("There's at least one duplicate day."),
            Self::DayOfWeekEmpty => f.write_str(
                "An empty list of days means that no day is allowed. Is this what you want?",
            ),
            Self::DayOfWeekUnsorted => f.write_str("The days are unsorted."),
            Self::Duration(kind) => fmt::Display::fmt(kind, f),
            Self::EndTimeIsNearEndOfDay => f.write_str(
                "The `end_time` restriction is set to `23:59`. The spec states: \"To stop at \
                 end of the day use: 00:00.\".",
            ),
            Self::MaxZeroNeverMatch => f.write_str(
                "This element contains a zero `max_*` restriction and so will never match. \
                 This element can be removed.",
            ),
            Self::MinPriceIsGreaterThanMax => {
                f.write_str("The `min_price` is greater than `max_price`.")
            }
            Self::NeverValid => f.write_str(
                "The `start_time` and `end_time` are equal and so the element is never valid.",
            ),
            Self::Money(kind) => fmt::Display::fmt(kind, f),
            Self::Number(kind) => fmt::Display::fmt(kind, f),
            Self::StartDateTimeIsAfterEndDateTime => {
                f.write_str("The `start_date_time` is after the `end_date_time`.")
            }
            Self::String(kind) => fmt::Display::fmt(kind, f),
        }
    }
}

/// Lint a `v2.2.1` tariff.
///
/// * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>)
#[instrument(skip_all)]
fn lint_v221(tariff: &schema::v221::Tariff<'_>, mut warnings: warning::Set<Warning>) -> Report {
    if let Integrity::Ok(country_code) = &tariff.country_code {
        lint_country_code(country_code, &mut warnings);
    }

    if let Integrity::Ok(party_id) = &tariff.party_id {
        lint_party_id(party_id, &mut warnings);
    }

    if let Integrity::Ok(currency) = &tariff.currency {
        lint_currency(currency, &mut warnings);
    }

    lint_min_max_price(&tariff.min_price, &tariff.max_price, &mut warnings);
    lint_start_end_date_time(
        &tariff.start_date_time,
        &tariff.end_date_time,
        &mut warnings,
    );

    if let Integrity::Ok(elements) = &tariff.elements {
        for element in elements {
            let Integrity::Ok(element) = element else {
                continue;
            };
            let Integrity::Ok(Some(restrictions)) = &element.restrictions else {
                continue;
            };

            lint_times(
                &restrictions.start_time,
                &restrictions.end_time,
                &mut warnings,
            );
            lint_day_of_week(&restrictions.day_of_week, &mut warnings);
            lint_max_zero::<Ampere>(&restrictions.max_current, &mut warnings);
            lint_max_zero::<Seconds>(&restrictions.max_duration, &mut warnings);
            lint_max_zero::<Kwh>(&restrictions.max_kwh, &mut warnings);
            lint_max_zero::<Kw>(&restrictions.max_power, &mut warnings);
        }
    }

    Report { warnings }
}

/// Lint a `v2.1.1` tariff.
///
/// A `v2.1.1` tariff carries only `currency`, `id` and `elements`, so the lints that read
/// `country_code`, `party_id`, the price bounds or the active window do not apply to it.
///
/// * See: [OCPI spec 2.1.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#31-tariff-object>)
#[instrument(skip_all)]
fn lint_v211(tariff: &schema::v211::Tariff<'_>, mut warnings: warning::Set<Warning>) -> Report {
    if let Integrity::Ok(currency) = &tariff.currency {
        lint_currency(currency, &mut warnings);
    }

    if let Integrity::Ok(elements) = &tariff.elements {
        for element in elements {
            let Integrity::Ok(element) = element else {
                continue;
            };
            let Integrity::Ok(Some(restrictions)) = &element.restrictions else {
                continue;
            };

            // `v2.1.1` restrictions have no `max_current`.
            lint_times(
                &restrictions.start_time,
                &restrictions.end_time,
                &mut warnings,
            );
            lint_day_of_week(&restrictions.day_of_week, &mut warnings);
            lint_max_zero::<Seconds>(&restrictions.max_duration, &mut warnings);
            lint_max_zero::<Kwh>(&restrictions.max_kwh, &mut warnings);
            lint_max_zero::<Kw>(&restrictions.max_power, &mut warnings);
        }
    }

    Report { warnings }
}

/// Lint the `country_code` field.
///
/// An alpha-3 code is accepted and converted, but the caller is told to use alpha-2. The
/// remaining findings - a bad code, an unusable length, escapes, lower case - come from the
/// lowering rather than from this function.
#[instrument(skip_all)]
fn lint_country_code(source: &schema::Str<'_>, warnings: &mut warning::Set<Warning>) {
    let Some(code_set) = country::CodeSet::from_schema(source).deescalate_error_into(warnings)
    else {
        return;
    };

    debug!("code_set: {code_set:?}");

    if let country::CodeSet::Alpha3(_) = code_set {
        warnings.insert(source.element(), Warning::CpoCountryCodeShouldBeAlpha2);
    }
}

/// The `party_id` is the three character ISO-15118 ID of the CPO.
type PartyId<'buf> = string::CiExactLen<'buf, 3>;

/// Lint the `party_id` field.
///
/// The length and lexical checks come from the lowering. The case advice is this layer's
/// own: `CiExactLen` is case-insensitive by definition, so it has no opinion on it.
#[instrument(skip_all)]
fn lint_party_id(source: &schema::Str<'_>, warnings: &mut warning::Set<Warning>) {
    let party_id: Option<PartyId<'_>> =
        PartyId::from_schema(source).deescalate_error_into(warnings);

    let Some(party_id) = party_id else {
        return;
    };

    // The value is held as written, so an escape sequence still reads as its own letters -
    // the `n` of a `\n` would otherwise be taken for lower case. A string carrying escapes
    // is already reported by the lowering, and its casing is not the useful thing to say
    // about it.
    if source.value().lexical_issues().escapes {
        return;
    }

    if party_id.chars().any(char::is_lowercase) {
        warnings.insert(
            source.element(),
            Warning::String(string::Warning::PreferUppercase),
        );
    }
}

/// Lint the `currency` field.
///
/// Every finding comes from the lowering: an unknown code, a code the ISO standard reserves,
/// and the case advice.
#[instrument(skip_all)]
fn lint_currency(source: &schema::Str<'_>, warnings: &mut warning::Set<Warning>) {
    let code: Option<currency::Code> =
        currency::Code::from_schema(source).deescalate_error_into(warnings);

    debug!("code: {code:?}");
}

/// The `min_price` should not be greater than the `max_price`.
///
/// Both bounds are lowered even when only one is present, so a problem with the value itself
/// is still reported; the comparison only happens when both are usable.
#[instrument(skip_all)]
fn lint_min_max_price(
    min_price: &Integrity<Option<schema::v221::Price<'_>>>,
    max_price: &Integrity<Option<schema::v221::Price<'_>>>,
    warnings: &mut warning::Set<Warning>,
) {
    let min = lower_price(min_price, warnings);
    let max = lower_price(max_price, warnings);

    let (Some((min, min_elem)), Some((max, _))) = (min, max) else {
        return;
    };

    if min > max {
        warnings.insert(min_elem, Warning::MinPriceIsGreaterThanMax);
    }
}

/// Lower an optional `Price` field, keeping the element its warnings anchor to.
fn lower_price<'a, 'buf>(
    price: &'a Integrity<Option<schema::v221::Price<'buf>>>,
    warnings: &mut warning::Set<Warning>,
) -> Option<(Price, &'a json::Element<'buf>)> {
    let Integrity::Ok(Some(price)) = price else {
        return None;
    };

    let lowered = Price::from_schema(price).deescalate_error_into(warnings)?;

    Some((lowered, price.element()))
}

/// Lint both `start_date_time` and `end_date_time`.
///
/// The two may be equal - a tariff active for an instant is odd but not wrong - so only a
/// `start_date_time` strictly after the `end_date_time` is reported.
#[instrument(skip_all)]
fn lint_start_end_date_time(
    start_date_time: &Integrity<Option<schema::Str<'_>>>,
    end_date_time: &Integrity<Option<schema::Str<'_>>>,
    warnings: &mut warning::Set<Warning>,
) {
    let start = lower_date_time(start_date_time, warnings);
    let end = lower_date_time(end_date_time, warnings);

    let (Some((start, start_elem)), Some((end, _))) = (start, end) else {
        return;
    };

    if start > end {
        warnings.insert(start_elem, Warning::StartDateTimeIsAfterEndDateTime);
    }
}

/// Lower an optional `DateTime` field, keeping the element its warnings anchor to.
fn lower_date_time<'a, 'buf>(
    date_time: &'a Integrity<Option<schema::Str<'buf>>>,
    warnings: &mut warning::Set<Warning>,
) -> Option<(DateTime<Utc>, &'a json::Element<'buf>)> {
    let Integrity::Ok(Some(date_time)) = date_time else {
        return None;
    };

    let lowered = DateTime::<Utc>::from_schema(date_time).deescalate_error_into(warnings)?;

    Some((lowered, date_time.element()))
}

/// The time of day as hour and minute.
///
/// Seconds are deliberately not compared: OCPI writes these restrictions as `HH:MM`, and the
/// spec's advice about the end of the day is phrased in those terms.
#[derive(Copy, Clone, Eq, PartialEq)]
struct HourMin {
    hour: u32,
    min: u32,
}

/// Midnight, which OCPI uses to mean both the start and the end of a day.
const DAY_BOUNDARY: HourMin = HourMin { hour: 0, min: 0 };

/// The spec asks for `00:00` to end a day, so `23:59` is a near miss worth flagging.
const NEAR_END_OF_DAY: HourMin = HourMin { hour: 23, min: 59 };

/// True if the time is at, or as good as at, the end of the day.
fn is_day_end(time: HourMin) -> bool {
    time == NEAR_END_OF_DAY || time == DAY_BOUNDARY
}

/// Lint the `start_time` and `end_time` restrictions.
///
/// * 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>)
#[instrument(skip_all)]
fn lint_times(
    start_time: &Integrity<Option<schema::Str<'_>>>,
    end_time: &Integrity<Option<schema::Str<'_>>>,
    warnings: &mut warning::Set<Warning>,
) {
    let start = lower_time(start_time, warnings);
    let end = lower_time(end_time, warnings);

    // With both bounds present the pair is judged together; with only one, that bound alone
    // can still describe the whole day.
    if let (Some((start, start_elem)), Some((end, end_elem))) = (start, end) {
        if end == NEAR_END_OF_DAY {
            warnings.insert(end_elem, Warning::EndTimeIsNearEndOfDay);
        }

        if start == DAY_BOUNDARY && is_day_end(end) {
            warnings.insert(start_elem, Warning::ContainsEntireDay);
        } else if start == end {
            warnings.insert(start_elem, Warning::NeverValid);
        }

        return;
    }

    if let Some((start, start_elem)) = start {
        if start == DAY_BOUNDARY {
            warnings.insert(start_elem, Warning::ContainsEntireDay);
        }
    } else if let Some((end, end_elem)) = end {
        if is_day_end(end) {
            warnings.insert(end_elem, Warning::ContainsEntireDay);
        }
    }
}

/// Lower an optional `HH:MM` field, keeping the element its warnings anchor to.
fn lower_time<'a, 'buf>(
    time: &'a Integrity<Option<schema::Str<'buf>>>,
    warnings: &mut warning::Set<Warning>,
) -> Option<(HourMin, &'a json::Element<'buf>)> {
    let Integrity::Ok(Some(time)) = time else {
        return None;
    };

    let lowered: Option<chrono::NaiveTime> =
        chrono::NaiveTime::from_schema(time).deescalate_error_into(warnings);
    let lowered = lowered?;

    let hour_min = HourMin {
        hour: chrono::Timelike::hour(&lowered),
        min: chrono::Timelike::minute(&lowered),
    };

    Some((hour_min, time.element()))
}

/// Every day of the week; a list holding all of them says the same as no list at all.
const ALL_DAYS: usize = 7;

/// Lint the `day_of_week` restriction.
///
/// A day the schema could not read is skipped rather than abandoning the list, so the
/// ordering and duplicate checks still describe the days that are readable. Every warning
/// here is about the list as a whole, so all of them anchor to the array.
///
/// * 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>)
#[instrument(skip_all)]
fn lint_day_of_week<T>(
    day_of_week: &Integrity<Option<schema::List<'_, schema::Enum<'_, T>>>>,
    warnings: &mut warning::Set<Warning>,
) where
    T: OcpiEnum,
    Weekday: for<'a> FromSchema<'a, T, Warning = std::convert::Infallible>,
{
    let Integrity::Ok(Some(days)) = day_of_week else {
        return;
    };

    let elem = days.element();

    // An empty list matches no day at all, which is rarely what the author meant.
    if days.is_empty() {
        warnings.insert(elem, Warning::DayOfWeekEmpty);
        return;
    }

    let mut lowered: Vec<Weekday> = Vec::with_capacity(days.len());

    for day in days {
        let Integrity::Ok(day) = day else {
            continue;
        };

        // The schema already proved the value is one of the enum's variants, so mapping it
        // to a `Weekday` cannot fail and cannot warn. `Infallible` makes the `Err` arm
        // uninhabited, but `let` still needs it spelled out.
        let Ok(day) = Weekday::from_schema(&day.value()) else {
            continue;
        };
        lowered.push(day.ignore_warnings());
    }

    if !lowered.is_sorted() {
        warnings.insert(elem, Warning::DayOfWeekUnsorted);
    }

    let unique: std::collections::BTreeSet<_> = lowered.iter().copied().collect();

    if unique.len() != lowered.len() {
        warnings.insert(elem, Warning::DayOfWeekDuplicates);
    }

    if unique.len() == ALL_DAYS {
        warnings.insert(elem, Warning::ContainsEntireWeek);
    }
}

/// Lint a `max_*` restriction, which never matches anything when it is zero.
#[instrument(skip_all)]
fn lint_max_zero<T>(
    max: &Integrity<Option<schema::Number<'_>>>,
    warnings: &mut warning::Set<Warning>,
) where
    T: for<'a> FromSchema<'a, schema::Number<'a>> + number::IsZero,
    for<'a> <T as FromSchema<'a, schema::Number<'a>>>::Warning: Into<Warning>,
{
    let Integrity::Ok(Some(max)) = max else {
        return;
    };

    let value: Option<T> = T::from_schema(max).deescalate_error_into(warnings);

    if value.is_some_and(|v| v.is_zero()) {
        warnings.insert(max.element(), Warning::MaxZeroNeverMatch);
    }
}