1use std::collections::HashMap;
4#[cfg(feature = "fs-loader")]
5use std::path::Path;
6
7use crate::core::Reloadable;
8use crate::error::I18nError;
9use crate::flat_backend::FlatBackend;
10
11fn parse_toml(locale: &str, content: &str) -> Result<HashMap<String, String>, I18nError> {
18 let data: toml::map::Map<String, toml::Value> =
19 toml::from_str(content).map_err(|e| I18nError::TomlParseError {
20 locale: locale.to_string(),
21 source: e,
22 })?;
23
24 let mut messages = HashMap::new();
25 flatten_toml(&data, "", &mut messages);
26 Ok(messages)
27}
28
29fn flatten_toml(
31 values: &toml::map::Map<String, toml::Value>,
32 prefix: &str,
33 out: &mut HashMap<String, String>,
34) {
35 for (key, value) in values {
36 let full_key = if prefix.is_empty() {
37 key.clone()
38 } else {
39 format!("{prefix}.{key}")
40 };
41
42 match value {
43 toml::Value::String(s) => {
44 out.insert(full_key, s.clone());
45 }
46 toml::Value::Table(table) => {
47 let is_plural = table.keys().any(|k| {
49 matches!(
50 k.as_str(),
51 "zero" | "one" | "two" | "few" | "many" | "other"
52 )
53 });
54
55 if is_plural {
56 for (cat, val) in table {
58 if let toml::Value::String(s) = val {
59 out.insert(format!("{full_key}.{cat}"), s.clone());
60 }
61 }
62 } else {
63 flatten_toml(table, &full_key, out);
65 }
66 }
67 toml::Value::Integer(i) => {
68 out.insert(full_key, i.to_string());
69 }
70 _ => {
71 }
73 }
74 }
75}
76
77pub struct TomlBackend {
85 inner: FlatBackend,
86}
87
88impl Default for TomlBackend {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl TomlBackend {
95 pub fn new() -> Self {
97 Self {
98 inner: FlatBackend::new(),
99 }
100 }
101
102 pub fn from_embedded(pairs: &[(&str, &str)]) -> Result<Self, I18nError> {
112 let backend = Self::new();
113 for (locale, content) in pairs {
114 backend.add_locale_from_str(locale, content)?;
115 }
116 Ok(backend)
117 }
118
119 #[cfg(feature = "fs-loader")]
123 pub fn from_dir(path: impl AsRef<Path>) -> Result<Self, I18nError> {
124 let backend = Self::new();
125 backend
126 .inner
127 .load_dir(path, "toml", &|locale, content| parse_toml(locale, content))?;
128 Ok(backend)
129 }
130
131 #[cfg(feature = "fs-loader")]
137 pub fn try_from_dir(path: impl AsRef<Path>) -> Result<Self, I18nError> {
138 let backend = Self::new();
139 backend
140 .inner
141 .try_load_dir(path, "toml", &|locale, content| parse_toml(locale, content))?;
142 Ok(backend)
143 }
144
145 pub fn add_locale_from_str(&self, locale: &str, content: &str) -> Result<(), I18nError> {
147 let messages = parse_toml(locale, content)?;
148 self.inner.add_locale(locale, messages);
149 Ok(())
150 }
151
152 pub fn inner(&self) -> &FlatBackend {
154 &self.inner
155 }
156}
157
158impl crate::core::Backend for TomlBackend {
159 fn get(&self, locale: &str, key: &str) -> Option<String> {
160 self.inner.get(locale, key)
161 }
162
163 fn available_locales(&self) -> Vec<String> {
164 self.inner.available_locales()
165 }
166
167 fn has_locale(&self, locale: &str) -> bool {
168 self.inner.has_locale(locale)
169 }
170
171 fn dump(&self, locale: &str) -> HashMap<String, String> {
172 self.inner.dump(locale)
173 }
174}
175
176impl Reloadable for TomlBackend {
177 fn reload_from_str(&self, locale: &str, content: &str) -> Result<(), I18nError> {
178 self.add_locale_from_str(locale, content)
179 }
180
181 fn file_extension(&self) -> &'static str {
182 "toml"
183 }
184}
185
186pub struct ChainedBackend {
194 backends: Vec<std::sync::Arc<dyn crate::core::Backend>>,
195}
196
197impl ChainedBackend {
198 pub fn new(backends: Vec<std::sync::Arc<dyn crate::core::Backend>>) -> Self {
202 Self { backends }
203 }
204
205 pub fn push_front(&mut self, backend: std::sync::Arc<dyn crate::core::Backend>) {
207 self.backends.insert(0, backend);
208 }
209
210 pub fn push_back(&mut self, backend: std::sync::Arc<dyn crate::core::Backend>) {
212 self.backends.push(backend);
213 }
214}
215
216impl crate::core::Backend for ChainedBackend {
217 fn get(&self, locale: &str, key: &str) -> Option<String> {
218 for backend in &self.backends {
219 if let Some(value) = backend.get(locale, key) {
220 return Some(value);
221 }
222 }
223 None
224 }
225
226 fn available_locales(&self) -> Vec<String> {
227 let mut locales = std::collections::HashSet::new();
228 for backend in &self.backends {
229 for locale in backend.available_locales() {
230 locales.insert(locale);
231 }
232 }
233 locales.into_iter().collect()
234 }
235
236 fn has_locale(&self, locale: &str) -> bool {
237 self.backends.iter().any(|b| b.has_locale(locale))
238 }
239
240 fn dump(&self, locale: &str) -> HashMap<String, String> {
241 let mut merged = HashMap::new();
245 for backend in self.backends.iter().rev() {
246 for (k, v) in backend.dump(locale) {
247 merged.insert(k, v);
248 }
249 }
250 merged
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::core::Backend;
258 use std::sync::Arc;
259
260 #[test]
261 fn test_flatten_simple() {
262 let toml_str = r#"
263app.title = "My App"
264app.desc = "A cool app"
265"#;
266 let backend = TomlBackend::new();
267 backend.add_locale_from_str("en", toml_str).unwrap();
268
269 assert_eq!(backend.get("en", "app.title").unwrap(), "My App");
270 assert_eq!(backend.get("en", "app.desc").unwrap(), "A cool app");
271 }
272
273 #[test]
274 fn test_flatten_nested_table() {
275 let toml_str = r#"
276[errors]
277not_found = "Not found"
278unauthorized = "Unauthorized"
279
280[errors.auth]
281expired = "Session expired"
282"#;
283 let backend = TomlBackend::new();
284 backend.add_locale_from_str("en", toml_str).unwrap();
285
286 assert_eq!(backend.get("en", "errors.not_found").unwrap(), "Not found");
287 assert_eq!(
288 backend.get("en", "errors.auth.expired").unwrap(),
289 "Session expired"
290 );
291 }
292
293 #[test]
294 fn test_plural_group() {
295 let toml_str = r#"
296[items]
297zero = "No items"
298one = "One item"
299other = "%{count} items"
300"#;
301 let backend = TomlBackend::new();
302 backend.add_locale_from_str("en", toml_str).unwrap();
303
304 assert_eq!(backend.get("en", "items.zero").unwrap(), "No items");
305 assert_eq!(backend.get("en", "items.one").unwrap(), "One item");
306 assert_eq!(backend.get("en", "items.other").unwrap(), "%{count} items");
307 }
308
309 #[test]
310 fn test_toml_backend_implements_reloadable() {
311 let backend = TomlBackend::new();
312 assert_eq!(backend.file_extension(), "toml");
313 backend.reload_from_str("en", r#"hello = "Hi""#).unwrap();
314 assert_eq!(backend.get("en", "hello").unwrap(), "Hi");
315 }
316
317 #[test]
318 fn test_chained_backend() {
319 let b1 = {
320 let b = TomlBackend::new();
321 b.add_locale_from_str("en", r#"hello = "Hi""#).unwrap();
322 b.add_locale_from_str("zh-CN", r#"hello = "你好""#).unwrap();
323 Arc::new(b) as Arc<dyn crate::core::Backend>
324 };
325 let b2 = {
326 let b = TomlBackend::new();
327 b.add_locale_from_str(
328 "en",
329 r#"hello = "Hello"
330bye = "Goodbye""#,
331 )
332 .unwrap();
333 Arc::new(b) as Arc<dyn crate::core::Backend>
334 };
335
336 let chained = ChainedBackend::new(vec![b1, b2]);
337
338 assert_eq!(chained.get("en", "hello").unwrap(), "Hi");
340 assert_eq!(chained.get("en", "bye").unwrap(), "Goodbye");
342 assert!(chained.has_locale("zh-CN"));
344 }
345
346 #[cfg(feature = "fs-loader")]
347 #[test]
348 fn test_try_from_dir_missing_directory() {
349 let backend = TomlBackend::try_from_dir("/no/such/i18n/dir").unwrap();
350 assert!(backend.available_locales().is_empty());
352 }
353
354 #[cfg(feature = "fs-loader")]
355 #[test]
356 fn test_try_from_dir_existing_directory() {
357 let dir = tempfile::tempdir().unwrap();
358 std::fs::write(
359 dir.path().join("en.toml"),
360 r#"hello = "Hi"
361bye = "Goodbye""#,
362 )
363 .unwrap();
364
365 let backend = TomlBackend::try_from_dir(dir.path()).unwrap();
366 assert_eq!(backend.get("en", "hello").unwrap(), "Hi");
367 assert_eq!(backend.get("en", "bye").unwrap(), "Goodbye");
368 }
369
370 #[test]
371 fn test_chained_backend_dump_priority() {
372 let b1 = {
374 let b = TomlBackend::new();
375 b.add_locale_from_str("en", r#"a = "A1""#).unwrap();
376 Arc::new(b) as Arc<dyn crate::core::Backend>
377 };
378 let b2 = {
379 let b = TomlBackend::new();
380 b.add_locale_from_str(
381 "en",
382 r#"a = "A2"
383b = "B""#,
384 )
385 .unwrap();
386 Arc::new(b) as Arc<dyn crate::core::Backend>
387 };
388
389 let chained = ChainedBackend::new(vec![b1, b2]);
390 let dumped = chained.dump("en");
391
392 assert_eq!(dumped.get("a").unwrap(), "A1");
394 assert_eq!(dumped.get("b").unwrap(), "B");
395 assert_eq!(dumped.len(), 2);
396
397 assert!(chained.dump("zh-CN").is_empty());
399 }
400
401 #[test]
402 fn test_toml_backend_dump() {
403 let backend = TomlBackend::new();
404 backend
405 .add_locale_from_str(
406 "en",
407 r#"hello = "Hi"
408bye = "Goodbye""#,
409 )
410 .unwrap();
411
412 let dumped = backend.dump("en");
413 assert_eq!(dumped.get("hello").unwrap(), "Hi");
414 assert_eq!(dumped.get("bye").unwrap(), "Goodbye");
415 assert_eq!(dumped.len(), 2);
416
417 assert!(backend.dump("zh-CN").is_empty());
419 }
420
421 #[cfg(feature = "fs-loader")]
422 #[test]
423 fn test_compiled_plus_optional_runtime_override() {
424 let compiled = {
426 let b = TomlBackend::new();
427 b.add_locale_from_str(
428 "en",
429 r#"hello = "Hello"
430bye = "Goodbye"
431welcome = "Welcome""#,
432 )
433 .unwrap();
434 Arc::new(b) as Arc<dyn crate::core::Backend>
435 };
436
437 let dir = tempfile::tempdir().unwrap();
439 std::fs::write(dir.path().join("en.toml"), r#"hello = "Hi (overridden)""#).unwrap();
440
441 let runtime = TomlBackend::try_from_dir(dir.path()).unwrap();
442
443 let chained = ChainedBackend::new(vec![
444 Arc::new(runtime) as Arc<dyn crate::core::Backend>,
445 compiled,
446 ]);
447
448 assert_eq!(chained.get("en", "hello").unwrap(), "Hi (overridden)");
450 assert_eq!(chained.get("en", "bye").unwrap(), "Goodbye");
452 assert_eq!(chained.get("en", "welcome").unwrap(), "Welcome");
453 }
454}