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(start_elem, Warning::StartDateTimeIsAfterEndDateTime);
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(end_elem, Warning::EndTimeIsNearEndOfDay);
151 }
152
153 if start_time == DAY_BOUNDARY && is_day_end(end_time) {
154 warnings.insert(start_elem, Warning::ContainsEntireDay);
155 } else if start_time == end_time {
156 warnings.insert(start_elem, Warning::NeverValid);
157 }
158 } else if let Some((start_time, start_elem)) = start {
159 if start_time == DAY_BOUNDARY {
160 warnings.insert(start_elem, Warning::ContainsEntireDay);
161 }
162 } else if let Some((end_time, end_elem)) = end {
163 if is_day_end(end_time) {
164 warnings.insert(end_elem, Warning::ContainsEntireDay);
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 super::{price_components, restrictions};
220 use crate::{
221 from_warning_all,
222 json::{self, FieldsAsExt as _},
223 lint::Item,
224 required_field,
225 tariff::v2x::DimensionType,
226 warning::{self, DeescalateError as _, GatherWarnings as _, IntoCaveat as _},
227 Verdict,
228 };
229
230 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
231 pub enum Warning {
232 Empty,
234
235 FieldRequired { field_name: Cow<'static, str> },
237
238 InvalidType { type_found: json::ValueKind },
240
241 MissingCatchAll,
243
244 PriceComponents(price_components::Warning),
246
247 Restrictions(restrictions::Warning),
249 }
250
251 impl Warning {
252 fn invalid_type(elem: &json::Element<'_>) -> Self {
253 Self::InvalidType {
254 type_found: elem.value().kind(),
255 }
256 }
257 }
258
259 impl fmt::Display for Warning {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 match self {
262 Self::Empty => write!(
263 f,
264 "An empty list of days means that no day is allowed. Is this what you want?"
265 ),
266 Self::FieldRequired { field_name } => {
267 write!(f, "Field is required: `{field_name}`")
268 }
269 Self::InvalidType { type_found } => {
270 write!(f, "The value should be an array but is `{type_found}`")
271 }
272 Self::MissingCatchAll => write!(
273 f,
274 "The last element should have no restrictions so that it catches all cases."
275 ),
276 Self::PriceComponents(warning) => fmt::Display::fmt(warning, f),
277 Self::Restrictions(warning) => fmt::Display::fmt(warning, f),
278 }
279 }
280 }
281
282 impl crate::Warning for Warning {
283 fn id(&self) -> warning::Id {
284 match self {
285 Self::Empty => warning::Id::from_static("empty"),
286 Self::FieldRequired { field_name } => {
287 warning::Id::from_string(format!("field_required({field_name})"))
288 }
289 Self::InvalidType { type_found } => {
290 warning::Id::from_string(format!("invalid_type({type_found})"))
291 }
292 Self::MissingCatchAll => warning::Id::from_static("missing_catch_all"),
293 Self::PriceComponents(warning) => warning.id(),
294 Self::Restrictions(warning) => warning.id(),
295 }
296 }
297 }
298
299 from_warning_all!(
300 price_components::Warning => Warning::PriceComponents,
301 restrictions::Warning => Warning::Restrictions
302 );
303
304 #[instrument(skip_all)]
308 pub(crate) fn lint(elem: &json::Element<'_>) -> Verdict<(), Warning> {
309 #[expect(
311 dead_code,
312 reason = "The `ElementSummary` will be used in an upcoming analysis PR."
313 )]
314 struct ElementSummary {
315 dimensions: HashSet<DimensionType>,
316 has_restrictions: bool,
317 }
318
319 let mut warnings = warning::Set::<Warning>::new();
320
321 let Some(elements) = elem.as_array() else {
323 return warnings.bail(elem, Warning::invalid_type(elem));
324 };
325
326 if elements.is_empty() {
328 return warnings.bail(elem, Warning::Empty);
329 }
330
331 let _elements = elements
333 .iter()
334 .map(|elem| {
335 let Some(fields) = elem.as_object_fields() else {
336 warnings.insert(elem, Warning::invalid_type(elem));
337 return Item::Invalid;
338 };
339 let restrictions = fields.find_field("restrictions");
340 let mut has_restrictions = false;
341
342 if let Some(field) = restrictions {
344 let report = restrictions::lint(field.element())
345 .deescalate_error_into(&mut warnings)
346 .gather_warnings_into(&mut warnings);
347
348 if let Some(report) = report {
349 let restrictions::Report { is_empty } = report;
350 has_restrictions = is_empty;
351 }
352 }
353
354 let fields = fields.as_raw_map();
355 let dimensions = required_field!(elem, fields, "price_components", warnings)
357 .and_then(|elem| {
358 price_components::lint(elem)
359 .deescalate_error_into(&mut warnings)
360 .gather_warnings_into(&mut warnings)
361 })
362 .unwrap_or_default();
363
364 let dimensions = dimensions.into_iter().filter_map(Option::from).collect();
366
367 Item::Valid(ElementSummary {
368 dimensions,
369 has_restrictions,
370 })
371 })
372 .collect::<Vec<_>>();
373
374 Ok(().into_caveat(warnings))
375 }
376}
377
378pub mod price_components {
379 use std::{borrow::Cow, fmt};
380
381 use tracing::instrument;
382
383 use crate::{
384 enumeration, from_warning_all,
385 json::{self, FieldsAsExt as _, FromJson as _},
386 lint::Item,
387 number, required_field,
388 tariff::v2x::DimensionType,
389 warning::{self, DeescalateError as _, GatherWarnings as _, IntoCaveat as _},
390 Money, Verdict,
391 };
392
393 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
394 pub enum Warning {
395 Empty,
397
398 FieldRequired {
400 field_name: Cow<'static, str>,
401 },
402
403 InvalidType {
405 type_found: json::ValueKind,
406 },
407
408 Money(number::Warning),
409
410 Type(enumeration::Warning),
411 }
412
413 impl Warning {
414 fn invalid_type(elem: &json::Element<'_>) -> Self {
415 Self::InvalidType {
416 type_found: elem.value().kind(),
417 }
418 }
419 }
420
421 from_warning_all!(
422 enumeration::Warning => Warning::Type,
423 number::Warning => Warning::Money
424 );
425
426 impl fmt::Display for Warning {
427 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428 match self {
429 Self::Empty => write!(
430 f,
431 "An empty list of days means that no day is allowed. Is this what you want?"
432 ),
433 Self::FieldRequired { field_name } => {
434 write!(f, "Field is required: `{field_name}`")
435 }
436 Self::InvalidType { type_found } => write!(
437 f,
438 "The value should be an object but a `{type_found}` was found."
439 ),
440 Self::Money(w) => fmt::Display::fmt(w, f),
441 Self::Type(w) => fmt::Display::fmt(w, f),
442 }
443 }
444 }
445
446 impl crate::Warning for Warning {
447 fn id(&self) -> warning::Id {
448 match self {
449 Self::Empty => warning::Id::from_static("empty"),
450 Self::FieldRequired { field_name } => {
451 warning::Id::from_string(format!("field_required({field_name})"))
452 }
453 Self::InvalidType { type_found } => {
454 warning::Id::from_string(format!("invalid_type({type_found})"))
455 }
456 Self::Money(w) => w.id(),
457 Self::Type(w) => w.id(),
458 }
459 }
460 }
461
462 #[instrument(skip_all)]
464 pub(super) fn lint(elem: &json::Element<'_>) -> Verdict<Vec<Item<DimensionType>>, Warning> {
465 let mut warnings = warning::Set::<Warning>::new();
466
467 let Some(items) = elem.as_array() else {
469 return warnings.bail(elem, Warning::invalid_type(elem));
470 };
471
472 if items.is_empty() {
474 return warnings.bail(elem, Warning::Empty);
475 }
476
477 let dimensions: Vec<Item<DimensionType>> = items
478 .iter()
479 .map(|elem| {
480 let Some(fields) = elem.as_object_fields() else {
481 warnings.insert(elem, Warning::invalid_type(elem));
482 return Item::Invalid;
483 };
484
485 let fields = fields.as_raw_map();
486
487 {
488 let price_elem = fields.get("price");
489
490 if let Some(elem) = price_elem {
491 let _money = Money::from_json(elem)
492 .deescalate_error_into(&mut warnings)
493 .gather_warnings_into(&mut warnings);
494 }
495 }
496
497 {
498 let Some(type_elem) = required_field!(elem, fields, "type", warnings) else {
499 return Item::Invalid;
501 };
502
503 let dimension = DimensionType::from_json(type_elem)
504 .deescalate_error_into(&mut warnings)
505 .gather_warnings_into(&mut warnings);
506
507 Item::from(dimension)
508 }
509 })
510 .collect();
511
512 Ok(dimensions.into_caveat(warnings))
513 }
514}
515
516pub mod restrictions {
517 use std::fmt;
523
524 use tracing::instrument;
525
526 use super::{time, weekday};
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 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
537 pub enum Warning {
538 Duration(duration::Warning),
539
540 InvalidType {
542 type_found: json::ValueKind,
543 },
544
545 Number(number::Warning),
546
547 MaxZeroNeverMatch,
548
549 Time(time::Warning),
551
552 Weekday(weekday::Warning),
554 }
555
556 impl Warning {
557 fn invalid_type(elem: &json::Element<'_>) -> Self {
558 Self::InvalidType {
559 type_found: elem.value().kind(),
560 }
561 }
562 }
563
564 impl fmt::Display for Warning {
565 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
566 match self {
567 Self::Duration(warning) => fmt::Display::fmt(warning, f),
568 Self::InvalidType { type_found } => {
569 write!(f, "The value should be an object but is `{type_found}`")
570 }
571 Self::MaxZeroNeverMatch => write!(f, "This element contains a `max_*` restriction and so will never match. This element can be removed"),
572 Self::Number(warning) => fmt::Display::fmt(warning, f),
573 Self::Time(warning) => fmt::Display::fmt(warning, f),
574 Self::Weekday(warning) => fmt::Display::fmt(warning, f),
575 }
576 }
577 }
578
579 impl crate::Warning for Warning {
580 fn id(&self) -> warning::Id {
581 match self {
582 Self::Duration(warning) => warning.id(),
583 Self::InvalidType { type_found } => {
584 warning::Id::from_string(format!("invalid_type({type_found})"))
585 }
586 Self::MaxZeroNeverMatch => warning::Id::from_static("max_zero_will_never_match"),
587 Self::Number(warning) => warning.id(),
588 Self::Time(warning) => warning.id(),
589 Self::Weekday(warning) => warning.id(),
590 }
591 }
592 }
593
594 from_warning_all!(
595 duration::Warning => Warning::Duration,
596 number::Warning => Warning::Number,
597 time::Warning => Warning::Time,
598 weekday::Warning => Warning::Weekday
599 );
600
601 pub struct Report {
603 pub is_empty: bool,
605 }
606
607 #[instrument(skip_all)]
609 pub(super) fn lint(elem: &json::Element<'_>) -> Verdict<Report, Warning> {
610 let mut warnings = warning::Set::<Warning>::new();
611
612 let Some(fields) = elem.as_object_fields() else {
613 return warnings.bail(elem, Warning::invalid_type(elem));
614 };
615
616 let fields = fields.as_raw_map();
617
618 {
619 let start_time = fields.get("start_time").map(|e| &**e);
620 let end_time = fields.get("end_time").map(|e| &**e);
621
622 let _drop: Option<()> = time::lint(start_time, end_time)
623 .deescalate_error_into(&mut warnings)
624 .gather_warnings_into(&mut warnings);
625 }
626
627 {
628 let day_of_week = fields.get("day_of_week").map(|e| &**e);
629
630 let _drop: Option<()> = weekday::lint(day_of_week)
631 .deescalate_error_into(&mut warnings)
632 .gather_warnings_into(&mut warnings);
633 }
634
635 {
636 fields
637 .get("max_current")
638 .map(|elem| from_json_lint_zero::<Ampere>(elem, &mut warnings));
639
640 fields
641 .get("max_duration")
642 .map(|elem| from_json_lint_zero::<Seconds>(elem, &mut warnings));
643
644 fields
645 .get("max_kwh")
646 .map(|elem| from_json_lint_zero::<Kwh>(elem, &mut warnings));
647
648 fields
649 .get("max_power")
650 .map(|elem| from_json_lint_zero::<Kw>(elem, &mut warnings));
651 }
652
653 Ok(Report {
654 is_empty: fields.is_empty(),
655 }
656 .into_caveat(warnings))
657 }
658
659 fn from_json_lint_zero<'elem, 'buf, T>(
662 element: &'elem json::Element<'buf>,
663 warnings: &mut warning::Set<Warning>,
664 ) -> Option<T>
665 where
666 T: json::FromJson<'buf, Warning: Into<Warning>> + number::IsZero,
667 {
668 let value = T::from_json(element)
669 .deescalate_error_into(warnings)
670 .gather_warnings_into(warnings);
671
672 if value.as_ref().is_some_and(number::IsZero::is_zero) {
673 warnings.insert(element, Warning::MaxZeroNeverMatch);
674 }
675
676 value
677 }
678}
679
680pub mod weekday {
681 use std::{collections::BTreeSet, fmt, sync::LazyLock};
689
690 use crate::{
691 from_warning_all,
692 json::{self, FromJson as _},
693 warning::{self, GatherWarnings as _, IntoCaveat as _},
694 Verdict, Weekday,
695 };
696
697 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
698 pub enum Warning {
699 ContainsEntireWeek,
701
702 Enum(crate::enumeration::Warning),
704
705 Duplicates,
707
708 Empty,
710
711 InvalidType { type_found: json::ValueKind },
713
714 Unsorted,
716 }
717
718 impl Warning {
719 fn invalid_type(elem: &json::Element<'_>) -> Self {
720 Self::InvalidType {
721 type_found: elem.value().kind(),
722 }
723 }
724 }
725
726 impl fmt::Display for Warning {
727 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
728 match self {
729 Self::ContainsEntireWeek => write!(f, "All days of the week are defined. You can simply leave out the `day_of_week` field."),
730 Self::Enum(warning) => fmt::Display::fmt(warning, f),
731 Self::Duplicates => write!(f, "There's at least one duplicate day."),
732 Self::Empty => write!(
733 f,
734 "An empty list of days means that no day is allowed. Is this what you want?"
735 ),
736 Self::InvalidType { type_found } => {
737 write!(f, "The value should be an array but is `{type_found}`")
738 }
739 Self::Unsorted => write!(f, "The days are unsorted."),
740 }
741 }
742 }
743
744 impl crate::Warning for Warning {
745 fn id(&self) -> warning::Id {
746 match self {
747 Self::ContainsEntireWeek => warning::Id::from_static("contains_entire_week"),
748 Self::Enum(warning) => warning.id(),
749 Self::Duplicates => warning::Id::from_static("duplicates"),
750 Self::Empty => warning::Id::from_static("empty"),
751 Self::InvalidType { type_found } => {
752 warning::Id::from_string(format!("invalid_type({type_found})"))
753 }
754 Self::Unsorted => warning::Id::from_static("unsorted"),
755 }
756 }
757 }
758
759 from_warning_all!(crate::enumeration::Warning => Warning::Enum);
760
761 pub(super) fn lint(elem: Option<&json::Element<'_>>) -> Verdict<(), Warning> {
763 static ALL_DAYS_OF_WEEK: LazyLock<BTreeSet<Weekday>> = LazyLock::new(|| {
765 BTreeSet::from([
766 Weekday::Monday,
767 Weekday::Tuesday,
768 Weekday::Wednesday,
769 Weekday::Thursday,
770 Weekday::Friday,
771 Weekday::Saturday,
772 Weekday::Sunday,
773 ])
774 });
775
776 let mut warnings = warning::Set::<Warning>::new();
777
778 let Some(elem) = elem else {
780 return Ok(().into_caveat(warnings));
781 };
782
783 let Some(items) = elem.as_array() else {
785 return warnings.bail(elem, Warning::invalid_type(elem));
786 };
787
788 if items.is_empty() {
791 warnings.insert(elem, Warning::Empty);
792 return Ok(().into_caveat(warnings));
793 }
794
795 let days = items
797 .iter()
798 .map(Weekday::from_json)
799 .collect::<Result<Vec<_>, _>>()?;
800
801 let days = days.gather_warnings_into(&mut warnings);
803
804 if !days.is_sorted() {
806 warnings.insert(elem, Warning::Unsorted);
807 }
808
809 let day_set: BTreeSet<_> = days.iter().copied().collect();
810
811 if day_set.len() != days.len() {
814 warnings.insert(elem, Warning::Duplicates);
815 }
816
817 if day_set == *ALL_DAYS_OF_WEEK {
820 warnings.insert(elem, Warning::ContainsEntireWeek);
821 }
822
823 Ok(().into_caveat(warnings))
824 }
825}