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    /// True when this locale was substituted for one that could not be
119    /// resolved (e.g. a style declaring `en-GB` falling back to the embedded
120    /// `en-US` baseline). Runtime-only: never serialized, never part of the
121    /// locale schema. Consumers that treat locale data as authoritative for
122    /// deriving style defaults — grammar-option resolution in particular —
123    /// must not do so when this is set, since the data belongs to a
124    /// different language than the one that was asked for.
125    #[serde(skip)]
126    #[cfg_attr(feature = "schema", schemars(skip))]
127    pub resolved_by_fallback: bool,
128}
129
130/// Default message evaluator (MF2).
131fn 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    /// Create the English (US) locale, the fallback baseline every other
191    /// locale inherits from and the default for the majority of embedded
192    /// styles (which declare no `info.default-locale`).
193    ///
194    /// This parses the embedded canonical asset
195    /// (`embedded/locales/en-US.yaml`) so the YAML is the single source of
196    /// truth — there is no separate hand-maintained Rust copy to drift out
197    /// of sync with it. The parse is memoized in a `std::sync::OnceLock`
198    /// since it is pure and immutable; callers get a `clone()` of the cached
199    /// result (still a deep copy of its maps/vecs, but far cheaper than
200    /// re-parsing the YAML) rather than re-parsing on every call.
201    ///
202    /// Seeds from [`Locale::default()`] (not `from_raw`'s usual
203    /// `Locale::en_us()` seed) via `from_raw_with_base` to avoid infinite
204    /// recursion through this very function.
205    ///
206    /// # Panics
207    ///
208    /// Panics if the embedded `en-US.yaml` asset is missing, not valid
209    /// UTF-8, or fails to parse. This cannot happen at runtime: the asset is
210    /// embedded at compile time and covered by
211    /// `bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable`-style
212    /// tests, so a failure here indicates a broken build, not bad input.
213    #[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    /// Record whether this locale is what `requested` asked for.
232    ///
233    /// Loaders call this on every resolution outcome so downstream consumers
234    /// can tell a locale that was actually found from one substituted for a
235    /// request that could not be satisfied (see [`Locale::resolved_by_fallback`]).
236    /// Compares case-insensitively: BCP 47 language tags are case-insensitive
237    /// (`en-us` and `en-US` name the same locale), so a canonical-cased match
238    /// against a differently-cased request is not a fallback.
239    #[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    /// Create the Québec French locale by applying its regional typography to
246    /// the bundled French lexical locale.
247    ///
248    /// # Panics
249    ///
250    /// Panics if either embedded French locale asset fails to parse, which
251    /// indicates a broken build rather than invalid runtime input.
252    #[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    /// Build a rendering locale that speaks `item`'s terms, roles, locators,
275    /// messages, and date names/patterns inside `self`'s (the style's)
276    /// typography and identity.
277    ///
278    /// This is the `options.multilingual.term-locale: item` hybrid: "terms
279    /// are the item speaking; typography is the document speaking" (see
280    /// `docs/specs/PER_ITEM_TERM_LOCALE.md` §4). The field list is written
281    /// out explicitly, not built by cloning `self` and overwriting a few
282    /// fields, so that a field added to `Locale` later must be placed on one
283    /// side of the split deliberately rather than silently inheriting the
284    /// wrong one.
285    #[must_use]
286    pub fn with_term_surfaces_from(&self, item: &Locale) -> Locale {
287        Locale {
288            // Identity and typography: stay with the style locale. The id
289            // is also read as a data-translation target (multilingual
290            // titles/archive names) and for term-casing tailoring; both
291            // uses are out of scope for this switch (§4).
292            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            // Word and date surfaces: switch to the item locale.
301            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        // `Locale` derives `Deserialize` directly for its own canonical
346        // round-trip format (e.g. cache/IPC), which is always the
347        // EDTF-sub-year-code-keyed map — distinct from the raw-file loader
348        // (`RawLocale`/`from_yaml_str`), which additionally accepts the
349        // legacy sequence form. See `docs/specs/LOCALE_DATE_NAME_KEYING.md`.
350        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    /// Build an isolated locales directory under the system temp dir.
483    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        // No `en-GB.yaml` on disk: the prefix scan should match the `en`-prefixed
520        // file it does find (`en-US.yaml`) rather than the terminal `en_us()`
521        // fallback, but the result is still not what was requested.
522        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        // BCP 47 tags are case-insensitive: a lower-cased request that the
536        // prefix scan resolves to the canonically-cased file on disk is an
537        // exact match, not a substitution.
538        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    /// v2 locale with grammar-options overrides punctuation_in_quote correctly.
580    #[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        // grammar_options is the authoritative source for v2 locales
590        assert!(!locale.grammar_options.punctuation_in_quote);
591        // legacy field is synced from grammar_options
592        assert!(!locale.punctuation_in_quote);
593    }
594
595    /// v1 locale (no grammar-options) derives punctuation_in_quote from locale ID.
596    #[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        // en-US uses American style (inside)
603        assert!(locale.punctuation_in_quote);
604        assert!(locale.grammar_options.punctuation_in_quote);
605    }
606
607    /// Partial locales inherit base messages, date formats, and aliases.
608    #[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    /// apply_override merges messages key-by-key into the base locale.
664    #[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    /// The hardcoded en-US locale includes phrase messages used by style
682    /// `message:` components, not only legacy term compatibility messages.
683    #[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    /// apply_override with grammar_options replaces block and syncs punctuation_in_quote.
698    #[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    /// `apply_override` replaces only the named month, leaving the other
715    /// eleven (and all four seasons) untouched — the bean's core ask:
716    /// a style overrides one abbreviation without redeclaring the rest.
717    #[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    /// An out-of-range override key never reaches `apply_override` — it is
741    /// rejected at deserialize time (see `sub_year_code_rejects_out_of_range_key`
742    /// in `types.rs`), so `apply_override` itself has nothing to validate.
743    /// This test instead confirms a season override merges independently of
744    /// the month tables.
745    #[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    /// Every embedded locale round-trips through raw-YAML parsing to the
764    /// canonical EDTF-sub-year-code-keyed map without losing or duplicating
765    /// a month or season name, regardless of whether the source YAML uses
766    /// the legacy sequence form or the canonical map form.
767    #[test]
768    fn embedded_locales_have_complete_keyed_month_and_season_tables() {
769        // ar-AR has no authored short-month forms in its source YAML
770        // (a pre-existing content gap, unrelated to this keying change);
771        // every other bundled locale defines both full and abbreviated
772        // forms for all 12 months.
773        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    /// Round-trip regression guard for the new ja-JP/ko-KR/ru-RU locales
827    /// (`csl26-tfi8`): parses each embedded file and spot-checks a handful
828    /// of the values a future edit to that YAML could silently regress.
829    #[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    /// CI enforcement for the locale-completeness lint (`csl26-itri`): every
859    /// embedded v2 locale must ship `grammar-options` and `date-formats`, or
860    /// its typography/dates silently fall back to English. Scoped to just
861    /// the two completeness findings (not general lint errors) so this test
862    /// doesn't couple to unrelated pre-existing lint issues in other
863    /// embedded locales.
864    #[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    /// Round-trip regression guard for `Locale::en_us()` parsing the
894    /// embedded `en-US.yaml` asset: asserts the critical values a future
895    /// edit to that YAML could silently regress, since `en_us()` is the
896    /// fallback baseline for the large majority of embedded styles.
897    #[test]
898    fn en_us_locale_round_trip_carries_critical_values() {
899        let locale = Locale::en_us();
900
901        // Role labels (CSL reference: scripts/locales-en-US.xml).
902        assert_eq!(
903            locale.resolved_role_term(&ContributorRole::Translator, false, &TermForm::Short, None),
904            Some("trans.".to_string())
905        );
906
907        // Locator labels (CSL reference: chap./chaps.).
908        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        // No-date term is form-aware (see general_term fix).
918        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        // Core general terms.
928        assert_eq!(locale.terms.and.as_deref(), Some("and"));
929        assert_eq!(locale.terms.et_al.as_deref(), Some("et al."));
930
931        // Month names.
932        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        // Number formats (single-sourced explicitly in the YAML, Step 4).
943        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        // Sort articles.
949        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}