1#[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
37pub(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#[derive(Debug)]
56pub struct Report {
57 pub warnings: warning::Set<Warning>,
60}
61
62#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
67pub enum Warning {
68 ContainsEntireDay,
71
72 ContainsEntireWeek,
74
75 Country(country::Warning),
77
78 CpoCountryCodeShouldBeAlpha2,
80
81 Currency(currency::Warning),
83
84 DateTime(datetime::Warning),
86
87 DayOfWeekDuplicates,
89
90 DayOfWeekEmpty,
92
93 DayOfWeekUnsorted,
95
96 Duration(duration::Warning),
98
99 EndTimeIsNearEndOfDay,
105
106 MaxZeroNeverMatch,
108
109 MinPriceIsGreaterThanMax,
111
112 NeverValid,
114
115 Money(money::Warning),
117
118 Number(number::Warning),
120
121 RestrictionsEmpty,
123
124 StartDateTimeIsAfterEndDateTime,
126
127 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 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#[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#[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 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#[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#[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
377type PartyId<'buf> = string::CiExactLen<'buf, 3>;
379
380#[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 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#[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#[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
443fn 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#[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
479fn 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#[derive(Copy, Clone, Eq, PartialEq)]
498struct HourMin {
499 hour: u32,
500 min: u32,
501}
502
503const DAY_BOUNDARY: HourMin = HourMin { hour: 0, min: 0 };
505
506const NEAR_END_OF_DAY: HourMin = HourMin { hour: 23, min: 59 };
508
509fn is_day_end(time: HourMin) -> bool {
511 time == NEAR_END_OF_DAY || time == DAY_BOUNDARY
512}
513
514#[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 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
553fn 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
574const ALL_DAYS: usize = 7;
576
577#[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 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 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#[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}