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