Skip to main content

citum_schema_style/locale/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Locale definitions for Citum.
7//!
8//! Locales provide language-specific terms, date formats, and punctuation rules
9//! for citation formatting.
10
11mod date_patterns;
12/// Locator text normalization.
13pub mod locator;
14/// Message evaluation for parameterized locale strings.
15pub mod message;
16mod message_ids;
17/// Raw locale types used during locale file parsing.
18pub mod raw;
19mod raw_conversion;
20mod sort;
21mod terms;
22/// Structured locale types used by the processor.
23pub 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
39/// A list of month names (12 elements for Jan-Dec).
40pub type MonthList = Vec<String>;
41
42/// A locale definition containing language-specific terms and formatting rules.
43///
44/// The `evaluator` field holds the message evaluation engine, selected based on
45/// `evaluation.message_syntax`. This allows for trait-based swapping to ICU4X
46/// implementations in the future without changing call sites.
47#[derive(Clone, Deserialize, Serialize)]
48#[cfg_attr(feature = "schema", derive(JsonSchema))]
49#[serde(rename_all = "kebab-case")]
50pub struct Locale {
51    /// The locale identifier (e.g., "en-US", "de-DE").
52    #[cfg_attr(feature = "schema", schemars(skip))]
53    pub locale: String,
54    /// Date-related terms (months, seasons).
55    #[serde(default)]
56    pub dates: DateTerms,
57    /// Contributor role terms (editor, translator, etc.).
58    #[serde(default)]
59    #[cfg_attr(feature = "schema", schemars(skip))]
60    pub roles: HashMap<ContributorRole, ContributorTerm>,
61    /// Authored terms for combinations such as `writer-director`.
62    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
63    #[cfg_attr(feature = "schema", schemars(skip))]
64    pub role_combinations: HashMap<String, ContributorTerm>,
65    /// Locator terms (page, chapter, etc.).
66    #[serde(default)]
67    #[cfg_attr(feature = "schema", schemars(skip))]
68    pub locators: HashMap<LocatorType, LocatorTerm>,
69    /// General terms (and, et al., etc.).
70    #[serde(default)]
71    pub terms: Terms,
72    /// Whether to place periods/commas inside quotation marks.
73    /// true = American style ("text."), false = British style ("text".)
74    #[serde(default)]
75    pub punctuation_in_quote: bool,
76    /// Articles to strip from titles when sorting (e.g., "the", "a", "an" for English).
77    /// These should be lowercase and will be matched case-insensitively.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub sort_articles: Vec<String>,
80    /// Schema version from the source locale file (None = legacy v1).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub locale_schema_version: Option<String>,
83    /// Runtime evaluation configuration.
84    #[serde(default)]
85    pub evaluation: EvaluationConfig,
86    /// ICU MF1 messages keyed by message ID (populated for v2 locales).
87    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
88    pub messages: HashMap<String, String>,
89    /// Named date format presets: symbolic name → CLDR pattern.
90    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91    pub date_formats: HashMap<String, String>,
92    /// Number formatting options.
93    #[serde(default)]
94    pub number_formats: NumberFormats,
95    /// Grammar options.
96    #[serde(default)]
97    pub grammar_options: GrammarOptions,
98    /// Partial semantic punctuation realization table owned by this locale.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub punctuation_realization: Option<crate::options::PunctuationRealization>,
101    /// Backwards-compatibility aliases: old term key → new message ID.
102    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
103    pub legacy_term_aliases: HashMap<String, String>,
104    /// Vocabulary maps for genre and medium display text.
105    #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
106    pub vocab: VocabMap,
107    /// Reference-type description terms, keyed by CSL-style `ref_type`
108    /// spelling (e.g. `"dataset"`, `"article-journal"`). Used by the
109    /// `type-label` template component to resolve a localized fallback
110    /// label when a reference has no `genre`/`medium` override. See
111    /// `docs/specs/TYPE_CLASSIFICATION_CENTRALIZATION.md`.
112    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
113    pub type_terms: HashMap<String, SimpleTerm>,
114    /// Message evaluator implementation (not serialized; set during load).
115    #[serde(skip, default = "default_evaluator")]
116    #[cfg_attr(feature = "schema", schemars(skip))]
117    pub evaluator: Arc<dyn MessageEvaluator>,
118}
119
120/// Default message evaluator (MF2).
121fn default_evaluator() -> Arc<dyn MessageEvaluator> {
122    Arc::new(Mf2MessageEvaluator)
123}
124
125impl Default for Locale {
126    fn default() -> Self {
127        Self {
128            locale: String::default(),
129            dates: DateTerms::default(),
130            roles: HashMap::default(),
131            role_combinations: HashMap::default(),
132            locators: HashMap::default(),
133            terms: Terms::default(),
134            punctuation_in_quote: false,
135            sort_articles: Vec::default(),
136            locale_schema_version: None,
137            evaluation: EvaluationConfig::default(),
138            messages: HashMap::default(),
139            date_formats: HashMap::default(),
140            number_formats: NumberFormats::default(),
141            grammar_options: GrammarOptions::default(),
142            punctuation_realization: None,
143            legacy_term_aliases: HashMap::default(),
144            vocab: VocabMap::default(),
145            type_terms: HashMap::default(),
146            evaluator: default_evaluator(),
147        }
148    }
149}
150
151impl fmt::Debug for Locale {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        f.debug_struct("Locale")
154            .field("locale", &self.locale)
155            .field("dates", &self.dates)
156            .field("roles", &self.roles)
157            .field("role_combinations", &self.role_combinations)
158            .field("locators", &self.locators)
159            .field("terms", &self.terms)
160            .field("punctuation_in_quote", &self.punctuation_in_quote)
161            .field("sort_articles", &self.sort_articles)
162            .field("locale_schema_version", &self.locale_schema_version)
163            .field("evaluation", &self.evaluation)
164            .field("messages", &self.messages)
165            .field("date_formats", &self.date_formats)
166            .field("number_formats", &self.number_formats)
167            .field("grammar_options", &self.grammar_options)
168            .field("punctuation_realization", &self.punctuation_realization)
169            .field("legacy_term_aliases", &self.legacy_term_aliases)
170            .field("vocab", &self.vocab)
171            .field("type_terms", &self.type_terms)
172            .field("evaluator", &"<MessageEvaluator>")
173            .finish()
174    }
175}
176
177impl Locale {
178    /// Create the English (US) locale, the fallback baseline every other
179    /// locale inherits from and the default for the majority of embedded
180    /// styles (which declare no `info.default-locale`).
181    ///
182    /// This parses the embedded canonical asset
183    /// (`embedded/locales/en-US.yaml`) so the YAML is the single source of
184    /// truth — there is no separate hand-maintained Rust copy to drift out
185    /// of sync with it. The parse is memoized in a `std::sync::OnceLock`
186    /// since it is pure and immutable; callers get a `clone()` of the cached
187    /// result (still a deep copy of its maps/vecs, but far cheaper than
188    /// re-parsing the YAML) rather than re-parsing on every call.
189    ///
190    /// Seeds from [`Locale::default()`] (not `from_raw`'s usual
191    /// `Locale::en_us()` seed) via `from_raw_with_base` to avoid infinite
192    /// recursion through this very function.
193    ///
194    /// # Panics
195    ///
196    /// Panics if the embedded `en-US.yaml` asset is missing, not valid
197    /// UTF-8, or fails to parse. This cannot happen at runtime: the asset is
198    /// embedded at compile time and covered by
199    /// `bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable`-style
200    /// tests, so a failure here indicates a broken build, not bad input.
201    #[allow(
202        clippy::expect_used,
203        reason = "Embedded en-US.yaml locale must parse; failure indicates a broken build, not bad input"
204    )]
205    pub fn en_us() -> Self {
206        static EN_US: std::sync::OnceLock<Locale> = std::sync::OnceLock::new();
207        EN_US
208            .get_or_init(|| {
209                let bytes = crate::embedded::get_locale_bytes("en-US")
210                    .expect("en-US is a compile-time embedded locale");
211                let yaml = std::str::from_utf8(bytes).expect("embedded en-US.yaml is valid UTF-8");
212                let raw: RawLocale =
213                    serde_yaml::from_str(yaml).expect("embedded en-US.yaml parses");
214                Self::from_raw_with_base(raw, Locale::default())
215            })
216            .clone()
217    }
218
219    /// Create the Québec French locale by applying its regional typography to
220    /// the bundled French lexical locale.
221    ///
222    /// # Panics
223    ///
224    /// Panics if either embedded French locale asset fails to parse, which
225    /// indicates a broken build rather than invalid runtime input.
226    #[allow(
227        clippy::expect_used,
228        reason = "Embedded French locale assets must parse; failure indicates a broken build"
229    )]
230    #[must_use]
231    pub fn fr_ca() -> Self {
232        let mut french = Self::from_yaml_str(include_str!("../../embedded/locales/fr-FR.yaml"))
233            .expect("embedded fr-FR.yaml parses");
234        let raw: RawLocale =
235            serde_yaml::from_str(include_str!("../../embedded/locales/fr-CA.yaml"))
236                .expect("embedded fr-CA.yaml parses");
237        french.locale = raw.locale;
238        french.locale_schema_version = raw.locale_schema_version;
239        french.date_formats.extend(raw.date_formats);
240        if let Some(grammar_options) = raw.grammar_options {
241            french.punctuation_in_quote = grammar_options.punctuation_in_quote;
242            french.grammar_options = grammar_options;
243        }
244        french.punctuation_realization = raw.punctuation_realization;
245        french
246    }
247
248    /// Build a rendering locale that speaks `item`'s terms, roles, locators,
249    /// messages, and date names/patterns inside `self`'s (the style's)
250    /// typography and identity.
251    ///
252    /// This is the `options.multilingual.term-locale: item` hybrid: "terms
253    /// are the item speaking; typography is the document speaking" (see
254    /// `docs/specs/PER_ITEM_TERM_LOCALE.md` §4). The field list is written
255    /// out explicitly, not built by cloning `self` and overwriting a few
256    /// fields, so that a field added to `Locale` later must be placed on one
257    /// side of the split deliberately rather than silently inheriting the
258    /// wrong one.
259    #[must_use]
260    pub fn with_term_surfaces_from(&self, item: &Locale) -> Locale {
261        Locale {
262            // Identity and typography: stay with the style locale. The id
263            // is also read as a data-translation target (multilingual
264            // titles/archive names) and for term-casing tailoring; both
265            // uses are out of scope for this switch (§4).
266            locale: self.locale.clone(),
267            punctuation_in_quote: self.punctuation_in_quote,
268            sort_articles: self.sort_articles.clone(),
269            locale_schema_version: self.locale_schema_version.clone(),
270            number_formats: self.number_formats.clone(),
271            grammar_options: self.grammar_options.clone(),
272            punctuation_realization: item.punctuation_realization.clone(),
273            // Word and date surfaces: switch to the item locale.
274            dates: item.dates.clone(),
275            roles: item.roles.clone(),
276            role_combinations: item.role_combinations.clone(),
277            locators: item.locators.clone(),
278            terms: item.terms.clone(),
279            evaluation: item.evaluation.clone(),
280            messages: item.messages.clone(),
281            date_formats: item.date_formats.clone(),
282            legacy_term_aliases: item.legacy_term_aliases.clone(),
283            vocab: item.vocab.clone(),
284            type_terms: item.type_terms.clone(),
285            evaluator: item.evaluator.clone(),
286        }
287    }
288}
289
290#[cfg(test)]
291#[allow(
292    clippy::unwrap_used,
293    clippy::expect_used,
294    clippy::panic,
295    clippy::indexing_slicing,
296    clippy::todo,
297    clippy::unimplemented,
298    clippy::unreachable,
299    clippy::get_unwrap,
300    reason = "Panicking is acceptable and often desired in tests."
301)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_en_us_locale_model_defaults() {
307        let locale = Locale::en_us();
308        assert_eq!(locale.locale, "en-US");
309        assert!(locale.punctuation_in_quote);
310        assert_eq!(locale.sort_articles, ["the", "a", "an"]);
311        assert!(locale.roles.contains_key(&ContributorRole::Editor));
312        assert!(locale.locators.contains_key(&LocatorType::Page));
313    }
314
315    #[test]
316    fn test_locale_deserialization() {
317        let json = r#"{
318            "locale": "en-US",
319            "dates": {
320                "months": {
321                    "long": ["January", "February", "March", "April", "May", "June",
322                             "July", "August", "September", "October", "November", "December"],
323                    "short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
324                              "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
325                },
326                "seasons": ["Spring", "Summer", "Autumn", "Winter"]
327            },
328            "roles": {},
329            "terms": {
330                "and": "and",
331                "et-al": "et al."
332            }
333        }"#;
334
335        let locale: Locale = serde_json::from_str(json).unwrap();
336        assert_eq!(locale.locale, "en-US");
337        assert_eq!(locale.dates.months.long[0], "January");
338        assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
339    }
340
341    #[test]
342    fn locale_punctuation_realization_deserializes_as_a_partial_table() {
343        let locale = Locale::from_yaml_str(
344            r#"
345locale: test
346punctuation-realization:
347  colon: "\u00A0: "
348"#,
349        )
350        .expect("locale punctuation realization should parse");
351
352        let realization = locale
353            .punctuation_realization
354            .expect("locale punctuation realization should be present");
355        assert_eq!(realization.colon.as_deref(), Some("\u{a0}: "));
356        assert_eq!(realization.semicolon, None);
357    }
358
359    #[test]
360    fn fr_ca_inherits_french_lexical_data_and_overrides_punctuation_realization() {
361        let locale = Locale::fr_ca();
362
363        assert_eq!(locale.locale, "fr-CA");
364        assert_eq!(
365            locale.dates.months.long.first().map(String::as_str),
366            Some("janvier")
367        );
368        assert_eq!(
369            locale
370                .punctuation_realization
371                .as_ref()
372                .and_then(|table| table.semicolon.as_deref()),
373            Some("; ")
374        );
375    }
376
377    #[test]
378    fn test_yaml_locale_loading() {
379        let yaml = r#"
380locale: de-DE
381dates:
382  months:
383    long:
384      - Januar
385      - Februar
386      - März
387      - April
388      - Mai
389      - Juni
390      - Juli
391      - August
392      - September
393      - Oktober
394      - November
395      - Dezember
396    short:
397      - Jan.
398      - Feb.
399      - März
400      - Apr.
401      - Mai
402      - Juni
403      - Juli
404      - Aug.
405      - Sep.
406      - Okt.
407      - Nov.
408      - Dez.
409  seasons:
410    - Frühling
411    - Sommer
412    - Herbst
413    - Winter
414terms:
415  and:
416    long: und
417    symbol: "&"
418  et_al:
419    long: "u. a."
420"#;
421
422        let locale = Locale::from_yaml_str(yaml).unwrap();
423        assert_eq!(locale.locale, "de-DE");
424        assert_eq!(locale.terms.and.as_deref(), Some("und"));
425        assert_eq!(locale.terms.et_al.as_deref(), Some("u. a."));
426        assert_eq!(locale.dates.months.long[0], "Januar");
427        assert_eq!(locale.dates.months.long[2], "März");
428    }
429
430    /// v2 locale with grammar-options overrides punctuation_in_quote correctly.
431    #[test]
432    fn test_v2_grammar_options_sync_punctuation_in_quote() {
433        let yaml = r#"
434locale-schema-version: "2"
435locale: en-GB
436grammar-options:
437  punctuation-in-quote: false
438"#;
439        let locale = Locale::from_yaml_str(yaml).unwrap();
440        // grammar_options is the authoritative source for v2 locales
441        assert!(!locale.grammar_options.punctuation_in_quote);
442        // legacy field is synced from grammar_options
443        assert!(!locale.punctuation_in_quote);
444    }
445
446    /// v1 locale (no grammar-options) derives punctuation_in_quote from locale ID.
447    #[test]
448    fn test_v1_locale_derives_punctuation_from_locale_id() {
449        let yaml = r#"
450locale: en-US
451"#;
452        let locale = Locale::from_yaml_str(yaml).unwrap();
453        // en-US uses American style (inside)
454        assert!(locale.punctuation_in_quote);
455        assert!(locale.grammar_options.punctuation_in_quote);
456    }
457
458    /// Partial locales inherit base messages, date formats, and aliases.
459    #[test]
460    fn test_partial_locale_merges_raw_maps_with_base() {
461        let yaml = r#"
462locale-schema-version: "2"
463locale: zz-ZZ
464messages:
465  pattern.in-container: "inside {$container}"
466date-formats:
467  numeric-short: "dd/MM/y"
468locators:
469  page:
470    long:
471      singular: page-localized
472      plural: pages-localized
473legacy-term-aliases:
474  page: term.page-label-long
475"#;
476        let locale = Locale::from_yaml_str(yaml).unwrap();
477
478        assert_eq!(
479            locale
480                .messages
481                .get("pattern.originally-published-as")
482                .map(String::as_str),
483            Some("originally published as {$title}")
484        );
485        assert_eq!(
486            locale
487                .messages
488                .get("pattern.in-container")
489                .map(String::as_str),
490            Some("inside {$container}")
491        );
492        assert_eq!(
493            locale.date_formats.get("textual-full").map(String::as_str),
494            Some("MMMM d, yyyy")
495        );
496        assert_eq!(
497            locale.date_formats.get("numeric-short").map(String::as_str),
498            Some("dd/MM/y")
499        );
500        assert_eq!(
501            locale.legacy_term_aliases.get("and").map(String::as_str),
502            Some("term.and")
503        );
504        assert_eq!(
505            locale.legacy_term_aliases.get("page").map(String::as_str),
506            Some("term.page-label-long")
507        );
508        assert_eq!(
509            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Long, None),
510            Some("page-localized".to_string())
511        );
512    }
513
514    /// apply_override merges messages key-by-key into the base locale.
515    #[test]
516    fn test_apply_override_merges_messages() {
517        let mut locale = Locale::en_us();
518        locale
519            .messages
520            .insert("term.page-label".into(), "p.".into());
521        let ov = LocaleOverride {
522            messages: [("term.page-label".into(), "pg.".into())].into(),
523            ..Default::default()
524        };
525        locale.apply_override(&ov);
526        assert_eq!(
527            locale.messages.get("term.page-label").map(|s| s.as_str()),
528            Some("pg.")
529        );
530    }
531
532    /// The hardcoded en-US locale includes phrase messages used by style
533    /// `message:` components, not only legacy term compatibility messages.
534    #[test]
535    fn test_en_us_locale_resolves_phrase_messages() {
536        let locale = Locale::en_us();
537        let args = MessageArgs {
538            named: [("container".to_string(), "Book Title".to_string())].into(),
539            ..Default::default()
540        };
541
542        assert_eq!(
543            locale.resolve_message("pattern.in-container", &args),
544            Some("in Book Title".to_string())
545        );
546    }
547
548    /// apply_override with grammar_options replaces block and syncs punctuation_in_quote.
549    #[test]
550    fn test_apply_override_grammar_options_syncs_punctuation() {
551        let mut locale = Locale::en_us();
552        locale.punctuation_in_quote = false;
553        let ov = LocaleOverride {
554            grammar_options: Some(GrammarOptions {
555                punctuation_in_quote: true,
556                ..Default::default()
557            }),
558            ..Default::default()
559        };
560        locale.apply_override(&ov);
561        assert!(locale.punctuation_in_quote);
562        assert!(locale.grammar_options.punctuation_in_quote);
563    }
564
565    #[test]
566    fn embedded_locale_ids_include_all_bundled_locale_files() {
567        for id in [
568            "en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR", "zh-CN", "ja-JP",
569            "ko-KR", "ru-RU",
570        ] {
571            assert!(
572                crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
573                "{id} should be listed as an embedded locale"
574            );
575        }
576    }
577
578    #[test]
579    fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
580        for id in ["ar-AR", "eu-ES"] {
581            let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
582            let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
583            let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
584
585            assert_eq!(locale.locale, id);
586        }
587    }
588
589    /// Round-trip regression guard for the new ja-JP/ko-KR/ru-RU locales
590    /// (`csl26-tfi8`): parses each embedded file and spot-checks a handful
591    /// of the values a future edit to that YAML could silently regress.
592    #[test]
593    fn bundled_ja_jp_ko_kr_ru_ru_locales_are_embedded_and_parseable() {
594        for (id, editor_short, and_term) in [
595            ("ja-JP", "編", "と"),
596            ("ko-KR", "편", "및"),
597            ("ru-RU", "ред.", "и"),
598        ] {
599            let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
600            let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
601            let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
602
603            assert_eq!(locale.locale, id);
604            assert_eq!(
605                locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
606                Some(editor_short.to_string()),
607                "{id} editor short-form role term"
608            );
609            assert_eq!(
610                locale.resolved_general_term(&GeneralTerm::And, &TermForm::Long, None),
611                Some(and_term.to_string()),
612                "{id} 'and' term"
613            );
614            assert!(
615                locale.date_formats.contains_key("iso"),
616                "{id} should carry date-formats"
617            );
618        }
619    }
620
621    /// CI enforcement for the locale-completeness lint (`csl26-itri`): every
622    /// embedded v2 locale must ship `grammar-options` and `date-formats`, or
623    /// its typography/dates silently fall back to English. Scoped to just
624    /// the two completeness findings (not general lint errors) so this test
625    /// doesn't couple to unrelated pre-existing lint issues in other
626    /// embedded locales.
627    #[test]
628    fn embedded_v2_locales_pass_completeness_lint() {
629        for &id in crate::embedded::EMBEDDED_LOCALE_IDS {
630            let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
631            let raw: RawLocale =
632                serde_yaml::from_slice(bytes).expect("embedded locale should parse as RawLocale");
633
634            if raw.locale_schema_version.as_deref() != Some("2") {
635                continue;
636            }
637
638            let report = crate::lint::lint_raw_locale(&raw);
639            assert!(
640                !report
641                    .findings
642                    .iter()
643                    .any(|finding| finding.path == "grammar-options"),
644                "{id} is missing grammar-options"
645            );
646            assert!(
647                !report
648                    .findings
649                    .iter()
650                    .any(|finding| finding.path == "date-formats"),
651                "{id} is missing date-formats"
652            );
653        }
654    }
655
656    /// Round-trip regression guard for `Locale::en_us()` parsing the
657    /// embedded `en-US.yaml` asset: asserts the critical values a future
658    /// edit to that YAML could silently regress, since `en_us()` is the
659    /// fallback baseline for the large majority of embedded styles.
660    #[test]
661    fn en_us_locale_round_trip_carries_critical_values() {
662        let locale = Locale::en_us();
663
664        // Role labels (CSL reference: scripts/locales-en-US.xml).
665        assert_eq!(
666            locale.resolved_role_term(&ContributorRole::Translator, false, &TermForm::Short, None),
667            Some("trans.".to_string())
668        );
669
670        // Locator labels (CSL reference: chap./chaps.).
671        assert_eq!(
672            locale.locator_term(&LocatorType::Chapter, false, &TermForm::Short, None),
673            Some("chap.")
674        );
675        assert_eq!(
676            locale.locator_term(&LocatorType::Chapter, true, &TermForm::Short, None),
677            Some("chaps.")
678        );
679
680        // No-date term is form-aware (see general_term fix).
681        assert_eq!(
682            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
683            Some("no date")
684        );
685        assert_eq!(
686            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
687            Some("n.d.")
688        );
689
690        // Core general terms.
691        assert_eq!(locale.terms.and.as_deref(), Some("and"));
692        assert_eq!(locale.terms.et_al.as_deref(), Some("et al."));
693
694        // Month names.
695        assert_eq!(
696            locale.dates.months.long.first().map(String::as_str),
697            Some("January")
698        );
699
700        // Number formats (single-sourced explicitly in the YAML, Step 4).
701        assert_eq!(locale.number_formats.decimal_separator, ".");
702        assert_eq!(locale.number_formats.thousands_separator, ",");
703        assert_eq!(locale.number_formats.minimum_digits, 1);
704        assert_eq!(locale.number_formats.digit_system, DigitSystem::Western);
705
706        // Sort articles.
707        assert_eq!(locale.sort_articles, ["the", "a", "an"]);
708    }
709
710    #[test]
711    fn locale_number_formats_accept_each_supported_digit_system() {
712        for (digit_system, expected) in [
713            ("western", DigitSystem::Western),
714            ("arabic-indic", DigitSystem::ArabicIndic),
715            ("extended-arabic-indic", DigitSystem::ExtendedArabicIndic),
716            ("devanagari", DigitSystem::Devanagari),
717        ] {
718            let locale = Locale::from_yaml_str(&format!(
719                "locale: test\nnumber-formats:\n  digit-system: {digit_system}\n"
720            ))
721            .expect("locale should parse");
722
723            assert_eq!(locale.number_formats.digit_system, expected);
724        }
725    }
726}