ocpi_tariffs/lint/
tariff.rs

1pub(crate) mod v211;
2pub(crate) mod v221;
3pub mod v2x;
4
5use std::{borrow::Cow, fmt};
6
7use crate::{
8    country, currency, datetime, from_warning_set_to, money, number, tariff, warning,
9    Versioned as _,
10};
11
12use super::Error;
13
14/// Lint the given tariff and return a report of any `Warning`s found.
15///
16/// * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>)
17/// * See: [OCPI spec 2.1.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#31-tariff-object>)
18pub(crate) fn lint(tariff: &tariff::Versioned<'_>) -> Result<Report, Error> {
19    match tariff.version() {
20        crate::Version::V221 => v221::lint(tariff.as_element()),
21        crate::Version::V211 => v211::lint(tariff.as_element()),
22    }
23}
24
25/// A tariff linting report.
26#[derive(Debug)]
27pub struct Report {
28    /// Any `Warning`s found while linting the tariff.
29    warnings: warning::Report<WarningKind>,
30}
31
32impl Report {
33    pub fn into_warning_report(self) -> warning::Report<WarningKind> {
34        self.warnings
35    }
36}
37
38#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
39pub enum WarningKind {
40    Country(country::WarningKind),
41
42    /// Both the CDR and tariff have a `country_code` that should be an alpha-2.
43    CpoCountryCodeShouldBeAlpha2,
44
45    Currency(currency::WarningKind),
46
47    DateTime(datetime::WarningKind),
48
49    Elements(v2x::elements::WarningKind),
50
51    /// The `min_price` is greater than `max_price`.
52    MinPriceIsGreaterThanMax,
53
54    Money(money::WarningKind),
55
56    Number(number::WarningKind),
57
58    /// The given field is required.
59    RequiredField(&'static str),
60
61    /// The `start_date_time` is after the `end_date_time`.
62    StartDateTimeIsAfterEndDateTime,
63}
64
65from_warning_set_to!(country::WarningKind => WarningKind);
66from_warning_set_to!(datetime::WarningKind => WarningKind);
67from_warning_set_to!(number::WarningKind => WarningKind);
68from_warning_set_to!(money::WarningKind => WarningKind);
69from_warning_set_to!(v2x::elements::WarningKind => WarningKind);
70
71impl From<country::WarningKind> for WarningKind {
72    fn from(warn_kind: country::WarningKind) -> Self {
73        Self::Country(warn_kind)
74    }
75}
76
77impl From<currency::WarningKind> for WarningKind {
78    fn from(warn_kind: currency::WarningKind) -> Self {
79        Self::Currency(warn_kind)
80    }
81}
82
83impl From<datetime::WarningKind> for WarningKind {
84    fn from(warn_kind: datetime::WarningKind) -> Self {
85        Self::DateTime(warn_kind)
86    }
87}
88
89impl From<v2x::elements::WarningKind> for WarningKind {
90    fn from(warn_kind: v2x::elements::WarningKind) -> Self {
91        Self::Elements(warn_kind)
92    }
93}
94
95impl From<number::WarningKind> for WarningKind {
96    fn from(warn_kind: number::WarningKind) -> Self {
97        Self::Number(warn_kind)
98    }
99}
100
101impl From<money::WarningKind> for WarningKind {
102    fn from(warn_kind: money::WarningKind) -> Self {
103        Self::Money(warn_kind)
104    }
105}
106
107impl warning::Kind for WarningKind {
108    fn id(&self) -> Cow<'static, str> {
109        match self {
110            WarningKind::CpoCountryCodeShouldBeAlpha2 => "cpo_country_code_should_be_alpha2".into(),
111            WarningKind::Country(kind) => format!("country.{}", kind.id()).into(),
112            WarningKind::Currency(kind) => format!("currency.{}", kind.id()).into(),
113            WarningKind::DateTime(kind) => format!("datetime.{}", kind.id()).into(),
114            WarningKind::Elements(kind) => format!("elements.{}", kind.id()).into(),
115            WarningKind::MinPriceIsGreaterThanMax => "min_price_is_greater_than_max".into(),
116            WarningKind::Number(kind) => format!("number.{}", kind.id()).into(),
117            WarningKind::Money(kind) => format!("price.{}", kind.id()).into(),
118            WarningKind::StartDateTimeIsAfterEndDateTime => {
119                "start_date_time_is_after_end_date_time".into()
120            }
121            WarningKind::RequiredField(field_name) => format!("required_field.{field_name}").into(),
122        }
123    }
124}
125
126impl fmt::Display for WarningKind {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            WarningKind::CpoCountryCodeShouldBeAlpha2 => {
130                f.write_str("The value should be an alpha-2 ISO 3166-1 country code")
131            }
132            WarningKind::Country(kind) => fmt::Display::fmt(kind, f),
133            WarningKind::Currency(kind) => fmt::Display::fmt(kind, f),
134            WarningKind::DateTime(kind) => fmt::Display::fmt(kind, f),
135            WarningKind::Elements(kind) => fmt::Display::fmt(kind, f),
136            WarningKind::MinPriceIsGreaterThanMax => {
137                f.write_str("The `min_price` is greater than `max_price`.")
138            }
139            WarningKind::Number(kind) => fmt::Display::fmt(kind, f),
140            WarningKind::Money(kind) => fmt::Display::fmt(kind, f),
141            WarningKind::StartDateTimeIsAfterEndDateTime => {
142                f.write_str("The `start_date_time` is after the `end_date_time`.")
143            }
144            WarningKind::RequiredField(field_name) => write!(f, "Required field `{field_name}`"),
145        }
146    }
147}
148
149#[cfg(test)]
150pub mod test {
151    #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
152    #![allow(clippy::panic, reason = "tests are allowed panic")]
153
154    use std::{
155        collections::{BTreeMap, BTreeSet},
156        path::Path,
157    };
158
159    use assert_matches::assert_matches;
160
161    use crate::{
162        guess, json, tariff, test,
163        warning::{ElementReport, Kind as _},
164        Version, Versioned,
165    };
166
167    use super::Report;
168
169    pub fn lint_tariff(tariff_json: &str, path: &Path, expected_version: Version) {
170        const LINT_FEATURE: &str = "lint";
171        const PARSE_FEATURE: &str = "parse";
172
173        test::setup();
174        let expect_json = test::read_expect_json(path, PARSE_FEATURE);
175        let parse_expect = tariff::test::parse_expect_json(expect_json.as_deref());
176        let report = tariff::parse_and_report(tariff_json).unwrap();
177
178        let tariff = {
179            let version = tariff::test::assert_parse_report(report, parse_expect);
180            let tariff = assert_matches!(version, guess::Version::Certain(tariff) => tariff);
181            assert_eq!(tariff.version(), expected_version);
182            tariff
183        };
184
185        let expect_json = test::read_expect_json(path, LINT_FEATURE);
186        let lint_expect = parse_expect_json(expect_json.as_deref());
187
188        let report = super::lint(&tariff).unwrap();
189
190        assert_report(tariff.as_element(), report, lint_expect);
191    }
192
193    /// Expectations for the result of calling `tariff::lint`.
194    #[derive(serde::Deserialize)]
195    pub struct Expect<'buf> {
196        #[serde(borrow)]
197        warnings: BTreeMap<&'buf str, Vec<&'buf str>>,
198    }
199
200    #[track_caller]
201    pub fn parse_expect_json(expect_json: Option<&str>) -> Option<Expect<'_>> {
202        expect_json.map(|json| serde_json::from_str(json).expect("Unable to parse expect JSON"))
203    }
204
205    #[track_caller]
206    pub fn assert_report(element: &json::Element<'_>, report: Report, expect: Option<Expect<'_>>) {
207        let Report { warnings } = report;
208
209        // If there are warnings reported and there is no expect file
210        // then panic printing the fields of the expect JSON object that would silence these warnings.
211        // These can be copied into an `output_lint__*.json` file.
212        assert!(
213            warnings.is_empty() || expect.is_some(),
214            "Warnings reported:\n{{\n  \"warnings\": {{\n    {}\n  }}\n}}",
215            warnings
216                .iter(element)
217                .map(|ElementReport { element, warnings }| {
218                    format!(
219                        "\"{}\": [{}]",
220                        element.path(),
221                        warnings
222                            .iter()
223                            .map(|w| format!("\"{}\"", w.id()))
224                            .collect::<Vec<_>>()
225                            .join(", ")
226                    )
227                })
228                .collect::<Vec<_>>()
229                .join(",\n")
230        );
231
232        let Some(Expect {
233            warnings: warnings_expect,
234        }) = expect
235        else {
236            return;
237        };
238
239        for warning in warnings.iter(element) {
240            let ElementReport { element, warnings } = warning;
241            let path_str = element.path().to_string();
242            let Some(warnings_expect) = warnings_expect.get(&*path_str) else {
243                let warning_ids = warnings
244                    .iter()
245                    .map(|k| format!("  \"{}\",", k.id()))
246                    .collect::<Vec<_>>()
247                    .join("\n");
248
249                panic!("No warnings expected for `Element` at `{path_str}` but {} warnings were reported:\n[\n{}\n]", warnings.len(), warning_ids);
250            };
251
252            let warnings_expect = warnings_expect.iter().collect::<BTreeSet<_>>();
253
254            for warning_kind in warnings {
255                let id = warning_kind.id();
256                assert!(
257                    warnings_expect.contains(&&*id),
258                    "Unexpected warning `{id}` for `Element` at `{path_str}`"
259                );
260            }
261        }
262    }
263}