Skip to main content

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