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 { type_found } => {
291 warning::Id::from_string(format!("invalid_type({type_found})"))
292 }
293 Self::MissingCatchAll => warning::Id::from_static("missing_catch_all"),
294 Self::PriceComponents(warning) => warning.id(),
295 Self::Restrictions(warning) => warning.id(),
296 }
297 }
298 }
299
300 from_warning_all!(
301 price_components::Warning => Warning::PriceComponents,
302 restrictions::Warning => Warning::Restrictions
303 );
304
305 #[instrument(skip_all)]
309 pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), Warning> {
310 #[expect(
312 dead_code,
313 reason = "The `ElementSummary` will be used in an upcoming analysis PR."
314 )]
315 struct ElementSummary {
316 dimensions: HashSet<DimensionType>,
317 has_restrictions: bool,
318 }
319
320 let mut warnings = warning::Set::<Warning>::new();
321
322 let Some(elements) = elem.as_array() else {
324 return warnings.bail(Warning::invalid_type(elem), elem);
325 };
326
327 if elements.is_empty() {
329 return warnings.bail(Warning::Empty, elem);
330 }
331
332 let _elements = elements
334 .iter()
335 .map(|elem| {
336 let Some(fields) = elem.as_object_fields() else {
337 warnings.insert(Warning::invalid_type(elem), elem);
338 return Item::Invalid;
339 };
340 let restrictions = fields.find_field("restrictions");
341 let mut has_restrictions = false;
342
343 if let Some(field) = restrictions {
345 let report = restrictions::lint(field.element())
346 .deescalate_error_into(&mut warnings)
347 .gather_warnings_into(&mut warnings);
348
349 if let Some(report) = report {
350 let restrictions::Report { is_empty } = report;
351 has_restrictions = is_empty;
352 }
353 }
354
355 let fields = fields.as_raw_map();
356 let dimensions = required_field!(elem, fields, "price_components", warnings)
358 .and_then(|elem| {
359 price_components::lint(elem)
360 .deescalate_error_into(&mut warnings)
361 .gather_warnings_into(&mut warnings)
362 })
363 .unwrap_or_default();
364
365 let dimensions = dimensions.into_iter().filter_map(Option::from).collect();
367
368 Item::Valid(ElementSummary {
369 dimensions,
370 has_restrictions,
371 })
372 })
373 .collect::<Vec<_>>();
374
375 Ok(().into_caveat(warnings))
376 }
377}
378
379pub mod price_components {
380 use std::{borrow::Cow, fmt};
381
382 use tracing::instrument;
383
384 use crate::{
385 enumeration, from_warning_all,
386 json::{self, FieldsAsExt as _, FromJson as _},
387 lint::Item,
388 number, required_field,
389 tariff::v2x::DimensionType,
390 warning::{self, DeescalateError as _, GatherWarnings as _, IntoCaveat as _},
391 Money, Verdict,
392 };
393
394 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
395 pub enum Warning {
396 Empty,
398
399 FieldRequired {
401 field_name: Cow<'static, str>,
402 },
403
404 InvalidType {
406 type_found: json::ValueKind,
407 },
408
409 Money(number::Warning),
410
411 Type(enumeration::Warning),
412 }
413
414 impl Warning {
415 fn invalid_type(elem: &json::Element<'_>) -> Self {
416 Self::InvalidType {
417 type_found: elem.value().kind(),
418 }
419 }
420 }
421
422 from_warning_all!(
423 enumeration::Warning => Warning::Type,
424 number::Warning => Warning::Money
425 );
426
427 impl fmt::Display for Warning {
428 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429 match self {
430 Self::Empty => write!(
431 f,
432 "An empty list of days means that no day is allowed. Is this what you want?"
433 ),
434 Self::FieldRequired { field_name } => {
435 write!(f, "Field is required: `{field_name}`")
436 }
437 Self::InvalidType { type_found } => write!(
438 f,
439 "The value should be an object but a `{type_found}` was found."
440 ),
441 Self::Money(w) => fmt::Display::fmt(w, f),
442 Self::Type(w) => fmt::Display::fmt(w, f),
443 }
444 }
445 }
446
447 impl crate::Warning for Warning {
448 fn id(&self) -> warning::Id {
449 match self {
450 Self::Empty => warning::Id::from_static("empty"),
451 Self::FieldRequired { field_name } => {
452 warning::Id::from_string(format!("field_required({field_name})"))
453 }
454 Self::InvalidType { type_found } => {
455 warning::Id::from_string(format!("invalid_type({type_found})"))
456 }
457 Self::Money(w) => w.id(),
458 Self::Type(w) => w.id(),
459 }
460 }
461 }
462
463 #[instrument(skip_all)]
465 pub(super) fn lint(elem: &json::Element<'_>) -> Verdict<Vec<Item<DimensionType>>, Warning> {
466 let mut warnings = warning::Set::<Warning>::new();
467
468 let Some(items) = elem.as_array() else {
470 return warnings.bail(Warning::invalid_type(elem), elem);
471 };
472
473 if items.is_empty() {
475 return warnings.bail(Warning::Empty, elem);
476 }
477
478 let dimensions: Vec<Item<DimensionType>> = items
479 .iter()
480 .map(|elem| {
481 let Some(fields) = elem.as_object_fields() else {
482 warnings.insert(Warning::invalid_type(elem), elem);
483 return Item::Invalid;
484 };
485
486 let fields = fields.as_raw_map();
487
488 {
489 let price_elem = fields.get("price");
490
491 if let Some(elem) = price_elem {
492 let _money = Money::from_json(elem)
493 .deescalate_error_into(&mut warnings)
494 .gather_warnings_into(&mut warnings);
495 }
496 }
497
498 {
499 let Some(type_elem) = required_field!(elem, fields, "type", warnings) else {
500 return Item::Invalid;
502 };
503
504 let dimension = DimensionType::from_json(type_elem)
505 .deescalate_error_into(&mut warnings)
506 .gather_warnings_into(&mut warnings);
507
508 Item::from(dimension)
509 }
510 })
511 .collect();
512
513 Ok(dimensions.into_caveat(warnings))
514 }
515}
516
517pub mod restrictions {
518 use std::fmt;
524
525 use tracing::instrument;
526
527 use crate::{
528 duration::{self, Seconds},
529 from_warning_all,
530 json::{self, FieldsAsExt as _},
531 number,
532 warning::{self, DeescalateError as _, GatherWarnings as _, IntoCaveat as _},
533 Ampere, Kw, Kwh, Verdict,
534 };
535
536 use super::{time, weekday};
537
538 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
539 pub enum Warning {
540 Duration(duration::Warning),
541
542 InvalidType {
544 type_found: json::ValueKind,
545 },
546
547 Number(number::Warning),
548
549 MaxZeroNeverMatch,
550
551 Time(time::Warning),
553
554 Weekday(weekday::Warning),
556 }
557
558 impl Warning {
559 fn invalid_type(elem: &json::Element<'_>) -> Self {
560 Self::InvalidType {
561 type_found: elem.value().kind(),
562 }
563 }
564 }
565
566 impl fmt::Display for Warning {
567 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
568 match self {
569 Self::Duration(warning) => fmt::Display::fmt(warning, f),
570 Self::InvalidType { type_found } => {
571 write!(f, "The value should be an object but is `{type_found}`")
572 }
573 Self::MaxZeroNeverMatch => write!(f, "This element contains a `max_*` restriction and so will never match. This element can be removed"),
574 Self::Number(warning) => fmt::Display::fmt(warning, f),
575 Self::Time(warning) => fmt::Display::fmt(warning, f),
576 Self::Weekday(warning) => fmt::Display::fmt(warning, f),
577 }
578 }
579 }
580
581 impl crate::Warning for Warning {
582 fn id(&self) -> warning::Id {
583 match self {
584 Self::Duration(warning) => warning.id(),
585 Self::InvalidType { type_found } => {
586 warning::Id::from_string(format!("invalid_type({type_found})"))
587 }
588 Self::MaxZeroNeverMatch => warning::Id::from_static("max_zero_will_never_match"),
589 Self::Number(warning) => warning.id(),
590 Self::Time(warning) => warning.id(),
591 Self::Weekday(warning) => warning.id(),
592 }
593 }
594 }
595
596 from_warning_all!(
597 duration::Warning => Warning::Duration,
598 number::Warning => Warning::Number,
599 time::Warning => Warning::Time,
600 weekday::Warning => Warning::Weekday
601 );
602
603 pub struct Report {
605 pub is_empty: bool,
607 }
608
609 #[instrument(skip_all)]
611 pub(super) fn lint(elem: &json::Element<'_>) -> Verdict<Report, Warning> {
612 let mut warnings = warning::Set::<Warning>::new();
613
614 let Some(fields) = elem.as_object_fields() else {
615 return warnings.bail(Warning::invalid_type(elem), elem);
616 };
617
618 let fields = fields.as_raw_map();
619
620 {
621 let start_time = fields.get("start_time").map(|e| &**e);
622 let end_time = fields.get("end_time").map(|e| &**e);
623
624 let _drop: Option<()> = time::lint(start_time, end_time)
625 .deescalate_error_into(&mut warnings)
626 .gather_warnings_into(&mut warnings);
627 }
628
629 {
630 let day_of_week = fields.get("day_of_week").map(|e| &**e);
631
632 let _drop: Option<()> = weekday::lint(day_of_week)
633 .deescalate_error_into(&mut warnings)
634 .gather_warnings_into(&mut warnings);
635 }
636
637 {
638 fields
639 .get("max_current")
640 .map(|elem| from_json_lint_zero::<Ampere>(elem, &mut warnings));
641
642 fields
643 .get("max_duration")
644 .map(|elem| from_json_lint_zero::<Seconds>(elem, &mut warnings));
645
646 fields
647 .get("max_kwh")
648 .map(|elem| from_json_lint_zero::<Kwh>(elem, &mut warnings));
649
650 fields
651 .get("max_power")
652 .map(|elem| from_json_lint_zero::<Kw>(elem, &mut warnings));
653 }
654
655 Ok(Report {
656 is_empty: fields.is_empty(),
657 }
658 .into_caveat(warnings))
659 }
660
661 fn from_json_lint_zero<'elem, 'buf, T>(
664 element: &'elem json::Element<'buf>,
665 warnings: &mut warning::Set<Warning>,
666 ) -> Option<T>
667 where
668 T: json::FromJson<'buf, Warning: Into<Warning>> + number::IsZero,
669 {
670 let value = T::from_json(element)
671 .deescalate_error_into(warnings)
672 .gather_warnings_into(warnings);
673
674 if value.as_ref().is_some_and(number::IsZero::is_zero) {
675 warnings.insert(Warning::MaxZeroNeverMatch, element);
676 }
677
678 value
679 }
680}
681
682pub mod weekday {
683 use std::{collections::BTreeSet, fmt, sync::LazyLock};
691
692 use crate::{
693 from_warning_all,
694 json::{self, FromJson as _},
695 warning::{self, GatherWarnings as _, IntoCaveat as _},
696 Verdict, Weekday,
697 };
698
699 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
700 pub enum Warning {
701 ContainsEntireWeek,
703
704 Enum(crate::enumeration::Warning),
706
707 Duplicates,
709
710 Empty,
712
713 InvalidType { type_found: json::ValueKind },
715
716 Unsorted,
718 }
719
720 impl Warning {
721 fn invalid_type(elem: &json::Element<'_>) -> Self {
722 Self::InvalidType {
723 type_found: elem.value().kind(),
724 }
725 }
726 }
727
728 impl fmt::Display for Warning {
729 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
730 match self {
731 Self::ContainsEntireWeek => write!(f, "All days of the week are defined. You can simply leave out the `day_of_week` field."),
732 Self::Enum(warning) => fmt::Display::fmt(warning, f),
733 Self::Duplicates => write!(f, "There's at least one duplicate day."),
734 Self::Empty => write!(
735 f,
736 "An empty list of days means that no day is allowed. Is this what you want?"
737 ),
738 Self::InvalidType { type_found } => {
739 write!(f, "The value should be an array but is `{type_found}`")
740 }
741 Self::Unsorted => write!(f, "The days are unsorted."),
742 }
743 }
744 }
745
746 impl crate::Warning for Warning {
747 fn id(&self) -> warning::Id {
748 match self {
749 Self::ContainsEntireWeek => warning::Id::from_static("contains_entire_week"),
750 Self::Enum(warning) => warning.id(),
751 Self::Duplicates => warning::Id::from_static("duplicates"),
752 Self::Empty => warning::Id::from_static("empty"),
753 Self::InvalidType { type_found } => {
754 warning::Id::from_string(format!("invalid_type({type_found})"))
755 }
756 Self::Unsorted => warning::Id::from_static("unsorted"),
757 }
758 }
759 }
760
761 from_warning_all!(crate::enumeration::Warning => Warning::Enum);
762
763 pub(super) fn lint(elem: Option<&json::Element<'_>>) -> Verdict<(), Warning> {
765 static ALL_DAYS_OF_WEEK: LazyLock<BTreeSet<Weekday>> = LazyLock::new(|| {
767 BTreeSet::from([
768 Weekday::Monday,
769 Weekday::Tuesday,
770 Weekday::Wednesday,
771 Weekday::Thursday,
772 Weekday::Friday,
773 Weekday::Saturday,
774 Weekday::Sunday,
775 ])
776 });
777
778 let mut warnings = warning::Set::<Warning>::new();
779
780 let Some(elem) = elem else {
782 return Ok(().into_caveat(warnings));
783 };
784
785 let Some(items) = elem.as_array() else {
787 return warnings.bail(Warning::invalid_type(elem), elem);
788 };
789
790 if items.is_empty() {
793 warnings.insert(Warning::Empty, elem);
794 return Ok(().into_caveat(warnings));
795 }
796
797 let days = items
799 .iter()
800 .map(Weekday::from_json)
801 .collect::<Result<Vec<_>, _>>()?;
802
803 let days = days.gather_warnings_into(&mut warnings);
805
806 if !days.is_sorted() {
808 warnings.insert(Warning::Unsorted, elem);
809 }
810
811 let day_set: BTreeSet<_> = days.iter().copied().collect();
812
813 if day_set.len() != days.len() {
816 warnings.insert(Warning::Duplicates, elem);
817 }
818
819 if day_set == *ALL_DAYS_OF_WEEK {
822 warnings.insert(Warning::ContainsEntireWeek, elem);
823 }
824
825 Ok(().into_caveat(warnings))
826 }
827}