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, 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
202 .get(TIMEZONE_FIELD)
203 .and_then(|elem| try_parse_location_timezone(elem).gather_warnings_into(&mut warnings));
204
205 if let Some(tz) = tz {
206 return Some(Source::Found(tz)).into_caveat(warnings);
207 }
208
209 debug!("No time-zone found in CDR; trying to infer time-zone from country");
210
211 let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
213 warnings.with_elem(WarningKind::NoLocationCountry, location_elem);
218 return None.into_caveat(warnings);
219 };
220
221 let Some(timezone) =
222 infer_timezone_from_location_country(country_elem).gather_warnings_into(&mut warnings)
223 else {
224 return None.into_caveat(warnings);
225 };
226
227 Some(Source::Inferred(timezone)).into_caveat(warnings)
228}
229
230impl From<json::decode::WarningKind> for WarningKind {
231 fn from(warn_kind: json::decode::WarningKind) -> Self {
232 Self::Decode(warn_kind)
233 }
234}
235
236impl From<country::WarningKind> for WarningKind {
237 fn from(warn_kind: country::WarningKind) -> Self {
238 Self::Country(warn_kind)
239 }
240}
241
242fn try_parse_location_timezone(tz_elem: &json::Element<'_>) -> Verdict<Tz, WarningKind> {
244 let tz = tz_elem.as_value();
245 debug!(tz = %tz, "Raw time-zone found in CDR");
246
247 let mut warnings = warning::Set::new();
248 let Some(tz) = tz.as_raw_str() else {
249 warnings.with_elem(WarningKind::InvalidTimezoneType, tz_elem);
250 return Err(warnings);
251 };
252
253 let tz = tz
254 .decode_escapes(tz_elem)
255 .gather_warnings_into(&mut warnings);
256
257 if matches!(tz, Cow::Owned(_)) {
258 warnings.with_elem(WarningKind::ContainsEscapeCodes, tz_elem);
259 }
260
261 debug!(%tz, "Escaped time-zone found in CDR");
262
263 let Ok(tz) = tz.parse::<Tz>() else {
264 warnings.with_elem(WarningKind::InvalidTimezone, tz_elem);
265 return Err(warnings);
266 };
267
268 Ok(tz.into_caveat(warnings))
269}
270
271#[instrument(skip_all)]
273fn infer_timezone_from_location_country(
274 country_elem: &json::Element<'_>,
275) -> Verdict<Tz, WarningKind> {
276 let mut warnings = warning::Set::new();
277 let code_set = country::CodeSet::from_json(country_elem)?.gather_warnings_into(&mut warnings);
278
279 let country_code = match code_set {
283 country::CodeSet::Alpha2(code) => {
284 warnings.with_elem(WarningKind::LocationCountryShouldBeAlpha3, country_elem);
285 code
286 }
287 country::CodeSet::Alpha3(code) => code,
288 };
289 let tz = try_detect_timezone(country_code).exit_with_warning(warnings, || {
290 Warning::with_elem(
291 WarningKind::CantInferTimezoneFromCountry(country_code.into_str()),
292 country_elem,
293 )
294 })?;
295
296 Ok(tz)
297}
298
299#[instrument]
307fn try_detect_timezone(country_code: country::Code) -> Option<Tz> {
308 let tz = match country_code {
309 country::Code::Ad => Tz::Europe__Andorra,
310 country::Code::Al => Tz::Europe__Tirane,
311 country::Code::At => Tz::Europe__Vienna,
312 country::Code::Ba => Tz::Europe__Sarajevo,
313 country::Code::Be => Tz::Europe__Brussels,
314 country::Code::Bg => Tz::Europe__Sofia,
315 country::Code::By => Tz::Europe__Minsk,
316 country::Code::Ch => Tz::Europe__Zurich,
317 country::Code::Cy => Tz::Europe__Nicosia,
318 country::Code::Cz => Tz::Europe__Prague,
319 country::Code::De => Tz::Europe__Berlin,
320 country::Code::Dk => Tz::Europe__Copenhagen,
321 country::Code::Ee => Tz::Europe__Tallinn,
322 country::Code::Es => Tz::Europe__Madrid,
323 country::Code::Fi => Tz::Europe__Helsinki,
324 country::Code::Fr => Tz::Europe__Paris,
325 country::Code::Gb => Tz::Europe__London,
326 country::Code::Gr => Tz::Europe__Athens,
327 country::Code::Hr => Tz::Europe__Zagreb,
328 country::Code::Hu => Tz::Europe__Budapest,
329 country::Code::Ie => Tz::Europe__Dublin,
330 country::Code::Is => Tz::Iceland,
331 country::Code::It => Tz::Europe__Rome,
332 country::Code::Li => Tz::Europe__Vaduz,
333 country::Code::Lt => Tz::Europe__Vilnius,
334 country::Code::Lu => Tz::Europe__Luxembourg,
335 country::Code::Lv => Tz::Europe__Riga,
336 country::Code::Mc => Tz::Europe__Monaco,
337 country::Code::Md => Tz::Europe__Chisinau,
338 country::Code::Me => Tz::Europe__Podgorica,
339 country::Code::Mk => Tz::Europe__Skopje,
340 country::Code::Mt => Tz::Europe__Malta,
341 country::Code::Nl => Tz::Europe__Amsterdam,
342 country::Code::No => Tz::Europe__Oslo,
343 country::Code::Pl => Tz::Europe__Warsaw,
344 country::Code::Pt => Tz::Europe__Lisbon,
345 country::Code::Ro => Tz::Europe__Bucharest,
346 country::Code::Rs => Tz::Europe__Belgrade,
347 country::Code::Ru => Tz::Europe__Moscow,
348 country::Code::Se => Tz::Europe__Stockholm,
349 country::Code::Si => Tz::Europe__Ljubljana,
350 country::Code::Sk => Tz::Europe__Bratislava,
351 country::Code::Sm => Tz::Europe__San_Marino,
352 country::Code::Tr => Tz::Turkey,
353 country::Code::Ua => Tz::Europe__Kiev,
354 _ => return None,
355 };
356
357 debug!(%tz, "time-zone detected");
358
359 Some(tz)
360}
361
362#[cfg(test)]
363pub mod test {
364 #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
365 #![allow(clippy::panic, reason = "tests are allowed panic")]
366
367 use std::collections::BTreeMap;
368
369 use crate::{
370 cdr,
371 test::{ExpectFile, ExpectValue, Expectation},
372 warning::{self, test},
373 };
374
375 use super::{Source, WarningKind};
376
377 #[derive(serde::Deserialize)]
379 pub(crate) struct FindOrInferExpect {
380 #[serde(default)]
382 timezone: Expectation<String>,
383
384 #[serde(default)]
386 warnings: Expectation<BTreeMap<String, Vec<String>>>,
387 }
388
389 #[track_caller]
390 pub(crate) fn assert_find_or_infer_outcome(
391 cdr: &cdr::Versioned<'_>,
392 timezone: Source,
393 expect: ExpectFile<FindOrInferExpect>,
394 warnings: &warning::Set<WarningKind>,
395 ) {
396 let ExpectFile {
397 value: expect,
398 expect_file_name,
399 } = expect;
400
401 let root = cdr.as_element();
402
403 let Some(expect) = expect else {
404 assert!(
405 warnings.is_empty(),
406 "There is no expectation file at `{expect_file_name}` but the timezone has warnings;\n{:?}",
407 warnings.group_by_elem(root).into_id_map()
408 );
409 return;
410 };
411
412 if let Expectation::Present(ExpectValue::Some(expected)) = &expect.timezone {
413 assert_eq!(expected, &timezone.into_timezone().to_string());
414 }
415
416 test::assert_warnings(&expect_file_name, root, warnings, expect.warnings);
417 }
418}
419
420#[cfg(test)]
421mod test_find_or_infer {
422 use assert_matches::assert_matches;
423
424 use crate::{cdr, json, test, timezone::WarningKind, warning, Version};
425
426 use super::{find_or_infer, Source};
427
428 #[test]
429 fn should_find_timezone() {
430 const JSON: &str = r#"{
431 "country_code": "NL",
432 "cdr_location": {
433 "time_zone": "Europe/Amsterdam"
434 }
435}"#;
436
437 test::setup();
438 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
439
440 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
441 assert_matches!(*warnings, []);
442 }
443
444 #[test]
445 fn should_find_timezone_but_warn_about_use_of_location_for_v221_cdr() {
446 const JSON: &str = r#"{
447 "country_code": "NL",
448 "location": {
449 "time_zone": "Europe/Amsterdam"
450 }
451}"#;
452
453 test::setup();
454 let cdr::ParseReport {
456 cdr,
457 unexpected_fields,
458 } = cdr::parse_with_version(JSON, Version::V221).unwrap();
459
460 assert_unexpected_fields(&unexpected_fields, &["$.location", "$.location.time_zone"]);
462
463 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
464 let warnings = warnings.into_kind_vec();
465 let timezone_source = timezone_source.unwrap();
466
467 assert_matches!(
468 timezone_source,
469 Source::Found(chrono_tz::Tz::Europe__Amsterdam)
470 );
471 assert_matches!(*warnings, [WarningKind::V221CdrHasLocationField]);
473 }
474
475 #[test]
476 fn should_find_timezone_without_cdr_country() {
477 const JSON: &str = r#"{
478 "cdr_location": {
479 "time_zone": "Europe/Amsterdam"
480 }
481}"#;
482
483 test::setup();
484 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
485
486 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
487 assert_matches!(*warnings, []);
488 }
489
490 #[test]
491 fn should_infer_timezone_and_warn_about_invalid_type() {
492 const JSON: &str = r#"{
493 "country_code": "NL",
494 "cdr_location": {
495 "time_zone": null,
496 "country": "BEL"
497 }
498}"#;
499
500 test::setup();
501 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
502 let warnings = warnings.into_kind_vec();
503
504 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
505 assert_matches!(*warnings, [WarningKind::InvalidTimezoneType]);
506 }
507
508 #[test]
509 fn should_find_timezone_and_warn_about_invalid_type() {
510 const JSON: &str = r#"{
511 "country_code": "NL",
512 "cdr_location": {
513 "time_zone": "Europe/Hamsterdam",
514 "country": "BEL"
515 }
516}"#;
517
518 test::setup();
519 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
520 let warnings = warnings.into_kind_vec();
521
522 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
523 assert_matches!(*warnings, [WarningKind::InvalidTimezone]);
524 }
525
526 #[test]
527 fn should_find_timezone_and_warn_about_escape_codes_and_invalid_type() {
528 const JSON: &str = r#"{
529 "country_code": "NL",
530 "cdr_location": {
531 "time_zone": "Europe\/Hamsterdam",
532 "country": "BEL"
533 }
534}"#;
535
536 test::setup();
537 let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
538 let warnings = warnings.into_parts_vec();
539
540 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
541 let elem_id = assert_matches!(
542 &*warnings,
543 [
544 (WarningKind::ContainsEscapeCodes, elem_id),
545 (WarningKind::InvalidTimezone, _)
546 ] => elem_id
547 );
548
549 let cdr_elem = cdr.into_element();
550 let elem_map = json::test::ElementMap::for_elem(&cdr_elem);
551 let elem = elem_map.get(*elem_id);
552 assert_eq!(elem.path(), "$.cdr_location.time_zone");
553 }
554
555 #[test]
556 fn should_find_timezone_and_warn_about_escape_codes() {
557 const JSON: &str = r#"{
558 "country_code": "NL",
559 "cdr_location": {
560 "time_zone": "Europe\/Amsterdam",
561 "country": "BEL"
562 }
563}"#;
564
565 test::setup();
566 let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
567 let warnings = warnings.into_parts_vec();
568
569 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
570 let elem_id = assert_matches!(
571 &*warnings,
572 [( WarningKind::ContainsEscapeCodes, elem_id )] => elem_id
573 );
574 assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.time_zone");
575 }
576
577 #[test]
578 fn should_infer_timezone_from_location_country() {
579 const JSON: &str = r#"{
580 "country_code": "NL",
581 "cdr_location": {
582 "country": "BEL"
583 }
584}"#;
585
586 test::setup();
587 let (_cdr, timezone, warnings) = parse_expect_v221(JSON);
588
589 assert_matches!(
590 timezone,
591 Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
592 );
593 assert_matches!(*warnings, []);
594 }
595
596 #[test]
597 fn should_find_timezone_but_report_alpha2_location_country_code() {
598 const JSON: &str = r#"{
599 "country_code": "NL",
600 "cdr_location": {
601 "country": "BE"
602 }
603}"#;
604
605 test::setup();
606 let (cdr, timezone, warnings) = parse_expect_v221(JSON);
607 let warnings = warnings.into_parts_vec();
608
609 assert_matches!(
610 timezone,
611 Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
612 );
613 let elem_id = assert_matches!(
614 &*warnings,
615 [(
616 WarningKind::LocationCountryShouldBeAlpha3,
617 elem_id
618 )] => elem_id
619 );
620
621 assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.country");
622 }
623
624 #[test]
625 fn should_not_find_timezone_due_to_no_location() {
626 const JSON: &str = r#"{ "country_code": "BE" }"#;
627
628 test::setup();
629 let (cdr, source, warnings) = parse_expect_v221(JSON);
630 let warnings = warnings.into_parts_vec();
631 assert_matches!(source, None);
632
633 let elem_id = assert_matches!(&*warnings, [( WarningKind::NoLocation, elem_id)] => elem_id);
634
635 assert_elem_path(cdr.as_element(), *elem_id, "$");
636 }
637
638 #[test]
639 fn should_not_find_timezone_due_to_no_country() {
640 const JSON: &str = r#"{
642 "country_code": "BELGIUM",
643 "cdr_location": {}
644}"#;
645
646 test::setup();
647 let (cdr, source, warnings) = parse_expect_v221(JSON);
648 let warnings = warnings.into_parts_vec();
649
650 assert_matches!(source, None);
651 let elem_id =
652 assert_matches!(&*warnings, [(WarningKind::NoLocationCountry, elem_id)] => elem_id);
653 assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location");
654 }
655
656 #[test]
657 fn should_not_find_timezone_due_to_country_having_many_timezones() {
658 const JSON: &str = r#"{
659 "country_code": "BE",
660 "cdr_location": {
661 "country": "CHN"
662 }
663}"#;
664
665 test::setup();
666 let (cdr, source, warnings) = parse_expect_v221(JSON);
667 let warnings = warnings.into_parts_vec();
668 assert_matches!(source, None);
669
670 let elem_id = assert_matches!(
671 &*warnings,
672 [(WarningKind::CantInferTimezoneFromCountry("CN"), elem_id)] => elem_id
673 );
674
675 assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.country");
676 }
677
678 #[test]
679 fn should_fail_due_to_json_not_being_object() {
680 const JSON: &str = r#"["not_a_cdr"]"#;
681
682 test::setup();
683 let (cdr, source, warnings) = parse_expect_v221(JSON);
684 let warnings = warnings.into_parts_vec();
685 assert_matches!(source, None);
686
687 let elem_id = assert_matches!(
688 &*warnings,
689 [(WarningKind::ShouldBeAnObject, elem_id)] => elem_id
690 );
691 assert_elem_path(cdr.as_element(), *elem_id, "$");
692 }
693
694 #[track_caller]
696 fn parse_expect_v221(
697 json: &str,
698 ) -> (
699 cdr::Versioned<'_>,
700 Option<Source>,
701 warning::Set<WarningKind>,
702 ) {
703 let cdr::ParseReport {
704 cdr,
705 unexpected_fields,
706 } = cdr::parse_with_version(json, Version::V221).unwrap();
707 test::assert_no_unexpected_fields(&unexpected_fields);
708
709 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
710 (cdr, timezone_source, warnings)
711 }
712
713 #[track_caller]
715 fn parse_expect_v221_and_time_zone_field(
716 json: &str,
717 ) -> (cdr::Versioned<'_>, Source, warning::Set<WarningKind>) {
718 let cdr::ParseReport {
719 cdr,
720 unexpected_fields,
721 } = cdr::parse_with_version(json, Version::V221).unwrap();
722 assert_unexpected_fields(&unexpected_fields, &["$.cdr_location.time_zone"]);
723
724 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
725 (cdr, timezone_source.unwrap(), warnings)
726 }
727
728 #[track_caller]
730 fn assert_elem_path(elem: &json::Element<'_>, elem_id: json::ElemId, path: &str) {
731 let elem_map = json::test::ElementMap::for_elem(elem);
732 let elem = elem_map.get(elem_id);
733
734 assert_eq!(elem.path(), path);
735 }
736
737 #[track_caller]
738 fn assert_unexpected_fields(
739 unexpected_fields: &json::UnexpectedFields<'_>,
740 expected: &[&'static str],
741 ) {
742 if unexpected_fields.len() != expected.len() {
743 let unexpected_fields = unexpected_fields
744 .into_iter()
745 .map(|path| path.to_string())
746 .collect::<Vec<_>>();
747
748 panic!(
749 "The unexpected fields and expected fields lists have different lengths.\n\nUnexpected fields found:\n{}",
750 unexpected_fields.join(",\n")
751 );
752 }
753
754 let unmatched_paths = unexpected_fields
755 .into_iter()
756 .zip(expected.iter())
757 .filter(|(a, b)| a != *b)
758 .collect::<Vec<_>>();
759
760 if !unmatched_paths.is_empty() {
761 let unmatched_paths = unmatched_paths
762 .into_iter()
763 .map(|(a, b)| format!("{a} != {b}"))
764 .collect::<Vec<_>>();
765
766 panic!(
767 "The unexpected fields don't match the expected fields.\n\nUnexpected fields found:\n{}",
768 unmatched_paths.join(",\n")
769 );
770 }
771 }
772}