use std::collections::HashMap;
use std::path::Path;
use std::sync::RwLock;
pub mod admin;
pub mod db;
pub mod middleware;
pub mod tera_tags;
pub mod timezone;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Locale(String);
impl Locale {
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into().to_lowercase())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn base_language(&self) -> &str {
self.0.split('-').next().unwrap_or(&self.0)
}
#[must_use]
pub fn is_rtl(&self) -> bool {
is_rtl_base(self.base_language())
}
#[must_use]
pub fn direction(&self) -> &'static str {
if self.is_rtl() {
"rtl"
} else {
"ltr"
}
}
}
#[must_use]
pub fn is_rtl_language(locale: &str) -> bool {
let lower = locale.to_ascii_lowercase();
let base = lower.split('-').next().unwrap_or(&lower);
is_rtl_base(base)
}
#[must_use]
pub fn text_direction(locale: &str) -> &'static str {
if is_rtl_language(locale) {
"rtl"
} else {
"ltr"
}
}
impl Locale {
#[must_use]
pub fn display_name(&self) -> &'static str {
language_display_name(self.base_language())
}
#[must_use]
pub fn native_name(&self) -> &'static str {
language_native_name(self.base_language())
}
}
#[must_use]
pub fn language_display_name(locale: &str) -> &'static str {
let lower = locale.to_ascii_lowercase();
let base = lower.split('-').next().unwrap_or(&lower);
match base {
"en" => "English",
"fr" => "French",
"de" => "German",
"es" => "Spanish",
"it" => "Italian",
"pt" => "Portuguese",
"nl" => "Dutch",
"ru" => "Russian",
"ja" => "Japanese",
"zh" => "Chinese",
"ko" => "Korean",
"ar" => "Arabic",
"he" | "iw" => "Hebrew",
"fa" => "Persian",
"ur" => "Urdu",
"tr" => "Turkish",
"pl" => "Polish",
"uk" => "Ukrainian",
"cs" => "Czech",
"sk" => "Slovak",
"hu" => "Hungarian",
"ro" => "Romanian",
"bg" => "Bulgarian",
"el" => "Greek",
"sv" => "Swedish",
"no" | "nb" | "nn" => "Norwegian",
"da" => "Danish",
"fi" => "Finnish",
"hi" => "Hindi",
"bn" => "Bengali",
"ta" => "Tamil",
"th" => "Thai",
"vi" => "Vietnamese",
"id" => "Indonesian",
"ms" => "Malay",
"ps" => "Pashto",
"yi" | "ji" => "Yiddish",
"dv" => "Divehi",
"ckb" => "Sorani Kurdish",
"ug" => "Uyghur",
"sd" => "Sindhi",
"syr" => "Syriac",
_ => "Unknown",
}
}
#[must_use]
pub fn language_native_name(locale: &str) -> &'static str {
let lower = locale.to_ascii_lowercase();
let base = lower.split('-').next().unwrap_or(&lower);
match base {
"en" => "English",
"fr" => "français",
"de" => "Deutsch",
"es" => "español",
"it" => "italiano",
"pt" => "português",
"nl" => "Nederlands",
"ru" => "русский",
"ja" => "日本語",
"zh" => "中文",
"ko" => "한국어",
"ar" => "العربية",
"he" | "iw" => "עברית",
"fa" => "فارسی",
"ur" => "اردو",
"tr" => "Türkçe",
"pl" => "polski",
"uk" => "українська",
"cs" => "čeština",
"sk" => "slovenčina",
"hu" => "magyar",
"ro" => "română",
"bg" => "български",
"el" => "Ελληνικά",
"sv" => "svenska",
"no" | "nb" | "nn" => "norsk",
"da" => "dansk",
"fi" => "suomi",
"hi" => "हिन्दी",
"bn" => "বাংলা",
"ta" => "தமிழ்",
"th" => "ไทย",
"vi" => "Tiếng Việt",
"id" => "Bahasa Indonesia",
"ms" => "Bahasa Melayu",
"ps" => "پښتو",
"yi" | "ji" => "ייִדיש",
"dv" => "ދިވެހި",
"ckb" => "کوردیی ناوەندی",
"ug" => "ئۇيغۇرچە",
"sd" => "سنڌي",
"syr" => "ܠܫܢܐ ܣܘܪܝܝܐ",
_ => "Unknown",
}
}
fn is_rtl_base(base: &str) -> bool {
matches!(
base,
"ar" | "he" | "iw" | "fa" | "ur" | "ps" | "yi" | "ji" | "dv" | "ckb" | "ug" | "sd" | "syr" )
}
impl std::fmt::Display for Locale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, thiserror::Error)]
pub enum I18nError {
#[error("io error: {0}")]
Io(String),
#[error("parse error in {file}: {detail}")]
Parse { file: String, detail: String },
}
pub struct Translator {
default_locale: Locale,
fallback_chain: Vec<Locale>,
catalogs: RwLock<HashMap<Locale, HashMap<String, String>>>,
overrides: RwLock<HashMap<Locale, HashMap<String, String>>>,
}
impl Translator {
#[must_use]
pub fn new(default_locale: Locale) -> Self {
Self {
default_locale,
fallback_chain: Vec::new(),
catalogs: RwLock::new(HashMap::new()),
overrides: RwLock::new(HashMap::new()),
}
}
#[must_use]
pub fn with_fallback_chain(mut self, chain: &[&str]) -> Self {
let mut seen: std::collections::HashSet<Locale> = std::collections::HashSet::new();
seen.insert(self.default_locale.clone());
self.fallback_chain = chain
.iter()
.map(|s| Locale::new(*s))
.filter(|loc| seen.insert(loc.clone()))
.collect();
self
}
#[must_use]
pub fn fallback_chain(&self) -> &[Locale] {
&self.fallback_chain
}
#[must_use]
pub fn add_locale(self, locale: Locale, catalog: HashMap<String, String>) -> Self {
self.catalogs
.write()
.expect("translator poisoned")
.insert(locale, catalog);
self
}
pub fn insert_locale(&self, locale: Locale, catalog: HashMap<String, String>) {
self.catalogs
.write()
.expect("translator poisoned")
.insert(locale, catalog);
}
#[must_use]
pub fn translate(&self, locale: &str, key: &str, params: &[(&str, &str)]) -> String {
let req = Locale::new(locale);
{
let ov = self.overrides.read().expect("translator poisoned");
if let Some(v) = ov.get(&req).and_then(|c| c.get(key)).or_else(|| {
ov.get(&Locale::new(req.base_language()))
.and_then(|c| c.get(key))
}) {
return substitute(v, params);
}
}
let cats = self.catalogs.read().expect("translator poisoned");
let mut template: Option<String> = cats
.get(&req)
.and_then(|c| c.get(key))
.or_else(|| {
cats.get(&Locale::new(req.base_language()))
.and_then(|c| c.get(key))
})
.cloned();
if template.is_none() {
for fb in &self.fallback_chain {
if let Some(v) = cats.get(fb).and_then(|c| c.get(key)) {
template = Some(v.clone());
break;
}
}
}
let template = template
.or_else(|| {
cats.get(&self.default_locale)
.and_then(|c| c.get(key))
.cloned()
})
.unwrap_or_else(|| key.to_owned());
substitute(&template, params)
}
#[must_use]
pub fn has_locale(&self, locale: &str) -> bool {
let cats = self.catalogs.read().expect("translator poisoned");
let req = Locale::new(locale);
cats.contains_key(&req) || cats.contains_key(&Locale::new(req.base_language()))
}
#[must_use]
pub fn locales(&self) -> Vec<String> {
self.catalogs
.read()
.expect("translator poisoned")
.keys()
.map(|l| l.0.clone())
.collect()
}
#[must_use]
pub fn entries(&self) -> Vec<(String, String, String)> {
let cats = self.catalogs.read().expect("translator poisoned");
let mut out = Vec::new();
for (loc, catalog) in cats.iter() {
for (k, v) in catalog {
out.push((loc.0.clone(), k.clone(), v.clone()));
}
}
out
}
pub fn load_overrides(&self, rows: impl IntoIterator<Item = (String, String, String)>) {
let mut map: HashMap<Locale, HashMap<String, String>> = HashMap::new();
for (locale, key, value) in rows {
map.entry(Locale::new(&locale))
.or_default()
.insert(key, value);
}
*self.overrides.write().expect("translator poisoned") = map;
}
pub fn set_override(&self, locale: &str, key: impl Into<String>, value: impl Into<String>) {
self.overrides
.write()
.expect("translator poisoned")
.entry(Locale::new(locale))
.or_default()
.insert(key.into(), value.into());
}
pub fn clear_overrides(&self) {
self.overrides.write().expect("translator poisoned").clear();
}
#[must_use]
pub fn override_count(&self) -> usize {
self.overrides
.read()
.expect("translator poisoned")
.values()
.map(HashMap::len)
.sum()
}
pub fn from_directory(dir: &Path, default_locale: Locale) -> Result<Self, I18nError> {
let t = Translator::new(default_locale);
let entries = std::fs::read_dir(dir).map_err(|e| I18nError::Io(e.to_string()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let raw = std::fs::read_to_string(&path).map_err(|e| I18nError::Io(e.to_string()))?;
let catalog: HashMap<String, String> =
serde_json::from_str(&raw).map_err(|e| I18nError::Parse {
file: path.display().to_string(),
detail: e.to_string(),
})?;
t.insert_locale(Locale::new(stem), catalog);
}
Ok(t)
}
#[cfg(feature = "config")]
pub fn from_settings(settings: &crate::config::I18nSettings) -> Result<Self, I18nError> {
let default = settings.default_locale.as_deref().unwrap_or("en");
let mut t = Translator::new(Locale::new(default));
let allowlist: Option<std::collections::HashSet<String>> = if settings.languages.is_empty()
{
None
} else {
Some(settings.languages.iter().cloned().collect())
};
for raw_path in &settings.locale_paths {
let path = std::path::Path::new(raw_path);
let entries = std::fs::read_dir(path)
.map_err(|e| I18nError::Io(format!("{}: {e}", path.display())))?;
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Some(allow) = &allowlist {
if !allow.contains(stem) {
continue;
}
}
let raw = std::fs::read_to_string(&p).map_err(|e| I18nError::Io(e.to_string()))?;
let catalog: HashMap<String, String> =
serde_json::from_str(&raw).map_err(|e| I18nError::Parse {
file: p.display().to_string(),
detail: e.to_string(),
})?;
t.insert_locale(Locale::new(stem), catalog);
}
}
if !settings.fallback_chain.is_empty() {
let chain: Vec<&str> = settings.fallback_chain.iter().map(String::as_str).collect();
t = t.with_fallback_chain(&chain);
}
Ok(t)
}
#[must_use]
pub fn gettext(&self, locale: &str, key: &str) -> String {
self.translate(locale, key, &[])
}
#[must_use]
pub fn gettext_fmt(&self, locale: &str, key: &str, params: &[(&str, &str)]) -> String {
self.translate(locale, key, params)
}
#[must_use]
pub fn pgettext(&self, locale: &str, context: &str, key: &str) -> String {
let composite = format!("{context}\u{0004}{key}");
let translated = self.translate(locale, &composite, &[]);
if translated == composite {
return self.translate(locale, key, &[]);
}
translated
}
#[must_use]
pub fn pgettext_fmt(
&self,
locale: &str,
context: &str,
key: &str,
params: &[(&str, &str)],
) -> String {
let raw = self.pgettext(locale, context, key);
substitute(&raw, params)
}
#[must_use]
pub fn ngettext(&self, locale: &str, singular: &str, plural: &str, count: i64) -> String {
let key = if count == 1 { singular } else { plural };
let count_str = count.to_string();
self.translate(locale, key, &[("count", &count_str)])
}
#[must_use]
pub fn ngettext_fmt(
&self,
locale: &str,
singular: &str,
plural: &str,
count: i64,
params: &[(&str, &str)],
) -> String {
let key = if count == 1 { singular } else { plural };
let count_str = count.to_string();
let mut all: Vec<(&str, &str)> = Vec::with_capacity(params.len() + 1);
all.push(("count", &count_str));
all.extend_from_slice(params);
self.translate(locale, key, &all)
}
}
fn substitute(template: &str, params: &[(&str, &str)]) -> String {
let mut out = template.to_owned();
for (name, value) in params {
let placeholder = format!("{{{name}}}");
out = out.replace(&placeholder, value);
}
out
}
#[must_use]
pub fn negotiate_language<S: AsRef<str>>(accept_language: &str, available: &[S]) -> Option<String> {
let mut prefs = parse_accept_language(accept_language);
prefs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let avail_lower: Vec<String> = available
.iter()
.map(|s| s.as_ref().to_lowercase())
.collect();
for (lang, _q) in prefs {
let lang_lower = lang.to_lowercase();
if let Some(matched) = avail_lower.iter().find(|a| **a == lang_lower) {
return Some(matched.clone());
}
let base = lang_lower.split('-').next().unwrap_or(&lang_lower);
if let Some(matched) = avail_lower
.iter()
.find(|a| **a == base || a.starts_with(&format!("{base}-")))
{
return Some(matched.clone());
}
}
None
}
fn parse_accept_language(header: &str) -> Vec<(String, f32)> {
header
.split(',')
.filter_map(|raw| {
let mut parts = raw.split(';').map(str::trim);
let lang = parts.next()?.to_owned();
if lang.is_empty() {
return None;
}
let mut q = 1.0;
for kv in parts {
if let Some(rest) = kv.strip_prefix("q=") {
if let Ok(parsed) = rest.parse::<f32>() {
q = parsed;
}
}
}
Some((lang, q))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_translator() -> Translator {
let mut en = HashMap::new();
en.insert("hello".into(), "Hello".into());
en.insert("greet".into(), "Hi, {name}!".into());
let mut fr = HashMap::new();
fr.insert("hello".into(), "Bonjour".into());
fr.insert("greet".into(), "Salut, {name} !".into());
Translator::new(Locale::new("en"))
.add_locale(Locale::new("en"), en)
.add_locale(Locale::new("fr"), fr)
}
#[test]
fn translate_basic() {
let t = make_translator();
assert_eq!(t.translate("en", "hello", &[]), "Hello");
assert_eq!(t.translate("fr", "hello", &[]), "Bonjour");
}
#[test]
fn translate_substitutes_params() {
let t = make_translator();
assert_eq!(
t.translate("en", "greet", &[("name", "Alice")]),
"Hi, Alice!"
);
assert_eq!(
t.translate("fr", "greet", &[("name", "Alice")]),
"Salut, Alice !"
);
}
#[test]
fn unknown_locale_falls_back_to_default() {
let t = make_translator();
assert_eq!(t.translate("ja", "hello", &[]), "Hello");
}
#[test]
fn unknown_key_returns_key_itself() {
let t = make_translator();
assert_eq!(t.translate("en", "unknown.key", &[]), "unknown.key");
}
#[test]
fn region_falls_back_to_base_language() {
let t = make_translator();
assert_eq!(t.translate("fr-FR", "hello", &[]), "Bonjour");
}
#[test]
fn has_locale_with_base_match() {
let t = make_translator();
assert!(t.has_locale("en"));
assert!(t.has_locale("fr"));
assert!(t.has_locale("en-US"));
assert!(!t.has_locale("ja"));
}
#[test]
fn fallback_chain_walks_in_order() {
let mut es: HashMap<String, String> = HashMap::new();
es.insert("welcome".into(), "Bienvenido".into());
let mut pt: HashMap<String, String> = HashMap::new();
pt.insert("hello".into(), "Olá".into());
let mut en: HashMap<String, String> = HashMap::new();
en.insert("hello".into(), "Hello".into());
let t = Translator::new(Locale::new("en"))
.with_fallback_chain(&["pt"])
.add_locale(Locale::new("es"), es)
.add_locale(Locale::new("pt"), pt)
.add_locale(Locale::new("en"), en);
assert_eq!(t.translate("es", "welcome", &[]), "Bienvenido");
assert_eq!(t.translate("es", "hello", &[]), "Olá");
}
#[test]
fn fallback_chain_then_default() {
let mut en: HashMap<String, String> = HashMap::new();
en.insert("hello".into(), "Hello".into());
let pt: HashMap<String, String> = HashMap::new();
let t = Translator::new(Locale::new("en"))
.with_fallback_chain(&["pt"])
.add_locale(Locale::new("pt"), pt)
.add_locale(Locale::new("en"), en);
assert_eq!(t.translate("es", "hello", &[]), "Hello");
}
#[test]
fn fallback_chain_accessor_deduplicates() {
let t = Translator::new(Locale::new("en")).with_fallback_chain(&["pt", "pt", "en", "es"]);
let chain: Vec<&str> = t.fallback_chain().iter().map(Locale::as_str).collect();
assert_eq!(chain, vec!["pt", "es"]);
}
#[test]
fn locales_lists_registered() {
let t = make_translator();
let mut locales = t.locales();
locales.sort();
assert_eq!(locales, vec!["en".to_string(), "fr".to_string()]);
}
#[test]
fn locale_is_rtl_for_arabic_hebrew_persian_urdu() {
for code in ["ar", "he", "fa", "ur", "ps", "yi", "dv", "ckb", "ug", "sd"] {
let loc = Locale::new(code);
assert!(loc.is_rtl(), "{code} should be RTL");
assert_eq!(loc.direction(), "rtl", "{code} direction should be rtl");
}
}
#[test]
fn locale_is_ltr_for_western_and_cjk() {
for code in ["en", "fr", "de", "es", "ja", "zh", "ko", "ru", "tr", "pt"] {
let loc = Locale::new(code);
assert!(!loc.is_rtl(), "{code} should be LTR");
assert_eq!(loc.direction(), "ltr", "{code} direction should be ltr");
}
}
#[test]
fn rtl_check_uses_base_language_for_region_subtag() {
assert!(Locale::new("ar-EG").is_rtl());
assert!(Locale::new("AR-SA").is_rtl()); assert!(Locale::new("he-IL").is_rtl());
assert!(!Locale::new("en-US").is_rtl());
assert!(!Locale::new("pt-BR").is_rtl());
}
#[test]
fn rtl_check_handles_retired_iso_codes() {
assert!(Locale::new("iw").is_rtl()); assert!(Locale::new("ji").is_rtl()); }
#[test]
fn display_name_returns_english_label_for_known_locales() {
assert_eq!(Locale::new("en").display_name(), "English");
assert_eq!(Locale::new("fr-FR").display_name(), "French");
assert_eq!(Locale::new("zh-CN").display_name(), "Chinese");
assert_eq!(Locale::new("ar-EG").display_name(), "Arabic");
assert_eq!(Locale::new("he-IL").display_name(), "Hebrew");
assert_eq!(Locale::new("iw").display_name(), "Hebrew"); }
#[test]
fn native_name_returns_endonym_for_known_locales() {
assert_eq!(Locale::new("en").native_name(), "English");
assert_eq!(Locale::new("fr-CA").native_name(), "français");
assert_eq!(Locale::new("ja").native_name(), "日本語");
assert_eq!(Locale::new("zh-TW").native_name(), "中文");
assert_eq!(Locale::new("ar").native_name(), "العربية");
assert_eq!(Locale::new("he").native_name(), "עברית");
}
#[test]
fn unknown_locale_falls_back_to_unknown_label() {
assert_eq!(Locale::new("xx").display_name(), "Unknown");
assert_eq!(Locale::new("xx").native_name(), "Unknown");
}
#[test]
fn norwegian_variants_share_one_label() {
for code in ["no", "nb", "nn", "nb-NO", "nn-NO"] {
assert_eq!(Locale::new(code).display_name(), "Norwegian", "{code}");
assert_eq!(Locale::new(code).native_name(), "norsk", "{code}");
}
}
#[test]
fn bare_string_helpers_match_locale_methods() {
for code in ["ar", "fa-IR", "he-IL", "iw"] {
assert!(is_rtl_language(code), "{code} should be RTL");
assert_eq!(text_direction(code), "rtl");
}
for code in ["en", "fr-FR", "ja", "zh-CN"] {
assert!(!is_rtl_language(code), "{code} should be LTR");
assert_eq!(text_direction(code), "ltr");
}
}
#[test]
fn negotiate_picks_highest_q() {
let lang = negotiate_language("en;q=0.5,fr;q=0.9,de;q=0.1", &["en", "fr", "de"]);
assert_eq!(lang.as_deref(), Some("fr"));
}
#[test]
fn negotiate_falls_back_to_base() {
let lang = negotiate_language("fr-FR,fr;q=0.9,en;q=0.8", &["en", "fr"]);
assert_eq!(lang.as_deref(), Some("fr"));
}
#[test]
fn negotiate_no_match_returns_none() {
let lang = negotiate_language("ja,zh", &["en", "fr"]);
assert_eq!(lang, None);
}
#[test]
fn negotiate_uses_default_q_of_1() {
let lang = negotiate_language("en,fr;q=0.5", &["en", "fr"]);
assert_eq!(lang.as_deref(), Some("en"));
}
#[test]
fn negotiate_empty_accept_language_returns_none() {
let lang = negotiate_language("", &["en", "fr"]);
assert_eq!(lang, None);
}
fn translator_with_en_fr() -> Translator {
let mut en = HashMap::new();
en.insert("welcome".into(), "Welcome, {name}!".into());
en.insert(
"count_unread".into(),
"You have {count} unread message.".into(),
);
en.insert(
"count_unread_plural".into(),
"You have {count} unread messages.".into(),
);
en.insert("verb\u{0004}save".into(), "Save".into()); en.insert("noun\u{0004}save".into(), "Discount".into()); en.insert("save".into(), "Save (bare)".into());
let mut fr = HashMap::new();
fr.insert("welcome".into(), "Bienvenue, {name} !".into());
fr.insert(
"count_unread".into(),
"Vous avez {count} message non lu.".into(),
);
fr.insert(
"count_unread_plural".into(),
"Vous avez {count} messages non lus.".into(),
);
Translator::new(Locale::new("en"))
.add_locale(Locale::new("en"), en)
.add_locale(Locale::new("fr"), fr)
}
#[test]
fn gettext_returns_translated_string_no_params() {
let t = translator_with_en_fr();
assert_eq!(t.gettext("en", "welcome"), "Welcome, {name}!");
}
#[test]
fn gettext_fmt_substitutes_placeholders() {
let t = translator_with_en_fr();
assert_eq!(
t.gettext_fmt("fr", "welcome", &[("name", "Alice")]),
"Bienvenue, Alice !"
);
}
#[test]
fn gettext_falls_back_to_key_on_miss() {
let t = translator_with_en_fr();
assert_eq!(t.gettext("en", "no_such_key"), "no_such_key");
}
#[test]
fn pgettext_uses_context_disambiguated_entry() {
let t = translator_with_en_fr();
assert_eq!(t.pgettext("en", "verb", "save"), "Save");
assert_eq!(t.pgettext("en", "noun", "save"), "Discount");
}
#[test]
fn pgettext_falls_back_to_bare_key_when_context_entry_missing() {
let t = translator_with_en_fr();
assert_eq!(t.pgettext("en", "adjective", "save"), "Save (bare)");
}
#[test]
fn pgettext_fmt_substitutes_placeholders() {
let mut en = HashMap::new();
en.insert("button\u{0004}greet".into(), "Hi, {name}".into());
let t = Translator::new(Locale::new("en")).add_locale(Locale::new("en"), en);
assert_eq!(
t.pgettext_fmt("en", "button", "greet", &[("name", "Bob")]),
"Hi, Bob"
);
}
#[test]
fn ngettext_picks_singular_when_count_is_one() {
let t = translator_with_en_fr();
let s = t.ngettext("en", "count_unread", "count_unread_plural", 1);
assert_eq!(s, "You have 1 unread message.");
}
#[test]
fn ngettext_picks_plural_for_zero_and_many() {
let t = translator_with_en_fr();
let s0 = t.ngettext("en", "count_unread", "count_unread_plural", 0);
let s5 = t.ngettext("en", "count_unread", "count_unread_plural", 5);
assert_eq!(s0, "You have 0 unread messages.");
assert_eq!(s5, "You have 5 unread messages.");
}
#[test]
fn ngettext_works_in_french_locale() {
let t = translator_with_en_fr();
let s1 = t.ngettext("fr", "count_unread", "count_unread_plural", 1);
let s3 = t.ngettext("fr", "count_unread", "count_unread_plural", 3);
assert_eq!(s1, "Vous avez 1 message non lu.");
assert_eq!(s3, "Vous avez 3 messages non lus.");
}
#[test]
fn ngettext_fmt_overlays_extra_placeholders() {
let mut en = HashMap::new();
en.insert(
"item_singular".into(),
"{count} {item} sold to {customer}.".into(),
);
en.insert(
"item_plural".into(),
"{count} {item}s sold to {customer}.".into(),
);
let t = Translator::new(Locale::new("en")).add_locale(Locale::new("en"), en);
let s = t.ngettext_fmt(
"en",
"item_singular",
"item_plural",
2,
&[("item", "book"), ("customer", "Alice")],
);
assert_eq!(s, "2 books sold to Alice.");
}
}