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 let candidate_id = format!("term.{}", Self::general_term_to_message_id(term));
280 if let Some(msg) = self.messages.get(&candidate_id) {
281 if !msg.contains('{') {
283 return Some(msg.as_str());
284 }
285 }
286 let legacy_key = Self::general_term_to_legacy_key(term);
288 if let Some(msg_id) = self.legacy_term_aliases.get(legacy_key)
289 && let Some(msg) = self.messages.get(msg_id)
290 && !msg.contains('{')
291 {
292 return Some(msg.as_str());
293 }
294
295 if *term != GeneralTerm::NoDate
297 && let Some(simple) = self.terms.general.get(term)
298 {
299 return match *form {
300 TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
301 TermForm::Short => {
302 Self::resolve_gendered_value(&simple.short, requested_gender.clone())
303 .filter(|value| !value.is_empty())
304 .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
305 }
306
307 _ => Self::resolve_gendered_value(&simple.long, requested_gender),
308 };
309 }
310
311 match term {
313 GeneralTerm::And => self.terms.and.as_deref(),
314 GeneralTerm::EtAl => self.terms.et_al.as_deref(),
315 GeneralTerm::AndOthers => self.terms.and_others.as_deref(),
316 GeneralTerm::Accessed => self.terms.accessed.as_deref(),
317 GeneralTerm::Ibid => self.terms.ibid.as_deref(),
318 GeneralTerm::In => self.terms.in_.as_deref(),
319 GeneralTerm::NoDate => self
320 .terms
321 .general
322 .get(term)
323 .and_then(|value| Self::resolve_no_date_value(value, form, requested_gender))
324 .or(self.terms.no_date.as_deref()),
325 GeneralTerm::Retrieved => self.terms.retrieved.as_deref(),
326 GeneralTerm::At => self.terms.at.as_deref(),
327 GeneralTerm::By => self.terms.by.as_deref(),
328 GeneralTerm::From => self.terms.from.as_deref(),
329 GeneralTerm::Of => self
330 .terms
331 .general
332 .get(term)
333 .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
334 GeneralTerm::To => self
335 .terms
336 .general
337 .get(term)
338 .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
339 GeneralTerm::Anonymous => {
340 Self::resolve_gendered_value(&self.terms.anonymous.long, requested_gender)
341 }
342 GeneralTerm::Circa => {
343 Self::resolve_gendered_value(&self.terms.circa.long, requested_gender)
344 }
345 GeneralTerm::Volume => {
347 self.locator_term(&LocatorType::Volume, false, form, requested_gender)
348 }
349 GeneralTerm::Issue => {
350 self.locator_term(&LocatorType::Issue, false, form, requested_gender)
351 }
352 GeneralTerm::Page => {
353 self.locator_term(&LocatorType::Page, false, form, requested_gender)
354 }
355 GeneralTerm::Chapter => {
356 self.locator_term(&LocatorType::Chapter, false, form, requested_gender)
357 }
358 GeneralTerm::Section => {
359 self.locator_term(&LocatorType::Section, false, form, requested_gender)
360 }
361 GeneralTerm::Here => self
362 .terms
363 .general
364 .get(term)
365 .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
366 GeneralTerm::Deposited => self
367 .terms
368 .general
369 .get(term)
370 .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
371 _ => None,
372 }
373 }
374
375 pub fn resolved_general_term(
377 &self,
378 term: &GeneralTerm,
379 form: &TermForm,
380 requested_gender: Option<GrammaticalGender>,
381 ) -> Option<String> {
382 if let Some(message_id) = Self::general_message_id(term, form)
383 && let Some(resolved) =
384 self.resolve_message_text(message_id, None, requested_gender.clone())
385 {
386 return Some(resolved);
387 }
388
389 self.general_term(term, form, requested_gender)
390 .map(ToOwned::to_owned)
391 }
392
393 pub fn resolved_archive_term(&self, field: ArchiveHierarchyField) -> Option<String> {
396 self.resolve_message_text(field.message_id(), Some(1), None)
397 }
398
399 pub fn and_term(&self, use_symbol: bool) -> &str {
401 if use_symbol {
402 self.terms.and_symbol.as_deref().unwrap_or("&")
403 } else {
404 self.terms.and.as_deref().unwrap_or("and")
405 }
406 }
407
408 pub fn et_al(&self) -> &str {
410 self.terms.et_al.as_deref().unwrap_or("et al.")
411 }
412
413 pub fn month_name(&self, month: u8, short: bool) -> &str {
415 let idx = (month.saturating_sub(1)) as usize;
416 if short {
417 self.dates
418 .months
419 .short
420 .get(idx)
421 .map(|s| s.as_str())
422 .unwrap_or("")
423 } else {
424 self.dates
425 .months
426 .long
427 .get(idx)
428 .map(|s| s.as_str())
429 .unwrap_or("")
430 }
431 }
432}
433
434#[cfg(test)]
435#[allow(
436 clippy::unwrap_used,
437 clippy::expect_used,
438 clippy::panic,
439 clippy::indexing_slicing,
440 clippy::todo,
441 clippy::unimplemented,
442 clippy::unreachable,
443 clippy::get_unwrap,
444 reason = "Panicking is acceptable and often desired in tests."
445)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn test_en_us_locale_terms() {
451 let locale = Locale::en_us();
452 assert_eq!(locale.and_term(false), "and");
453 assert_eq!(locale.and_term(true), "&");
454 assert_eq!(locale.et_al(), "et al.");
455 }
456
457 #[test]
458 fn test_month_names() {
459 let locale = Locale::en_us();
460 assert_eq!(locale.month_name(1, false), "January");
461 assert_eq!(locale.month_name(1, true), "Jan.");
462 assert_eq!(locale.month_name(12, false), "December");
463 }
464
465 #[test]
466 fn test_role_terms() {
467 let locale = Locale::en_us();
468
469 assert_eq!(
470 locale.role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
471 Some("ed.")
472 );
473 assert_eq!(
474 locale.role_term(&ContributorRole::Editor, true, &TermForm::Short, None),
475 Some("eds.")
476 );
477 assert_eq!(
478 locale.role_term(&ContributorRole::Translator, false, &TermForm::Verb, None),
479 Some("translated by")
480 );
481 }
482
483 #[test]
484 fn test_no_date_term_resolves_long_and_short_forms() {
485 let locale = Locale::en_us();
486
487 assert_eq!(
488 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
489 Some("no date")
490 );
491 assert_eq!(
492 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
493 Some("n.d.")
494 );
495 }
496
497 #[test]
498 fn test_no_date_term_falls_back_to_legacy_short_form() {
499 let mut locale = Locale::default();
500 locale.terms.no_date = Some("n.d.".to_string());
501
502 assert_eq!(
503 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
504 Some("n.d.")
505 );
506 assert_eq!(
507 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
508 Some("n.d.")
509 );
510 }
511
512 #[test]
513 fn test_yaml_no_date_term_preserves_long_and_short_forms() {
514 let yaml = r#"
515locale: en-US
516dates:
517 months:
518 long: [January, February, March, April, May, June, July, August, September, October, November, December]
519 short: [Jan., Feb., Mar., Apr., May, June, July, Aug., Sept., Oct., Nov., Dec.]
520 seasons: [Spring, Summer, Autumn, Winter]
521roles: {}
522terms:
523 no date:
524 long: no date
525 short: n.d.
526"#;
527
528 let locale = Locale::from_yaml_str(yaml).unwrap();
529 assert_eq!(
530 locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
531 Some("no date")
532 );
533 assert_eq!(
534 locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
535 Some("n.d.")
536 );
537 assert_eq!(locale.terms.no_date.as_deref(), Some("n.d."));
538 }
539
540 #[test]
541 fn test_resolved_locator_term_evaluates_plural_message() {
542 let locale = Locale::en_us();
543
544 assert_eq!(
545 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
546 Some("p.".to_string())
547 );
548 assert_eq!(
549 locale.resolved_locator_term(&LocatorType::Page, true, &TermForm::Short, None),
550 Some("pp.".to_string())
551 );
552 }
553
554 #[test]
555 fn test_resolved_locator_term_falls_back_to_custom_locale_form_then_raw_key() {
556 let locale = Locale::from_yaml_str(
557 r#"
558locale: en-US
559locators:
560 reel:
561 long:
562 singular: "reel"
563 plural: "reels"
564"#,
565 )
566 .expect("custom locale should parse");
567
568 assert_eq!(
569 locale.resolved_locator_term(
570 &LocatorType::Custom("reel".to_string()),
571 false,
572 &TermForm::Short,
573 None,
574 ),
575 Some("reel".to_string())
576 );
577 assert_eq!(
578 locale.resolved_locator_term(
579 &LocatorType::Custom("movement".to_string()),
580 false,
581 &TermForm::Short,
582 None,
583 ),
584 Some("movement".to_string())
585 );
586 }
587
588 #[test]
589 fn test_legacy_locator_terms_under_terms_still_populate_locators() {
590 let locale = Locale::from_yaml_str(
591 r#"
592locale: en-US
593terms:
594 page:
595 short:
596 singular: "pg."
597 plural: "pgs."
598"#,
599 )
600 .expect("legacy locator terms should parse");
601
602 assert_eq!(
603 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
604 Some("pg.".to_string())
605 );
606 }
607
608 #[test]
609 fn test_explicit_locators_override_legacy_terms_for_builtin_keys() {
610 let locale = Locale::from_yaml_str(
611 r#"
612locale: en-US
613terms:
614 page:
615 short:
616 singular: "pg."
617 plural: "pgs."
618locators:
619 page:
620 short:
621 singular: "p."
622 plural: "pp."
623"#,
624 )
625 .expect("mixed locator forms should parse");
626
627 assert_eq!(
628 locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
629 Some("p.".to_string())
630 );
631 }
632
633 #[test]
634 fn test_non_locator_terms_are_not_reclassified_as_custom_locators() {
635 let locale = Locale::from_yaml_str(
636 r#"
637locale: en-US
638terms:
639 and:
640 long: "und"
641"#,
642 )
643 .expect("general terms should parse");
644
645 assert_eq!(locale.terms.and.as_deref(), Some("und"));
646 assert!(
647 !locale
648 .locators
649 .contains_key(&LocatorType::Custom("and".to_string()))
650 );
651 }
652
653 #[test]
654 fn test_resolved_role_term_evaluates_plural_message() {
655 let locale = Locale::en_us();
656
657 assert_eq!(
658 locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Long, None),
659 Some("editor".to_string())
660 );
661 assert_eq!(
662 locale.resolved_role_term(&ContributorRole::Editor, true, &TermForm::Long, None),
663 Some("editors".to_string())
664 );
665 }
666
667 #[test]
668 fn test_role_term_prefers_common_form_for_mixed_gender_requests() {
669 let locale = Locale::from_yaml_str(
670 r#"
671locale: es-ES
672roles:
673 editor:
674 long:
675 singular:
676 masculine: editor
677 feminine: editora
678 common: persona editora
679 plural:
680 masculine: editores
681 feminine: editoras
682 common: equipo editorial
683 short:
684 singular: ed.
685 plural: eds.
686 verb: editado por
687"#,
688 )
689 .expect("gendered locale should parse");
690
691 assert_eq!(
692 locale.role_term(
693 &ContributorRole::Editor,
694 false,
695 &TermForm::Long,
696 Some(GrammaticalGender::Feminine),
697 ),
698 Some("editora")
699 );
700 assert_eq!(
701 locale.role_term(
702 &ContributorRole::Editor,
703 true,
704 &TermForm::Long,
705 Some(GrammaticalGender::Common),
706 ),
707 Some("equipo editorial")
708 );
709 }
710
711 #[test]
712 fn test_no_date_term_falls_back_when_requested_gender_has_no_matching_slot() {
713 let locale = Locale::from_yaml_str(
714 r#"
715locale: es-ES
716terms:
717 no date:
718 long:
719 masculine: sin fecha
720 no_date: s. f.
721"#,
722 )
723 .expect("locale should parse");
724
725 assert_eq!(
726 locale.general_term(
727 &GeneralTerm::NoDate,
728 &TermForm::Long,
729 Some(GrammaticalGender::Common),
730 ),
731 Some("s. f.")
732 );
733 }
734
735 #[test]
736 fn test_es_es_locale_is_embedded() {
737 let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
738 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
739 let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
740
741 assert_eq!(locale.locale, "es-ES");
742 assert_eq!(
743 locale.resolved_role_term(
744 &ContributorRole::Editor,
745 false,
746 &TermForm::Long,
747 Some(GrammaticalGender::Feminine),
748 ),
749 Some("editora".to_string())
750 );
751 }
752
753 #[test]
754 fn test_es_es_role_term_resolves_gendered_mf2_message() {
755 let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
756 let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
757 let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
758
759 assert_eq!(
760 locale.resolved_role_term(
761 &ContributorRole::Editor,
762 true,
763 &TermForm::Long,
764 Some(GrammaticalGender::Masculine),
765 ),
766 Some("editores".to_string())
767 );
768 assert_eq!(
769 locale.resolved_role_term(
770 &ContributorRole::Translator,
771 true,
772 &TermForm::Long,
773 Some(GrammaticalGender::Feminine),
774 ),
775 Some("traductoras".to_string())
776 );
777 assert_eq!(
778 locale.resolved_role_term_neutral(&ContributorRole::Editor, true, &TermForm::Long),
779 Some("equipo editorial".to_string())
780 );
781 }
782
783 #[test]
784 fn test_role_term_falls_back_when_mf2_message_cannot_evaluate() {
785 let locale = Locale::from_yaml_str(
786 r#"
787locale: es-ES
788evaluation:
789 message-syntax: mf2
790messages:
791 role.editor.label-long: |
792 .match {$gender :unknown} {$count :plural}
793 when feminine one {editora}
794roles:
795 editor:
796 long:
797 singular:
798 feminine: editora heredada
799 plural:
800 feminine: editoras heredadas
801"#,
802 )
803 .expect("locale should parse");
804
805 assert_eq!(
806 locale.resolved_role_term(
807 &ContributorRole::Editor,
808 false,
809 &TermForm::Long,
810 Some(GrammaticalGender::Feminine),
811 ),
812 Some("editora heredada".to_string())
813 );
814 }
815}