#[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,
};
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),
}
}
#[derive(Debug)]
pub struct Report {
pub warnings: warning::Set<Warning>,
}
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum Warning {
ContainsEntireDay,
ContainsEntireWeek,
Country(country::Warning),
CpoCountryCodeShouldBeAlpha2,
Currency(currency::Warning),
DateTime(datetime::Warning),
DayOfWeekDuplicates,
DayOfWeekEmpty,
DayOfWeekUnsorted,
Duration(duration::Warning),
EndTimeIsNearEndOfDay,
MaxZeroNeverMatch,
MinPriceIsGreaterThanMax,
NeverValid,
Money(money::Warning),
Number(number::Warning),
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(),
}
}
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),
}
}
}
#[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 }
}
#[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;
};
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 }
}
#[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);
}
}
type PartyId<'buf> = string::CiExactLen<'buf, 3>;
#[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;
};
if source.value().lexical_issues().escapes {
return;
}
if party_id.chars().any(char::is_lowercase) {
warnings.insert(
source.element(),
Warning::String(string::Warning::PreferUppercase),
);
}
}
#[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:?}");
}
#[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);
}
}
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()))
}
#[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);
}
}
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()))
}
#[derive(Copy, Clone, Eq, PartialEq)]
struct HourMin {
hour: u32,
min: u32,
}
const DAY_BOUNDARY: HourMin = HourMin { hour: 0, min: 0 };
const NEAR_END_OF_DAY: HourMin = HourMin { hour: 23, min: 59 };
fn is_day_end(time: HourMin) -> bool {
time == NEAR_END_OF_DAY || time == DAY_BOUNDARY
}
#[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);
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);
}
}
}
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()))
}
const ALL_DAYS: usize = 7;
#[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();
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;
};
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);
}
}
#[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);
}
}