ocpi_tariffs/lint/
tariff.rs

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