use std::collections::HashMap;
#[cfg(feature = "fs-loader")]
use std::path::Path;
use crate::core::Reloadable;
use crate::error::I18nError;
use crate::flat_backend::FlatBackend;
fn parse_toml(locale: &str, content: &str) -> Result<HashMap<String, String>, I18nError> {
let data: toml::map::Map<String, toml::Value> =
toml::from_str(content).map_err(|e| I18nError::TomlParseError {
locale: locale.to_string(),
source: e,
})?;
let mut messages = HashMap::new();
flatten_toml(&data, "", &mut messages);
Ok(messages)
}
fn flatten_toml(
values: &toml::map::Map<String, toml::Value>,
prefix: &str,
out: &mut HashMap<String, String>,
) {
for (key, value) in values {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match value {
toml::Value::String(s) => {
out.insert(full_key, s.clone());
}
toml::Value::Table(table) => {
let is_plural = table.keys().any(|k| {
matches!(
k.as_str(),
"zero" | "one" | "two" | "few" | "many" | "other"
)
});
if is_plural {
for (cat, val) in table {
if let toml::Value::String(s) = val {
out.insert(format!("{full_key}.{cat}"), s.clone());
}
}
} else {
flatten_toml(table, &full_key, out);
}
}
toml::Value::Integer(i) => {
out.insert(full_key, i.to_string());
}
_ => {
}
}
}
}
pub struct TomlBackend {
inner: FlatBackend,
}
impl Default for TomlBackend {
fn default() -> Self {
Self::new()
}
}
impl TomlBackend {
pub fn new() -> Self {
Self {
inner: FlatBackend::new(),
}
}
pub fn from_embedded(pairs: &[(&str, &str)]) -> Result<Self, I18nError> {
let backend = Self::new();
for (locale, content) in pairs {
backend.add_locale_from_str(locale, content)?;
}
Ok(backend)
}
#[cfg(feature = "fs-loader")]
pub fn from_dir(path: impl AsRef<Path>) -> Result<Self, I18nError> {
let backend = Self::new();
backend
.inner
.load_dir(path, "toml", &|locale, content| parse_toml(locale, content))?;
Ok(backend)
}
#[cfg(feature = "fs-loader")]
pub fn try_from_dir(path: impl AsRef<Path>) -> Result<Self, I18nError> {
let backend = Self::new();
backend
.inner
.try_load_dir(path, "toml", &|locale, content| parse_toml(locale, content))?;
Ok(backend)
}
pub fn add_locale_from_str(&self, locale: &str, content: &str) -> Result<(), I18nError> {
let messages = parse_toml(locale, content)?;
self.inner.add_locale(locale, messages);
Ok(())
}
pub fn inner(&self) -> &FlatBackend {
&self.inner
}
}
impl crate::core::Backend for TomlBackend {
fn get(&self, locale: &str, key: &str) -> Option<String> {
self.inner.get(locale, key)
}
fn available_locales(&self) -> Vec<String> {
self.inner.available_locales()
}
fn has_locale(&self, locale: &str) -> bool {
self.inner.has_locale(locale)
}
fn dump(&self, locale: &str) -> HashMap<String, String> {
self.inner.dump(locale)
}
}
impl Reloadable for TomlBackend {
fn reload_from_str(&self, locale: &str, content: &str) -> Result<(), I18nError> {
self.add_locale_from_str(locale, content)
}
fn file_extension(&self) -> &'static str {
"toml"
}
}
pub struct ChainedBackend {
backends: Vec<std::sync::Arc<dyn crate::core::Backend>>,
}
impl ChainedBackend {
pub fn new(backends: Vec<std::sync::Arc<dyn crate::core::Backend>>) -> Self {
Self { backends }
}
pub fn push_front(&mut self, backend: std::sync::Arc<dyn crate::core::Backend>) {
self.backends.insert(0, backend);
}
pub fn push_back(&mut self, backend: std::sync::Arc<dyn crate::core::Backend>) {
self.backends.push(backend);
}
}
impl crate::core::Backend for ChainedBackend {
fn get(&self, locale: &str, key: &str) -> Option<String> {
for backend in &self.backends {
if let Some(value) = backend.get(locale, key) {
return Some(value);
}
}
None
}
fn available_locales(&self) -> Vec<String> {
let mut locales = std::collections::HashSet::new();
for backend in &self.backends {
for locale in backend.available_locales() {
locales.insert(locale);
}
}
locales.into_iter().collect()
}
fn has_locale(&self, locale: &str) -> bool {
self.backends.iter().any(|b| b.has_locale(locale))
}
fn dump(&self, locale: &str) -> HashMap<String, String> {
let mut merged = HashMap::new();
for backend in self.backends.iter().rev() {
for (k, v) in backend.dump(locale) {
merged.insert(k, v);
}
}
merged
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Backend;
use std::sync::Arc;
#[test]
fn test_flatten_simple() {
let toml_str = r#"
app.title = "My App"
app.desc = "A cool app"
"#;
let backend = TomlBackend::new();
backend.add_locale_from_str("en", toml_str).unwrap();
assert_eq!(backend.get("en", "app.title").unwrap(), "My App");
assert_eq!(backend.get("en", "app.desc").unwrap(), "A cool app");
}
#[test]
fn test_flatten_nested_table() {
let toml_str = r#"
[errors]
not_found = "Not found"
unauthorized = "Unauthorized"
[errors.auth]
expired = "Session expired"
"#;
let backend = TomlBackend::new();
backend.add_locale_from_str("en", toml_str).unwrap();
assert_eq!(backend.get("en", "errors.not_found").unwrap(), "Not found");
assert_eq!(
backend.get("en", "errors.auth.expired").unwrap(),
"Session expired"
);
}
#[test]
fn test_plural_group() {
let toml_str = r#"
[items]
zero = "No items"
one = "One item"
other = "%{count} items"
"#;
let backend = TomlBackend::new();
backend.add_locale_from_str("en", toml_str).unwrap();
assert_eq!(backend.get("en", "items.zero").unwrap(), "No items");
assert_eq!(backend.get("en", "items.one").unwrap(), "One item");
assert_eq!(backend.get("en", "items.other").unwrap(), "%{count} items");
}
#[test]
fn test_toml_backend_implements_reloadable() {
let backend = TomlBackend::new();
assert_eq!(backend.file_extension(), "toml");
backend.reload_from_str("en", r#"hello = "Hi""#).unwrap();
assert_eq!(backend.get("en", "hello").unwrap(), "Hi");
}
#[test]
fn test_chained_backend() {
let b1 = {
let b = TomlBackend::new();
b.add_locale_from_str("en", r#"hello = "Hi""#).unwrap();
b.add_locale_from_str("zh-CN", r#"hello = "你好""#).unwrap();
Arc::new(b) as Arc<dyn crate::core::Backend>
};
let b2 = {
let b = TomlBackend::new();
b.add_locale_from_str(
"en",
r#"hello = "Hello"
bye = "Goodbye""#,
)
.unwrap();
Arc::new(b) as Arc<dyn crate::core::Backend>
};
let chained = ChainedBackend::new(vec![b1, b2]);
assert_eq!(chained.get("en", "hello").unwrap(), "Hi");
assert_eq!(chained.get("en", "bye").unwrap(), "Goodbye");
assert!(chained.has_locale("zh-CN"));
}
#[cfg(feature = "fs-loader")]
#[test]
fn test_try_from_dir_missing_directory() {
let backend = TomlBackend::try_from_dir("/no/such/i18n/dir").unwrap();
assert!(backend.available_locales().is_empty());
}
#[cfg(feature = "fs-loader")]
#[test]
fn test_try_from_dir_existing_directory() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("en.toml"),
r#"hello = "Hi"
bye = "Goodbye""#,
)
.unwrap();
let backend = TomlBackend::try_from_dir(dir.path()).unwrap();
assert_eq!(backend.get("en", "hello").unwrap(), "Hi");
assert_eq!(backend.get("en", "bye").unwrap(), "Goodbye");
}
#[test]
fn test_chained_backend_dump_priority() {
let b1 = {
let b = TomlBackend::new();
b.add_locale_from_str("en", r#"a = "A1""#).unwrap();
Arc::new(b) as Arc<dyn crate::core::Backend>
};
let b2 = {
let b = TomlBackend::new();
b.add_locale_from_str(
"en",
r#"a = "A2"
b = "B""#,
)
.unwrap();
Arc::new(b) as Arc<dyn crate::core::Backend>
};
let chained = ChainedBackend::new(vec![b1, b2]);
let dumped = chained.dump("en");
assert_eq!(dumped.get("a").unwrap(), "A1");
assert_eq!(dumped.get("b").unwrap(), "B");
assert_eq!(dumped.len(), 2);
assert!(chained.dump("zh-CN").is_empty());
}
#[test]
fn test_toml_backend_dump() {
let backend = TomlBackend::new();
backend
.add_locale_from_str(
"en",
r#"hello = "Hi"
bye = "Goodbye""#,
)
.unwrap();
let dumped = backend.dump("en");
assert_eq!(dumped.get("hello").unwrap(), "Hi");
assert_eq!(dumped.get("bye").unwrap(), "Goodbye");
assert_eq!(dumped.len(), 2);
assert!(backend.dump("zh-CN").is_empty());
}
#[cfg(feature = "fs-loader")]
#[test]
fn test_compiled_plus_optional_runtime_override() {
let compiled = {
let b = TomlBackend::new();
b.add_locale_from_str(
"en",
r#"hello = "Hello"
bye = "Goodbye"
welcome = "Welcome""#,
)
.unwrap();
Arc::new(b) as Arc<dyn crate::core::Backend>
};
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("en.toml"), r#"hello = "Hi (overridden)""#).unwrap();
let runtime = TomlBackend::try_from_dir(dir.path()).unwrap();
let chained = ChainedBackend::new(vec![
Arc::new(runtime) as Arc<dyn crate::core::Backend>,
compiled,
]);
assert_eq!(chained.get("en", "hello").unwrap(), "Hi (overridden)");
assert_eq!(chained.get("en", "bye").unwrap(), "Goodbye");
assert_eq!(chained.get("en", "welcome").unwrap(), "Welcome");
}
}