1mod date_patterns;
12pub mod locator;
14pub mod message;
16mod message_ids;
17pub mod raw;
19mod raw_conversion;
20mod sort;
21mod terms;
22pub mod types;
24mod vocab;
25
26use crate::citation::LocatorType;
27use crate::template::ContributorRole;
28pub use message::{MessageArgs, MessageEvaluator, Mf2MessageEvaluator};
29pub use raw::{RawLocale, RawTermValue};
30#[cfg(feature = "schema")]
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::fmt;
35use std::sync::Arc;
36pub use terms::ArchiveHierarchyField;
37pub use types::*;
38
39pub type MonthList = Vec<String>;
41
42#[derive(Clone, Deserialize, Serialize)]
48#[cfg_attr(feature = "schema", derive(JsonSchema))]
49#[serde(rename_all = "kebab-case")]
50pub struct Locale {
51 #[cfg_attr(feature = "schema", schemars(skip))]
53 pub locale: String,
54 #[serde(default)]
56 pub dates: DateTerms,
57 #[serde(default)]
59 #[cfg_attr(feature = "schema", schemars(skip))]
60 pub roles: HashMap<ContributorRole, ContributorTerm>,
61 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
63 #[cfg_attr(feature = "schema", schemars(skip))]
64 pub role_combinations: HashMap<String, ContributorTerm>,
65 #[serde(default)]
67 #[cfg_attr(feature = "schema", schemars(skip))]
68 pub locators: HashMap<LocatorType, LocatorTerm>,
69 #[serde(default)]
71 pub terms: Terms,
72 #[serde(default)]
75 pub punctuation_in_quote: bool,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub sort_articles: Vec<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub locale_schema_version: Option<String>,
83 #[serde(default)]
85 pub evaluation: EvaluationConfig,
86 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
88 pub messages: HashMap<String, String>,
89 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91 pub date_formats: HashMap<String, String>,
92 #[serde(default)]
94 pub number_formats: NumberFormats,
95 #[serde(default)]
97 pub grammar_options: GrammarOptions,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub punctuation_realization: Option<crate::options::PunctuationRealization>,
101 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
103 pub legacy_term_aliases: HashMap<String, String>,
104 #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
106 pub vocab: VocabMap,
107 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
113 pub type_terms: HashMap<String, SimpleTerm>,
114 #[serde(skip, default = "default_evaluator")]
116 #[cfg_attr(feature = "schema", schemars(skip))]
117 pub evaluator: Arc<dyn MessageEvaluator>,
118 #[serde(skip)]
126 #[cfg_attr(feature = "schema", schemars(skip))]
127 pub resolved_by_fallback: bool,
128}
129
130fn default_evaluator() -> Arc<dyn MessageEvaluator> {
132 Arc::new(Mf2MessageEvaluator)
133}
134
135impl Default for Locale {
136 fn default() -> Self {
137 Self {
138 locale: String::default(),
139 dates: DateTerms::default(),
140 roles: HashMap::default(),
141 role_combinations: HashMap::default(),
142 locators: HashMap::default(),
143 terms: Terms::default(),
144 punctuation_in_quote: false,
145 sort_articles: Vec::default(),
146 locale_schema_version: None,
147 evaluation: EvaluationConfig::default(),
148 messages: HashMap::default(),
149 date_formats: HashMap::default(),
150 number_formats: NumberFormats::default(),
151 grammar_options: GrammarOptions::default(),
152 punctuation_realization: None,
153 legacy_term_aliases: HashMap::default(),
154 vocab: VocabMap::default(),
155 type_terms: HashMap::default(),
156 evaluator: default_evaluator(),
157 resolved_by_fallback: false,
158 }
159 }
160}
161
162impl fmt::Debug for Locale {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.debug_struct("Locale")
165 .field("locale", &self.locale)
166 .field("dates", &self.dates)
167 .field("roles", &self.roles)
168 .field("role_combinations", &self.role_combinations)
169 .field("locators", &self.locators)
170 .field("terms", &self.terms)
171 .field("punctuation_in_quote", &self.punctuation_in_quote)
172 .field("sort_articles", &self.sort_articles)
173 .field("locale_schema_version", &self.locale_schema_version)
174 .field("evaluation", &self.evaluation)
175 .field("messages", &self.messages)
176 .field("date_formats", &self.date_formats)
177 .field("number_formats", &self.number_formats)
178 .field("grammar_options", &self.grammar_options)
179 .field("punctuation_realization", &self.punctuation_realization)
180 .field("legacy_term_aliases", &self.legacy_term_aliases)
181 .field("vocab", &self.vocab)
182 .field("type_terms", &self.type_terms)
183 .field("evaluator", &"<MessageEvaluator>")
184 .field("resolved_by_fallback", &self.resolved_by_fallback)
185 .finish()
186 }
187}
188
189impl Locale {
190 #[allow(
214 clippy::expect_used,
215 reason = "Embedded en-US.yaml locale must parse; failure indicates a broken build, not bad input"
216 )]
217 pub fn en_us() -> Self {
218 static EN_US: std::sync::OnceLock<Locale> = std::sync::OnceLock::new();
219 EN_US
220 .get_or_init(|| {
221 let bytes = crate::embedded::get_locale_bytes("en-US")
222 .expect("en-US is a compile-time embedded locale");
223 let yaml = std::str::from_utf8(bytes).expect("embedded en-US.yaml is valid UTF-8");
224 let raw: RawLocale =
225 serde_yaml::from_str(yaml).expect("embedded en-US.yaml parses");
226 Self::from_raw_with_base(raw, Locale::default())
227 })
228 .clone()
229 }
230
231 #[must_use]
240 pub fn resolved_for(mut self, requested: &str) -> Self {
241 self.resolved_by_fallback = !self.locale.eq_ignore_ascii_case(requested);
242 self
243 }
244
245 #[allow(
253 clippy::expect_used,
254 reason = "Embedded French locale assets must parse; failure indicates a broken build"
255 )]
256 #[must_use]
257 pub fn fr_ca() -> Self {
258 let mut french = Self::from_yaml_str(include_str!("../../embedded/locales/fr-FR.yaml"))
259 .expect("embedded fr-FR.yaml parses");
260 let raw: RawLocale =
261 serde_yaml::from_str(include_str!("../../embedded/locales/fr-CA.yaml"))
262 .expect("embedded fr-CA.yaml parses");
263 french.locale = raw.locale;
264 french.locale_schema_version = raw.locale_schema_version;
265 french.date_formats.extend(raw.date_formats);
266 if let Some(grammar_options) = raw.grammar_options {
267 french.punctuation_in_quote = grammar_options.punctuation_in_quote;
268 french.grammar_options = grammar_options;
269 }
270 french.punctuation_realization = raw.punctuation_realization;
271 french
272 }
273
274 #[must_use]
286 pub fn with_term_surfaces_from(&self, item: &Locale) -> Locale {
287 Locale {
288 locale: self.locale.clone(),
293 resolved_by_fallback: self.resolved_by_fallback,
294 punctuation_in_quote: self.punctuation_in_quote,
295 sort_articles: self.sort_articles.clone(),
296 locale_schema_version: self.locale_schema_version.clone(),
297 number_formats: self.number_formats.clone(),
298 grammar_options: self.grammar_options.clone(),
299 punctuation_realization: item.punctuation_realization.clone(),
300 dates: item.dates.clone(),
302 roles: item.roles.clone(),
303 role_combinations: item.role_combinations.clone(),
304 locators: item.locators.clone(),
305 terms: item.terms.clone(),
306 evaluation: item.evaluation.clone(),
307 messages: item.messages.clone(),
308 date_formats: item.date_formats.clone(),
309 legacy_term_aliases: item.legacy_term_aliases.clone(),
310 vocab: item.vocab.clone(),
311 type_terms: item.type_terms.clone(),
312 evaluator: item.evaluator.clone(),
313 }
314 }
315}
316
317#[cfg(test)]
318#[allow(
319 clippy::unwrap_used,
320 clippy::expect_used,
321 clippy::panic,
322 clippy::indexing_slicing,
323 clippy::todo,
324 clippy::unimplemented,
325 clippy::unreachable,
326 clippy::get_unwrap,
327 reason = "Panicking is acceptable and often desired in tests."
328)]
329mod tests {
330 use super::*;
331 use std::collections::BTreeMap;
332
333 #[test]
334 fn test_en_us_locale_model_defaults() {
335 let locale = Locale::en_us();
336 assert_eq!(locale.locale, "en-US");
337 assert!(locale.punctuation_in_quote);
338 assert_eq!(locale.sort_articles, ["the", "a", "an"]);
339 assert!(locale.roles.contains_key(&ContributorRole::Editor));
340 assert!(locale.locators.contains_key(&LocatorType::Page));
341 }
342
343 #[test]
344 fn test_locale_deserialization() {
345 let json = r#"{
351 "locale": "en-US",
352 "dates": {
353 "months": {
354 "long": {
355 "1": "January", "2": "February", "3": "March", "4": "April",
356 "5": "May", "6": "June", "7": "July", "8": "August",
357 "9": "September", "10": "October", "11": "November", "12": "December"
358 },
359 "short": {
360 "1": "Jan", "2": "Feb", "3": "Mar", "4": "Apr", "5": "May", "6": "Jun",
361 "7": "Jul", "8": "Aug", "9": "Sep", "10": "Oct", "11": "Nov", "12": "Dec"
362 }
363 },
364 "seasons": {"21": "Spring", "22": "Summer", "23": "Autumn", "24": "Winter"}
365 },
366 "roles": {},
367 "terms": {
368 "and": "and",
369 "et-al": "et al."
370 }
371 }"#;
372
373 let locale: Locale = serde_json::from_str(json).unwrap();
374 assert_eq!(locale.locale, "en-US");
375 assert_eq!(
376 locale.dates.months.long[&SubYearCode::new(1).expect("valid month code")],
377 "January"
378 );
379 assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
380 }
381
382 #[test]
383 fn locale_punctuation_realization_deserializes_as_a_partial_table() {
384 let locale = Locale::from_yaml_str(
385 r#"
386locale: test
387punctuation-realization:
388 colon: "\u00A0: "
389"#,
390 )
391 .expect("locale punctuation realization should parse");
392
393 let realization = locale
394 .punctuation_realization
395 .expect("locale punctuation realization should be present");
396 assert_eq!(realization.colon.as_deref(), Some("\u{a0}: "));
397 assert_eq!(realization.semicolon, None);
398 }
399
400 #[test]
401 fn fr_ca_inherits_french_lexical_data_and_overrides_punctuation_realization() {
402 let locale = Locale::fr_ca();
403
404 assert_eq!(locale.locale, "fr-CA");
405 assert_eq!(
406 locale
407 .dates
408 .months
409 .long
410 .get(&SubYearCode::new(1).expect("valid month code"))
411 .map(String::as_str),
412 Some("janvier")
413 );
414 assert_eq!(
415 locale
416 .punctuation_realization
417 .as_ref()
418 .and_then(|table| table.semicolon.as_deref()),
419 Some("; ")
420 );
421 }
422
423 #[test]
424 fn test_yaml_locale_loading() {
425 let yaml = r#"
426locale: de-DE
427dates:
428 months:
429 long:
430 - Januar
431 - Februar
432 - März
433 - April
434 - Mai
435 - Juni
436 - Juli
437 - August
438 - September
439 - Oktober
440 - November
441 - Dezember
442 short:
443 - Jan.
444 - Feb.
445 - März
446 - Apr.
447 - Mai
448 - Juni
449 - Juli
450 - Aug.
451 - Sep.
452 - Okt.
453 - Nov.
454 - Dez.
455 seasons:
456 - Frühling
457 - Sommer
458 - Herbst
459 - Winter
460terms:
461 and:
462 long: und
463 symbol: "&"
464 et_al:
465 long: "u. a."
466"#;
467
468 let locale = Locale::from_yaml_str(yaml).unwrap();
469 assert_eq!(locale.locale, "de-DE");
470 assert_eq!(locale.terms.and.as_deref(), Some("und"));
471 assert_eq!(locale.terms.et_al.as_deref(), Some("u. a."));
472 assert_eq!(
473 locale.dates.months.long[&SubYearCode::new(1).expect("valid month code")],
474 "Januar"
475 );
476 assert_eq!(
477 locale.dates.months.long[&SubYearCode::new(3).expect("valid month code")],
478 "März"
479 );
480 }
481
482 fn temp_locales_dir(label: &str) -> std::path::PathBuf {
484 let now = std::time::SystemTime::now()
485 .duration_since(std::time::UNIX_EPOCH)
486 .expect("clock should be after epoch")
487 .as_nanos();
488 let dir = std::env::temp_dir().join(format!("citum-locale-load-{label}-{now}"));
489 std::fs::create_dir_all(&dir).expect("temp locales dir should be creatable");
490 dir
491 }
492
493 #[test]
494 fn load_exact_match_is_not_flagged_as_fallback() {
495 let dir = temp_locales_dir("exact");
496 std::fs::write(dir.join("de-DE.yaml"), "locale: de-DE\n")
497 .expect("locale file should write");
498
499 let locale = Locale::load("de-DE", &dir);
500
501 assert_eq!(locale.locale, "de-DE");
502 assert!(!locale.resolved_by_fallback);
503 let _ = std::fs::remove_dir_all(&dir);
504 }
505
506 #[test]
507 fn load_missing_locale_falls_back_to_en_us_and_is_flagged() {
508 let dir = temp_locales_dir("missing");
509
510 let locale = Locale::load("xx-XX", &dir);
511
512 assert_eq!(locale.locale, "en-US");
513 assert!(locale.resolved_by_fallback);
514 let _ = std::fs::remove_dir_all(&dir);
515 }
516
517 #[test]
518 fn load_prefix_substitution_is_flagged_as_fallback() {
519 let dir = temp_locales_dir("prefix");
523 std::fs::write(dir.join("en-US.yaml"), "locale: en-US\n")
524 .expect("locale file should write");
525
526 let locale = Locale::load("en-GB", &dir);
527
528 assert_eq!(locale.locale, "en-US");
529 assert!(locale.resolved_by_fallback);
530 let _ = std::fs::remove_dir_all(&dir);
531 }
532
533 #[test]
534 fn load_case_differing_request_is_not_flagged_as_fallback() {
535 let dir = temp_locales_dir("case");
539 std::fs::write(dir.join("en-US.yaml"), "locale: en-US\n")
540 .expect("locale file should write");
541
542 let locale = Locale::load("en-us", &dir);
543
544 assert_eq!(locale.locale, "en-US");
545 assert!(!locale.resolved_by_fallback);
546 let _ = std::fs::remove_dir_all(&dir);
547 }
548
549 #[test]
550 fn resolved_for_is_case_insensitive() {
551 let locale = Locale {
552 locale: "en-US".to_string(),
553 ..Locale::default()
554 }
555 .resolved_for("en-us");
556
557 assert!(
558 !locale.resolved_by_fallback,
559 "en-US and en-us name the same BCP 47 locale"
560 );
561 }
562
563 #[test]
564 fn apply_override_does_not_clear_the_fallback_flag() {
565 let dir = temp_locales_dir("override");
566 let mut locale = Locale::load("xx-XX", &dir);
567 assert!(locale.resolved_by_fallback);
568
569 let ov = LocaleOverride {
570 messages: [("term.page-label".into(), "pg.".into())].into(),
571 ..Default::default()
572 };
573 locale.apply_override(&ov);
574
575 assert!(locale.resolved_by_fallback);
576 let _ = std::fs::remove_dir_all(&dir);
577 }
578
579 #[test]
581 fn test_v2_grammar_options_sync_punctuation_in_quote() {
582 let yaml = r#"
583locale-schema-version: "2"
584locale: en-GB
585grammar-options:
586 punctuation-in-quote: false
587"#;
588 let locale = Locale::from_yaml_str(yaml).unwrap();
589 assert!(!locale.grammar_options.punctuation_in_quote);
591 assert!(!locale.punctuation_in_quote);
593 }
594
595 #[test]
597 fn test_v1_locale_derives_punctuation_from_locale_id() {
598 let yaml = r#"
599locale: en-US
600"#;
601 let locale = Locale::from_yaml_str(yaml).unwrap();
602 assert!(locale.punctuation_in_quote);
604 assert!(locale.grammar_options.punctuation_in_quote);
605 }
606
607 #[test]
609 fn test_partial_locale_merges_raw_maps_with_base() {
610 let yaml = r#"
611locale-schema-version: "2"
612locale: zz-ZZ
613messages:
614 pattern.in-container: "inside {$container}"
615date-formats:
616 numeric-short: "dd/MM/y"
617locators:
618 page:
619 long:
620 singular: page-localized
621 plural: pages-localized
622legacy-term-aliases:
623 page: term.page-label-long
624"#;
625 let locale = Locale::from_yaml_str(yaml).unwrap();
626
627 assert_eq!(
628 locale
629 .messages
630 .get("pattern.originally-published-as")
631 .map(String::as_str),
632 Some("originally published as {$title}")
633 );
634 assert_eq!(
635 locale
636 .messages
637 .get("pattern.in-container")
638 .map(String::as_str),
639 Some("inside {$container}")
640 );
641 assert_eq!(
642 locale.date_formats.get("textual-full").map(String::as_str),
643 Some("MMMM d, yyyy")
644 );
645 assert_eq!(
646 locale.date_formats.get("numeric-short").map(String::as_str),
647 Some("dd/MM/y")
648 );
649 assert_eq!(
650 locale.legacy_term_aliases.get("and").map(String::as_str),
651 Some("term.and")
652 );
653 assert_eq!(
654 locale.legacy_term_aliases.get("page").map(String::as_str),
655 Some("term.page-label-long")
656 );
657 assert_eq!(
658 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Long, None),
659 Some("page-localized".to_string())
660 );
661 }
662
663 #[test]
665 fn test_apply_override_merges_messages() {
666 let mut locale = Locale::en_us();
667 locale
668 .messages
669 .insert("term.page-label".into(), "p.".into());
670 let ov = LocaleOverride {
671 messages: [("term.page-label".into(), "pg.".into())].into(),
672 ..Default::default()
673 };
674 locale.apply_override(&ov);
675 assert_eq!(
676 locale.messages.get("term.page-label").map(|s| s.as_str()),
677 Some("pg.")
678 );
679 }
680
681 #[test]
684 fn test_en_us_locale_resolves_phrase_messages() {
685 let locale = Locale::en_us();
686 let args = MessageArgs {
687 named: [("container".to_string(), "Book Title".to_string())].into(),
688 ..Default::default()
689 };
690
691 assert_eq!(
692 locale.resolve_message("pattern.in-container", &args),
693 Some("in Book Title".to_string())
694 );
695 }
696
697 #[test]
699 fn test_apply_override_grammar_options_syncs_punctuation() {
700 let mut locale = Locale::en_us();
701 locale.punctuation_in_quote = false;
702 let ov = LocaleOverride {
703 grammar_options: Some(GrammarOptions {
704 punctuation_in_quote: true,
705 ..Default::default()
706 }),
707 ..Default::default()
708 };
709 locale.apply_override(&ov);
710 assert!(locale.punctuation_in_quote);
711 assert!(locale.grammar_options.punctuation_in_quote);
712 }
713
714 #[test]
718 fn test_apply_override_merges_single_month_name() {
719 let mut locale = Locale::en_us();
720 let july = SubYearCode::new(7).expect("valid month code");
721 let june = SubYearCode::new(6).expect("valid month code");
722
723 let ov = LocaleOverride {
724 dates: DateNameOverride {
725 months: MonthNames {
726 long: BTreeMap::new(),
727 short: [(july, "Jul.".to_string())].into(),
728 },
729 seasons: BTreeMap::new(),
730 },
731 ..Default::default()
732 };
733 locale.apply_override(&ov);
734
735 assert_eq!(locale.dates.months.short[&july], "Jul.");
736 assert_eq!(locale.dates.months.short[&june], "June");
737 assert_eq!(locale.dates.months.long[&july], "July");
738 }
739
740 #[test]
746 fn test_apply_override_merges_season_name_independent_of_months() {
747 let mut locale = Locale::en_us();
748 let spring = SubYearCode::new(21).expect("valid season code");
749
750 let ov = LocaleOverride {
751 dates: DateNameOverride {
752 months: MonthNames::default(),
753 seasons: [(spring, "Printemps".to_string())].into(),
754 },
755 ..Default::default()
756 };
757 locale.apply_override(&ov);
758
759 assert_eq!(locale.dates.seasons[&spring], "Printemps");
760 assert_eq!(locale.dates.months.long.len(), 12);
761 }
762
763 #[test]
768 fn embedded_locales_have_complete_keyed_month_and_season_tables() {
769 const NO_SHORT_MONTHS: &[&str] = &["ar-AR"];
774
775 for &id in crate::embedded::EMBEDDED_LOCALE_IDS {
776 let locale = crate::embedded::get_locale(id)
777 .unwrap_or_else(|| panic!("{id} should be embedded"));
778
779 assert_eq!(locale.dates.months.long.len(), 12, "{id} long months");
780 assert_eq!(locale.dates.seasons.len(), 4, "{id} seasons");
781 if !NO_SHORT_MONTHS.contains(&id) {
782 assert_eq!(locale.dates.months.short.len(), 12, "{id} short months");
783 }
784
785 for code in 1..=12u8 {
786 let key = SubYearCode::new(code).expect("valid month code");
787 assert!(
788 locale.dates.months.long.contains_key(&key),
789 "{id} missing long month {code}"
790 );
791 }
792 for code in 21..=24u8 {
793 let key = SubYearCode::new(code).expect("valid season code");
794 assert!(
795 locale.dates.seasons.contains_key(&key),
796 "{id} missing season {code}"
797 );
798 }
799 }
800 }
801
802 #[test]
803 fn embedded_locale_ids_include_all_bundled_locale_files() {
804 for id in [
805 "en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR", "zh-CN", "ja-JP",
806 "ko-KR", "ru-RU",
807 ] {
808 assert!(
809 crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
810 "{id} should be listed as an embedded locale"
811 );
812 }
813 }
814
815 #[test]
816 fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
817 for id in ["ar-AR", "eu-ES"] {
818 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
819 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
820 let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
821
822 assert_eq!(locale.locale, id);
823 }
824 }
825
826 #[test]
830 fn bundled_ja_jp_ko_kr_ru_ru_locales_are_embedded_and_parseable() {
831 for (id, editor_short, and_term) in [
832 ("ja-JP", "編", "と"),
833 ("ko-KR", "편", "및"),
834 ("ru-RU", "ред.", "и"),
835 ] {
836 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
837 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
838 let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
839
840 assert_eq!(locale.locale, id);
841 assert_eq!(
842 locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
843 Some(editor_short.to_string()),
844 "{id} editor short-form role term"
845 );
846 assert_eq!(
847 locale.resolved_general_term(&GeneralTerm::And, &TermForm::Long, None),
848 Some(and_term.to_string()),
849 "{id} 'and' term"
850 );
851 assert!(
852 locale.date_formats.contains_key("iso"),
853 "{id} should carry date-formats"
854 );
855 }
856 }
857
858 #[test]
865 fn embedded_v2_locales_pass_completeness_lint() {
866 for &id in crate::embedded::EMBEDDED_LOCALE_IDS {
867 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
868 let raw: RawLocale =
869 serde_yaml::from_slice(bytes).expect("embedded locale should parse as RawLocale");
870
871 if raw.locale_schema_version.as_deref() != Some("2") {
872 continue;
873 }
874
875 let report = crate::lint::lint_raw_locale(&raw);
876 assert!(
877 !report
878 .findings
879 .iter()
880 .any(|finding| finding.path == "grammar-options"),
881 "{id} is missing grammar-options"
882 );
883 assert!(
884 !report
885 .findings
886 .iter()
887 .any(|finding| finding.path == "date-formats"),
888 "{id} is missing date-formats"
889 );
890 }
891 }
892
893 #[test]
898 fn en_us_locale_round_trip_carries_critical_values() {
899 let locale = Locale::en_us();
900
901 assert_eq!(
903 locale.resolved_role_term(&ContributorRole::Translator, false, &TermForm::Short, None),
904 Some("trans.".to_string())
905 );
906
907 assert_eq!(
909 locale.locator_term(&LocatorType::Chapter, false, &TermForm::Short, None),
910 Some("chap.")
911 );
912 assert_eq!(
913 locale.locator_term(&LocatorType::Chapter, true, &TermForm::Short, None),
914 Some("chaps.")
915 );
916
917 assert_eq!(
919 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
920 Some("no date")
921 );
922 assert_eq!(
923 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
924 Some("n.d.")
925 );
926
927 assert_eq!(locale.terms.and.as_deref(), Some("and"));
929 assert_eq!(locale.terms.et_al.as_deref(), Some("et al."));
930
931 assert_eq!(
933 locale
934 .dates
935 .months
936 .long
937 .get(&SubYearCode::new(1).expect("valid month code"))
938 .map(String::as_str),
939 Some("January")
940 );
941
942 assert_eq!(locale.number_formats.decimal_separator, ".");
944 assert_eq!(locale.number_formats.thousands_separator, ",");
945 assert_eq!(locale.number_formats.minimum_digits, 1);
946 assert_eq!(locale.number_formats.digit_system, DigitSystem::Western);
947
948 assert_eq!(locale.sort_articles, ["the", "a", "an"]);
950 }
951
952 #[test]
953 fn locale_number_formats_accept_each_supported_digit_system() {
954 for (digit_system, expected) in [
955 ("western", DigitSystem::Western),
956 ("arabic-indic", DigitSystem::ArabicIndic),
957 ("extended-arabic-indic", DigitSystem::ExtendedArabicIndic),
958 ("devanagari", DigitSystem::Devanagari),
959 ] {
960 let locale = Locale::from_yaml_str(&format!(
961 "locale: test\nnumber-formats:\n digit-system: {digit_system}\n"
962 ))
963 .expect("locale should parse");
964
965 assert_eq!(locale.number_formats.digit_system, expected);
966 }
967 }
968}