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