1use std::{borrow::Cow, fmt};
3
4use chrono_tz::Tz;
5use tracing::{debug, instrument};
6
7use crate::{
8 cdr, country, from_warning_set_to, into_caveat_all,
9 json::{self, FieldsAsExt as _, FromJson as _},
10 warning::{self, GatherWarnings as _, OptionExt as _},
11 Caveat, IntoCaveat, ParseError, Verdict, VerdictExt, Version, Versioned, Warning,
12};
13
14#[derive(Debug)]
16pub enum WarningKind {
17 CantInferTimezoneFromCountry(&'static str),
19
20 ContainsEscapeCodes,
22
23 Country(country::WarningKind),
25
26 Decode(json::decode::WarningKind),
28
29 Deserialize(ParseError),
31
32 InvalidLocationType,
34
35 InvalidTimezone,
39
40 InvalidTimezoneType,
42
43 LocationCountryShouldBeAlpha3,
47
48 NoLocationCountry,
50
51 NoLocation,
53
54 Parser(json::Error),
56
57 ShouldBeAnObject,
59
60 V221CdrHasLocationField,
62}
63
64from_warning_set_to!(country::WarningKind => WarningKind);
65
66impl fmt::Display for WarningKind {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 match self {
69 WarningKind::CantInferTimezoneFromCountry(country_code) => write!(f, "Unable to infer timezone from the `location`'s `country`: `{country_code}`"),
70 WarningKind::ContainsEscapeCodes => f.write_str("The CDR location contains needless escape codes."),
71 WarningKind::Country(kind) => fmt::Display::fmt(kind, f),
72 WarningKind::Decode(warning) => fmt::Display::fmt(warning, f),
73 WarningKind::Deserialize(err) => fmt::Display::fmt(err, f),
74 WarningKind::InvalidLocationType => f.write_str("The CDR location is not a String."),
75 WarningKind::InvalidTimezone => f.write_str("The CDR location did not contain a valid IANA time-zone."),
76 WarningKind::InvalidTimezoneType => f.write_str("The CDR timezone is not a String."),
77 WarningKind::LocationCountryShouldBeAlpha3 => f.write_str("The `location.country` field should be an alpha-3 country code."),
78 WarningKind::NoLocationCountry => {
79 f.write_str("The CDR's `location` has no `country` element and so the timezone can't be inferred.")
80 },
81 WarningKind::NoLocation => {
82 f.write_str("The CDR has no `location` element and so the timezone can't be found or inferred.")
83 }
84 WarningKind::Parser(err) => fmt::Display::fmt(err, f),
85 WarningKind::ShouldBeAnObject => f.write_str("The CDR should be a JSON object"),
86 WarningKind::V221CdrHasLocationField => f.write_str("the v2.2.1 CDR contains a `location` field but the v2.2.1 spec defines a `cdr_location` field."),
87
88 }
89 }
90}
91
92impl warning::Kind for WarningKind {
93 fn id(&self) -> Cow<'static, str> {
94 match self {
95 WarningKind::CantInferTimezoneFromCountry(_) => {
96 "cant_infer_timezone_from_country".into()
97 }
98 WarningKind::ContainsEscapeCodes => "contains_escape_codes".into(),
99 WarningKind::Decode(warning) => format!("decode.{}", warning.id()).into(),
100 WarningKind::Deserialize(err) => format!("deserialize.{err}").into(),
101 WarningKind::Country(warning) => format!("country.{}", warning.id()).into(),
102 WarningKind::InvalidLocationType => "invalid_location_type".into(),
103 WarningKind::InvalidTimezone => "invalid_timezone".into(),
104 WarningKind::InvalidTimezoneType => "invalid_timezone_type".into(),
105 WarningKind::LocationCountryShouldBeAlpha3 => {
106 "location_country_should_be_alpha3".into()
107 }
108 WarningKind::NoLocationCountry => "no_location_country".into(),
109 WarningKind::NoLocation => "no_location".into(),
110 WarningKind::Parser(err) => format!("parser.{err}").into(),
111 WarningKind::ShouldBeAnObject => "should_be_an_object".into(),
112 WarningKind::V221CdrHasLocationField => "v221_cdr_has_location_field".into(),
113 }
114 }
115}
116
117#[derive(Copy, Clone, Debug)]
119pub enum Source {
120 Found(Tz),
122
123 Inferred(Tz),
125}
126
127into_caveat_all!(Source, Tz);
128
129impl Source {
130 pub fn into_timezone(self) -> Tz {
132 match self {
133 Source::Found(tz) | Source::Inferred(tz) => tz,
134 }
135 }
136}
137
138pub fn find_or_infer(cdr: &cdr::Versioned<'_>) -> Caveat<Option<Source>, WarningKind> {
152 const LOCATION_FIELD_V211: &str = "location";
153 const LOCATION_FIELD_V221: &str = "cdr_location";
154 const TIMEZONE_FIELD: &str = "time_zone";
155 const COUNTRY_FIELD: &str = "country";
156
157 let mut warnings = warning::Set::new();
158
159 let cdr_root = cdr.as_element();
160 let Some(fields) = cdr_root.as_object_fields() else {
161 warnings.with_elem(WarningKind::ShouldBeAnObject, cdr_root);
162 return None.into_caveat(warnings);
163 };
164
165 let cdr_fields = fields.as_raw_map();
166
167 let v211_location = cdr_fields.get(LOCATION_FIELD_V211);
168
169 if cdr.version() == Version::V221 && v211_location.is_some() {
174 warnings.with_elem(WarningKind::V221CdrHasLocationField, cdr_root);
175 }
176
177 let Some(location_elem) = v211_location.or_else(|| cdr_fields.get(LOCATION_FIELD_V221)) else {
184 warnings.with_elem(WarningKind::NoLocation, cdr_root);
185 return None.into_caveat(warnings);
186 };
187
188 let json::Value::Object(fields) = location_elem.as_value() else {
189 warnings.with_elem(WarningKind::InvalidLocationType, cdr_root);
190 return None.into_caveat(warnings);
191 };
192
193 let location_fields = fields.as_raw_map();
194
195 debug!("Searching for time-zone in CDR");
196
197 let tz = location_fields.get(TIMEZONE_FIELD).and_then(|elem| {
202 let tz = try_parse_location_timezone(elem).ok_caveat();
203 tz.gather_warnings_into(&mut warnings)
204 });
205
206 if let Some(tz) = tz {
207 return Some(Source::Found(tz)).into_caveat(warnings);
208 }
209
210 debug!("No time-zone found in CDR; trying to infer time-zone from country");
211
212 let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
214 warnings.with_elem(WarningKind::NoLocationCountry, location_elem);
219 return None.into_caveat(warnings);
220 };
221
222 let Some(timezone) = infer_timezone_from_location_country(country_elem)
223 .ok_caveat()
224 .gather_warnings_into(&mut warnings)
225 else {
226 return None.into_caveat(warnings);
227 };
228
229 Some(Source::Inferred(timezone)).into_caveat(warnings)
230}
231
232impl From<json::decode::WarningKind> for WarningKind {
233 fn from(warn_kind: json::decode::WarningKind) -> Self {
234 Self::Decode(warn_kind)
235 }
236}
237
238impl From<country::WarningKind> for WarningKind {
239 fn from(warn_kind: country::WarningKind) -> Self {
240 Self::Country(warn_kind)
241 }
242}
243
244fn try_parse_location_timezone(tz_elem: &json::Element<'_>) -> Verdict<Tz, WarningKind> {
246 let tz = tz_elem.as_value();
247 debug!(tz = %tz, "Raw time-zone found in CDR");
248
249 let mut warnings = warning::Set::new();
250 let Some(tz) = tz.as_raw_str() else {
251 warnings.with_elem(WarningKind::InvalidTimezoneType, tz_elem);
252 return Err(warnings);
253 };
254
255 let tz = tz
256 .decode_escapes(tz_elem)
257 .gather_warnings_into(&mut warnings);
258
259 if matches!(tz, Cow::Owned(_)) {
260 warnings.with_elem(WarningKind::ContainsEscapeCodes, tz_elem);
261 }
262
263 debug!(%tz, "Escaped time-zone found in CDR");
264
265 let Ok(tz) = tz.parse::<Tz>() else {
266 warnings.with_elem(WarningKind::InvalidTimezone, tz_elem);
267 return Err(warnings);
268 };
269
270 Ok(tz.into_caveat(warnings))
271}
272
273#[instrument(skip_all)]
275fn infer_timezone_from_location_country(
276 country_elem: &json::Element<'_>,
277) -> Verdict<Tz, WarningKind> {
278 let mut warnings = warning::Set::new();
279 let code_set = country::CodeSet::from_json(country_elem)?.gather_warnings_into(&mut warnings);
280
281 let country_code = match code_set {
285 country::CodeSet::Alpha2(code) => {
286 warnings.with_elem(WarningKind::LocationCountryShouldBeAlpha3, country_elem);
287 code
288 }
289 country::CodeSet::Alpha3(code) => code,
290 };
291 let tz = try_detect_timezone(country_code).exit_with_warning(warnings, || {
292 Warning::with_elem(
293 WarningKind::CantInferTimezoneFromCountry(country_code.into_str()),
294 country_elem,
295 )
296 })?;
297
298 Ok(tz)
299}
300
301#[instrument]
309fn try_detect_timezone(country_code: country::Code) -> Option<Tz> {
310 let tz = match country_code {
311 country::Code::Ad => Tz::Europe__Andorra,
312 country::Code::Al => Tz::Europe__Tirane,
313 country::Code::At => Tz::Europe__Vienna,
314 country::Code::Ba => Tz::Europe__Sarajevo,
315 country::Code::Be => Tz::Europe__Brussels,
316 country::Code::Bg => Tz::Europe__Sofia,
317 country::Code::By => Tz::Europe__Minsk,
318 country::Code::Ch => Tz::Europe__Zurich,
319 country::Code::Cy => Tz::Europe__Nicosia,
320 country::Code::Cz => Tz::Europe__Prague,
321 country::Code::De => Tz::Europe__Berlin,
322 country::Code::Dk => Tz::Europe__Copenhagen,
323 country::Code::Ee => Tz::Europe__Tallinn,
324 country::Code::Es => Tz::Europe__Madrid,
325 country::Code::Fi => Tz::Europe__Helsinki,
326 country::Code::Fr => Tz::Europe__Paris,
327 country::Code::Gb => Tz::Europe__London,
328 country::Code::Gr => Tz::Europe__Athens,
329 country::Code::Hr => Tz::Europe__Zagreb,
330 country::Code::Hu => Tz::Europe__Budapest,
331 country::Code::Ie => Tz::Europe__Dublin,
332 country::Code::Is => Tz::Iceland,
333 country::Code::It => Tz::Europe__Rome,
334 country::Code::Li => Tz::Europe__Vaduz,
335 country::Code::Lt => Tz::Europe__Vilnius,
336 country::Code::Lu => Tz::Europe__Luxembourg,
337 country::Code::Lv => Tz::Europe__Riga,
338 country::Code::Mc => Tz::Europe__Monaco,
339 country::Code::Md => Tz::Europe__Chisinau,
340 country::Code::Me => Tz::Europe__Podgorica,
341 country::Code::Mk => Tz::Europe__Skopje,
342 country::Code::Mt => Tz::Europe__Malta,
343 country::Code::Nl => Tz::Europe__Amsterdam,
344 country::Code::No => Tz::Europe__Oslo,
345 country::Code::Pl => Tz::Europe__Warsaw,
346 country::Code::Pt => Tz::Europe__Lisbon,
347 country::Code::Ro => Tz::Europe__Bucharest,
348 country::Code::Rs => Tz::Europe__Belgrade,
349 country::Code::Ru => Tz::Europe__Moscow,
350 country::Code::Se => Tz::Europe__Stockholm,
351 country::Code::Si => Tz::Europe__Ljubljana,
352 country::Code::Sk => Tz::Europe__Bratislava,
353 country::Code::Sm => Tz::Europe__San_Marino,
354 country::Code::Tr => Tz::Turkey,
355 country::Code::Ua => Tz::Europe__Kiev,
356 _ => return None,
357 };
358
359 debug!(%tz, "time-zone detected");
360
361 Some(tz)
362}
363
364#[cfg(test)]
365pub mod test {
366 #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
367 #![allow(clippy::panic, reason = "tests are allowed panic")]
368
369 use std::collections::BTreeMap;
370
371 use crate::{
372 cdr,
373 test::{ExpectFile, ExpectValue, Expectation},
374 warning::{self, test},
375 };
376
377 use super::{Source, WarningKind};
378
379 #[derive(serde::Deserialize)]
381 pub(crate) struct FindOrInferExpect {
382 #[serde(default)]
384 timezone: Expectation<String>,
385
386 #[serde(default)]
388 warnings: Expectation<BTreeMap<String, Vec<String>>>,
389 }
390
391 #[track_caller]
392 pub(crate) fn assert_find_or_infer_outcome(
393 cdr: &cdr::Versioned<'_>,
394 timezone: Source,
395 expect: ExpectFile<FindOrInferExpect>,
396 warnings: &warning::Set<WarningKind>,
397 ) {
398 let ExpectFile {
399 value: expect,
400 expect_file_name,
401 } = expect;
402
403 let root = cdr.as_element();
404
405 let Some(expect) = expect else {
406 assert!(
407 warnings.is_empty(),
408 "There is no expectation file at `{expect_file_name}` but the timezone has warnings;\n{:?}",
409 warnings.group_by_elem(root).into_stringified_map()
410 );
411 return;
412 };
413
414 if let Expectation::Present(ExpectValue::Some(expected)) = &expect.timezone {
415 assert_eq!(expected, &timezone.into_timezone().to_string());
416 }
417
418 test::assert_warnings(&expect_file_name, root, warnings, expect.warnings);
419 }
420}
421
422#[cfg(test)]
423mod test_find_or_infer {
424 use assert_matches::assert_matches;
425
426 use crate::{cdr, json, test, timezone::WarningKind, warning, Version};
427
428 use super::{find_or_infer, Source};
429
430 #[test]
431 fn should_find_timezone() {
432 const JSON: &str = r#"{
433 "country_code": "NL",
434 "cdr_location": {
435 "time_zone": "Europe/Amsterdam"
436 }
437}"#;
438
439 test::setup();
440 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
441
442 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
443 assert_matches!(*warnings, []);
444 }
445
446 #[test]
447 fn should_find_timezone_but_warn_about_use_of_location_for_v221_cdr() {
448 const JSON: &str = r#"{
449 "country_code": "NL",
450 "location": {
451 "time_zone": "Europe/Amsterdam"
452 }
453}"#;
454
455 test::setup();
456 let cdr::ParseReport {
458 cdr,
459 unexpected_fields,
460 } = cdr::parse_with_version(JSON, Version::V221).unwrap();
461
462 assert_unexpected_fields(&unexpected_fields, &["$.location", "$.location.time_zone"]);
464
465 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
466 let warnings = warnings.into_kind_vec();
467 let timezone_source = timezone_source.unwrap();
468
469 assert_matches!(
470 timezone_source,
471 Source::Found(chrono_tz::Tz::Europe__Amsterdam)
472 );
473 assert_matches!(*warnings, [WarningKind::V221CdrHasLocationField]);
475 }
476
477 #[test]
478 fn should_find_timezone_without_cdr_country() {
479 const JSON: &str = r#"{
480 "cdr_location": {
481 "time_zone": "Europe/Amsterdam"
482 }
483}"#;
484
485 test::setup();
486 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
487
488 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
489 assert_matches!(*warnings, []);
490 }
491
492 #[test]
493 fn should_infer_timezone_and_warn_about_invalid_type() {
494 const JSON: &str = r#"{
495 "country_code": "NL",
496 "cdr_location": {
497 "time_zone": null,
498 "country": "BEL"
499 }
500}"#;
501
502 test::setup();
503 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
504 let warnings = warnings.into_kind_vec();
505
506 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
507 assert_matches!(*warnings, [WarningKind::InvalidTimezoneType]);
508 }
509
510 #[test]
511 fn should_find_timezone_and_warn_about_invalid_type() {
512 const JSON: &str = r#"{
513 "country_code": "NL",
514 "cdr_location": {
515 "time_zone": "Europe/Hamsterdam",
516 "country": "BEL"
517 }
518}"#;
519
520 test::setup();
521 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
522 let warnings = warnings.into_kind_vec();
523
524 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
525 assert_matches!(*warnings, [WarningKind::InvalidTimezone]);
526 }
527
528 #[test]
529 fn should_find_timezone_and_warn_about_escape_codes_and_invalid_type() {
530 const JSON: &str = r#"{
531 "country_code": "NL",
532 "cdr_location": {
533 "time_zone": "Europe\/Hamsterdam",
534 "country": "BEL"
535 }
536}"#;
537
538 test::setup();
539 let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
540 let warnings = warnings.into_parts_vec();
541
542 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
543 let elem_id = assert_matches!(
544 &*warnings,
545 [
546 (WarningKind::ContainsEscapeCodes, elem_id),
547 (WarningKind::InvalidTimezone, _)
548 ] => elem_id
549 );
550
551 let cdr_elem = cdr.into_element();
552 let elem_map = json::test::ElementMap::for_elem(&cdr_elem);
553 let elem = elem_map.get(*elem_id);
554 assert_eq!(elem.path(), "$.cdr_location.time_zone");
555 }
556
557 #[test]
558 fn should_find_timezone_and_warn_about_escape_codes() {
559 const JSON: &str = r#"{
560 "country_code": "NL",
561 "cdr_location": {
562 "time_zone": "Europe\/Amsterdam",
563 "country": "BEL"
564 }
565}"#;
566
567 test::setup();
568 let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
569 let warnings = warnings.into_parts_vec();
570
571 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
572 let elem_id = assert_matches!(
573 &*warnings,
574 [( WarningKind::ContainsEscapeCodes, elem_id )] => elem_id
575 );
576 assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.time_zone");
577 }
578
579 #[test]
580 fn should_infer_timezone_from_location_country() {
581 const JSON: &str = r#"{
582 "country_code": "NL",
583 "cdr_location": {
584 "country": "BEL"
585 }
586}"#;
587
588 test::setup();
589 let (_cdr, timezone, warnings) = parse_expect_v221(JSON);
590
591 assert_matches!(
592 timezone,
593 Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
594 );
595 assert_matches!(*warnings, []);
596 }
597
598 #[test]
599 fn should_find_timezone_but_report_alpha2_location_country_code() {
600 const JSON: &str = r#"{
601 "country_code": "NL",
602 "cdr_location": {
603 "country": "BE"
604 }
605}"#;
606
607 test::setup();
608 let (cdr, timezone, warnings) = parse_expect_v221(JSON);
609 let warnings = warnings.into_parts_vec();
610
611 assert_matches!(
612 timezone,
613 Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
614 );
615 let elem_id = assert_matches!(
616 &*warnings,
617 [(
618 WarningKind::LocationCountryShouldBeAlpha3,
619 elem_id
620 )] => elem_id
621 );
622
623 assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.country");
624 }
625
626 #[test]
627 fn should_not_find_timezone_due_to_no_location() {
628 const JSON: &str = r#"{ "country_code": "BE" }"#;
629
630 test::setup();
631 let (cdr, source, warnings) = parse_expect_v221(JSON);
632 let warnings = warnings.into_parts_vec();
633 assert_matches!(source, None);
634
635 let elem_id = assert_matches!(&*warnings, [( WarningKind::NoLocation, elem_id)] => elem_id);
636
637 assert_elem_path(cdr.as_element(), *elem_id, "$");
638 }
639
640 #[test]
641 fn should_not_find_timezone_due_to_no_country() {
642 const JSON: &str = r#"{
644 "country_code": "BELGIUM",
645 "cdr_location": {}
646}"#;
647
648 test::setup();
649 let (cdr, source, warnings) = parse_expect_v221(JSON);
650 let warnings = warnings.into_parts_vec();
651
652 assert_matches!(source, None);
653 let elem_id =
654 assert_matches!(&*warnings, [(WarningKind::NoLocationCountry, elem_id)] => elem_id);
655 assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location");
656 }
657
658 #[test]
659 fn should_not_find_timezone_due_to_country_having_many_timezones() {
660 const JSON: &str = r#"{
661 "country_code": "BE",
662 "cdr_location": {
663 "country": "CHN"
664 }
665}"#;
666
667 test::setup();
668 let (cdr, source, warnings) = parse_expect_v221(JSON);
669 let warnings = warnings.into_parts_vec();
670 assert_matches!(source, None);
671
672 let elem_id = assert_matches!(
673 &*warnings,
674 [(WarningKind::CantInferTimezoneFromCountry("CN"), elem_id)] => elem_id
675 );
676
677 assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.country");
678 }
679
680 #[test]
681 fn should_fail_due_to_json_not_being_object() {
682 const JSON: &str = r#"["not_a_cdr"]"#;
683
684 test::setup();
685 let (cdr, source, warnings) = parse_expect_v221(JSON);
686 let warnings = warnings.into_parts_vec();
687 assert_matches!(source, None);
688
689 let elem_id = assert_matches!(
690 &*warnings,
691 [(WarningKind::ShouldBeAnObject, elem_id)] => elem_id
692 );
693 assert_elem_path(cdr.as_element(), *elem_id, "$");
694 }
695
696 #[track_caller]
698 fn parse_expect_v221(
699 json: &str,
700 ) -> (
701 cdr::Versioned<'_>,
702 Option<Source>,
703 warning::Set<WarningKind>,
704 ) {
705 let cdr::ParseReport {
706 cdr,
707 unexpected_fields,
708 } = cdr::parse_with_version(json, Version::V221).unwrap();
709 test::assert_no_unexpected_fields(&unexpected_fields);
710
711 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
712 (cdr, timezone_source, warnings)
713 }
714
715 #[track_caller]
717 fn parse_expect_v221_and_time_zone_field(
718 json: &str,
719 ) -> (cdr::Versioned<'_>, Source, warning::Set<WarningKind>) {
720 let cdr::ParseReport {
721 cdr,
722 unexpected_fields,
723 } = cdr::parse_with_version(json, Version::V221).unwrap();
724 assert_unexpected_fields(&unexpected_fields, &["$.cdr_location.time_zone"]);
725
726 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
727 (cdr, timezone_source.unwrap(), warnings)
728 }
729
730 #[track_caller]
732 fn assert_elem_path(elem: &json::Element<'_>, elem_id: json::ElemId, path: &str) {
733 let elem_map = json::test::ElementMap::for_elem(elem);
734 let elem = elem_map.get(elem_id);
735
736 assert_eq!(elem.path(), path);
737 }
738
739 #[track_caller]
740 fn assert_unexpected_fields(
741 unexpected_fields: &json::UnexpectedFields<'_>,
742 expected: &[&'static str],
743 ) {
744 if unexpected_fields.len() != expected.len() {
745 let unexpected_fields = unexpected_fields
746 .into_iter()
747 .map(|path| path.to_string())
748 .collect::<Vec<_>>();
749
750 panic!(
751 "The unexpected fields and expected fields lists have different lengths.\n\nUnexpected fields found:\n{}",
752 unexpected_fields.join(",\n")
753 );
754 }
755
756 let unmatched_paths = unexpected_fields
757 .into_iter()
758 .zip(expected.iter())
759 .filter(|(a, b)| a != *b)
760 .collect::<Vec<_>>();
761
762 if !unmatched_paths.is_empty() {
763 let unmatched_paths = unmatched_paths
764 .into_iter()
765 .map(|(a, b)| format!("{a} != {b}"))
766 .collect::<Vec<_>>();
767
768 panic!(
769 "The unexpected fields don't match the expected fields.\n\nUnexpected fields found:\n{}",
770 unmatched_paths.join(",\n")
771 );
772 }
773 }
774}