1mod date_patterns;
12pub mod locator;
14pub mod message;
16mod message_ids;
17pub mod raw;
19mod raw_conversion;
20mod sort;
21mod terms;
22pub mod types;
24mod vocab;
25
26use crate::citation::LocatorType;
27use crate::template::ContributorRole;
28pub use message::{MessageArgs, MessageEvaluator, Mf2MessageEvaluator};
29pub use raw::{RawLocale, RawTermValue};
30#[cfg(feature = "schema")]
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::fmt;
35use std::sync::Arc;
36pub use terms::ArchiveHierarchyField;
37pub use types::*;
38
39pub type MonthList = Vec<String>;
41
42#[derive(Clone, Deserialize, Serialize)]
48#[cfg_attr(feature = "schema", derive(JsonSchema))]
49#[serde(rename_all = "kebab-case")]
50pub struct Locale {
51 #[cfg_attr(feature = "schema", schemars(skip))]
53 pub locale: String,
54 #[serde(default)]
56 pub dates: DateTerms,
57 #[serde(default)]
59 #[cfg_attr(feature = "schema", schemars(skip))]
60 pub roles: HashMap<ContributorRole, ContributorTerm>,
61 #[serde(default)]
63 #[cfg_attr(feature = "schema", schemars(skip))]
64 pub locators: HashMap<LocatorType, LocatorTerm>,
65 #[serde(default)]
67 pub terms: Terms,
68 #[serde(default)]
71 pub punctuation_in_quote: bool,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub sort_articles: Vec<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub locale_schema_version: Option<String>,
79 #[serde(default)]
81 pub evaluation: EvaluationConfig,
82 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
84 pub messages: HashMap<String, String>,
85 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
87 pub date_formats: HashMap<String, String>,
88 #[serde(default)]
90 pub number_formats: NumberFormats,
91 #[serde(default)]
93 pub grammar_options: GrammarOptions,
94 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
96 pub legacy_term_aliases: HashMap<String, String>,
97 #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
99 pub vocab: VocabMap,
100 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
106 pub type_terms: HashMap<String, SimpleTerm>,
107 #[serde(skip, default = "default_evaluator")]
109 #[cfg_attr(feature = "schema", schemars(skip))]
110 pub evaluator: Arc<dyn MessageEvaluator>,
111}
112
113fn default_evaluator() -> Arc<dyn MessageEvaluator> {
115 Arc::new(Mf2MessageEvaluator)
116}
117
118impl Default for Locale {
119 fn default() -> Self {
120 Self {
121 locale: String::default(),
122 dates: DateTerms::default(),
123 roles: HashMap::default(),
124 locators: HashMap::default(),
125 terms: Terms::default(),
126 punctuation_in_quote: false,
127 sort_articles: Vec::default(),
128 locale_schema_version: None,
129 evaluation: EvaluationConfig::default(),
130 messages: HashMap::default(),
131 date_formats: HashMap::default(),
132 number_formats: NumberFormats::default(),
133 grammar_options: GrammarOptions::default(),
134 legacy_term_aliases: HashMap::default(),
135 vocab: VocabMap::default(),
136 type_terms: HashMap::default(),
137 evaluator: default_evaluator(),
138 }
139 }
140}
141
142impl fmt::Debug for Locale {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 f.debug_struct("Locale")
145 .field("locale", &self.locale)
146 .field("dates", &self.dates)
147 .field("roles", &self.roles)
148 .field("locators", &self.locators)
149 .field("terms", &self.terms)
150 .field("punctuation_in_quote", &self.punctuation_in_quote)
151 .field("sort_articles", &self.sort_articles)
152 .field("locale_schema_version", &self.locale_schema_version)
153 .field("evaluation", &self.evaluation)
154 .field("messages", &self.messages)
155 .field("date_formats", &self.date_formats)
156 .field("number_formats", &self.number_formats)
157 .field("grammar_options", &self.grammar_options)
158 .field("legacy_term_aliases", &self.legacy_term_aliases)
159 .field("vocab", &self.vocab)
160 .field("type_terms", &self.type_terms)
161 .field("evaluator", &"<MessageEvaluator>")
162 .finish()
163 }
164}
165
166impl Locale {
167 #[allow(
191 clippy::expect_used,
192 reason = "Embedded en-US.yaml locale must parse; failure indicates a broken build, not bad input"
193 )]
194 pub fn en_us() -> Self {
195 static EN_US: std::sync::OnceLock<Locale> = std::sync::OnceLock::new();
196 EN_US
197 .get_or_init(|| {
198 let bytes = crate::embedded::get_locale_bytes("en-US")
199 .expect("en-US is a compile-time embedded locale");
200 let yaml = std::str::from_utf8(bytes).expect("embedded en-US.yaml is valid UTF-8");
201 let raw: RawLocale =
202 serde_yaml::from_str(yaml).expect("embedded en-US.yaml parses");
203 Self::from_raw_with_base(raw, Locale::default())
204 })
205 .clone()
206 }
207}
208
209#[cfg(test)]
210#[allow(
211 clippy::unwrap_used,
212 clippy::expect_used,
213 clippy::panic,
214 clippy::indexing_slicing,
215 clippy::todo,
216 clippy::unimplemented,
217 clippy::unreachable,
218 clippy::get_unwrap,
219 reason = "Panicking is acceptable and often desired in tests."
220)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn test_en_us_locale_model_defaults() {
226 let locale = Locale::en_us();
227 assert_eq!(locale.locale, "en-US");
228 assert!(locale.punctuation_in_quote);
229 assert_eq!(locale.sort_articles, ["the", "a", "an"]);
230 assert!(locale.roles.contains_key(&ContributorRole::Editor));
231 assert!(locale.locators.contains_key(&LocatorType::Page));
232 }
233
234 #[test]
235 fn test_locale_deserialization() {
236 let json = r#"{
237 "locale": "en-US",
238 "dates": {
239 "months": {
240 "long": ["January", "February", "March", "April", "May", "June",
241 "July", "August", "September", "October", "November", "December"],
242 "short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
243 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
244 },
245 "seasons": ["Spring", "Summer", "Autumn", "Winter"]
246 },
247 "roles": {},
248 "terms": {
249 "and": "and",
250 "et-al": "et al."
251 }
252 }"#;
253
254 let locale: Locale = serde_json::from_str(json).unwrap();
255 assert_eq!(locale.locale, "en-US");
256 assert_eq!(locale.dates.months.long[0], "January");
257 assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
258 }
259
260 #[test]
261 fn test_yaml_locale_loading() {
262 let yaml = r#"
263locale: de-DE
264dates:
265 months:
266 long:
267 - Januar
268 - Februar
269 - März
270 - April
271 - Mai
272 - Juni
273 - Juli
274 - August
275 - September
276 - Oktober
277 - November
278 - Dezember
279 short:
280 - Jan.
281 - Feb.
282 - März
283 - Apr.
284 - Mai
285 - Juni
286 - Juli
287 - Aug.
288 - Sep.
289 - Okt.
290 - Nov.
291 - Dez.
292 seasons:
293 - Frühling
294 - Sommer
295 - Herbst
296 - Winter
297terms:
298 and:
299 long: und
300 symbol: "&"
301 et_al:
302 long: "u. a."
303"#;
304
305 let locale = Locale::from_yaml_str(yaml).unwrap();
306 assert_eq!(locale.locale, "de-DE");
307 assert_eq!(locale.terms.and.as_deref(), Some("und"));
308 assert_eq!(locale.terms.et_al.as_deref(), Some("u. a."));
309 assert_eq!(locale.dates.months.long[0], "Januar");
310 assert_eq!(locale.dates.months.long[2], "März");
311 }
312
313 #[test]
315 fn test_v2_grammar_options_sync_punctuation_in_quote() {
316 let yaml = r#"
317locale-schema-version: "2"
318locale: en-GB
319grammar-options:
320 punctuation-in-quote: false
321"#;
322 let locale = Locale::from_yaml_str(yaml).unwrap();
323 assert!(!locale.grammar_options.punctuation_in_quote);
325 assert!(!locale.punctuation_in_quote);
327 }
328
329 #[test]
331 fn test_v1_locale_derives_punctuation_from_locale_id() {
332 let yaml = r#"
333locale: en-US
334"#;
335 let locale = Locale::from_yaml_str(yaml).unwrap();
336 assert!(locale.punctuation_in_quote);
338 assert!(locale.grammar_options.punctuation_in_quote);
339 }
340
341 #[test]
343 fn test_partial_locale_merges_raw_maps_with_base() {
344 let yaml = r#"
345locale-schema-version: "2"
346locale: zz-ZZ
347messages:
348 pattern.in-container: "inside {$container}"
349date-formats:
350 numeric-short: "dd/MM/y"
351locators:
352 page:
353 long:
354 singular: page-localized
355 plural: pages-localized
356legacy-term-aliases:
357 page: term.page-label-long
358"#;
359 let locale = Locale::from_yaml_str(yaml).unwrap();
360
361 assert_eq!(
362 locale
363 .messages
364 .get("pattern.originally-published-as")
365 .map(String::as_str),
366 Some("originally published as {$title}")
367 );
368 assert_eq!(
369 locale
370 .messages
371 .get("pattern.in-container")
372 .map(String::as_str),
373 Some("inside {$container}")
374 );
375 assert_eq!(
376 locale.date_formats.get("textual-full").map(String::as_str),
377 Some("MMMM d, yyyy")
378 );
379 assert_eq!(
380 locale.date_formats.get("numeric-short").map(String::as_str),
381 Some("dd/MM/y")
382 );
383 assert_eq!(
384 locale.legacy_term_aliases.get("and").map(String::as_str),
385 Some("term.and")
386 );
387 assert_eq!(
388 locale.legacy_term_aliases.get("page").map(String::as_str),
389 Some("term.page-label-long")
390 );
391 assert_eq!(
392 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Long, None),
393 Some("page-localized".to_string())
394 );
395 }
396
397 #[test]
399 fn test_apply_override_merges_messages() {
400 let mut locale = Locale::en_us();
401 locale
402 .messages
403 .insert("term.page-label".into(), "p.".into());
404 let ov = LocaleOverride {
405 messages: [("term.page-label".into(), "pg.".into())].into(),
406 ..Default::default()
407 };
408 locale.apply_override(&ov);
409 assert_eq!(
410 locale.messages.get("term.page-label").map(|s| s.as_str()),
411 Some("pg.")
412 );
413 }
414
415 #[test]
418 fn test_en_us_locale_resolves_phrase_messages() {
419 let locale = Locale::en_us();
420 let args = MessageArgs {
421 named: [("container".to_string(), "Book Title".to_string())].into(),
422 ..Default::default()
423 };
424
425 assert_eq!(
426 locale.resolve_message("pattern.in-container", &args),
427 Some("in Book Title".to_string())
428 );
429 }
430
431 #[test]
433 fn test_apply_override_grammar_options_syncs_punctuation() {
434 let mut locale = Locale::en_us();
435 locale.punctuation_in_quote = false;
436 let ov = LocaleOverride {
437 grammar_options: Some(GrammarOptions {
438 punctuation_in_quote: true,
439 ..Default::default()
440 }),
441 ..Default::default()
442 };
443 locale.apply_override(&ov);
444 assert!(locale.punctuation_in_quote);
445 assert!(locale.grammar_options.punctuation_in_quote);
446 }
447
448 #[test]
449 fn embedded_locale_ids_include_all_bundled_locale_files() {
450 for id in [
451 "en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR",
452 ] {
453 assert!(
454 crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
455 "{id} should be listed as an embedded locale"
456 );
457 }
458 }
459
460 #[test]
461 fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
462 for id in ["ar-AR", "eu-ES"] {
463 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
464 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
465 let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
466
467 assert_eq!(locale.locale, id);
468 }
469 }
470
471 #[test]
476 fn en_us_locale_round_trip_carries_critical_values() {
477 let locale = Locale::en_us();
478
479 assert_eq!(
481 locale.resolved_role_term(&ContributorRole::Translator, false, &TermForm::Short, None),
482 Some("trans.".to_string())
483 );
484
485 assert_eq!(
487 locale.locator_term(&LocatorType::Chapter, false, &TermForm::Short, None),
488 Some("chap.")
489 );
490 assert_eq!(
491 locale.locator_term(&LocatorType::Chapter, true, &TermForm::Short, None),
492 Some("chaps.")
493 );
494
495 assert_eq!(
497 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
498 Some("no date")
499 );
500 assert_eq!(
501 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
502 Some("n.d.")
503 );
504
505 assert_eq!(locale.terms.and.as_deref(), Some("and"));
507 assert_eq!(locale.terms.et_al.as_deref(), Some("et al."));
508
509 assert_eq!(
511 locale.dates.months.long.first().map(String::as_str),
512 Some("January")
513 );
514
515 assert_eq!(locale.number_formats.decimal_separator, ".");
517 assert_eq!(locale.number_formats.thousands_separator, ",");
518 assert_eq!(locale.number_formats.minimum_digits, 1);
519
520 assert_eq!(locale.sort_articles, ["the", "a", "an"]);
522 }
523}