mod embedded;
pub mod locator;
pub mod message;
mod message_ids;
pub mod raw;
mod raw_conversion;
pub mod types;
use crate::citation::LocatorType;
use crate::template::ContributorRole;
pub use message::{MessageArgs, MessageEvaluator, Mf2MessageEvaluator};
pub use raw::{RawLocale, RawTermValue};
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
pub use types::*;
pub type MonthList = Vec<String>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArchiveHierarchyField {
Collection,
Series,
Box,
Folder,
Item,
}
impl ArchiveHierarchyField {
fn message_id(self) -> &'static str {
match self {
Self::Collection => "term.archive-collection-label",
Self::Series => "term.archive-series-label",
Self::Box => "term.archive-box-label",
Self::Folder => "term.archive-folder-label",
Self::Item => "term.archive-item-label",
}
}
}
#[derive(Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct Locale {
#[cfg_attr(feature = "schema", schemars(skip))]
pub locale: String,
#[serde(default)]
pub dates: DateTerms,
#[serde(default)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub roles: HashMap<ContributorRole, ContributorTerm>,
#[serde(default)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub locators: HashMap<LocatorType, LocatorTerm>,
#[serde(default)]
pub terms: Terms,
#[serde(default)]
pub punctuation_in_quote: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sort_articles: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locale_schema_version: Option<String>,
#[serde(default)]
pub evaluation: EvaluationConfig,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub messages: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub date_formats: HashMap<String, String>,
#[serde(default)]
pub number_formats: NumberFormats,
#[serde(default)]
pub grammar_options: GrammarOptions,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub legacy_term_aliases: HashMap<String, String>,
#[serde(default, skip_serializing_if = "VocabMap::is_empty")]
pub vocab: VocabMap,
#[serde(skip, default = "default_evaluator")]
#[cfg_attr(feature = "schema", schemars(skip))]
pub evaluator: Arc<dyn MessageEvaluator>,
}
fn default_evaluator() -> Arc<dyn MessageEvaluator> {
Arc::new(Mf2MessageEvaluator)
}
impl Default for Locale {
fn default() -> Self {
Self {
locale: String::default(),
dates: DateTerms::default(),
roles: HashMap::default(),
locators: HashMap::default(),
terms: Terms::default(),
punctuation_in_quote: false,
sort_articles: Vec::default(),
locale_schema_version: None,
evaluation: EvaluationConfig::default(),
messages: HashMap::default(),
date_formats: HashMap::default(),
number_formats: NumberFormats::default(),
grammar_options: GrammarOptions::default(),
legacy_term_aliases: HashMap::default(),
vocab: VocabMap::default(),
evaluator: default_evaluator(),
}
}
}
impl fmt::Debug for Locale {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Locale")
.field("locale", &self.locale)
.field("dates", &self.dates)
.field("roles", &self.roles)
.field("locators", &self.locators)
.field("terms", &self.terms)
.field("punctuation_in_quote", &self.punctuation_in_quote)
.field("sort_articles", &self.sort_articles)
.field("locale_schema_version", &self.locale_schema_version)
.field("evaluation", &self.evaluation)
.field("messages", &self.messages)
.field("date_formats", &self.date_formats)
.field("number_formats", &self.number_formats)
.field("grammar_options", &self.grammar_options)
.field("legacy_term_aliases", &self.legacy_term_aliases)
.field("vocab", &self.vocab)
.field("evaluator", &"<MessageEvaluator>")
.finish()
}
}
fn kebab_to_display(key: &str) -> String {
let mut words = key.split('-');
let mut result = String::new();
if let Some(first) = words.next() {
let mut chars = first.chars();
if let Some(c) = chars.next() {
result.extend(c.to_uppercase());
result.push_str(chars.as_str());
}
for word in words {
result.push(' ');
result.push_str(word);
}
}
result
}
impl Locale {
pub fn en_us() -> Self {
Self {
locale: "en-US".into(),
dates: DateTerms::en_us(),
roles: embedded::en_us_role_terms(),
locators: embedded::en_us_locator_terms(),
terms: Terms::en_us(),
punctuation_in_quote: true,
sort_articles: vec!["the".into(), "a".into(), "an".into()],
locale_schema_version: None,
evaluation: EvaluationConfig {
message_syntax: MessageSyntax::Mf2,
},
messages: embedded::en_us_archive_messages(),
date_formats: HashMap::new(),
number_formats: NumberFormats {
decimal_separator: ".".into(),
thousands_separator: ",".into(),
minimum_digits: 1,
},
grammar_options: GrammarOptions {
punctuation_in_quote: true,
nbsp_before_colon: false,
open_quote: "\u{201C}".into(),
close_quote: "\u{201D}".into(),
open_inner_quote: "\u{2018}".into(),
close_inner_quote: "\u{2019}".into(),
serial_comma: true,
page_range_delimiter: "\u{2013}".into(),
},
legacy_term_aliases: HashMap::new(),
vocab: embedded::embedded_en_us_vocab().clone(),
evaluator: Arc::new(Mf2MessageEvaluator),
}
}
pub fn strip_sort_articles<'a>(&self, s: &'a str) -> &'a str {
let s = s.trim();
const DEFAULT_ARTICLES: &[&str] = &["the", "a", "an"];
if self.sort_articles.is_empty() {
for article in DEFAULT_ARTICLES {
let prefix = format!("{} ", article);
if s.to_lowercase().starts_with(&prefix) {
#[allow(
clippy::string_slice,
reason = "prefix is derived from ASCII article"
)]
return &s[prefix.len()..];
}
}
} else {
for article in &self.sort_articles {
let prefix = format!("{} ", article);
if s.to_lowercase().starts_with(&prefix) {
#[allow(
clippy::string_slice,
reason = "prefix is derived from a defined article"
)]
return &s[prefix.len()..];
}
}
}
s
}
pub fn lookup_genre(&self, key: &str) -> String {
self.vocab
.genre
.get(key)
.cloned()
.unwrap_or_else(|| kebab_to_display(key))
}
pub fn lookup_medium(&self, key: &str) -> String {
self.vocab
.medium
.get(key)
.cloned()
.unwrap_or_else(|| kebab_to_display(key))
}
fn resolve_gendered_value(
value: &MaybeGendered<String>,
requested_gender: Option<GrammaticalGender>,
) -> Option<&str> {
value
.resolve_with_fallback(requested_gender)
.map(String::as_str)
}
fn resolve_gendered_value_neutral(value: &MaybeGendered<String>) -> Option<&str> {
value.resolve_neutral().map(String::as_str)
}
fn resolve_no_date_value<'a>(
value: &'a SimpleTerm,
form: &TermForm,
requested_gender: Option<GrammaticalGender>,
) -> Option<&'a str> {
match requested_gender {
Some(GrammaticalGender::Common) => match *form {
TermForm::Long => value
.long
.resolve_strict(Some(GrammaticalGender::Common))
.map(String::as_str),
TermForm::Short => value
.short
.resolve_strict(Some(GrammaticalGender::Common))
.map(String::as_str)
.filter(|value| !value.is_empty())
.or_else(|| {
value
.long
.resolve_strict(Some(GrammaticalGender::Common))
.map(String::as_str)
}),
_ => value
.long
.resolve_strict(Some(GrammaticalGender::Common))
.map(String::as_str),
},
_ => match *form {
TermForm::Long => Self::resolve_gendered_value(&value.long, requested_gender),
TermForm::Short => {
Self::resolve_gendered_value(&value.short, requested_gender.clone())
.filter(|value| !value.is_empty())
.or_else(|| Self::resolve_gendered_value(&value.long, requested_gender))
}
_ => Self::resolve_gendered_value(&value.long, requested_gender),
},
}
}
pub fn role_term(
&self,
role: &ContributorRole,
plural: bool,
form: &TermForm,
requested_gender: Option<GrammaticalGender>,
) -> Option<&str> {
let term = self.roles.get(role)?;
let simple = if plural { &term.plural } else { &term.singular };
let term_text = match *form {
TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
TermForm::Short => {
Self::resolve_gendered_value(&simple.short, requested_gender.clone())
.filter(|value| !value.is_empty())
.or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
}
TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, None),
TermForm::VerbShort => Self::resolve_gendered_value(&term.verb.short, None)
.filter(|value| !value.is_empty())
.or_else(|| Self::resolve_gendered_value(&term.verb.long, None)),
_ => Self::resolve_gendered_value(&simple.long, requested_gender),
};
match term_text {
Some(value) if !value.is_empty() => Some(value),
_ => None,
}
}
pub fn role_term_neutral(
&self,
role: &ContributorRole,
plural: bool,
form: &TermForm,
) -> Option<&str> {
let term = self.roles.get(role)?;
let simple = if plural { &term.plural } else { &term.singular };
let term_text = match *form {
TermForm::Long => Self::resolve_gendered_value_neutral(&simple.long),
TermForm::Short => Self::resolve_gendered_value_neutral(&simple.short)
.filter(|value| !value.is_empty())
.or_else(|| Self::resolve_gendered_value_neutral(&simple.long)),
TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, None),
TermForm::VerbShort => Self::resolve_gendered_value(&term.verb.short, None)
.filter(|value| !value.is_empty())
.or_else(|| Self::resolve_gendered_value(&term.verb.long, None)),
_ => Self::resolve_gendered_value_neutral(&simple.long),
};
match term_text {
Some(value) if !value.is_empty() => Some(value),
_ => None,
}
}
pub fn resolved_role_term(
&self,
role: &ContributorRole,
plural: bool,
form: &TermForm,
requested_gender: Option<GrammaticalGender>,
) -> Option<String> {
if let Some(message_id) = Self::role_message_id(role, form)
&& let Some(resolved) = self.resolve_message_text(
message_id,
Some(u64::from(plural) + 1),
requested_gender.clone(),
)
{
return Some(resolved);
}
self.role_term(role, plural, form, requested_gender)
.map(ToOwned::to_owned)
}
pub fn resolved_role_term_neutral(
&self,
role: &ContributorRole,
plural: bool,
form: &TermForm,
) -> Option<String> {
if let Some(message_id) = Self::role_message_id(role, form)
&& let Some(resolved) = self.resolve_message_text(
message_id,
Some(u64::from(plural) + 1),
Some(GrammaticalGender::Common),
)
{
return Some(resolved);
}
self.role_term_neutral(role, plural, form)
.map(ToOwned::to_owned)
}
pub fn locator_term(
&self,
locator: &LocatorType,
plural: bool,
form: &TermForm,
requested_gender: Option<GrammaticalGender>,
) -> Option<&str> {
let term = self.locators.get(locator)?;
let form_term = match *form {
TermForm::Long => &term.long,
TermForm::Short => &term.short,
TermForm::Symbol => &term.symbol,
_ => &term.short, };
if let Some(ft) = form_term {
let value = if plural { &ft.plural } else { &ft.singular };
Self::resolve_gendered_value(value, requested_gender)
} else {
None
}
}
pub fn resolved_locator_term(
&self,
locator: &LocatorType,
plural: bool,
form: &TermForm,
requested_gender: Option<GrammaticalGender>,
) -> Option<String> {
if let Some(message_id) = Self::locator_message_id(locator, form)
&& let Some(resolved) = self.resolve_message_text(
message_id,
Some(u64::from(plural) + 1),
requested_gender.clone(),
)
{
return Some(resolved);
}
self.locator_term(locator, plural, form, requested_gender.clone())
.map(ToOwned::to_owned)
.or_else(|| {
if let LocatorType::Custom(key) = locator {
self.locator_term_any_form(locator, plural, requested_gender)
.map(ToOwned::to_owned)
.or_else(|| Some(key.clone()))
} else {
None
}
})
}
fn locator_term_any_form(
&self,
locator: &LocatorType,
plural: bool,
requested_gender: Option<GrammaticalGender>,
) -> Option<&str> {
let term = self.locators.get(locator)?;
[&term.long, &term.short, &term.symbol]
.into_iter()
.flatten()
.next()
.map(|forms| {
if plural {
Self::resolve_gendered_value(&forms.plural, requested_gender).unwrap_or("")
} else {
Self::resolve_gendered_value(&forms.singular, requested_gender).unwrap_or("")
}
})
.filter(|value| !value.is_empty())
}
pub fn general_term(
&self,
term: &GeneralTerm,
form: &TermForm,
requested_gender: Option<GrammaticalGender>,
) -> Option<&str> {
let candidate_id = format!("term.{}", Self::general_term_to_message_id(term));
if let Some(msg) = self.messages.get(&candidate_id) {
if !msg.contains('{') {
return Some(msg.as_str());
}
}
let legacy_key = Self::general_term_to_legacy_key(term);
if let Some(msg_id) = self.legacy_term_aliases.get(legacy_key)
&& let Some(msg) = self.messages.get(msg_id)
&& !msg.contains('{')
{
return Some(msg.as_str());
}
if *term != GeneralTerm::NoDate
&& let Some(simple) = self.terms.general.get(term)
{
return match *form {
TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
TermForm::Short => {
Self::resolve_gendered_value(&simple.short, requested_gender.clone())
.filter(|value| !value.is_empty())
.or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
}
_ => Self::resolve_gendered_value(&simple.long, requested_gender),
};
}
match term {
GeneralTerm::And => self.terms.and.as_deref(),
GeneralTerm::EtAl => self.terms.et_al.as_deref(),
GeneralTerm::AndOthers => self.terms.and_others.as_deref(),
GeneralTerm::Accessed => self.terms.accessed.as_deref(),
GeneralTerm::Ibid => self.terms.ibid.as_deref(),
GeneralTerm::In => self.terms.in_.as_deref(),
GeneralTerm::NoDate => self
.terms
.general
.get(term)
.and_then(|value| Self::resolve_no_date_value(value, form, requested_gender))
.or(self.terms.no_date.as_deref()),
GeneralTerm::Retrieved => self.terms.retrieved.as_deref(),
GeneralTerm::At => self.terms.at.as_deref(),
GeneralTerm::By => self.terms.by.as_deref(),
GeneralTerm::From => self.terms.from.as_deref(),
GeneralTerm::Of => self
.terms
.general
.get(term)
.and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
GeneralTerm::To => self
.terms
.general
.get(term)
.and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
GeneralTerm::Anonymous => {
Self::resolve_gendered_value(&self.terms.anonymous.long, requested_gender)
}
GeneralTerm::Circa => {
Self::resolve_gendered_value(&self.terms.circa.long, requested_gender)
}
GeneralTerm::Volume => {
self.locator_term(&LocatorType::Volume, false, form, requested_gender)
}
GeneralTerm::Issue => {
self.locator_term(&LocatorType::Issue, false, form, requested_gender)
}
GeneralTerm::Page => {
self.locator_term(&LocatorType::Page, false, form, requested_gender)
}
GeneralTerm::Chapter => {
self.locator_term(&LocatorType::Chapter, false, form, requested_gender)
}
GeneralTerm::Section => {
self.locator_term(&LocatorType::Section, false, form, requested_gender)
}
GeneralTerm::Here => self
.terms
.general
.get(term)
.and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
GeneralTerm::Deposited => self
.terms
.general
.get(term)
.and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
_ => None,
}
}
pub fn resolved_general_term(
&self,
term: &GeneralTerm,
form: &TermForm,
requested_gender: Option<GrammaticalGender>,
) -> Option<String> {
if let Some(message_id) = Self::general_message_id(term, form)
&& let Some(resolved) =
self.resolve_message_text(message_id, None, requested_gender.clone())
{
return Some(resolved);
}
self.general_term(term, form, requested_gender)
.map(ToOwned::to_owned)
}
pub fn resolved_archive_term(&self, field: ArchiveHierarchyField) -> Option<String> {
self.resolve_message_text(field.message_id(), Some(1), None)
}
pub fn and_term(&self, use_symbol: bool) -> &str {
if use_symbol {
self.terms.and_symbol.as_deref().unwrap_or("&")
} else {
self.terms.and.as_deref().unwrap_or("and")
}
}
pub fn et_al(&self) -> &str {
self.terms.et_al.as_deref().unwrap_or("et al.")
}
pub fn month_name(&self, month: u8, short: bool) -> &str {
let idx = (month.saturating_sub(1)) as usize;
if short {
self.dates
.months
.short
.get(idx)
.map(|s| s.as_str())
.unwrap_or("")
} else {
self.dates
.months
.long
.get(idx)
.map(|s| s.as_str())
.unwrap_or("")
}
}
pub fn resolve_date_pattern(
&self,
message_id: &str,
year: Option<&str>,
month: Option<&str>,
day: Option<u32>,
) -> Option<String> {
let message = self.messages.get(message_id)?;
if self.evaluation.message_syntax == MessageSyntax::Static {
return None;
}
let day_str = day.map(|d| d.to_string());
let args = MessageArgs {
year: year.filter(|s| !s.is_empty()),
month: month.filter(|s| !s.is_empty()),
day: day_str.as_deref(),
..MessageArgs::default()
};
self.evaluator.evaluate(message, &args)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::*;
#[test]
fn test_en_us_locale() {
let locale = Locale::en_us();
assert_eq!(locale.locale, "en-US");
assert_eq!(locale.and_term(false), "and");
assert_eq!(locale.and_term(true), "&");
assert_eq!(locale.et_al(), "et al.");
}
#[test]
fn test_month_names() {
let locale = Locale::en_us();
assert_eq!(locale.month_name(1, false), "January");
assert_eq!(locale.month_name(1, true), "Jan.");
assert_eq!(locale.month_name(12, false), "December");
}
#[test]
fn test_role_terms() {
let locale = Locale::en_us();
assert_eq!(
locale.role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
Some("ed.")
);
assert_eq!(
locale.role_term(&ContributorRole::Editor, true, &TermForm::Short, None),
Some("eds.")
);
assert_eq!(
locale.role_term(&ContributorRole::Translator, false, &TermForm::Verb, None),
Some("translated by")
);
}
#[test]
fn test_no_date_term_resolves_long_and_short_forms() {
let locale = Locale::en_us();
assert_eq!(
locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
Some("no date")
);
assert_eq!(
locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
Some("n.d.")
);
}
#[test]
fn test_no_date_term_falls_back_to_legacy_short_form() {
let mut locale = Locale::default();
locale.terms.no_date = Some("n.d.".to_string());
assert_eq!(
locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
Some("n.d.")
);
assert_eq!(
locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
Some("n.d.")
);
}
#[test]
fn test_locale_deserialization() {
let json = r#"{
"locale": "en-US",
"dates": {
"months": {
"long": ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"],
"short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
},
"seasons": ["Spring", "Summer", "Autumn", "Winter"]
},
"roles": {},
"terms": {
"and": "and",
"et-al": "et al."
}
}"#;
let locale: Locale = serde_json::from_str(json).unwrap();
assert_eq!(locale.locale, "en-US");
assert_eq!(locale.dates.months.long[0], "January");
assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
}
#[test]
fn test_yaml_locale_loading() {
let yaml = r#"
locale: de-DE
dates:
months:
long:
- Januar
- Februar
- März
- April
- Mai
- Juni
- Juli
- August
- September
- Oktober
- November
- Dezember
short:
- Jan.
- Feb.
- März
- Apr.
- Mai
- Juni
- Juli
- Aug.
- Sep.
- Okt.
- Nov.
- Dez.
seasons:
- Frühling
- Sommer
- Herbst
- Winter
terms:
and:
long: und
symbol: "&"
et_al:
long: "u. a."
"#;
let locale = Locale::from_yaml_str(yaml).unwrap();
assert_eq!(locale.locale, "de-DE");
assert_eq!(locale.and_term(false), "und");
assert_eq!(locale.et_al(), "u. a.");
assert_eq!(locale.month_name(1, false), "Januar");
assert_eq!(locale.month_name(3, false), "März");
}
#[test]
fn test_yaml_no_date_term_preserves_long_and_short_forms() {
let yaml = r#"
locale: en-US
dates:
months:
long: [January, February, March, April, May, June, July, August, September, October, November, December]
short: [Jan., Feb., Mar., Apr., May, June, July, Aug., Sept., Oct., Nov., Dec.]
seasons: [Spring, Summer, Autumn, Winter]
roles: {}
terms:
no date:
long: no date
short: n.d.
"#;
let locale = Locale::from_yaml_str(yaml).unwrap();
assert_eq!(
locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
Some("no date")
);
assert_eq!(
locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
Some("n.d.")
);
assert_eq!(locale.terms.no_date.as_deref(), Some("n.d."));
}
#[test]
fn test_v2_grammar_options_sync_punctuation_in_quote() {
let yaml = r#"
locale-schema-version: "2"
locale: en-GB
grammar-options:
punctuation-in-quote: false
"#;
let locale = Locale::from_yaml_str(yaml).unwrap();
assert!(!locale.grammar_options.punctuation_in_quote);
assert!(!locale.punctuation_in_quote);
}
#[test]
fn test_v1_locale_derives_punctuation_from_locale_id() {
let yaml = r#"
locale: en-US
"#;
let locale = Locale::from_yaml_str(yaml).unwrap();
assert!(locale.punctuation_in_quote);
assert!(locale.grammar_options.punctuation_in_quote);
}
#[test]
fn test_apply_override_merges_messages() {
let mut locale = Locale::en_us();
locale
.messages
.insert("term.page-label".into(), "p.".into());
let ov = LocaleOverride {
messages: [("term.page-label".into(), "pg.".into())].into(),
..Default::default()
};
locale.apply_override(&ov);
assert_eq!(
locale.messages.get("term.page-label").map(|s| s.as_str()),
Some("pg.")
);
}
#[test]
fn test_apply_override_grammar_options_syncs_punctuation() {
let mut locale = Locale::en_us();
locale.punctuation_in_quote = false;
let ov = LocaleOverride {
grammar_options: Some(GrammarOptions {
punctuation_in_quote: true,
..Default::default()
}),
..Default::default()
};
locale.apply_override(&ov);
assert!(locale.punctuation_in_quote);
assert!(locale.grammar_options.punctuation_in_quote);
}
#[test]
fn test_resolved_locator_term_evaluates_plural_message() {
let locale = Locale::en_us();
assert_eq!(
locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
Some("p.".to_string())
);
assert_eq!(
locale.resolved_locator_term(&LocatorType::Page, true, &TermForm::Short, None),
Some("pp.".to_string())
);
}
#[test]
fn test_resolved_locator_term_falls_back_to_custom_locale_form_then_raw_key() {
let locale = Locale::from_yaml_str(
r#"
locale: en-US
locators:
reel:
long:
singular: "reel"
plural: "reels"
"#,
)
.expect("custom locale should parse");
assert_eq!(
locale.resolved_locator_term(
&LocatorType::Custom("reel".to_string()),
false,
&TermForm::Short,
None,
),
Some("reel".to_string())
);
assert_eq!(
locale.resolved_locator_term(
&LocatorType::Custom("movement".to_string()),
false,
&TermForm::Short,
None,
),
Some("movement".to_string())
);
}
#[test]
fn test_legacy_locator_terms_under_terms_still_populate_locators() {
let locale = Locale::from_yaml_str(
r#"
locale: en-US
terms:
page:
short:
singular: "pg."
plural: "pgs."
"#,
)
.expect("legacy locator terms should parse");
assert_eq!(
locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
Some("pg.".to_string())
);
}
#[test]
fn test_explicit_locators_override_legacy_terms_for_builtin_keys() {
let locale = Locale::from_yaml_str(
r#"
locale: en-US
terms:
page:
short:
singular: "pg."
plural: "pgs."
locators:
page:
short:
singular: "p."
plural: "pp."
"#,
)
.expect("mixed locator forms should parse");
assert_eq!(
locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
Some("p.".to_string())
);
}
#[test]
fn test_non_locator_terms_are_not_reclassified_as_custom_locators() {
let locale = Locale::from_yaml_str(
r#"
locale: en-US
terms:
and:
long: "und"
"#,
)
.expect("general terms should parse");
assert_eq!(locale.terms.and.as_deref(), Some("und"));
assert!(
!locale
.locators
.contains_key(&LocatorType::Custom("and".to_string()))
);
}
#[test]
fn test_resolved_role_term_evaluates_plural_message() {
let locale = Locale::en_us();
assert_eq!(
locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Long, None),
Some("editor".to_string())
);
assert_eq!(
locale.resolved_role_term(&ContributorRole::Editor, true, &TermForm::Long, None),
Some("editors".to_string())
);
}
#[test]
fn test_role_term_prefers_common_form_for_mixed_gender_requests() {
let locale = Locale::from_yaml_str(
r#"
locale: es-ES
roles:
editor:
long:
singular:
masculine: editor
feminine: editora
common: persona editora
plural:
masculine: editores
feminine: editoras
common: equipo editorial
short:
singular: ed.
plural: eds.
verb: editado por
"#,
)
.expect("gendered locale should parse");
assert_eq!(
locale.role_term(
&ContributorRole::Editor,
false,
&TermForm::Long,
Some(GrammaticalGender::Feminine),
),
Some("editora")
);
assert_eq!(
locale.role_term(
&ContributorRole::Editor,
true,
&TermForm::Long,
Some(GrammaticalGender::Common),
),
Some("equipo editorial")
);
}
#[test]
fn test_no_date_term_falls_back_when_requested_gender_has_no_matching_slot() {
let locale = Locale::from_yaml_str(
r#"
locale: es-ES
terms:
no date:
long:
masculine: sin fecha
no_date: s. f.
"#,
)
.expect("locale should parse");
assert_eq!(
locale.general_term(
&GeneralTerm::NoDate,
&TermForm::Long,
Some(GrammaticalGender::Common),
),
Some("s. f.")
);
}
#[test]
fn test_es_es_locale_is_embedded() {
let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
assert_eq!(locale.locale, "es-ES");
assert_eq!(
locale.resolved_role_term(
&ContributorRole::Editor,
false,
&TermForm::Long,
Some(GrammaticalGender::Feminine),
),
Some("editora".to_string())
);
}
#[test]
fn embedded_locale_ids_include_all_bundled_locale_files() {
for id in [
"en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR",
] {
assert!(
crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
"{id} should be listed as an embedded locale"
);
}
}
#[test]
fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
for id in ["ar-AR", "eu-ES"] {
let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");
assert_eq!(locale.locale, id);
}
}
#[test]
fn test_es_es_role_term_resolves_gendered_mf2_message() {
let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
assert_eq!(
locale.resolved_role_term(
&ContributorRole::Editor,
true,
&TermForm::Long,
Some(GrammaticalGender::Masculine),
),
Some("editores".to_string())
);
assert_eq!(
locale.resolved_role_term(
&ContributorRole::Translator,
true,
&TermForm::Long,
Some(GrammaticalGender::Feminine),
),
Some("traductoras".to_string())
);
assert_eq!(
locale.resolved_role_term_neutral(&ContributorRole::Editor, true, &TermForm::Long),
Some("equipo editorial".to_string())
);
}
#[test]
fn test_role_term_falls_back_when_mf2_message_cannot_evaluate() {
let locale = Locale::from_yaml_str(
r#"
locale: es-ES
evaluation:
message-syntax: mf2
messages:
role.editor.label-long: |
.match {$gender :unknown} {$count :plural}
when feminine one {editora}
roles:
editor:
long:
singular:
feminine: editora heredada
plural:
feminine: editoras heredadas
"#,
)
.expect("locale should parse");
assert_eq!(
locale.resolved_role_term(
&ContributorRole::Editor,
false,
&TermForm::Long,
Some(GrammaticalGender::Feminine),
),
Some("editora heredada".to_string())
);
}
#[test]
fn test_lookup_genre_known_key() {
let locale = Locale::from_yaml_str(
r#"
locale: en-US
vocab:
genre:
phd-thesis: "PhD thesis"
"#,
)
.unwrap();
assert_eq!(locale.lookup_genre("phd-thesis"), "PhD thesis");
}
#[test]
fn test_lookup_medium_known_key() {
let locale = Locale::from_yaml_str(
r#"
locale: en-US
vocab:
medium:
television: "Television"
"#,
)
.unwrap();
assert_eq!(locale.lookup_medium("television"), "Television");
}
#[test]
fn test_lookup_genre_fallback() {
let locale = Locale::en_us();
assert_eq!(locale.lookup_genre("unknown-key"), "Unknown key");
}
#[test]
fn test_en_us_locale_uses_embedded_vocab() {
let locale = Locale::en_us();
assert_eq!(locale.lookup_genre("phd-thesis"), "PhD thesis");
assert_eq!(locale.lookup_medium("audio-cd"), "Audio CD");
}
#[test]
fn test_from_yaml_str_inherits_embedded_vocab_defaults() {
let locale = Locale::from_yaml_str("locale: en-US\n").unwrap();
assert_eq!(locale.lookup_genre("phd-thesis"), "PhD thesis");
}
#[test]
fn test_partial_genre_vocab_override_preserves_medium_defaults() {
let locale = Locale::from_yaml_str(
r#"
locale: en-US
vocab:
genre:
phd-thesis: "Doctoral dissertation"
"#,
)
.unwrap();
assert_eq!(locale.lookup_genre("phd-thesis"), "Doctoral dissertation");
assert_eq!(locale.lookup_medium("audio-cd"), "Audio CD");
}
#[test]
fn test_partial_medium_vocab_override_preserves_genre_defaults() {
let locale = Locale::from_yaml_str(
r#"
locale: en-US
vocab:
medium:
television: "Broadcast television"
"#,
)
.unwrap();
assert_eq!(locale.lookup_medium("television"), "Broadcast television");
assert_eq!(locale.lookup_genre("phd-thesis"), "PhD thesis");
}
#[test]
fn test_kebab_to_display_single_word() {
assert_eq!(kebab_to_display("video"), "Video");
}
#[test]
fn test_kebab_to_display_multiple_words() {
assert_eq!(kebab_to_display("phd-thesis"), "Phd thesis");
assert_eq!(kebab_to_display("audio-cd"), "Audio cd");
}
}