1mod date_patterns;
12mod embedded;
13pub mod locator;
15pub mod message;
17mod message_ids;
18pub mod raw;
20mod raw_conversion;
21mod sort;
22mod terms;
23pub 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
40pub type MonthList = Vec<String>;
42
43#[derive(Clone, Deserialize, Serialize)]
49#[cfg_attr(feature = "schema", derive(JsonSchema))]
50#[serde(rename_all = "kebab-case")]
51pub struct Locale {
52 #[cfg_attr(feature = "schema", schemars(skip))]
54 pub locale: String,
55 #[serde(default)]
57 pub dates: DateTerms,
58 #[serde(default)]
60 #[cfg_attr(feature = "schema", schemars(skip))]
61 pub roles: HashMap<ContributorRole, ContributorTerm>,
62 #[serde(default)]
64 #[cfg_attr(feature = "schema", schemars(skip))]
65 pub locators: HashMap<LocatorType, LocatorTerm>,
66 #[serde(default)]
68 pub terms: Terms,
69 #[serde(default)]
72 pub punctuation_in_quote: bool,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub sort_articles: Vec<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub locale_schema_version: Option<String>,
80 #[serde(default)]
82 pub evaluation: EvaluationConfig,
83 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
85 pub messages: HashMap<String, String>,
86 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
88 pub date_formats: HashMap<String, String>,
89 #[serde(default)]
91 pub number_formats: NumberFormats,
92 #[serde(default)]
94 pub grammar_options: GrammarOptions,
95 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
97 pub legacy_term_aliases: HashMap<String, String>,
98 #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
100 pub vocab: VocabMap,
101 #[serde(skip, default = "default_evaluator")]
103 #[cfg_attr(feature = "schema", schemars(skip))]
104 pub evaluator: Arc<dyn MessageEvaluator>,
105}
106
107fn 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 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 #[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 assert!(!locale.grammar_options.punctuation_in_quote);
313 assert!(!locale.punctuation_in_quote);
315 }
316
317 #[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 assert!(locale.punctuation_in_quote);
326 assert!(locale.grammar_options.punctuation_in_quote);
327 }
328
329 #[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 #[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 #[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}