1pub(crate) mod currency {
4 use tracing::{debug, instrument};
5
6 use crate::{
7 currency,
8 json::{self, FromJson as _},
9 warning::{self, GatherWarnings as _, IntoCaveat as _},
10 Verdict,
11 };
12
13 #[instrument(skip_all)]
15 pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), currency::Warning> {
16 let mut warnings = warning::Set::<currency::Warning>::new();
17 let code = currency::Code::from_json(elem)?.gather_warnings_into(&mut warnings);
18
19 debug!("code: {code:?}");
20
21 Ok(().into_caveat(warnings))
22 }
23}
24
25pub(crate) mod datetime {
26 use chrono::{DateTime, Utc};
27 use tracing::instrument;
28
29 use crate::{
30 json::{self, FromJson as _},
31 lint::tariff::Warning,
32 warning::{self, GatherWarnings as _, IntoCaveat as _},
33 Verdict,
34 };
35
36 #[instrument(skip_all)]
41 pub(crate) fn lint_start_end(
42 start_date_time_elem: Option<&json::Element<'_>>,
43 end_date_time_elem: Option<&json::Element<'_>>,
44 ) -> Verdict<(), Warning> {
45 let mut warnings = warning::Set::<Warning>::new();
46
47 let start_date = start_date_time_elem
48 .map(DateTime::<Utc>::from_json)
49 .transpose()?
50 .gather_warnings_into(&mut warnings);
51 let end_date = end_date_time_elem
52 .map(DateTime::<Utc>::from_json)
53 .transpose()?
54 .gather_warnings_into(&mut warnings);
55
56 if let Some(((start, start_elem), end)) = start_date.zip(start_date_time_elem).zip(end_date)
57 {
58 if start > end {
59 warnings.insert(Warning::StartDateTimeIsAfterEndDateTime, start_elem);
60 }
61 }
62
63 Ok(().into_caveat(warnings))
64 }
65}
66
67pub mod time {
68 use std::fmt;
73
74 use chrono::{NaiveTime, Timelike as _};
75
76 use crate::{
77 datetime, from_warning_all,
78 json::{self, FromJson as _},
79 warning::{self, GatherWarnings as _, IntoCaveat as _},
80 Verdict,
81 };
82
83 const DAY_BOUNDARY: HourMin = HourMin::new(0, 0);
84 const NEAR_END_OF_DAY: HourMin = HourMin::new(23, 59);
85
86 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
87 pub enum Warning {
88 ContainsEntireDay,
91
92 EndTimeIsNearEndOfDay,
98
99 NeverValid,
101
102 DateTime(datetime::Warning),
104 }
105
106 impl fmt::Display for Warning {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 match self {
109 Self::ContainsEntireDay => f.write_str("Both `start_time` and `end_time` are defined and contain the entire day."),
110 Self::EndTimeIsNearEndOfDay => f.write_str(r#"
111The `end_time` restriction is set to `23::59`.
112
113The spec states: "To stop at end of the day use: 00:00.".
114
115See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>"#),
116 Self::NeverValid => f.write_str("The `start_time` and `end_time` are equal and so the element is never valid."),
117 Self::DateTime(kind) => fmt::Display::fmt(kind, f),
118 }
119 }
120 }
121
122 impl crate::Warning for Warning {
123 fn id(&self) -> warning::Id {
124 match self {
125 Self::ContainsEntireDay => warning::Id::from_static("contains_entire_day"),
126 Self::EndTimeIsNearEndOfDay => {
127 warning::Id::from_static("end_time_is_near_end_of_day")
128 }
129 Self::NeverValid => warning::Id::from_static("never_valid"),
130 Self::DateTime(kind) => kind.id(),
131 }
132 }
133 }
134
135 from_warning_all!(datetime::Warning => Warning::DateTime);
136
137 pub(crate) fn lint(
139 start_time_elem: Option<&json::Element<'_>>,
140 end_time_elem: Option<&json::Element<'_>>,
141 ) -> Verdict<(), Warning> {
142 let mut warnings = warning::Set::<Warning>::new();
143
144 let start = elem_to_time_hm(start_time_elem, &mut warnings)?;
145 let end = elem_to_time_hm(end_time_elem, &mut warnings)?;
146
147 if let Some(((start_time, start_elem), (end_time, end_elem))) = start.zip(end) {
149 if end_time == NEAR_END_OF_DAY {
150 warnings.insert(Warning::EndTimeIsNearEndOfDay, end_elem);
151 }
152
153 if start_time == DAY_BOUNDARY && is_day_end(end_time) {
154 warnings.insert(Warning::ContainsEntireDay, start_elem);
155 } else if start_time == end_time {
156 warnings.insert(Warning::NeverValid, start_elem);
157 }
158 } else if let Some((start_time, start_elem)) = start {
159 if start_time == DAY_BOUNDARY {
160 warnings.insert(Warning::ContainsEntireDay, start_elem);
161 }
162 } else if let Some((end_time, end_elem)) = end {
163 if is_day_end(end_time) {
164 warnings.insert(Warning::ContainsEntireDay, end_elem);
165 }
166 }
167
168 Ok(().into_caveat(warnings))
169 }
170
171 #[derive(Copy, Clone, Eq, PartialEq)]
173 struct HourMin {
174 hour: u32,
176
177 min: u32,
179 }
180
181 impl HourMin {
182 const fn new(hour: u32, min: u32) -> Self {
184 Self { hour, min }
185 }
186 }
187
188 fn is_day_end(time: HourMin) -> bool {
190 time == NEAR_END_OF_DAY || time == DAY_BOUNDARY
191 }
192
193 fn elem_to_time_hm<'a, 'buf>(
195 time_elem: Option<&'a json::Element<'buf>>,
196 warnings: &mut warning::Set<Warning>,
197 ) -> Result<Option<(HourMin, &'a json::Element<'buf>)>, warning::ErrorSet<Warning>> {
198 let v = time_elem.map(NaiveTime::from_json).transpose()?;
199
200 Ok(v.gather_warnings_into(warnings)
201 .map(|t| HourMin {
202 hour: t.hour(),
203 min: t.minute(),
204 })
205 .zip(time_elem))
206 }
207}
208
209pub mod elements {
210 use std::{borrow::Cow, collections::HashSet, fmt};
216
217 use tracing::instrument;
218
219 use crate::{
220 from_warning_all,
221 json::{self, FieldsAsExt as _},
222 lint::Item,
223 required_field,
224 tariff::v2x::DimensionType,
225 warning::{self, DeescalateError as _, GatherWarnings as _, IntoCaveat as _},
226 Verdict,
227 };
228
229 use super::{price_components, restrictions};
230
231 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
232 pub enum Warning {
233 Empty,
235
236 FieldRequired { field_name: Cow<'static, str> },
238
239 InvalidType { type_found: json::ValueKind },
241
242 MissingCatchAll,
244
245 PriceComponents(price_components::Warning),
247
248 Restrictions(restrictions::Warning),
250 }
251
252 impl Warning {
253 fn invalid_type(elem: &json::Element<'_>) -> Self {
254 Self::InvalidType {
255 type_found: elem.value().kind(),
256 }
257 }
258 }
259
260 impl fmt::Display for Warning {
261 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262 match self {
263 Self::Empty => write!(
264 f,
265 "An empty list of days means that no day is allowed. Is this what you want?"
266 ),
267 Self::FieldRequired { field_name } => {
268 write!(f, "Field is required: `{field_name}`")
269 }
270 Self::InvalidType { type_found } => {
271 write!(f, "The value should be an array but is `{type_found}`")
272 }
273 Self::MissingCatchAll => write!(
274 f,
275 "The last element should have no restrictions so that it catches all cases."
276 ),
277 Self::PriceComponents(warning) => fmt::Display::fmt(warning, f),
278 Self::Restrictions(warning) => fmt::Display::fmt(warning, f),
279 }
280 }
281 }
282
283 impl crate::Warning for Warning {
284 fn id(&self) -> warning::Id {
285 match self {
286 Self::Empty => warning::Id::from_static("empty"),
287 Self::FieldRequired { field_name } => {
288 warning::Id::from_string(format!("field_required({field_name})"))
289 }
290 Self::InvalidType { .. } => warning::Id::from_static("invalid_type"),
291 Self::MissingCatchAll => warning::Id::from_static("missing_catch_all"),
292 Self::PriceComponents(warning) => warning.id(),
293 Self::Restrictions(warning) => warning.id(),
294 }
295 }
296 }
297
298 from_warning_all!(
299 price_components::Warning => Warning::PriceComponents,
300 restrictions::Warning => Warning::Restrictions
301 );
302
303 #[instrument(skip_all)]
307 pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), Warning> {
308 #[expect(
310 dead_code,
311 reason = "The `ElementSummary` will be used in an upcoming analysis PR."
312 )]
313 struct ElementSummary {
314 dimensions: HashSet<DimensionType>,
315 has_restrictions: bool,
316 }
317
318 let mut warnings = warning::Set::<Warning>::new();
319
320 let Some(elements) = elem.as_array() else {
322 return warnings.bail(Warning::invalid_type(elem), elem);
323 };
324
325 if elements.is_empty() {
327 return warnings.bail(Warning::Empty, elem);
328 }
329
330 let _elements = elements
332 .iter()
333 .map(|elem| {
334 let Some(fields) = elem.as_object_fields() else {
335 warnings.insert(Warning::invalid_type(elem), elem);
336 return Item::Invalid;
337 };
338 let restrictions = fields.find_field("restrictions");
339 let mut has_restrictions = false;
340
341 if let Some(field) = restrictions {
343 let report = restrictions::lint(field.element())
344 .deescalate_error_into(&mut warnings)
345 .gather_warnings_into(&mut warnings);
346
347 if let Some(report) = report {
348 let restrictions::Report { is_empty } = report;
349 has_restrictions = is_empty;
350 }
351 }
352
353 let fields = fields.as_raw_map();
354 let dimensions = required_field!(elem, fields, "price_components", warnings)
356 .and_then(|elem| {
357 price_components::lint(elem)
358 .deescalate_error_into(&mut warnings)
359 .gather_warnings_into(&mut warnings)
360 })
361 .unwrap_or_default();
362
363 let dimensions = dimensions.into_iter().filter_map(Option::from).collect();
365
366 Item::Valid(ElementSummary {
367 dimensions,
368 has_restrictions,
369 })
370 })
371 .collect::<Vec<_>>();
372
373 Ok(().into_caveat(warnings))
374 }
375}
376
377pub mod price_components {
378 use std::{borrow::Cow, fmt};
379
380 use tracing::instrument;
381
382 use crate::{
383 enumeration, from_warning_all,
384 json::{self, FieldsAsExt as _, FromJson as _},
385 lint::Item,
386 number, required_field,
387 tariff::v2x::DimensionType,
388 warning::{self, DeescalateError, GatherWarnings as _, IntoCaveat as _},
389 Money, Verdict,
390 };
391
392 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
393 pub enum Warning {
394 Empty,
396
397 FieldRequired {
399 field_name: Cow<'static, str>,
400 },
401
402 InvalidType,
404
405 Money(number::Warning),
406
407 Type(enumeration::Warning),
408 }
409
410 from_warning_all!(
411 enumeration::Warning => Warning::Type,
412 number::Warning => Warning::Money
413 );
414
415 impl fmt::Display for Warning {
416 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417 match self {
418 Self::Empty => write!(
419 f,
420 "An empty list of days means that no day is allowed. Is this what you want?"
421 ),
422 Self::FieldRequired { field_name } => {
423 write!(f, "Field is required: `{field_name}`")
424 }
425 Self::InvalidType => write!(f, "The value should be an object."),
426 Self::Money(w) => fmt::Display::fmt(w, f),
427 Self::Type(w) => fmt::Display::fmt(w, f),
428 }
429 }
430 }
431
432 impl crate::Warning for Warning {
433 fn id(&self) -> warning::Id {
434 match self {
435 Self::Empty => warning::Id::from_static("empty"),
436 Self::FieldRequired { field_name } => {
437 warning::Id::from_string(format!("field_required({field_name})"))
438 }
439 Self::InvalidType => warning::Id::from_static("invalid_type"),
440 Self::Money(w) => w.id(),
441 Self::Type(w) => w.id(),
442 }
443 }
444 }
445
446 #[instrument(skip_all)]
448 pub(super) fn lint(elem: &json::Element<'_>) -> Verdict<Vec<Item<DimensionType>>, Warning> {
449 let mut warnings = warning::Set::<Warning>::new();
450
451 let Some(items) = elem.as_array() else {
453 return warnings.bail(Warning::InvalidType, elem);
454 };
455
456 if items.is_empty() {
458 return warnings.bail(Warning::Empty, elem);
459 }
460
461 let dimensions: Vec<Item<DimensionType>> = items
462 .iter()
463 .map(|elem| {
464 let Some(fields) = elem.as_object_fields() else {
465 warnings.insert(Warning::InvalidType, elem);
466 return Item::Invalid;
467 };
468
469 let fields = fields.as_raw_map();
470
471 {
472 let price_elem = fields.get("price");
473
474 if let Some(elem) = price_elem {
475 let _money = Money::from_json(elem)
476 .deescalate_error_into(&mut warnings)
477 .gather_warnings_into(&mut warnings);
478 }
479 }
480
481 {
482 let Some(type_elem) = required_field!(elem, fields, "type", warnings) else {
483 return Item::Invalid;
485 };
486
487 let dimension = DimensionType::from_json(type_elem)
488 .deescalate_error_into(&mut warnings)
489 .gather_warnings_into(&mut warnings);
490
491 Item::from(dimension)
492 }
493 })
494 .collect();
495
496 Ok(dimensions.into_caveat(warnings))
497 }
498}
499
500pub mod restrictions {
501 use std::fmt;
507
508 use tracing::instrument;
509
510 use crate::{
511 duration::{self, Seconds},
512 from_warning_all,
513 json::{self, FieldsAsExt as _},
514 number,
515 warning::{self, DeescalateError, GatherWarnings as _, IntoCaveat as _},
516 Ampere, Kw, Kwh, Verdict,
517 };
518
519 use super::{time, weekday};
520
521 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
522 pub enum Warning {
523 Duration(duration::Warning),
524
525 InvalidType {
527 type_found: json::ValueKind,
528 },
529
530 Number(number::Warning),
531
532 MaxZeroNeverMatch,
533
534 Time(time::Warning),
536
537 Weekday(weekday::Warning),
539 }
540
541 impl Warning {
542 fn invalid_type(elem: &json::Element<'_>) -> Self {
543 Self::InvalidType {
544 type_found: elem.value().kind(),
545 }
546 }
547 }
548
549 impl fmt::Display for Warning {
550 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
551 match self {
552 Self::Duration(warning) => fmt::Display::fmt(warning, f),
553 Self::InvalidType { type_found } => {
554 write!(f, "The value should be an object but is `{type_found}`")
555 }
556 Self::MaxZeroNeverMatch => write!(f, "This element contains a `max_*` restriction and so will never match. This element can be removed"),
557 Self::Number(warning) => fmt::Display::fmt(warning, f),
558 Self::Time(warning) => fmt::Display::fmt(warning, f),
559 Self::Weekday(warning) => fmt::Display::fmt(warning, f),
560 }
561 }
562 }
563
564 impl crate::Warning for Warning {
565 fn id(&self) -> warning::Id {
566 match self {
567 Self::Duration(warning) => warning.id(),
568 Self::InvalidType { .. } => warning::Id::from_static("invalid_type"),
569 Self::MaxZeroNeverMatch => warning::Id::from_static("max_zero_will_never_match"),
570 Self::Number(warning) => warning.id(),
571 Self::Time(warning) => warning.id(),
572 Self::Weekday(warning) => warning.id(),
573 }
574 }
575 }
576
577 from_warning_all!(
578 duration::Warning => Warning::Duration,
579 number::Warning => Warning::Number,
580 time::Warning => Warning::Time,
581 weekday::Warning => Warning::Weekday
582 );
583
584 pub struct Report {
586 pub is_empty: bool,
588 }
589
590 #[instrument(skip_all)]
592 pub(super) fn lint(elem: &json::Element<'_>) -> Verdict<Report, Warning> {
593 let mut warnings = warning::Set::<Warning>::new();
594
595 let Some(fields) = elem.as_object_fields() else {
596 return warnings.bail(Warning::invalid_type(elem), elem);
597 };
598
599 let fields = fields.as_raw_map();
600
601 {
602 let start_time = fields.get("start_time").map(|e| &**e);
603 let end_time = fields.get("end_time").map(|e| &**e);
604
605 let _drop: Option<()> = time::lint(start_time, end_time)
606 .deescalate_error_into(&mut warnings)
607 .gather_warnings_into(&mut warnings);
608 }
609
610 {
611 let day_of_week = fields.get("day_of_week").map(|e| &**e);
612
613 let _drop: Option<()> = weekday::lint(day_of_week)
614 .deescalate_error_into(&mut warnings)
615 .gather_warnings_into(&mut warnings);
616 }
617
618 {
619 fields
620 .get("max_current")
621 .map(|elem| from_json_lint_zero::<Ampere>(elem, &mut warnings));
622
623 fields
624 .get("max_duration")
625 .map(|elem| from_json_lint_zero::<Seconds>(elem, &mut warnings));
626
627 fields
628 .get("max_kwh")
629 .map(|elem| from_json_lint_zero::<Kwh>(elem, &mut warnings));
630
631 fields
632 .get("max_power")
633 .map(|elem| from_json_lint_zero::<Kw>(elem, &mut warnings));
634 }
635
636 Ok(Report {
637 is_empty: fields.is_empty(),
638 }
639 .into_caveat(warnings))
640 }
641
642 fn from_json_lint_zero<'elem, 'buf, T>(
645 element: &'elem json::Element<'buf>,
646 warnings: &mut warning::Set<Warning>,
647 ) -> Option<T>
648 where
649 T: json::FromJson<'buf, Warning: Into<Warning>> + number::IsZero,
650 {
651 let value = T::from_json(element)
652 .deescalate_error_into(warnings)
653 .gather_warnings_into(warnings);
654
655 if value.as_ref().is_some_and(number::IsZero::is_zero) {
656 warnings.insert(Warning::MaxZeroNeverMatch, element);
657 }
658
659 value
660 }
661}
662
663pub mod weekday {
664 use std::{collections::BTreeSet, fmt, sync::LazyLock};
672
673 use crate::{
674 from_warning_all,
675 json::{self, FromJson},
676 warning::{self, GatherWarnings as _, IntoCaveat as _},
677 Verdict, Weekday,
678 };
679
680 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
681 pub enum Warning {
682 ContainsEntireWeek,
684
685 Enum(crate::enumeration::Warning),
687
688 Duplicates,
690
691 Empty,
693
694 InvalidType { type_found: json::ValueKind },
696
697 Unsorted,
699 }
700
701 impl Warning {
702 fn invalid_type(elem: &json::Element<'_>) -> Self {
703 Self::InvalidType {
704 type_found: elem.value().kind(),
705 }
706 }
707 }
708
709 impl fmt::Display for Warning {
710 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
711 match self {
712 Self::ContainsEntireWeek => write!(f, "All days of the week are defined. You can simply leave out the `day_of_week` field."),
713 Self::Enum(warning) => fmt::Display::fmt(warning, f),
714 Self::Duplicates => write!(f, "There's at least one duplicate day."),
715 Self::Empty => write!(
716 f,
717 "An empty list of days means that no day is allowed. Is this what you want?"
718 ),
719 Self::InvalidType { type_found } => {
720 write!(f, "The value should be an array but is `{type_found}`")
721 }
722 Self::Unsorted => write!(f, "The days are unsorted."),
723 }
724 }
725 }
726
727 impl crate::Warning for Warning {
728 fn id(&self) -> warning::Id {
729 match self {
730 Self::ContainsEntireWeek => warning::Id::from_static("contains_entire_week"),
731 Self::Enum(warning) => warning.id(),
732 Self::Duplicates => warning::Id::from_static("duplicates"),
733 Self::Empty => warning::Id::from_static("empty"),
734 Self::InvalidType { .. } => warning::Id::from_static("invalid_type"),
735 Self::Unsorted => warning::Id::from_static("unsorted"),
736 }
737 }
738 }
739
740 from_warning_all!(crate::enumeration::Warning => Warning::Enum);
741
742 pub(super) fn lint(elem: Option<&json::Element<'_>>) -> Verdict<(), Warning> {
744 static ALL_DAYS_OF_WEEK: LazyLock<BTreeSet<Weekday>> = LazyLock::new(|| {
746 BTreeSet::from([
747 Weekday::Monday,
748 Weekday::Tuesday,
749 Weekday::Wednesday,
750 Weekday::Thursday,
751 Weekday::Friday,
752 Weekday::Saturday,
753 Weekday::Sunday,
754 ])
755 });
756
757 let mut warnings = warning::Set::<Warning>::new();
758
759 let Some(elem) = elem else {
761 return Ok(().into_caveat(warnings));
762 };
763
764 let Some(items) = elem.as_array() else {
766 return warnings.bail(Warning::invalid_type(elem), elem);
767 };
768
769 if items.is_empty() {
772 warnings.insert(Warning::Empty, elem);
773 return Ok(().into_caveat(warnings));
774 }
775
776 let days = items
778 .iter()
779 .map(Weekday::from_json)
780 .collect::<Result<Vec<_>, _>>()?;
781
782 let days = days.gather_warnings_into(&mut warnings);
784
785 if !days.is_sorted() {
787 warnings.insert(Warning::Unsorted, elem);
788 }
789
790 let day_set: BTreeSet<_> = days.iter().copied().collect();
791
792 if day_set.len() != days.len() {
795 warnings.insert(Warning::Duplicates, elem);
796 }
797
798 if day_set == *ALL_DAYS_OF_WEEK {
801 warnings.insert(Warning::ContainsEntireWeek, elem);
802 }
803
804 Ok(().into_caveat(warnings))
805 }
806}