1use crate::citation::LocatorType;
7use crate::template::ContributorRole;
8
9use super::Locale;
10use super::types::{GeneralTerm, GrammaticalGender, MaybeGendered, SimpleTerm, TermForm};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum ArchiveHierarchyField {
15 Collection,
17 Series,
19 Box,
21 Folder,
23 Item,
25}
26
27impl ArchiveHierarchyField {
28 fn message_id(self) -> &'static str {
30 match self {
31 Self::Collection => "term.archive-collection-label",
32 Self::Series => "term.archive-series-label",
33 Self::Box => "term.archive-box-label",
34 Self::Folder => "term.archive-folder-label",
35 Self::Item => "term.archive-item-label",
36 }
37 }
38}
39
40impl Locale {
41 fn resolve_gendered_value(
43 value: &MaybeGendered<String>,
44 requested_gender: Option<GrammaticalGender>,
45 ) -> Option<&str> {
46 value
47 .resolve_with_fallback(requested_gender)
48 .map(String::as_str)
49 }
50
51 fn resolve_gendered_value_neutral(value: &MaybeGendered<String>) -> Option<&str> {
52 value.resolve_neutral().map(String::as_str)
53 }
54
55 fn resolve_no_date_value<'a>(
56 value: &'a SimpleTerm,
57 form: &TermForm,
58 requested_gender: Option<GrammaticalGender>,
59 ) -> Option<&'a str> {
60 match requested_gender {
61 Some(GrammaticalGender::Common) => match *form {
62 TermForm::Long => value
63 .long
64 .resolve_strict(Some(GrammaticalGender::Common))
65 .map(String::as_str),
66 TermForm::Short => value
67 .short
68 .resolve_strict(Some(GrammaticalGender::Common))
69 .map(String::as_str)
70 .filter(|value| !value.is_empty())
71 .or_else(|| {
72 value
73 .long
74 .resolve_strict(Some(GrammaticalGender::Common))
75 .map(String::as_str)
76 }),
77 _ => value
78 .long
79 .resolve_strict(Some(GrammaticalGender::Common))
80 .map(String::as_str),
81 },
82 _ => match *form {
83 TermForm::Long => Self::resolve_gendered_value(&value.long, requested_gender),
84 TermForm::Short => {
85 Self::resolve_gendered_value(&value.short, requested_gender.clone())
86 .filter(|value| !value.is_empty())
87 .or_else(|| Self::resolve_gendered_value(&value.long, requested_gender))
88 }
89 _ => Self::resolve_gendered_value(&value.long, requested_gender),
90 },
91 }
92 }
93
94 pub fn role_term(
96 &self,
97 role: &ContributorRole,
98 plural: bool,
99 form: &TermForm,
100 requested_gender: Option<GrammaticalGender>,
101 ) -> Option<&str> {
102 let term = self.roles.get(role)?;
103 let simple = if plural { &term.plural } else { &term.singular };
104 let term_text = match *form {
105 TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
106 TermForm::Short => {
107 Self::resolve_gendered_value(&simple.short, requested_gender.clone())
108 .filter(|value| !value.is_empty())
109 .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
110 }
111 TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, None),
112 TermForm::VerbShort => Self::resolve_gendered_value(&term.verb.short, None)
113 .filter(|value| !value.is_empty())
114 .or_else(|| Self::resolve_gendered_value(&term.verb.long, None)),
115 _ => Self::resolve_gendered_value(&simple.long, requested_gender),
116 };
117
118 match term_text {
119 Some(value) if !value.is_empty() => Some(value),
120 _ => None,
121 }
122 }
123
124 pub fn role_term_neutral(
126 &self,
127 role: &ContributorRole,
128 plural: bool,
129 form: &TermForm,
130 ) -> Option<&str> {
131 let term = self.roles.get(role)?;
132 let simple = if plural { &term.plural } else { &term.singular };
133 let term_text = match *form {
134 TermForm::Long => Self::resolve_gendered_value_neutral(&simple.long),
135 TermForm::Short => Self::resolve_gendered_value_neutral(&simple.short)
136 .filter(|value| !value.is_empty())
137 .or_else(|| Self::resolve_gendered_value_neutral(&simple.long)),
138 TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, None),
139 TermForm::VerbShort => Self::resolve_gendered_value(&term.verb.short, None)
140 .filter(|value| !value.is_empty())
141 .or_else(|| Self::resolve_gendered_value(&term.verb.long, None)),
142 _ => Self::resolve_gendered_value_neutral(&simple.long),
143 };
144
145 match term_text {
146 Some(value) if !value.is_empty() => Some(value),
147 _ => None,
148 }
149 }
150
151 pub fn resolved_role_term(
153 &self,
154 role: &ContributorRole,
155 plural: bool,
156 form: &TermForm,
157 requested_gender: Option<GrammaticalGender>,
158 ) -> Option<String> {
159 if let Some(message_id) = Self::role_message_id(role, form)
160 && let Some(resolved) = self.resolve_message_text(
161 message_id,
162 Some(u64::from(plural) + 1),
163 requested_gender.clone(),
164 )
165 {
166 return Some(resolved);
167 }
168
169 self.role_term(role, plural, form, requested_gender)
170 .map(ToOwned::to_owned)
171 }
172
173 pub fn resolved_role_term_neutral(
175 &self,
176 role: &ContributorRole,
177 plural: bool,
178 form: &TermForm,
179 ) -> Option<String> {
180 if let Some(message_id) = Self::role_message_id(role, form)
181 && let Some(resolved) = self.resolve_message_text(
182 message_id,
183 Some(u64::from(plural) + 1),
184 Some(GrammaticalGender::Common),
185 )
186 {
187 return Some(resolved);
188 }
189
190 self.role_term_neutral(role, plural, form)
191 .map(ToOwned::to_owned)
192 }
193
194 pub fn locator_term(
196 &self,
197 locator: &LocatorType,
198 plural: bool,
199 form: &TermForm,
200 requested_gender: Option<GrammaticalGender>,
201 ) -> Option<&str> {
202 let term = self.locators.get(locator)?;
203 let form_term = match *form {
204 TermForm::Long => &term.long,
205 TermForm::Short => &term.short,
206 TermForm::Symbol => &term.symbol,
207 _ => &term.short, };
209
210 if let Some(ft) = form_term {
211 let value = if plural { &ft.plural } else { &ft.singular };
212 Self::resolve_gendered_value(value, requested_gender)
213 } else {
214 None
215 }
216 }
217
218 pub fn resolved_locator_term(
220 &self,
221 locator: &LocatorType,
222 plural: bool,
223 form: &TermForm,
224 requested_gender: Option<GrammaticalGender>,
225 ) -> Option<String> {
226 if let Some(message_id) = Self::locator_message_id(locator, form)
227 && let Some(resolved) = self.resolve_message_text(
228 message_id,
229 Some(u64::from(plural) + 1),
230 requested_gender.clone(),
231 )
232 {
233 return Some(resolved);
234 }
235
236 self.locator_term(locator, plural, form, requested_gender.clone())
237 .map(ToOwned::to_owned)
238 .or_else(|| {
239 if let LocatorType::Custom(key) = locator {
240 self.locator_term_any_form(locator, plural, requested_gender)
241 .map(ToOwned::to_owned)
242 .or_else(|| Some(key.clone()))
243 } else {
244 None
245 }
246 })
247 }
248
249 fn locator_term_any_form(
250 &self,
251 locator: &LocatorType,
252 plural: bool,
253 requested_gender: Option<GrammaticalGender>,
254 ) -> Option<&str> {
255 let term = self.locators.get(locator)?;
256 [&term.long, &term.short, &term.symbol]
257 .into_iter()
258 .flatten()
259 .next()
260 .map(|forms| {
261 if plural {
262 Self::resolve_gendered_value(&forms.plural, requested_gender).unwrap_or("")
263 } else {
264 Self::resolve_gendered_value(&forms.singular, requested_gender).unwrap_or("")
265 }
266 })
267 .filter(|value| !value.is_empty())
268 }
269
270 pub fn general_term(
272 &self,
273 term: &GeneralTerm,
274 form: &TermForm,
275 requested_gender: Option<GrammaticalGender>,
276 ) -> Option<&str> {
277 if let Some(candidate_id) = Self::general_message_id(term, form)
285 && let Some(msg) = self.messages.get(candidate_id)
286 {
287 if !msg.contains('{') {
289 return Some(msg.as_str());
290 }
291 }
292 let legacy_key = Self::general_term_to_legacy_key(term);
294 if let Some(msg_id) = self.legacy_term_aliases.get(legacy_key)
295 && let Some(msg) = self.messages.get(msg_id)
296 && !msg.contains('{')
297 {
298 return Some(msg.as_str());
299 }
300
301 if *term != GeneralTerm::NoDate
303 && let Some(simple) = self.terms.general.get(term)
304 {
305 return match *form {
306 TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
307 TermForm::Short => {
308 Self::resolve_gendered_value(&simple.short, requested_gender.clone())
309 .filter(|value| !value.is_empty())
310 .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
311 }
312
313 _ => Self::resolve_gendered_value(&simple.long, requested_gender),
314 };
315 }
316
317 match term {
319 GeneralTerm::And => self.terms.and.as_deref(),
320 GeneralTerm::EtAl => self.terms.et_al.as_deref(),
321 GeneralTerm::AndOthers => self.terms.and_others.as_deref(),
322 GeneralTerm::Accessed => self.terms.accessed.as_deref(),
323 GeneralTerm::Ibid => self.terms.ibid.as_deref(),
324 GeneralTerm::In => self.terms.in_.as_deref(),
325 GeneralTerm::NoDate => self
326 .terms
327 .general
328 .get(term)
329 .and_then(|value| Self::resolve_no_date_value(value, form, requested_gender))
330 .or(self.terms.no_date.as_deref()),
331 GeneralTerm::Retrieved => self.terms.retrieved.as_deref(),
332 GeneralTerm::At => self.terms.at.as_deref(),
333 GeneralTerm::By => self.terms.by.as_deref(),
334 GeneralTerm::From => self.terms.from.as_deref(),
335 GeneralTerm::Of => self
336 .terms
337 .general
338 .get(term)
339 .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
340 GeneralTerm::To => self
341 .terms
342 .general
343 .get(term)
344 .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
345 GeneralTerm::Anonymous => {
346 Self::resolve_gendered_value(&self.terms.anonymous.long, requested_gender)
347 }
348 GeneralTerm::Circa => {
349 Self::resolve_gendered_value(&self.terms.circa.long, requested_gender)
350 }
351 GeneralTerm::Volume => {
353 self.locator_term(&LocatorType::Volume, false, form, requested_gender)
354 }
355 GeneralTerm::Issue => {
356 self.locator_term(&LocatorType::Issue, false, form, requested_gender)
357 }
358 GeneralTerm::Page => {
359 self.locator_term(&LocatorType::Page, false, form, requested_gender)
360 }
361 GeneralTerm::Chapter => {
362 self.locator_term(&LocatorType::Chapter, false, form, requested_gender)
363 }
364 GeneralTerm::Section => {
365 self.locator_term(&LocatorType::Section, false, form, requested_gender)
366 }
367 GeneralTerm::Here => self
368 .terms
369 .general
370 .get(term)
371 .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
372 GeneralTerm::Deposited => self
373 .terms
374 .general
375 .get(term)
376 .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
377 _ => None,
378 }
379 }
380
381 pub fn resolved_general_term(
383 &self,
384 term: &GeneralTerm,
385 form: &TermForm,
386 requested_gender: Option<GrammaticalGender>,
387 ) -> Option<String> {
388 if let Some(message_id) = Self::general_message_id(term, form)
389 && let Some(resolved) =
390 self.resolve_message_text(message_id, None, requested_gender.clone())
391 {
392 return Some(resolved);
393 }
394
395 self.general_term(term, form, requested_gender)
396 .map(ToOwned::to_owned)
397 }
398
399 #[must_use]
407 pub fn resolved_type_term(&self, ref_type: &str, form: &TermForm) -> Option<String> {
408 let simple = self.type_terms.get(ref_type)?;
409 match form {
410 TermForm::Short => Self::resolve_gendered_value(&simple.short, None)
411 .filter(|value| !value.is_empty())
412 .or_else(|| Self::resolve_gendered_value(&simple.long, None)),
413 _ => Self::resolve_gendered_value(&simple.long, None),
414 }
415 .map(ToOwned::to_owned)
416 }
417
418 pub fn resolved_archive_term(&self, field: ArchiveHierarchyField) -> Option<String> {
421 self.resolve_message_text(field.message_id(), Some(1), None)
422 }
423
424 pub fn and_term(&self, use_symbol: bool) -> &str {
426 if use_symbol {
427 self.terms.and_symbol.as_deref().unwrap_or("&")
428 } else {
429 self.terms.and.as_deref().unwrap_or("and")
430 }
431 }
432
433 pub fn et_al(&self) -> &str {
435 self.terms.et_al.as_deref().unwrap_or("et al.")
436 }
437
438 pub fn month_name(&self, month: u8, short: bool) -> &str {
440 let idx = (month.saturating_sub(1)) as usize;
441 if short {
442 self.dates
443 .months
444 .short
445 .get(idx)
446 .map(|s| s.as_str())
447 .unwrap_or("")
448 } else {
449 self.dates
450 .months
451 .long
452 .get(idx)
453 .map(|s| s.as_str())
454 .unwrap_or("")
455 }
456 }
457}
458
459#[cfg(test)]
460#[allow(
461 clippy::unwrap_used,
462 clippy::expect_used,
463 clippy::panic,
464 clippy::indexing_slicing,
465 clippy::todo,
466 clippy::unimplemented,
467 clippy::unreachable,
468 clippy::get_unwrap,
469 reason = "Panicking is acceptable and often desired in tests."
470)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn test_en_us_locale_terms() {
476 let locale = Locale::en_us();
477 assert_eq!(locale.and_term(false), "and");
478 assert_eq!(locale.and_term(true), "&");
479 assert_eq!(locale.et_al(), "et al.");
480 }
481
482 #[test]
483 fn test_month_names() {
484 let locale = Locale::en_us();
485 assert_eq!(locale.month_name(1, false), "January");
486 assert_eq!(locale.month_name(1, true), "Jan.");
487 assert_eq!(locale.month_name(12, false), "December");
488 }
489
490 #[test]
491 fn test_role_terms() {
492 let locale = Locale::en_us();
493
494 assert_eq!(
495 locale.role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
496 Some("ed.")
497 );
498 assert_eq!(
499 locale.role_term(&ContributorRole::Editor, true, &TermForm::Short, None),
500 Some("eds.")
501 );
502 assert_eq!(
503 locale.role_term(&ContributorRole::Translator, false, &TermForm::Verb, None),
504 Some("translated by")
505 );
506 }
507
508 #[test]
509 fn test_no_date_term_resolves_long_and_short_forms() {
510 let locale = Locale::en_us();
511
512 assert_eq!(
513 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
514 Some("no date")
515 );
516 assert_eq!(
517 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
518 Some("n.d.")
519 );
520 }
521
522 #[test]
523 fn test_no_date_term_falls_back_to_legacy_short_form() {
524 let mut locale = Locale::default();
525 locale.terms.no_date = Some("n.d.".to_string());
526
527 assert_eq!(
528 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
529 Some("n.d.")
530 );
531 assert_eq!(
532 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
533 Some("n.d.")
534 );
535 }
536
537 #[test]
538 fn test_yaml_no_date_term_preserves_long_and_short_forms() {
539 let yaml = r#"
540locale: en-US
541dates:
542 months:
543 long: [January, February, March, April, May, June, July, August, September, October, November, December]
544 short: [Jan., Feb., Mar., Apr., May, June, July, Aug., Sept., Oct., Nov., Dec.]
545 seasons: [Spring, Summer, Autumn, Winter]
546roles: {}
547terms:
548 no date:
549 long: no date
550 short: n.d.
551"#;
552
553 let locale = Locale::from_yaml_str(yaml).unwrap();
554 assert_eq!(
555 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
556 Some("no date")
557 );
558 assert_eq!(
559 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
560 Some("n.d.")
561 );
562 assert_eq!(locale.terms.no_date.as_deref(), Some("n.d."));
563 }
564
565 #[test]
566 fn test_resolved_locator_term_evaluates_plural_message() {
567 let locale = Locale::en_us();
568
569 assert_eq!(
570 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
571 Some("p.".to_string())
572 );
573 assert_eq!(
574 locale.resolved_locator_term(&LocatorType::Page, true, &TermForm::Short, None),
575 Some("pp.".to_string())
576 );
577 }
578
579 #[test]
580 fn test_resolved_locator_term_falls_back_to_custom_locale_form_then_raw_key() {
581 let locale = Locale::from_yaml_str(
582 r#"
583locale: en-US
584locators:
585 reel:
586 long:
587 singular: "reel"
588 plural: "reels"
589"#,
590 )
591 .expect("custom locale should parse");
592
593 assert_eq!(
594 locale.resolved_locator_term(
595 &LocatorType::Custom("reel".to_string()),
596 false,
597 &TermForm::Short,
598 None,
599 ),
600 Some("reel".to_string())
601 );
602 assert_eq!(
603 locale.resolved_locator_term(
604 &LocatorType::Custom("movement".to_string()),
605 false,
606 &TermForm::Short,
607 None,
608 ),
609 Some("movement".to_string())
610 );
611 }
612
613 #[test]
614 fn test_legacy_locator_terms_under_terms_still_populate_locators() {
615 let locale = Locale::from_yaml_str(
616 r#"
617locale: en-US
618terms:
619 page:
620 short:
621 singular: "pg."
622 plural: "pgs."
623"#,
624 )
625 .expect("legacy locator terms should parse");
626
627 assert_eq!(
628 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
629 Some("pg.".to_string())
630 );
631 }
632
633 #[test]
634 fn test_explicit_locators_override_legacy_terms_for_builtin_keys() {
635 let locale = Locale::from_yaml_str(
636 r#"
637locale: en-US
638terms:
639 page:
640 short:
641 singular: "pg."
642 plural: "pgs."
643locators:
644 page:
645 short:
646 singular: "p."
647 plural: "pp."
648"#,
649 )
650 .expect("mixed locator forms should parse");
651
652 assert_eq!(
653 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
654 Some("p.".to_string())
655 );
656 }
657
658 #[test]
659 fn test_non_locator_terms_are_not_reclassified_as_custom_locators() {
660 let locale = Locale::from_yaml_str(
661 r#"
662locale: en-US
663terms:
664 and:
665 long: "und"
666"#,
667 )
668 .expect("general terms should parse");
669
670 assert_eq!(locale.terms.and.as_deref(), Some("und"));
671 assert!(
672 !locale
673 .locators
674 .contains_key(&LocatorType::Custom("and".to_string()))
675 );
676 }
677
678 #[test]
679 fn test_resolved_role_term_evaluates_plural_message() {
680 let locale = Locale::en_us();
681
682 assert_eq!(
683 locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Long, None),
684 Some("editor".to_string())
685 );
686 assert_eq!(
687 locale.resolved_role_term(&ContributorRole::Editor, true, &TermForm::Long, None),
688 Some("editors".to_string())
689 );
690 }
691
692 #[test]
693 fn test_role_term_prefers_common_form_for_mixed_gender_requests() {
694 let locale = Locale::from_yaml_str(
695 r#"
696locale: es-ES
697roles:
698 editor:
699 long:
700 singular:
701 masculine: editor
702 feminine: editora
703 common: persona editora
704 plural:
705 masculine: editores
706 feminine: editoras
707 common: equipo editorial
708 short:
709 singular: ed.
710 plural: eds.
711 verb: editado por
712"#,
713 )
714 .expect("gendered locale should parse");
715
716 assert_eq!(
717 locale.role_term(
718 &ContributorRole::Editor,
719 false,
720 &TermForm::Long,
721 Some(GrammaticalGender::Feminine),
722 ),
723 Some("editora")
724 );
725 assert_eq!(
726 locale.role_term(
727 &ContributorRole::Editor,
728 true,
729 &TermForm::Long,
730 Some(GrammaticalGender::Common),
731 ),
732 Some("equipo editorial")
733 );
734 }
735
736 #[test]
737 fn test_no_date_term_falls_back_when_requested_gender_has_no_matching_slot() {
738 let locale = Locale::from_yaml_str(
739 r#"
740locale: es-ES
741terms:
742 no date:
743 long:
744 masculine: sin fecha
745 no_date: s. f.
746"#,
747 )
748 .expect("locale should parse");
749
750 assert_eq!(
751 locale.general_term(
752 &GeneralTerm::NoDate,
753 &TermForm::Long,
754 Some(GrammaticalGender::Common),
755 ),
756 Some("s. f.")
757 );
758 }
759
760 #[test]
761 fn test_es_es_locale_is_embedded() {
762 let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
763 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
764 let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
765
766 assert_eq!(locale.locale, "es-ES");
767 assert_eq!(
768 locale.resolved_role_term(
769 &ContributorRole::Editor,
770 false,
771 &TermForm::Long,
772 Some(GrammaticalGender::Feminine),
773 ),
774 Some("editora".to_string())
775 );
776 }
777
778 #[test]
779 fn test_es_es_role_term_resolves_gendered_mf2_message() {
780 let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
781 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
782 let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
783
784 assert_eq!(
785 locale.resolved_role_term(
786 &ContributorRole::Editor,
787 true,
788 &TermForm::Long,
789 Some(GrammaticalGender::Masculine),
790 ),
791 Some("editores".to_string())
792 );
793 assert_eq!(
794 locale.resolved_role_term(
795 &ContributorRole::Translator,
796 true,
797 &TermForm::Long,
798 Some(GrammaticalGender::Feminine),
799 ),
800 Some("traductoras".to_string())
801 );
802 assert_eq!(
803 locale.resolved_role_term_neutral(&ContributorRole::Editor, true, &TermForm::Long),
804 Some("equipo editorial".to_string())
805 );
806 }
807
808 #[test]
809 fn test_role_term_falls_back_when_mf2_message_cannot_evaluate() {
810 let locale = Locale::from_yaml_str(
811 r#"
812locale: es-ES
813evaluation:
814 message-syntax: mf2
815messages:
816 role.editor.label-long: |
817 .match {$gender :unknown} {$count :plural}
818 when feminine one {editora}
819roles:
820 editor:
821 long:
822 singular:
823 feminine: editora heredada
824 plural:
825 feminine: editoras heredadas
826"#,
827 )
828 .expect("locale should parse");
829
830 assert_eq!(
831 locale.resolved_role_term(
832 &ContributorRole::Editor,
833 false,
834 &TermForm::Long,
835 Some(GrammaticalGender::Feminine),
836 ),
837 Some("editora heredada".to_string())
838 );
839 }
840}