Skip to main content

citum_schema_style/locale/
raw_conversion.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Conversion from [`raw::RawLocale`] (serde-facing schema) into the runtime
7//! [`Locale`] type, plus the file/YAML/JSON/CBOR loaders that drive it.
8//!
9//! Parsing helpers (`extract_*`, `parse_*`, `from_raw_gendered_string`) live
10//! alongside `from_raw` so a reader sees the whole transformation in one
11//! place. Public Locale APIs unrelated to raw conversion stay in `mod.rs`.
12
13use super::Locale;
14use super::message::{MessageEvaluator, Mf2MessageEvaluator, NoOpEvaluator};
15use super::raw;
16use super::types::{
17    ContributorTerm, DateTerms, LocaleOverride, LocatorTerm, MaybeGendered, MessageSyntax,
18    MonthNames, SimpleTerm, SingularPlural, TermForm,
19};
20use crate::citation::LocatorType;
21use crate::template::ContributorRole;
22use std::collections::HashMap;
23use std::sync::Arc;
24
25impl Locale {
26    /// Load a locale from a YAML string.
27    ///
28    /// # Errors
29    ///
30    /// Returns an error when the YAML cannot be parsed into a locale.
31    pub fn from_yaml_str(yaml: &str) -> Result<Self, String> {
32        let raw: raw::RawLocale = serde_yaml::from_str(yaml)
33            .map_err(|e| format!("Failed to parse locale YAML: {}", e))?;
34
35        Ok(Self::from_raw(raw))
36    }
37
38    /// Load a locale by ID (e.g., "en-US", "de-DE") from a locales directory.
39    /// Falls back to en-US if the locale file is not found.
40    pub fn load(locale_id: &str, locales_dir: &std::path::Path) -> Self {
41        let extensions = ["yaml", "yml", "json", "cbor"];
42
43        for ext in &extensions {
44            let file_name = format!("{}.{}", locale_id, ext);
45            let file_path = locales_dir.join(&file_name);
46
47            if file_path.exists() {
48                match Self::from_file(&file_path) {
49                    Ok(locale) => return locale,
50                    Err(e) => {
51                        eprintln!(
52                            "Warning: Failed to load locale {}.{}: {}",
53                            locale_id, ext, e
54                        );
55                    }
56                }
57            }
58        }
59
60        if locale_id.contains('-') {
61            let base = locale_id.split('-').next().unwrap_or("en");
62            if let Ok(entries) = std::fs::read_dir(locales_dir) {
63                for entry in entries.flatten() {
64                    let name = entry.file_name();
65                    let name_str = name.to_string_lossy();
66                    if (name_str.starts_with(base)
67                        && extensions.iter().any(|ext| name_str.ends_with(ext)))
68                        && let Ok(locale) = Self::from_file(&entry.path())
69                    {
70                        return locale;
71                    }
72                }
73            }
74        }
75
76        Self::en_us()
77    }
78
79    /// Load locale from a file path directly (detects format).
80    ///
81    /// # Errors
82    ///
83    /// Returns an error when the file cannot be read or its contents cannot be
84    /// parsed as a supported locale format.
85    pub fn from_file(path: &std::path::Path) -> Result<Self, String> {
86        let bytes =
87            std::fs::read(path).map_err(|e| format!("Failed to read locale file: {}", e))?;
88        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
89
90        match ext {
91            "cbor" => ciborium::de::from_reader::<raw::RawLocale, _>(std::io::Cursor::new(&bytes))
92                .map(Self::from_raw)
93                .map_err(|e| format!("Failed to parse CBOR locale: {}", e)),
94            "json" => serde_json::from_slice::<raw::RawLocale>(&bytes)
95                .map(Self::from_raw)
96                .map_err(|e| format!("Failed to parse JSON locale: {}", e)),
97            _ => {
98                let content = String::from_utf8_lossy(&bytes);
99                Self::from_yaml_str(&content)
100            }
101        }
102    }
103
104    /// Convert a RawLocale to a Locale, seeding from [`Locale::en_us()`].
105    ///
106    /// This is the inheritance model non-en-US locales rely on: a partial
107    /// locale file (e.g. `ar-AR`, `eu-ES`) only overrides the fields it
108    /// specifies, and everything else falls back to the English baseline —
109    /// but the inheritance is field-granular, not uniform. `roles`,
110    /// `locators`, `terms`, `vocab`, `messages`, `date_formats`, and
111    /// `legacy_term_aliases` are merged key-by-key into the base, so an
112    /// omitted key keeps the base's value while an explicit raw entry
113    /// overrides it. Inherited message fallbacks do not shadow structured
114    /// terms supplied by the raw locale.
115    fn from_raw(raw: raw::RawLocale) -> Self {
116        Self::from_raw_with_base(raw, Locale::en_us())
117    }
118
119    /// Convert a RawLocale to a Locale, seeding from the given `base` locale
120    /// instead of always inheriting from [`Locale::en_us()`].
121    ///
122    /// This exists so [`Locale::en_us()`] itself can parse the embedded
123    /// `en-US.yaml` without infinite recursion: it seeds from
124    /// [`Locale::default()`] (a non-circular, fully-formed empty locale)
125    /// rather than from `en_us()`. Non-en-US locale loading is unaffected —
126    /// [`Locale::from_raw`] still calls this with `Locale::en_us()` as the
127    /// base, preserving partial-locale inheritance behavior.
128    #[allow(
129        clippy::too_many_lines,
130        reason = "Complex parsing of raw locale data with multiple term types"
131    )]
132    pub(super) fn from_raw_with_base(raw: raw::RawLocale, base: Self) -> Self {
133        let punctuation_in_quote = raw.locale.starts_with("en-US")
134            || (raw.locale.starts_with("en") && !raw.locale.starts_with("en-GB"));
135
136        let mut locale = base;
137        locale.locale = raw.locale.clone();
138        Self::remove_base_messages_shadowed_by_raw_terms(&raw, &mut locale.messages);
139        locale.dates = DateTerms {
140            months: MonthNames {
141                long: raw.dates.months.long,
142                short: raw.dates.months.short,
143            },
144            seasons: raw.dates.seasons,
145            uncertainty_term: raw.dates.uncertainty_term,
146            open_ended_term: raw.dates.open_ended_term,
147            am: raw.dates.am,
148            pm: raw.dates.pm,
149            timezone_utc: raw.dates.timezone_utc,
150            before_era: raw.dates.before_era,
151            ad: raw.dates.ad,
152            bc: raw.dates.bc,
153            bce: raw.dates.bce,
154            ce: raw.dates.ce,
155        };
156        locale.punctuation_in_quote = punctuation_in_quote;
157        locale.sort_articles = Self::default_articles_for_locale(&raw.locale);
158
159        locale.locale_schema_version = raw.locale_schema_version;
160        locale.evaluation = raw.evaluation.unwrap_or_default();
161        locale.messages.extend(raw.messages);
162        locale.date_formats.extend(raw.date_formats);
163        locale.legacy_term_aliases.extend(raw.legacy_term_aliases);
164
165        if let Some(raw_vocab) = raw.vocab {
166            locale.vocab.genre.extend(raw_vocab.genre);
167            locale.vocab.medium.extend(raw_vocab.medium);
168        }
169
170        if let Some(go) = raw.grammar_options {
171            locale.grammar_options = go;
172        } else {
173            locale.grammar_options.punctuation_in_quote = locale.punctuation_in_quote;
174        }
175        locale.punctuation_in_quote = locale.grammar_options.punctuation_in_quote;
176        locale.punctuation_realization = raw.punctuation_realization;
177
178        if let Some(nf) = raw.number_formats {
179            locale.number_formats = nf;
180        }
181
182        let explicit_locator_keys: std::collections::HashSet<LocatorType> = raw
183            .locators
184            .keys()
185            .filter_map(|key| Self::parse_builtin_locator_type(key))
186            .collect();
187
188        for (key, value) in &raw.locators {
189            if let Some(locator_type) = Self::parse_locator_type(key) {
190                let locator_term = LocatorTerm {
191                    long: Self::extract_singular_plural(value.long.as_ref().as_ref()),
192                    short: Self::extract_singular_plural(value.short.as_ref().as_ref()),
193                    symbol: Self::extract_singular_plural(value.symbol.as_ref().as_ref()),
194                    gender: value.gender.clone(),
195                };
196                locale.locators.insert(locator_type, locator_term);
197            }
198        }
199
200        for (key, value) in &raw.terms {
201            if let Some(locator_type) = Self::parse_builtin_locator_type(key)
202                && !explicit_locator_keys.contains(&locator_type)
203                && let Some(forms) = Self::get_forms(value)
204            {
205                let locator_term = LocatorTerm {
206                    long: Self::extract_singular_plural(forms.get("long").as_ref()),
207                    short: Self::extract_singular_plural(forms.get("short").as_ref()),
208                    symbol: Self::extract_singular_plural(forms.get("symbol").as_ref()),
209                    gender: None,
210                };
211                locale.locators.insert(locator_type, locator_term);
212                continue;
213            }
214
215            match key.as_str() {
216                "and" => {
217                    if let Some(forms) = Self::get_forms(value) {
218                        if let Some(v) = forms.get("long").and_then(|v| v.as_string()) {
219                            locale.terms.and = Some(v.to_string());
220                        }
221                        if let Some(v) = forms.get("symbol").and_then(|v| v.as_string()) {
222                            locale.terms.and_symbol = Some(v.to_string());
223                        }
224                    }
225                }
226                "et_al" => {
227                    if let Some(forms) = Self::get_forms(value)
228                        && let Some(v) = forms.get("long").and_then(|v| v.as_string())
229                    {
230                        locale.terms.et_al = Some(v.to_string());
231                    }
232                }
233                "and others" | "and_others" => {
234                    if let Some(forms) = Self::get_forms(value)
235                        && let Some(v) = forms.get("long").and_then(|v| v.as_string())
236                    {
237                        locale.terms.and_others = Some(v.to_string());
238                    }
239                }
240                "accessed" => {
241                    if let Some(forms) = Self::get_forms(value)
242                        && let Some(v) = forms.get("long").and_then(|v| v.as_string())
243                    {
244                        locale.terms.accessed = Some(v.to_string());
245                    }
246                }
247                "ibid" => {
248                    if let Some(forms) = Self::get_forms(value)
249                        && let Some(v) = forms.get("long").and_then(|v| v.as_string())
250                    {
251                        locale.terms.ibid = Some(v.to_string());
252                    }
253                }
254                "no date" => {
255                    let simple = Self::extract_simple_term_from_raw(value);
256                    let short_fallback = simple.short.as_default_str().to_string();
257                    locale
258                        .terms
259                        .general
260                        .insert(super::types::GeneralTerm::NoDate, simple);
261                    locale.terms.no_date.get_or_insert(short_fallback);
262                }
263                "no_date" => {
264                    let simple = Self::extract_simple_term_from_raw(value);
265                    locale.terms.no_date = Some(simple.short.as_str().to_string());
266                    locale
267                        .terms
268                        .general
269                        .entry(super::types::GeneralTerm::NoDate)
270                        .or_insert(simple);
271                }
272                _ => {
273                    if let Some(general_term) = Self::parse_general_term(key) {
274                        let simple = Self::extract_simple_term_from_raw(value);
275                        locale.terms.general.insert(general_term, simple);
276                    } else {
277                        let normalized = Self::normalize_term_key(key);
278                        if Self::is_known_type_term_key(&normalized) {
279                            let simple = Self::extract_simple_term_from_raw(value);
280                            locale.type_terms.insert(normalized, simple);
281                        }
282                    }
283                }
284            }
285        }
286
287        for (key, role_term) in &raw.roles {
288            let contributor_term = ContributorTerm {
289                singular: Self::extract_simple_term(&role_term.long, &role_term.short, false),
290                plural: Self::extract_simple_term(&role_term.long, &role_term.short, true),
291                verb: Self::extract_verb_term(&role_term.verb, &role_term.verb_short),
292            };
293            if let Some(role) = Self::parse_role_name(key) {
294                locale.roles.insert(role, contributor_term);
295            } else {
296                let canonical = if key == "editortranslator" {
297                    "editor-translator".to_string()
298                } else {
299                    Self::normalize_term_key(key)
300                };
301                locale.role_combinations.insert(canonical, contributor_term);
302            }
303        }
304
305        locale.evaluator = match locale.evaluation.message_syntax {
306            MessageSyntax::Mf2 => Arc::new(Mf2MessageEvaluator) as Arc<dyn MessageEvaluator>,
307            MessageSyntax::Static => Arc::new(NoOpEvaluator),
308        };
309
310        locale
311    }
312
313    /// Get default articles for a locale based on language code.
314    fn default_articles_for_locale(locale_id: &str) -> Vec<String> {
315        #[allow(clippy::string_slice, reason = "locale_id is expected to be ASCII")]
316        let lang = &locale_id[..2.min(locale_id.len())];
317        match lang {
318            "en" => vec!["the".into(), "a".into(), "an".into()],
319            "de" => vec![
320                "der".into(),
321                "die".into(),
322                "das".into(),
323                "ein".into(),
324                "eine".into(),
325            ],
326            "fr" => vec![
327                "le".into(),
328                "la".into(),
329                "les".into(),
330                "l'".into(),
331                "un".into(),
332                "une".into(),
333            ],
334            "es" => vec![
335                "el".into(),
336                "la".into(),
337                "los".into(),
338                "las".into(),
339                "un".into(),
340                "una".into(),
341            ],
342            "it" => vec![
343                "il".into(),
344                "lo".into(),
345                "la".into(),
346                "i".into(),
347                "gli".into(),
348                "le".into(),
349                "un".into(),
350                "una".into(),
351            ],
352            "pt" => vec![
353                "o".into(),
354                "a".into(),
355                "os".into(),
356                "as".into(),
357                "um".into(),
358                "uma".into(),
359            ],
360            "nl" => vec!["de".into(), "het".into(), "een".into()],
361            _ => vec![],
362        }
363    }
364
365    fn get_forms(value: &raw::RawTermValue) -> Option<&HashMap<String, raw::RawTermValue>> {
366        match value {
367            raw::RawTermValue::Forms(forms) => Some(forms),
368            _ => None,
369        }
370    }
371
372    fn parse_locator_type(name: &str) -> Option<LocatorType> {
373        LocatorType::from_key(name).ok()
374    }
375
376    fn parse_builtin_locator_type(name: &str) -> Option<LocatorType> {
377        match Self::parse_locator_type(name)? {
378            LocatorType::Custom(_) => None,
379            locator => Some(locator),
380        }
381    }
382
383    fn parse_role_name(name: &str) -> Option<ContributorRole> {
384        match name {
385            "author" => Some(ContributorRole::Author),
386            "chair" => Some(ContributorRole::Chair),
387            "editor" => Some(ContributorRole::Editor),
388            "translator" => Some(ContributorRole::Translator),
389            "annotator" => Some(ContributorRole::Annotator),
390            "commentator" => Some(ContributorRole::Commentator),
391            "foreword-author" => Some(ContributorRole::ForewordAuthor),
392            "introduction-author" => Some(ContributorRole::IntroductionAuthor),
393            "afterword-author" => Some(ContributorRole::AfterwordAuthor),
394            "director" => Some(ContributorRole::Director),
395            "compiler" => Some(ContributorRole::Composer),
396            "illustrator" => Some(ContributorRole::Illustrator),
397            "collection-editor" => Some(ContributorRole::CollectionEditor),
398            "container-author" => Some(ContributorRole::ContainerAuthor),
399            "editorial-director" => Some(ContributorRole::EditorialDirector),
400            "textual-editor" | "textual_editor" => Some(ContributorRole::TextualEditor),
401            "interviewer" => Some(ContributorRole::Interviewer),
402            "original-author" => Some(ContributorRole::OriginalAuthor),
403            "recipient" => Some(ContributorRole::Recipient),
404            "reviewed-author" => Some(ContributorRole::ReviewedAuthor),
405            "performer" => Some(ContributorRole::Performer),
406            "composer" => Some(ContributorRole::Composer),
407            "writer" => Some(ContributorRole::Writer),
408            "producer" => Some(ContributorRole::Producer),
409            _ => None,
410        }
411    }
412
413    fn remove_base_messages_shadowed_by_raw_terms(
414        raw: &raw::RawLocale,
415        messages: &mut HashMap<String, String>,
416    ) {
417        for key in raw.locators.keys() {
418            if let Some(locator) = Self::parse_builtin_locator_type(key) {
419                for form in [TermForm::Long, TermForm::Short] {
420                    if let Some(message_id) = Self::locator_message_id(&locator, &form) {
421                        messages.remove(message_id);
422                    }
423                }
424            }
425        }
426
427        for key in raw.roles.keys() {
428            if let Some(role) = Self::parse_role_name(key) {
429                for form in [
430                    TermForm::Long,
431                    TermForm::Short,
432                    TermForm::Verb,
433                    TermForm::VerbShort,
434                ] {
435                    if let Some(message_id) = Self::role_message_id(&role, &form) {
436                        messages.remove(message_id);
437                    }
438                }
439            }
440        }
441
442        for key in raw.terms.keys() {
443            if let Some(term) = Self::parse_general_term(key) {
444                for form in [TermForm::Long, TermForm::Short] {
445                    if let Some(message_id) = Self::general_message_id(&term, &form) {
446                        messages.remove(message_id);
447                    }
448                }
449            }
450        }
451    }
452
453    fn extract_singular_plural(value: Option<&&raw::RawTermValue>) -> Option<SingularPlural> {
454        match value {
455            Some(raw::RawTermValue::SingularPlural { singular, plural }) => Some(SingularPlural {
456                singular: Self::from_raw_gendered_string(singular),
457                plural: Self::from_raw_gendered_string(plural),
458            }),
459            Some(raw::RawTermValue::Simple(s)) => Some(SingularPlural {
460                singular: MaybeGendered::Plain(s.clone()),
461                plural: MaybeGendered::Plain(s.clone()),
462            }),
463            Some(raw::RawTermValue::Gendered {
464                masculine,
465                feminine,
466                neuter,
467                common,
468            }) => Some(SingularPlural {
469                singular: MaybeGendered::Gendered {
470                    masculine: masculine.clone(),
471                    feminine: feminine.clone(),
472                    neuter: neuter.clone(),
473                    common: common.clone(),
474                },
475                plural: MaybeGendered::Gendered {
476                    masculine: masculine.clone(),
477                    feminine: feminine.clone(),
478                    neuter: neuter.clone(),
479                    common: common.clone(),
480                },
481            }),
482            Some(raw::RawTermValue::Forms(forms)) => {
483                let singular = forms
484                    .get("singular")
485                    .map(Self::extract_maybe_gendered_string);
486                let plural = forms.get("plural").map(Self::extract_maybe_gendered_string);
487
488                singular.map(|s| SingularPlural {
489                    plural: plural.unwrap_or_else(|| s.clone()),
490                    singular: s,
491                })
492            }
493            _ => None,
494        }
495    }
496
497    fn extract_simple_term(
498        long: &Option<raw::RawTermValue>,
499        short: &Option<raw::RawTermValue>,
500        plural: bool,
501    ) -> SimpleTerm {
502        let long_str = long
503            .as_ref()
504            .map(|v| Self::extract_simple_gendered_term(v, plural))
505            .unwrap_or_default();
506
507        let short_str = short
508            .as_ref()
509            .map(|v| Self::extract_simple_gendered_term(v, plural))
510            .unwrap_or_default();
511
512        SimpleTerm {
513            long: long_str,
514            short: short_str,
515        }
516    }
517
518    fn extract_verb_term(
519        verb: &Option<raw::RawTermValue>,
520        verb_short: &Option<raw::RawTermValue>,
521    ) -> SimpleTerm {
522        let long_str = verb
523            .as_ref()
524            .and_then(|v| v.as_string())
525            .unwrap_or("")
526            .into();
527
528        let short_str = verb_short
529            .as_ref()
530            .and_then(|v| v.as_string())
531            .unwrap_or("")
532            .into();
533
534        SimpleTerm {
535            long: long_str,
536            short: short_str,
537        }
538    }
539
540    /// Normalize a locale term key to canonical kebab-case.
541    ///
542    /// Locale YAML files and style templates may use underscores or spaces
543    /// interchangeably with hyphens (e.g. `no_date`, `no date`, `no-date`).
544    /// This helper converts all three forms to the single canonical
545    /// kebab-case key so `parse_general_term` only needs to match one pattern
546    /// per term.
547    fn normalize_term_key(s: &str) -> String {
548        s.replace(['_', ' '], "-")
549    }
550
551    /// Parse a locale term key into a structured general-term identifier.
552    pub fn parse_general_term(name: &str) -> Option<super::types::GeneralTerm> {
553        use super::types::GeneralTerm;
554        match Self::normalize_term_key(name).as_str() {
555            "in" => Some(GeneralTerm::In),
556            "accessed" => Some(GeneralTerm::Accessed),
557            "cited" => Some(GeneralTerm::Cited),
558            "retrieved" => Some(GeneralTerm::Retrieved),
559            "at" => Some(GeneralTerm::At),
560            "from" => Some(GeneralTerm::From),
561            "of" => Some(GeneralTerm::Of),
562            "to" => Some(GeneralTerm::To),
563            "by" => Some(GeneralTerm::By),
564            "no-date" => Some(GeneralTerm::NoDate),
565            "anonymous" => Some(GeneralTerm::Anonymous),
566            "circa" => Some(GeneralTerm::Circa),
567            "available-at" => Some(GeneralTerm::AvailableAt),
568            "ibid" => Some(GeneralTerm::Ibid),
569            "and" => Some(GeneralTerm::And),
570            "role-conjunction" => Some(GeneralTerm::RoleConjunction),
571            "et-al" => Some(GeneralTerm::EtAl),
572            "and-others" => Some(GeneralTerm::AndOthers),
573            "forthcoming" => Some(GeneralTerm::Forthcoming),
574            "online" => Some(GeneralTerm::Online),
575            "here" => Some(GeneralTerm::Here),
576            "deposited" => Some(GeneralTerm::Deposited),
577            "review-of" => Some(GeneralTerm::ReviewOf),
578            "original-work-published" => Some(GeneralTerm::OriginalWorkPublished),
579            "personal-communication" => Some(GeneralTerm::PersonalCommunication),
580            "patent" => Some(GeneralTerm::Patent),
581            "issued" => Some(GeneralTerm::Issued),
582            "volume" => Some(GeneralTerm::Volume),
583            "issue" => Some(GeneralTerm::Issue),
584            "page" => Some(GeneralTerm::Page),
585            "chapter" => Some(GeneralTerm::Chapter),
586            "edition" => Some(GeneralTerm::Edition),
587            "section" => Some(GeneralTerm::Section),
588            "version" => Some(GeneralTerm::Version),
589            _ => None,
590        }
591    }
592
593    /// Reference-type description term keys recognized for
594    /// [`Locale::type_terms`] (the `type-label` component's fallback data).
595    ///
596    /// Legacy locale files (`terms:`) carry a wide mix of CSL 1.0 term keys
597    /// — general terms, locator labels, and reference-type descriptions —
598    /// under one flat map. General terms and locators are already claimed by
599    /// `parse_general_term` and `parse_builtin_locator_type` above; this is
600    /// an explicit allowlist of the *remaining* keys that are genuinely
601    /// reference-type descriptions, cross-referenced against the finite set
602    /// of strings `citum_schema_data`'s `Reference::ref_type()` can actually
603    /// produce (see `citum-engine/src/values/type_class.rs` module docs).
604    ///
605    /// This is deliberately an explicit list, not "capture anything
606    /// unclaimed" — the unclaimed remainder also includes unrelated dead
607    /// data (era terms, punctuation terms, number-variable labels like
608    /// `version`/`printing`) that must not leak into `type_terms`.
609    ///
610    /// Membership here only makes `en-US.yaml`'s existing term *reachable*;
611    /// it does not guarantee every `ref_type()` output has a term to reach.
612    /// A handful of real `ref_type()` outputs currently have no authored
613    /// term in `en-US.yaml` at all — `book`, `brief`, `bill-proceeding`,
614    /// `bill-record`, `chapter`, `figure`, `manual`, `statute` — so
615    /// `type-label` falls back to an empty label for those types today.
616    /// That is a locale-content gap (nothing to allowlist), not a bug in
617    /// this function; only `dataset` is exercised by a shipped style so far.
618    fn is_known_type_term_key(normalized_key: &str) -> bool {
619        const KNOWN_TYPE_TERM_KEYS: &[&str] = &[
620            "article-journal",
621            "article-magazine",
622            "article-newspaper",
623            "broadcast",
624            "classic",
625            "collection",
626            "dataset",
627            "document",
628            "entry",
629            "entry-dictionary",
630            "entry-encyclopedia",
631            "event",
632            "graphic",
633            "hearing",
634            "interview",
635            "legal-case",
636            "legislation",
637            "manuscript",
638            "map",
639            "motion-picture",
640            "musical-score",
641            "pamphlet",
642            "paper-conference",
643            "performance",
644            "periodical",
645            "personal-communication",
646            "post",
647            "post-weblog",
648            "preprint",
649            "regulation",
650            "report",
651            "review",
652            "review-book",
653            "software",
654            "song",
655            "speech",
656            "standard",
657            "thesis",
658            "treaty",
659            "webpage",
660        ];
661        KNOWN_TYPE_TERM_KEYS.contains(&normalized_key)
662    }
663
664    fn extract_simple_term_from_raw(value: &raw::RawTermValue) -> SimpleTerm {
665        match value {
666            raw::RawTermValue::Simple(s) => SimpleTerm {
667                long: s.clone().into(),
668                short: s.clone().into(),
669            },
670            raw::RawTermValue::Gendered {
671                masculine,
672                feminine,
673                neuter,
674                common,
675            } => SimpleTerm {
676                long: MaybeGendered::Gendered {
677                    masculine: masculine.clone(),
678                    feminine: feminine.clone(),
679                    neuter: neuter.clone(),
680                    common: common.clone(),
681                },
682                short: MaybeGendered::Gendered {
683                    masculine: masculine.clone(),
684                    feminine: feminine.clone(),
685                    neuter: neuter.clone(),
686                    common: common.clone(),
687                },
688            },
689            raw::RawTermValue::Forms(forms) => {
690                let long = forms
691                    .get("long")
692                    .map(Self::extract_maybe_gendered_string)
693                    .unwrap_or_default();
694                let short = forms
695                    .get("short")
696                    .map(Self::extract_maybe_gendered_string)
697                    .unwrap_or_else(|| long.clone());
698                SimpleTerm { long, short }
699            }
700            raw::RawTermValue::SingularPlural { singular, .. } => SimpleTerm {
701                long: Self::from_raw_gendered_string(singular),
702                short: Self::from_raw_gendered_string(singular),
703            },
704        }
705    }
706
707    fn from_raw_gendered_string(value: &raw::RawGenderedString) -> MaybeGendered<String> {
708        match value {
709            raw::RawGenderedString::Simple(value) => MaybeGendered::Plain(value.clone()),
710            raw::RawGenderedString::Gendered {
711                masculine,
712                feminine,
713                neuter,
714                common,
715            } => MaybeGendered::Gendered {
716                masculine: masculine.clone(),
717                feminine: feminine.clone(),
718                neuter: neuter.clone(),
719                common: common.clone(),
720            },
721        }
722    }
723
724    fn extract_maybe_gendered_string(value: &raw::RawTermValue) -> MaybeGendered<String> {
725        match value {
726            raw::RawTermValue::Simple(value) => MaybeGendered::Plain(value.clone()),
727            raw::RawTermValue::Gendered {
728                masculine,
729                feminine,
730                neuter,
731                common,
732            } => MaybeGendered::Gendered {
733                masculine: masculine.clone(),
734                feminine: feminine.clone(),
735                neuter: neuter.clone(),
736                common: common.clone(),
737            },
738            raw::RawTermValue::SingularPlural { singular, .. } => {
739                Self::from_raw_gendered_string(singular)
740            }
741            raw::RawTermValue::Forms(forms) => forms
742                .get("long")
743                .or_else(|| forms.get("singular"))
744                .map(Self::extract_maybe_gendered_string)
745                .unwrap_or_default(),
746        }
747    }
748
749    fn extract_simple_gendered_term(
750        value: &raw::RawTermValue,
751        plural: bool,
752    ) -> MaybeGendered<String> {
753        match value {
754            raw::RawTermValue::Simple(value) => MaybeGendered::Plain(value.clone()),
755            raw::RawTermValue::Gendered {
756                masculine,
757                feminine,
758                neuter,
759                common,
760            } => MaybeGendered::Gendered {
761                masculine: masculine.clone(),
762                feminine: feminine.clone(),
763                neuter: neuter.clone(),
764                common: common.clone(),
765            },
766            raw::RawTermValue::SingularPlural {
767                singular,
768                plural: plural_value,
769            } => {
770                if plural {
771                    Self::from_raw_gendered_string(plural_value)
772                } else {
773                    Self::from_raw_gendered_string(singular)
774                }
775            }
776            raw::RawTermValue::Forms(forms) => {
777                let key = if plural { "plural" } else { "singular" };
778                forms
779                    .get(key)
780                    .or_else(|| forms.get("long"))
781                    .map(Self::extract_maybe_gendered_string)
782                    .unwrap_or_default()
783            }
784        }
785    }
786
787    /// Apply a partial override, merging its fields into this locale.
788    ///
789    /// Performs key-by-key insertion or replacement for:
790    /// - `messages`: new or updated message IDs
791    /// - `grammar_options`: if `Some`, replaces the entire block and syncs
792    ///   `punctuation_in_quote` field
793    /// - `legacy_term_aliases`: new or updated term aliases
794    pub fn apply_override(&mut self, ov: &LocaleOverride) {
795        for (k, v) in &ov.messages {
796            self.messages.insert(k.clone(), v.clone());
797        }
798        if let Some(go) = &ov.grammar_options {
799            self.grammar_options = go.clone();
800            self.punctuation_in_quote = go.punctuation_in_quote;
801        }
802        for (k, v) in &ov.legacy_term_aliases {
803            self.legacy_term_aliases.insert(k.clone(), v.clone());
804        }
805    }
806}