use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type", content = "value")]
pub enum I18nValue {
String(String),
I64(i64),
F64(f64),
Bool(bool),
}
impl From<&str> for I18nValue {
fn from(s: &str) -> Self {
I18nValue::String(s.to_owned())
}
}
impl From<String> for I18nValue {
fn from(s: String) -> Self {
I18nValue::String(s)
}
}
impl From<i64> for I18nValue {
fn from(n: i64) -> Self {
I18nValue::I64(n)
}
}
impl From<i32> for I18nValue {
fn from(n: i32) -> Self {
I18nValue::I64(n as i64)
}
}
impl From<u32> for I18nValue {
fn from(n: u32) -> Self {
I18nValue::I64(n as i64)
}
}
impl From<f64> for I18nValue {
fn from(n: f64) -> Self {
I18nValue::F64(n)
}
}
impl From<bool> for I18nValue {
fn from(b: bool) -> Self {
I18nValue::Bool(b)
}
}
pub type TransVars = HashMap<String, I18nValue>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum I18nError {
LocaleUnavailable(String),
FormatError(String),
Unavailable(String),
Unknown(String),
}
impl I18nError {
pub fn locale_unavailable(msg: impl Into<String>) -> Self {
I18nError::LocaleUnavailable(msg.into())
}
pub fn format_error(msg: impl Into<String>) -> Self {
I18nError::FormatError(msg.into())
}
pub fn unavailable(msg: impl Into<String>) -> Self {
I18nError::Unavailable(msg.into())
}
pub fn unknown(msg: impl Into<String>) -> Self {
I18nError::Unknown(msg.into())
}
pub fn message(&self) -> &str {
match self {
I18nError::LocaleUnavailable(m)
| I18nError::FormatError(m)
| I18nError::Unavailable(m)
| I18nError::Unknown(m) => m,
}
}
}
impl std::fmt::Display for I18nError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tag = match self {
I18nError::LocaleUnavailable(_) => "locale-unavailable",
I18nError::FormatError(_) => "format-error",
I18nError::Unavailable(_) => "unavailable",
I18nError::Unknown(_) => "unknown",
};
write!(f, "i18n {}: {}", tag, self.message())
}
}
impl std::error::Error for I18nError {}
pub trait I18nPlugin: Send + Sync {
fn name(&self) -> &str;
fn translate(
&self,
key: &str,
locale: &str,
vars: &TransVars,
) -> Result<String, I18nError>;
fn supported_locales(&self) -> Vec<String>;
fn fallback_locale(&self) -> String;
fn has_locale(&self, locale: &str) -> bool {
self.supported_locales()
.iter()
.any(|l| l.eq_ignore_ascii_case(locale))
}
fn negotiate(&self, requested: &str) -> String {
let mut candidate = requested.to_owned();
loop {
if self.has_locale(&candidate) {
return candidate;
}
match candidate.rfind('-') {
Some(idx) => candidate.truncate(idx),
None => return self.fallback_locale(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn i18n_value_from_impls() {
assert_eq!(I18nValue::from("x"), I18nValue::String("x".into()));
assert_eq!(I18nValue::from(String::from("x")), I18nValue::String("x".into()));
assert_eq!(I18nValue::from(5i64), I18nValue::I64(5));
assert_eq!(I18nValue::from(5i32), I18nValue::I64(5));
assert_eq!(I18nValue::from(5u32), I18nValue::I64(5));
assert_eq!(I18nValue::from(1.5f64), I18nValue::F64(1.5));
assert_eq!(I18nValue::from(true), I18nValue::Bool(true));
}
#[test]
fn i18n_error_helpers_and_display() {
let e = I18nError::locale_unavailable("en missing");
assert!(matches!(e, I18nError::LocaleUnavailable(_)));
assert_eq!(e.message(), "en missing");
assert!(e.to_string().contains("locale-unavailable"));
assert!(e.to_string().contains("en missing"));
let f = I18nError::format_error("bad placeholder");
assert!(matches!(f, I18nError::FormatError(_)));
}
struct FakeI18n {
locales: Vec<String>,
fallback: String,
}
impl I18nPlugin for FakeI18n {
fn name(&self) -> &str {
"fake"
}
fn translate(
&self,
key: &str,
_locale: &str,
_vars: &TransVars,
) -> Result<String, I18nError> {
Ok(format!("{{{}}}", key))
}
fn supported_locales(&self) -> Vec<String> {
self.locales.clone()
}
fn fallback_locale(&self) -> String {
self.fallback.clone()
}
}
#[test]
fn default_has_locale_is_case_insensitive() {
let p = FakeI18n {
locales: vec!["en".into(), "fr-CA".into(), "zh-Hant-TW".into()],
fallback: "en".into(),
};
assert!(p.has_locale("en"));
assert!(p.has_locale("EN"));
assert!(p.has_locale("fr-ca"));
assert!(p.has_locale("zh-hant-tw"));
assert!(!p.has_locale("de"));
}
#[test]
fn default_negotiate_walks_to_broader_tag() {
let p = FakeI18n {
locales: vec!["en".into(), "fr".into()],
fallback: "en".into(),
};
assert_eq!(p.negotiate("en"), "en");
assert_eq!(p.negotiate("fr-CA"), "fr");
assert_eq!(p.negotiate("zh-Hant-TW"), "en");
assert_eq!(p.negotiate("de"), "en");
}
#[test]
fn default_negotiate_exact_multi_tag_match() {
let p = FakeI18n {
locales: vec!["en".into(), "fr-CA".into(), "fr".into()],
fallback: "en".into(),
};
assert_eq!(p.negotiate("fr-CA"), "fr-CA");
assert_eq!(p.negotiate("fr-FR"), "fr");
}
}