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, json, money, number, string, tariff, warning,
9    Versioned as _, Warning,
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    let null_field_warnings = json::walk::DepthFirst::new(tariff.as_element())
20        .filter(|&elem| elem.value().is_null())
21        .map(|elem| Warning::with_elem(WarningKind::NeedlessNullField, elem))
22        .collect::<Vec<_>>();
23
24    let warnings = warning::Set::from_vec(null_field_warnings);
25
26    match tariff.version() {
27        crate::Version::V221 => v221::lint(tariff.as_element(), warnings),
28        crate::Version::V211 => v211::lint(tariff.as_element(), warnings),
29    }
30}
31
32/// A tariff linting report.
33#[derive(Debug)]
34pub struct Report {
35    /// Any `Warning`s found while linting the tariff.
36    pub warnings: warning::Set<WarningKind>,
37}
38
39#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
40pub enum WarningKind {
41    Country(country::WarningKind),
42
43    /// Both the CDR and tariff have a `country_code` that should be an alpha-2.
44    CpoCountryCodeShouldBeAlpha2,
45
46    Currency(currency::WarningKind),
47
48    DateTime(datetime::WarningKind),
49
50    Elements(v2x::elements::WarningKind),
51
52    /// The `min_price` is greater than `max_price`.
53    MinPriceIsGreaterThanMax,
54
55    Money(money::WarningKind),
56
57    /// The field is null. It can simply be removed.
58    NeedlessNullField,
59
60    Number(number::WarningKind),
61
62    /// The given field is required.
63    FieldRequired {
64        field_name: Cow<'static, str>,
65    },
66
67    String(string::WarningKind),
68
69    /// The `start_date_time` is after the `end_date_time`.
70    StartDateTimeIsAfterEndDateTime,
71}
72
73from_warning_set_to!(country::WarningKind => WarningKind);
74from_warning_set_to!(datetime::WarningKind => WarningKind);
75from_warning_set_to!(number::WarningKind => WarningKind);
76from_warning_set_to!(money::WarningKind => WarningKind);
77from_warning_set_to!(string::WarningKind => WarningKind);
78from_warning_set_to!(v2x::elements::WarningKind => WarningKind);
79
80impl From<country::WarningKind> for WarningKind {
81    fn from(warn_kind: country::WarningKind) -> Self {
82        Self::Country(warn_kind)
83    }
84}
85
86impl From<currency::WarningKind> for WarningKind {
87    fn from(warn_kind: currency::WarningKind) -> Self {
88        Self::Currency(warn_kind)
89    }
90}
91
92impl From<datetime::WarningKind> for WarningKind {
93    fn from(warn_kind: datetime::WarningKind) -> Self {
94        Self::DateTime(warn_kind)
95    }
96}
97
98impl From<number::WarningKind> for WarningKind {
99    fn from(warn_kind: number::WarningKind) -> Self {
100        Self::Number(warn_kind)
101    }
102}
103
104impl From<money::WarningKind> for WarningKind {
105    fn from(warn_kind: money::WarningKind) -> Self {
106        Self::Money(warn_kind)
107    }
108}
109
110impl From<string::WarningKind> for WarningKind {
111    fn from(warn_kind: string::WarningKind) -> Self {
112        Self::String(warn_kind)
113    }
114}
115
116impl From<v2x::elements::WarningKind> for WarningKind {
117    fn from(warn_kind: v2x::elements::WarningKind) -> Self {
118        Self::Elements(warn_kind)
119    }
120}
121
122impl warning::Kind for WarningKind {
123    fn id(&self) -> Cow<'static, str> {
124        match self {
125            Self::CpoCountryCodeShouldBeAlpha2 => "cpo_country_code_should_be_alpha2".into(),
126            Self::Country(kind) => kind.id(),
127            Self::Currency(kind) => kind.id(),
128            Self::DateTime(kind) => kind.id(),
129            Self::Elements(kind) => kind.id(),
130            Self::MinPriceIsGreaterThanMax => "min_price_is_greater_than_max".into(),
131            Self::Number(kind) => kind.id(),
132            Self::Money(kind) => kind.id(),
133            Self::NeedlessNullField => "needless_null_field".into(),
134            Self::StartDateTimeIsAfterEndDateTime => {
135                "start_date_time_is_after_end_date_time".into()
136            }
137            Self::String(kind) => kind.id(),
138            Self::FieldRequired { field_name } => format!("field_required({field_name})").into(),
139        }
140    }
141}
142
143impl fmt::Display for WarningKind {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Self::CpoCountryCodeShouldBeAlpha2 => {
147                f.write_str("The value should be an alpha-2 ISO 3166-1 country code")
148            }
149            Self::Country(kind) => fmt::Display::fmt(kind, f),
150            Self::Currency(kind) => fmt::Display::fmt(kind, f),
151            Self::DateTime(kind) => fmt::Display::fmt(kind, f),
152            Self::Elements(kind) => fmt::Display::fmt(kind, f),
153            Self::MinPriceIsGreaterThanMax => {
154                f.write_str("The `min_price` is greater than `max_price`.")
155            }
156            Self::Number(kind) => fmt::Display::fmt(kind, f),
157            Self::Money(kind) => fmt::Display::fmt(kind, f),
158            Self::NeedlessNullField => write!(f, "Null field: the field can simply be removed."),
159            Self::StartDateTimeIsAfterEndDateTime => {
160                f.write_str("The `start_date_time` is after the `end_date_time`.")
161            }
162            Self::String(kind) => fmt::Display::fmt(kind, f),
163            Self::FieldRequired { field_name } => {
164                write!(f, "Field is required: {field_name}")
165            }
166        }
167    }
168}
169
170#[cfg(test)]
171pub mod test {
172    #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
173    #![allow(clippy::panic, reason = "tests are allowed panic")]
174
175    use std::{collections::BTreeMap, path::Path};
176
177    use assert_matches::assert_matches;
178
179    use crate::{
180        guess, json, tariff,
181        test::{self, ExpectFile, Expectation},
182        warning, Version, Versioned,
183    };
184
185    use super::Report;
186
187    pub fn lint_tariff(tariff_json: &str, path: &Path, expected_version: Version) {
188        const LINT_FEATURE: &str = "lint";
189        const PARSE_FEATURE: &str = "parse";
190
191        test::setup();
192        let expect_json = test::read_expect_json(path, PARSE_FEATURE);
193        let parse_expect = test::parse_expect_json(expect_json.as_deref());
194        let report = tariff::parse_and_report(tariff_json).unwrap();
195
196        let tariff = {
197            let version = tariff::test::assert_parse_guess_report(report, parse_expect);
198            let tariff = assert_matches!(version, guess::Version::Certain(tariff) => tariff);
199            assert_eq!(tariff.version(), expected_version);
200            tariff
201        };
202
203        let expect_json = test::read_expect_json(path, LINT_FEATURE);
204        let lint_expect = test::parse_expect_json(expect_json.as_deref());
205
206        let report = super::lint(&tariff).unwrap();
207
208        assert_report(tariff.as_element(), report, lint_expect);
209    }
210
211    /// Expectations for the result of calling `tariff::lint`.
212    #[derive(serde::Deserialize)]
213    pub struct Expect {
214        #[serde(default)]
215        warnings: Expectation<BTreeMap<String, Vec<String>>>,
216    }
217
218    #[track_caller]
219    pub(crate) fn assert_report(
220        root: &json::Element<'_>,
221        report: Report,
222        expect: ExpectFile<Expect>,
223    ) {
224        let Report { warnings } = report;
225
226        // If there are warnings reported and there is no `expect` file
227        // then panic printing the fields of the expect JSON object that would silence these warnings.
228        // These can be copied into an `output_lint__*.json` file.
229        let ExpectFile {
230            value: expect,
231            expect_file_name,
232        } = expect;
233
234        let Some(expect) = expect else {
235            assert!(
236                warnings.is_empty(),
237                "There is no expectation file at `{expect_file_name}` but the tariff has warnings;\n{:?}",
238                warnings.group_by_elem(root).into_stringified_map()
239            );
240            return;
241        };
242        warning::test::assert_warnings(&expect_file_name, root, &warnings, expect.warnings);
243    }
244}