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;
12mod embedded;
13/// Locator text normalization.
14pub mod locator;
15/// Message evaluation for parameterized locale strings.
16pub mod message;
17mod message_ids;
18/// Raw locale types used during locale file parsing.
19pub mod raw;
20mod raw_conversion;
21mod sort;
22mod terms;
23/// Structured locale types used by the processor.
24pub mod types;
25mod vocab;
26
27use crate::citation::LocatorType;
28use crate::template::ContributorRole;
29pub use message::{MessageArgs, MessageEvaluator, Mf2MessageEvaluator};
30pub use raw::{RawLocale, RawTermValue};
31#[cfg(feature = "schema")]
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::fmt;
36use std::sync::Arc;
37pub use terms::ArchiveHierarchyField;
38pub use types::*;
39
40/// A list of month names (12 elements for Jan-Dec).
41pub type MonthList = Vec<String>;
42
43/// A locale definition containing language-specific terms and formatting rules.
44///
45/// The `evaluator` field holds the message evaluation engine, selected based on
46/// `evaluation.message_syntax`. This allows for trait-based swapping to ICU4X
47/// implementations in the future without changing call sites.
48#[derive(Clone, Deserialize, Serialize)]
49#[cfg_attr(feature = "schema", derive(JsonSchema))]
50#[serde(rename_all = "kebab-case")]
51pub struct Locale {
52    /// The locale identifier (e.g., "en-US", "de-DE").
53    #[cfg_attr(feature = "schema", schemars(skip))]
54    pub locale: String,
55    /// Date-related terms (months, seasons).
56    #[serde(default)]
57    pub dates: DateTerms,
58    /// Contributor role terms (editor, translator, etc.).
59    #[serde(default)]
60    #[cfg_attr(feature = "schema", schemars(skip))]
61    pub roles: HashMap<ContributorRole, ContributorTerm>,
62    /// Locator terms (page, chapter, etc.).
63    #[serde(default)]
64    #[cfg_attr(feature = "schema", schemars(skip))]
65    pub locators: HashMap<LocatorType, LocatorTerm>,
66    /// General terms (and, et al., etc.).
67    #[serde(default)]
68    pub terms: Terms,
69    /// Whether to place periods/commas inside quotation marks.
70    /// true = American style ("text."), false = British style ("text".)
71    #[serde(default)]
72    pub punctuation_in_quote: bool,
73    /// Articles to strip from titles when sorting (e.g., "the", "a", "an" for English).
74    /// These should be lowercase and will be matched case-insensitively.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub sort_articles: Vec<String>,
77    /// Schema version from the source locale file (None = legacy v1).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub locale_schema_version: Option<String>,
80    /// Runtime evaluation configuration.
81    #[serde(default)]
82    pub evaluation: EvaluationConfig,
83    /// ICU MF1 messages keyed by message ID (populated for v2 locales).
84    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
85    pub messages: HashMap<String, String>,
86    /// Named date format presets: symbolic name → CLDR pattern.
87    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
88    pub date_formats: HashMap<String, String>,
89    /// Number formatting options.
90    #[serde(default)]
91    pub number_formats: NumberFormats,
92    /// Grammar options.
93    #[serde(default)]
94    pub grammar_options: GrammarOptions,
95    /// Backwards-compatibility aliases: old term key → new message ID.
96    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
97    pub legacy_term_aliases: HashMap<String, String>,
98    /// Vocabulary maps for genre and medium display text.
99    #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
100    pub vocab: VocabMap,
101    /// Message evaluator implementation (not serialized; set during load).
102    #[serde(skip, default = "default_evaluator")]
103    #[cfg_attr(feature = "schema", schemars(skip))]
104    pub evaluator: Arc<dyn MessageEvaluator>,
105}
106
107/// Default message evaluator (MF2).
108fn default_evaluator() -> Arc<dyn MessageEvaluator> {
109    Arc::new(Mf2MessageEvaluator)
110}
111
112impl Default for Locale {
113    fn default() -> Self {
114        Self {
115            locale: String::default(),
116            dates: DateTerms::default(),
117            roles: HashMap::default(),
118            locators: HashMap::default(),
119            terms: Terms::default(),
120            punctuation_in_quote: false,
121            sort_articles: Vec::default(),
122            locale_schema_version: None,
123            evaluation: EvaluationConfig::default(),
124            messages: HashMap::default(),
125            date_formats: HashMap::default(),
126            number_formats: NumberFormats::default(),
127            grammar_options: GrammarOptions::default(),
128            legacy_term_aliases: HashMap::default(),
129            vocab: VocabMap::default(),
130            evaluator: default_evaluator(),
131        }
132    }
133}
134
135impl fmt::Debug for Locale {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.debug_struct("Locale")
138            .field("locale", &self.locale)
139            .field("dates", &self.dates)
140            .field("roles", &self.roles)
141            .field("locators", &self.locators)
142            .field("terms", &self.terms)
143            .field("punctuation_in_quote", &self.punctuation_in_quote)
144            .field("sort_articles", &self.sort_articles)
145            .field("locale_schema_version", &self.locale_schema_version)
146            .field("evaluation", &self.evaluation)
147            .field("messages", &self.messages)
148            .field("date_formats", &self.date_formats)
149            .field("number_formats", &self.number_formats)
150            .field("grammar_options", &self.grammar_options)
151            .field("legacy_term_aliases", &self.legacy_term_aliases)
152            .field("vocab", &self.vocab)
153            .field("evaluator", &"<MessageEvaluator>")
154            .finish()
155    }
156}
157
158impl Locale {
159    /// Create a new English (US) locale with default terms.
160    pub fn en_us() -> Self {
161        Self {
162            locale: "en-US".into(),
163            dates: DateTerms::en_us(),
164            roles: embedded::en_us_role_terms(),
165            locators: embedded::en_us_locator_terms(),
166            terms: Terms::en_us(),
167            punctuation_in_quote: true,
168            sort_articles: vec!["the".into(), "a".into(), "an".into()],
169            locale_schema_version: None,
170            evaluation: EvaluationConfig {
171                message_syntax: MessageSyntax::Mf2,
172            },
173            messages: embedded::en_us_archive_messages(),
174            date_formats: HashMap::new(),
175            number_formats: NumberFormats {
176                decimal_separator: ".".into(),
177                thousands_separator: ",".into(),
178                minimum_digits: 1,
179            },
180            grammar_options: GrammarOptions {
181                punctuation_in_quote: true,
182                nbsp_before_colon: false,
183                open_quote: "\u{201C}".into(),
184                close_quote: "\u{201D}".into(),
185                open_inner_quote: "\u{2018}".into(),
186                close_inner_quote: "\u{2019}".into(),
187                serial_comma: true,
188                page_range_delimiter: "\u{2013}".into(),
189            },
190            legacy_term_aliases: HashMap::new(),
191            vocab: embedded::embedded_en_us_vocab().clone(),
192            evaluator: Arc::new(Mf2MessageEvaluator),
193        }
194    }
195}
196
197#[cfg(test)]
198#[allow(
199    clippy::unwrap_used,
200    clippy::expect_used,
201    clippy::panic,
202    clippy::indexing_slicing,
203    clippy::todo,
204    clippy::unimplemented,
205    clippy::unreachable,
206    clippy::get_unwrap,
207    reason = "Panicking is acceptable and often desired in tests."
208)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_en_us_locale_model_defaults() {
214        let locale = Locale::en_us();
215        assert_eq!(locale.locale, "en-US");
216        assert!(locale.punctuation_in_quote);
217        assert_eq!(locale.sort_articles, ["the", "a", "an"]);
218        assert!(locale.roles.contains_key(&ContributorRole::Editor));
219        assert!(locale.locators.contains_key(&LocatorType::Page));
220    }
221
222    #[test]
223    fn test_locale_deserialization() {
224        let json = r#"{
225            "locale": "en-US",
226            "dates": {
227                "months": {
228                    "long": ["January", "February", "March", "April", "May", "June",
229                             "July", "August", "September", "October", "November", "December"],
230                    "short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
231                              "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
232                },
233                "seasons": ["Spring", "Summer", "Autumn", "Winter"]
234            },
235            "roles": {},
236            "terms": {
237                "and": "and",
238                "et-al": "et al."
239            }
240        }"#;
241
242        let locale: Locale = serde_json::from_str(json).unwrap();
243        assert_eq!(locale.locale, "en-US");
244        assert_eq!(locale.dates.months.long[0], "January");
245        assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
246    }
247
248    #[test]
249    fn test_yaml_locale_loading() {
250        let yaml = r#"
251locale: de-DE
252dates:
253  months:
254    long:
255      - Januar
256      - Februar
257      - März
258      - April
259      - Mai
260      - Juni
261      - Juli
262      - August
263      - September
264      - Oktober
265      - November
266      - Dezember
267    short:
268      - Jan.
269      - Feb.
270      - März
271      - Apr.
272      - Mai
273      - Juni
274      - Juli
275      - Aug.
276      - Sep.
277      - Okt.
278      - Nov.
279      - Dez.
280  seasons:
281    - Frühling
282    - Sommer
283    - Herbst
284    - Winter
285terms:
286  and:
287    long: und
288    symbol: "&"
289  et_al:
290    long: "u. a."
291"#;
292
293        let locale = Locale::from_yaml_str(yaml).unwrap();
294        assert_eq!(locale.locale, "de-DE");
295        assert_eq!(locale.terms.and.as_deref(), Some("und"));
296        assert_eq!(locale.terms.et_al.as_deref(), Some("u. a."));
297        assert_eq!(locale.dates.months.long[0], "Januar");
298        assert_eq!(locale.dates.months.long[2], "März");
299    }
300
301    /// v2 locale with grammar-options overrides punctuation_in_quote correctly.
302    #[test]
303    fn test_v2_grammar_options_sync_punctuation_in_quote() {
304        let yaml = r#"
305locale-schema-version: "2"
306locale: en-GB
307grammar-options:
308  punctuation-in-quote: false
309"#;
310        let locale = Locale::from_yaml_str(yaml).unwrap();
311        // grammar_options is the authoritative source for v2 locales
312        assert!(!locale.grammar_options.punctuation_in_quote);
313        // legacy field is synced from grammar_options
314        assert!(!locale.punctuation_in_quote);
315    }
316
317    /// v1 locale (no grammar-options) derives punctuation_in_quote from locale ID.
318    #[test]
319    fn test_v1_locale_derives_punctuation_from_locale_id() {
320        let yaml = r#"
321locale: en-US
322"#;
323        let locale = Locale::from_yaml_str(yaml).unwrap();
324        // en-US uses American style (inside)
325        assert!(locale.punctuation_in_quote);
326        assert!(locale.grammar_options.punctuation_in_quote);
327    }
328
329    /// apply_override merges messages key-by-key into the base locale.
330    #[test]
331    fn test_apply_override_merges_messages() {
332        let mut locale = Locale::en_us();
333        locale
334            .messages
335            .insert("term.page-label".into(), "p.".into());
336        let ov = LocaleOverride {
337            messages: [("term.page-label".into(), "pg.".into())].into(),
338            ..Default::default()
339        };
340        locale.apply_override(&ov);
341        assert_eq!(
342            locale.messages.get("term.page-label").map(|s| s.as_str()),
343            Some("pg.")
344        );
345    }
346
347    /// The hardcoded en-US locale includes phrase messages used by style
348    /// `message:` components, not only legacy term compatibility messages.
349    #[test]
350    fn test_en_us_locale_resolves_phrase_messages() {
351        let locale = Locale::en_us();
352        let args = MessageArgs {
353            named: [("container".to_string(), "Book Title".to_string())].into(),
354            ..Default::default()
355        };
356
357        assert_eq!(
358            locale.resolve_message("pattern.in-container", &args),
359            Some("in Book Title".to_string())
360        );
361    }
362
363    /// apply_override with grammar_options replaces block and syncs punctuation_in_quote.
364    #[test]
365    fn test_apply_override_grammar_options_syncs_punctuation() {
366        let mut locale = Locale::en_us();
367        locale.punctuation_in_quote = false;
368        let ov = LocaleOverride {
369            grammar_options: Some(GrammarOptions {
370                punctuation_in_quote: true,
371                ..Default::default()
372            }),
373            ..Default::default()
374        };
375        locale.apply_override(&ov);
376        assert!(locale.punctuation_in_quote);
377        assert!(locale.grammar_options.punctuation_in_quote);
378    }
379
380    #[test]
381    fn embedded_locale_ids_include_all_bundled_locale_files() {
382        for id in [
383            "en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR",
384        ] {
385            assert!(
386                crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
387                "{id} should be listed as an embedded locale"
388            );
389        }
390    }
391
392    #[test]
393    fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
394        for id in ["ar-AR", "eu-ES"] {
395            let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
396            let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
397            let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
398
399            assert_eq!(locale.locale, id);
400        }
401    }
402}