1#[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
38pub(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#[derive(Debug)]
57pub struct Report {
58 pub warnings: warning::Set<Warning>,
61}
62
63#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
69pub enum Warning {
70 ContainsEntireDay,
73
74 ContainsEntireWeek,
76
77 Country(country::Warning),
78
79 CpoCountryCodeShouldBeAlpha2,
81
82 Currency(currency::Warning),
83
84 DateTime(datetime::Warning),
85
86 DayOfWeekDuplicates,
88
89 DayOfWeekEmpty,
91
92 DayOfWeekUnsorted,
94
95 Duration(duration::Warning),
96
97 EndTimeIsNearEndOfDay,
103
104 MaxZeroNeverMatch,
106
107 MinPriceIsGreaterThanMax,
109
110 NeverValid,
112
113 Money(money::Warning),
114
115 Number(number::Warning),
116
117 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 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#[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#[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 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#[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
340type PartyId<'buf> = string::CiExactLen<'buf, 3>;
342
343#[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 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#[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#[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
406fn 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#[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
442fn 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#[derive(Copy, Clone, Eq, PartialEq)]
461struct HourMin {
462 hour: u32,
463 min: u32,
464}
465
466const DAY_BOUNDARY: HourMin = HourMin { hour: 0, min: 0 };
468
469const NEAR_END_OF_DAY: HourMin = HourMin { hour: 23, min: 59 };
471
472fn is_day_end(time: HourMin) -> bool {
474 time == NEAR_END_OF_DAY || time == DAY_BOUNDARY
475}
476
477#[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 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
516fn 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
537const ALL_DAYS: usize = 7;
539
540#[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 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 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#[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}