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, skip_serializing_if = "HashMap::is_empty")]
63 #[cfg_attr(feature = "schema", schemars(skip))]
64 pub role_combinations: HashMap<String, ContributorTerm>,
65 #[serde(default)]
67 #[cfg_attr(feature = "schema", schemars(skip))]
68 pub locators: HashMap<LocatorType, LocatorTerm>,
69 #[serde(default)]
71 pub terms: Terms,
72 #[serde(default)]
75 pub punctuation_in_quote: bool,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub sort_articles: Vec<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub locale_schema_version: Option<String>,
83 #[serde(default)]
85 pub evaluation: EvaluationConfig,
86 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
88 pub messages: HashMap<String, String>,
89 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91 pub date_formats: HashMap<String, String>,
92 #[serde(default)]
94 pub number_formats: NumberFormats,
95 #[serde(default)]
97 pub grammar_options: GrammarOptions,
98 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
100 pub legacy_term_aliases: HashMap<String, String>,
101 #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
103 pub vocab: VocabMap,
104 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
110 pub type_terms: HashMap<String, SimpleTerm>,
111 #[serde(skip, default = "default_evaluator")]
113 #[cfg_attr(feature = "schema", schemars(skip))]
114 pub evaluator: Arc<dyn MessageEvaluator>,
115}
116
117fn default_evaluator() -> Arc<dyn MessageEvaluator> {
119 Arc::new(Mf2MessageEvaluator)
120}
121
122impl Default for Locale {
123 fn default() -> Self {
124 Self {
125 locale: String::default(),
126 dates: DateTerms::default(),
127 roles: HashMap::default(),
128 role_combinations: HashMap::default(),
129 locators: HashMap::default(),
130 terms: Terms::default(),
131 punctuation_in_quote: false,
132 sort_articles: Vec::default(),
133 locale_schema_version: None,
134 evaluation: EvaluationConfig::default(),
135 messages: HashMap::default(),
136 date_formats: HashMap::default(),
137 number_formats: NumberFormats::default(),
138 grammar_options: GrammarOptions::default(),
139 legacy_term_aliases: HashMap::default(),
140 vocab: VocabMap::default(),
141 type_terms: HashMap::default(),
142 evaluator: default_evaluator(),
143 }
144 }
145}
146
147impl fmt::Debug for Locale {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 f.debug_struct("Locale")
150 .field("locale", &self.locale)
151 .field("dates", &self.dates)
152 .field("roles", &self.roles)
153 .field("role_combinations", &self.role_combinations)
154 .field("locators", &self.locators)
155 .field("terms", &self.terms)
156 .field("punctuation_in_quote", &self.punctuation_in_quote)
157 .field("sort_articles", &self.sort_articles)
158 .field("locale_schema_version", &self.locale_schema_version)
159 .field("evaluation", &self.evaluation)
160 .field("messages", &self.messages)
161 .field("date_formats", &self.date_formats)
162 .field("number_formats", &self.number_formats)
163 .field("grammar_options", &self.grammar_options)
164 .field("legacy_term_aliases", &self.legacy_term_aliases)
165 .field("vocab", &self.vocab)
166 .field("type_terms", &self.type_terms)
167 .field("evaluator", &"<MessageEvaluator>")
168 .finish()
169 }
170}
171
172impl Locale {
173 #[allow(
197 clippy::expect_used,
198 reason = "Embedded en-US.yaml locale must parse; failure indicates a broken build, not bad input"
199 )]
200 pub fn en_us() -> Self {
201 static EN_US: std::sync::OnceLock<Locale> = std::sync::OnceLock::new();
202 EN_US
203 .get_or_init(|| {
204 let bytes = crate::embedded::get_locale_bytes("en-US")
205 .expect("en-US is a compile-time embedded locale");
206 let yaml = std::str::from_utf8(bytes).expect("embedded en-US.yaml is valid UTF-8");
207 let raw: RawLocale =
208 serde_yaml::from_str(yaml).expect("embedded en-US.yaml parses");
209 Self::from_raw_with_base(raw, Locale::default())
210 })
211 .clone()
212 }
213}
214
215#[cfg(test)]
216#[allow(
217 clippy::unwrap_used,
218 clippy::expect_used,
219 clippy::panic,
220 clippy::indexing_slicing,
221 clippy::todo,
222 clippy::unimplemented,
223 clippy::unreachable,
224 clippy::get_unwrap,
225 reason = "Panicking is acceptable and often desired in tests."
226)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn test_en_us_locale_model_defaults() {
232 let locale = Locale::en_us();
233 assert_eq!(locale.locale, "en-US");
234 assert!(locale.punctuation_in_quote);
235 assert_eq!(locale.sort_articles, ["the", "a", "an"]);
236 assert!(locale.roles.contains_key(&ContributorRole::Editor));
237 assert!(locale.locators.contains_key(&LocatorType::Page));
238 }
239
240 #[test]
241 fn test_locale_deserialization() {
242 let json = r#"{
243 "locale": "en-US",
244 "dates": {
245 "months": {
246 "long": ["January", "February", "March", "April", "May", "June",
247 "July", "August", "September", "October", "November", "December"],
248 "short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
249 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
250 },
251 "seasons": ["Spring", "Summer", "Autumn", "Winter"]
252 },
253 "roles": {},
254 "terms": {
255 "and": "and",
256 "et-al": "et al."
257 }
258 }"#;
259
260 let locale: Locale = serde_json::from_str(json).unwrap();
261 assert_eq!(locale.locale, "en-US");
262 assert_eq!(locale.dates.months.long[0], "January");
263 assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
264 }
265
266 #[test]
267 fn test_yaml_locale_loading() {
268 let yaml = r#"
269locale: de-DE
270dates:
271 months:
272 long:
273 - Januar
274 - Februar
275 - März
276 - April
277 - Mai
278 - Juni
279 - Juli
280 - August
281 - September
282 - Oktober
283 - November
284 - Dezember
285 short:
286 - Jan.
287 - Feb.
288 - März
289 - Apr.
290 - Mai
291 - Juni
292 - Juli
293 - Aug.
294 - Sep.
295 - Okt.
296 - Nov.
297 - Dez.
298 seasons:
299 - Frühling
300 - Sommer
301 - Herbst
302 - Winter
303terms:
304 and:
305 long: und
306 symbol: "&"
307 et_al:
308 long: "u. a."
309"#;
310
311 let locale = Locale::from_yaml_str(yaml).unwrap();
312 assert_eq!(locale.locale, "de-DE");
313 assert_eq!(locale.terms.and.as_deref(), Some("und"));
314 assert_eq!(locale.terms.et_al.as_deref(), Some("u. a."));
315 assert_eq!(locale.dates.months.long[0], "Januar");
316 assert_eq!(locale.dates.months.long[2], "März");
317 }
318
319 #[test]
321 fn test_v2_grammar_options_sync_punctuation_in_quote() {
322 let yaml = r#"
323locale-schema-version: "2"
324locale: en-GB
325grammar-options:
326 punctuation-in-quote: false
327"#;
328 let locale = Locale::from_yaml_str(yaml).unwrap();
329 assert!(!locale.grammar_options.punctuation_in_quote);
331 assert!(!locale.punctuation_in_quote);
333 }
334
335 #[test]
337 fn test_v1_locale_derives_punctuation_from_locale_id() {
338 let yaml = r#"
339locale: en-US
340"#;
341 let locale = Locale::from_yaml_str(yaml).unwrap();
342 assert!(locale.punctuation_in_quote);
344 assert!(locale.grammar_options.punctuation_in_quote);
345 }
346
347 #[test]
349 fn test_partial_locale_merges_raw_maps_with_base() {
350 let yaml = r#"
351locale-schema-version: "2"
352locale: zz-ZZ
353messages:
354 pattern.in-container: "inside {$container}"
355date-formats:
356 numeric-short: "dd/MM/y"
357locators:
358 page:
359 long:
360 singular: page-localized
361 plural: pages-localized
362legacy-term-aliases:
363 page: term.page-label-long
364"#;
365 let locale = Locale::from_yaml_str(yaml).unwrap();
366
367 assert_eq!(
368 locale
369 .messages
370 .get("pattern.originally-published-as")
371 .map(String::as_str),
372 Some("originally published as {$title}")
373 );
374 assert_eq!(
375 locale
376 .messages
377 .get("pattern.in-container")
378 .map(String::as_str),
379 Some("inside {$container}")
380 );
381 assert_eq!(
382 locale.date_formats.get("textual-full").map(String::as_str),
383 Some("MMMM d, yyyy")
384 );
385 assert_eq!(
386 locale.date_formats.get("numeric-short").map(String::as_str),
387 Some("dd/MM/y")
388 );
389 assert_eq!(
390 locale.legacy_term_aliases.get("and").map(String::as_str),
391 Some("term.and")
392 );
393 assert_eq!(
394 locale.legacy_term_aliases.get("page").map(String::as_str),
395 Some("term.page-label-long")
396 );
397 assert_eq!(
398 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Long, None),
399 Some("page-localized".to_string())
400 );
401 }
402
403 #[test]
405 fn test_apply_override_merges_messages() {
406 let mut locale = Locale::en_us();
407 locale
408 .messages
409 .insert("term.page-label".into(), "p.".into());
410 let ov = LocaleOverride {
411 messages: [("term.page-label".into(), "pg.".into())].into(),
412 ..Default::default()
413 };
414 locale.apply_override(&ov);
415 assert_eq!(
416 locale.messages.get("term.page-label").map(|s| s.as_str()),
417 Some("pg.")
418 );
419 }
420
421 #[test]
424 fn test_en_us_locale_resolves_phrase_messages() {
425 let locale = Locale::en_us();
426 let args = MessageArgs {
427 named: [("container".to_string(), "Book Title".to_string())].into(),
428 ..Default::default()
429 };
430
431 assert_eq!(
432 locale.resolve_message("pattern.in-container", &args),
433 Some("in Book Title".to_string())
434 );
435 }
436
437 #[test]
439 fn test_apply_override_grammar_options_syncs_punctuation() {
440 let mut locale = Locale::en_us();
441 locale.punctuation_in_quote = false;
442 let ov = LocaleOverride {
443 grammar_options: Some(GrammarOptions {
444 punctuation_in_quote: true,
445 ..Default::default()
446 }),
447 ..Default::default()
448 };
449 locale.apply_override(&ov);
450 assert!(locale.punctuation_in_quote);
451 assert!(locale.grammar_options.punctuation_in_quote);
452 }
453
454 #[test]
455 fn embedded_locale_ids_include_all_bundled_locale_files() {
456 for id in [
457 "en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR", "zh-CN", "ja-JP",
458 "ko-KR", "ru-RU",
459 ] {
460 assert!(
461 crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
462 "{id} should be listed as an embedded locale"
463 );
464 }
465 }
466
467 #[test]
468 fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
469 for id in ["ar-AR", "eu-ES"] {
470 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
471 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
472 let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
473
474 assert_eq!(locale.locale, id);
475 }
476 }
477
478 #[test]
482 fn bundled_ja_jp_ko_kr_ru_ru_locales_are_embedded_and_parseable() {
483 for (id, editor_short, and_term) in [
484 ("ja-JP", "編", "と"),
485 ("ko-KR", "편", "및"),
486 ("ru-RU", "ред.", "и"),
487 ] {
488 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
489 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
490 let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
491
492 assert_eq!(locale.locale, id);
493 assert_eq!(
494 locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
495 Some(editor_short.to_string()),
496 "{id} editor short-form role term"
497 );
498 assert_eq!(
499 locale.resolved_general_term(&GeneralTerm::And, &TermForm::Long, None),
500 Some(and_term.to_string()),
501 "{id} 'and' term"
502 );
503 assert!(
504 locale.date_formats.contains_key("iso"),
505 "{id} should carry date-formats"
506 );
507 }
508 }
509
510 #[test]
517 fn embedded_v2_locales_pass_completeness_lint() {
518 for &id in crate::embedded::EMBEDDED_LOCALE_IDS {
519 let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
520 let raw: RawLocale =
521 serde_yaml::from_slice(bytes).expect("embedded locale should parse as RawLocale");
522
523 if raw.locale_schema_version.as_deref() != Some("2") {
524 continue;
525 }
526
527 let report = crate::lint::lint_raw_locale(&raw);
528 assert!(
529 !report
530 .findings
531 .iter()
532 .any(|finding| finding.path == "grammar-options"),
533 "{id} is missing grammar-options"
534 );
535 assert!(
536 !report
537 .findings
538 .iter()
539 .any(|finding| finding.path == "date-formats"),
540 "{id} is missing date-formats"
541 );
542 }
543 }
544
545 #[test]
550 fn en_us_locale_round_trip_carries_critical_values() {
551 let locale = Locale::en_us();
552
553 assert_eq!(
555 locale.resolved_role_term(&ContributorRole::Translator, false, &TermForm::Short, None),
556 Some("trans.".to_string())
557 );
558
559 assert_eq!(
561 locale.locator_term(&LocatorType::Chapter, false, &TermForm::Short, None),
562 Some("chap.")
563 );
564 assert_eq!(
565 locale.locator_term(&LocatorType::Chapter, true, &TermForm::Short, None),
566 Some("chaps.")
567 );
568
569 assert_eq!(
571 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
572 Some("no date")
573 );
574 assert_eq!(
575 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
576 Some("n.d.")
577 );
578
579 assert_eq!(locale.terms.and.as_deref(), Some("and"));
581 assert_eq!(locale.terms.et_al.as_deref(), Some("et al."));
582
583 assert_eq!(
585 locale.dates.months.long.first().map(String::as_str),
586 Some("January")
587 );
588
589 assert_eq!(locale.number_formats.decimal_separator, ".");
591 assert_eq!(locale.number_formats.thousands_separator, ",");
592 assert_eq!(locale.number_formats.minimum_digits, 1);
593
594 assert_eq!(locale.sort_articles, ["the", "a", "an"]);
596 }
597}