use std::collections::HashMap;
use crate::plural::{PluralCategory, PluralForms, PluralRule};
pub type Locale = String;
#[derive(Debug, Clone)]
pub enum I18nError {
InvalidLocale(String),
ParseError(String),
DuplicateKey { locale: String, key: String },
}
impl std::fmt::Display for I18nError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidLocale(l) => write!(f, "invalid locale: {l}"),
Self::ParseError(msg) => write!(f, "parse error: {msg}"),
Self::DuplicateKey { locale, key } => {
write!(f, "duplicate key '{key}' in locale '{locale}'")
}
}
}
}
impl std::error::Error for I18nError {}
#[derive(Debug, Clone)]
pub enum StringEntry {
Simple(String),
Plural(PluralForms),
}
#[derive(Debug, Clone, Default)]
pub struct LocaleStrings {
strings: HashMap<String, StringEntry>,
}
impl LocaleStrings {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.strings
.insert(key.into(), StringEntry::Simple(value.into()));
}
pub fn insert_plural(&mut self, key: impl Into<String>, forms: PluralForms) {
self.strings.insert(key.into(), StringEntry::Plural(forms));
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&StringEntry> {
self.strings.get(key)
}
#[must_use]
pub fn len(&self) -> usize {
self.strings.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.strings.is_empty()
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.strings.keys().map(String::as_str)
}
}
#[derive(Debug, Clone)]
pub struct StringCatalog {
locales: HashMap<Locale, LocaleStrings>,
fallback_chain: Vec<Locale>,
plural_rules: HashMap<Locale, PluralRule>,
}
impl Default for StringCatalog {
fn default() -> Self {
Self::new()
}
}
impl StringCatalog {
#[must_use]
pub fn new() -> Self {
Self {
locales: HashMap::new(),
fallback_chain: Vec::new(),
plural_rules: HashMap::new(),
}
}
pub fn add_locale(&mut self, locale: impl Into<String>, strings: LocaleStrings) {
let locale = locale.into();
let rule = PluralRule::for_locale(&locale);
self.plural_rules.insert(locale.clone(), rule);
self.locales.insert(locale, strings);
}
pub fn set_fallback_chain(&mut self, chain: Vec<Locale>) {
self.fallback_chain = chain;
}
pub fn set_plural_rule(&mut self, locale: impl Into<String>, rule: PluralRule) {
self.plural_rules.insert(locale.into(), rule);
}
#[must_use]
pub fn get(&self, locale: &str, key: &str) -> Option<&str> {
if let Some(entry) = self.locales.get(locale).and_then(|ls| ls.get(key)) {
return match entry {
StringEntry::Simple(s) => Some(s.as_str()),
StringEntry::Plural(p) => Some(&p.other),
};
}
for fallback in &self.fallback_chain {
if fallback == locale {
continue; }
if let Some(entry) = self
.locales
.get(fallback.as_str())
.and_then(|ls| ls.get(key))
{
return match entry {
StringEntry::Simple(s) => Some(s.as_str()),
StringEntry::Plural(p) => Some(&p.other),
};
}
}
None
}
#[must_use]
pub fn get_plural(&self, locale: &str, key: &str, count: i64) -> Option<&str> {
let rule = self
.plural_rules
.get(locale)
.cloned()
.unwrap_or(PluralRule::English);
let category = rule.categorize(count);
if let Some(result) = self.get_plural_from(locale, key, category) {
return Some(result);
}
for fallback in &self.fallback_chain {
if fallback == locale {
continue;
}
let fb_rule = self
.plural_rules
.get(fallback.as_str())
.cloned()
.unwrap_or(PluralRule::English);
let fb_category = fb_rule.categorize(count);
if let Some(result) = self.get_plural_from(fallback, key, fb_category) {
return Some(result);
}
}
None
}
fn get_plural_from(&self, locale: &str, key: &str, category: PluralCategory) -> Option<&str> {
self.locales
.get(locale)
.and_then(|ls| ls.get(key))
.map(|entry| match entry {
StringEntry::Plural(forms) => forms.select(category),
StringEntry::Simple(s) => s.as_str(),
})
}
#[must_use]
pub fn format(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> Option<String> {
self.get(locale, key)
.map(|template| interpolate(template, args))
}
#[must_use]
pub fn format_plural(
&self,
locale: &str,
key: &str,
count: i64,
extra_args: &[(&str, &str)],
) -> Option<String> {
self.get_plural(locale, key, count).map(|template| {
let count_str = count.to_string();
let mut all_args: Vec<(&str, &str)> = vec![("count", &count_str)];
all_args.extend_from_slice(extra_args);
interpolate(template, &all_args)
})
}
#[must_use]
pub fn locales(&self) -> Vec<&str> {
self.locales.keys().map(String::as_str).collect()
}
#[must_use]
pub fn all_keys(&self) -> Vec<String> {
let mut keys: Vec<String> = self
.locales
.values()
.flat_map(|ls| ls.keys().map(String::from))
.collect();
keys.sort_unstable();
keys.dedup();
keys
}
#[must_use]
pub fn missing_keys(&self, locale: &str, reference_keys: &[&str]) -> Vec<String> {
let mut missing = Vec::new();
for &key in reference_keys {
if self.get(locale, key).is_none() {
missing.push(key.to_string());
}
}
missing.sort_unstable();
missing
}
#[must_use]
pub fn coverage_report(&self) -> CoverageReport {
let all = self.all_keys();
let ref_keys: Vec<&str> = all.iter().map(String::as_str).collect();
let total = ref_keys.len();
let mut locale_tags: Vec<String> = self.locales.keys().cloned().collect();
locale_tags.sort_unstable();
let locales = locale_tags
.into_iter()
.map(|tag| {
let missing = self.missing_keys(&tag, &ref_keys);
let present = total.saturating_sub(missing.len());
let coverage_percent = if total == 0 {
100.0
} else {
(present as f32 / total as f32) * 100.0
};
LocaleCoverage {
locale: tag,
present,
missing,
coverage_percent,
}
})
.collect();
CoverageReport {
total_keys: total,
locales,
}
}
}
#[derive(Debug, Clone)]
pub struct CoverageReport {
pub total_keys: usize,
pub locales: Vec<LocaleCoverage>,
}
#[derive(Debug, Clone)]
pub struct LocaleCoverage {
pub locale: String,
pub present: usize,
pub missing: Vec<String>,
pub coverage_percent: f32,
}
fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
let mut result = String::with_capacity(template.len());
let mut chars = template.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '{' {
let mut placeholder = String::new();
let mut found_close = false;
for c in chars.by_ref() {
if c == '}' {
found_close = true;
break;
}
placeholder.push(c);
}
if found_close {
if let Some(&(_, value)) = args.iter().find(|&&(name, _)| name == placeholder) {
result.push_str(value);
} else {
result.push('{');
result.push_str(&placeholder);
result.push('}');
}
} else {
result.push('{');
result.push_str(&placeholder);
}
} else {
result.push(ch);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plural::PluralForms;
fn english_catalog() -> StringCatalog {
let mut catalog = StringCatalog::new();
let mut en = LocaleStrings::new();
en.insert("greeting", "Hello");
en.insert("welcome", "Welcome, {name}!");
en.insert("farewell", "Goodbye, {name}. See you {when}.");
en.insert_plural(
"items",
PluralForms {
one: "{count} item".into(),
other: "{count} items".into(),
..Default::default()
},
);
catalog.add_locale("en", en);
catalog.set_fallback_chain(vec!["en".into()]);
catalog
}
#[test]
fn simple_lookup() {
let catalog = english_catalog();
assert_eq!(catalog.get("en", "greeting"), Some("Hello"));
}
#[test]
fn missing_key_returns_none() {
let catalog = english_catalog();
assert_eq!(catalog.get("en", "nonexistent"), None);
}
#[test]
fn missing_locale_falls_back() {
let catalog = english_catalog();
assert_eq!(catalog.get("fr", "greeting"), Some("Hello"));
}
#[test]
fn fallback_chain_order() {
let mut catalog = StringCatalog::new();
let mut en = LocaleStrings::new();
en.insert("greeting", "Hello");
en.insert("color", "Color");
let mut es = LocaleStrings::new();
es.insert("greeting", "Hola");
let mut es_mx = LocaleStrings::new();
es_mx.insert("greeting", "Qué onda");
catalog.add_locale("en", en);
catalog.add_locale("es", es);
catalog.add_locale("es-MX", es_mx);
catalog.set_fallback_chain(vec!["es-MX".into(), "es".into(), "en".into()]);
assert_eq!(catalog.get("es-MX", "greeting"), Some("Qué onda"));
assert_eq!(catalog.get("es-MX", "color"), Some("Color"));
}
#[test]
fn plural_english_singular() {
let catalog = english_catalog();
assert_eq!(catalog.get_plural("en", "items", 1), Some("{count} item"));
}
#[test]
fn plural_english_plural() {
let catalog = english_catalog();
assert_eq!(catalog.get_plural("en", "items", 0), Some("{count} items"));
assert_eq!(catalog.get_plural("en", "items", 2), Some("{count} items"));
assert_eq!(
catalog.get_plural("en", "items", 100),
Some("{count} items")
);
}
#[test]
fn plural_russian() {
let mut catalog = StringCatalog::new();
let mut ru = LocaleStrings::new();
ru.insert_plural(
"files",
PluralForms {
one: "{count} файл".into(),
few: Some("{count} файла".into()),
many: Some("{count} файлов".into()),
other: "{count} файлов".into(),
..Default::default()
},
);
catalog.add_locale("ru", ru);
assert_eq!(catalog.get_plural("ru", "files", 1), Some("{count} файл"));
assert_eq!(catalog.get_plural("ru", "files", 3), Some("{count} файла"));
assert_eq!(catalog.get_plural("ru", "files", 5), Some("{count} файлов"));
assert_eq!(catalog.get_plural("ru", "files", 21), Some("{count} файл"));
}
#[test]
fn interpolation_single_arg() {
let catalog = english_catalog();
assert_eq!(
catalog.format("en", "welcome", &[("name", "Alice")]),
Some("Welcome, Alice!".into())
);
}
#[test]
fn interpolation_multiple_args() {
let catalog = english_catalog();
assert_eq!(
catalog.format("en", "farewell", &[("name", "Bob"), ("when", "tomorrow")]),
Some("Goodbye, Bob. See you tomorrow.".into())
);
}
#[test]
fn interpolation_missing_arg_left_as_is() {
let catalog = english_catalog();
assert_eq!(
catalog.format("en", "welcome", &[]),
Some("Welcome, {name}!".into())
);
}
#[test]
fn format_plural_auto_count() {
let catalog = english_catalog();
assert_eq!(
catalog.format_plural("en", "items", 1, &[]),
Some("1 item".into())
);
assert_eq!(
catalog.format_plural("en", "items", 42, &[]),
Some("42 items".into())
);
}
#[test]
fn interpolation_edge_cases() {
assert_eq!(interpolate("Hello {world", &[]), "Hello {world");
assert_eq!(interpolate("Hello {}", &[]), "Hello {}");
assert_eq!(interpolate("Hello World", &[]), "Hello World");
assert_eq!(interpolate("{x} and {x}", &[("x", "A")]), "A and A");
}
#[test]
fn empty_catalog() {
let catalog = StringCatalog::new();
assert_eq!(catalog.get("en", "anything"), None);
assert_eq!(catalog.get_plural("en", "anything", 1), None);
assert!(catalog.locales().is_empty());
}
#[test]
fn locale_listing() {
let catalog = english_catalog();
let locales = catalog.locales();
assert_eq!(locales.len(), 1);
assert!(locales.contains(&"en"));
}
#[test]
fn locale_strings_len() {
let catalog = english_catalog();
let en = catalog.locales.get("en").unwrap();
assert_eq!(en.len(), 4); assert!(!en.is_empty());
}
#[test]
fn simple_entry_from_plural_lookup() {
let catalog = english_catalog();
assert_eq!(catalog.get_plural("en", "greeting", 1), Some("Hello"));
}
fn multi_locale_catalog() -> StringCatalog {
let mut catalog = StringCatalog::new();
let mut en = LocaleStrings::new();
en.insert("greeting", "Hello");
en.insert("farewell", "Goodbye");
en.insert("submit", "Submit");
catalog.add_locale("en", en);
let mut es = LocaleStrings::new();
es.insert("greeting", "Hola");
es.insert("farewell", "Adiós");
catalog.add_locale("es", es);
let mut fr = LocaleStrings::new();
fr.insert("greeting", "Bonjour");
catalog.add_locale("fr", fr);
catalog.set_fallback_chain(vec!["en".into()]);
catalog
}
#[test]
fn locale_strings_keys() {
let mut ls = LocaleStrings::new();
ls.insert("alpha", "A");
ls.insert("beta", "B");
let mut keys: Vec<&str> = ls.keys().collect();
keys.sort_unstable();
assert_eq!(keys, vec!["alpha", "beta"]);
}
#[test]
fn all_keys_is_sorted_and_deduped() {
let catalog = multi_locale_catalog();
let keys = catalog.all_keys();
assert_eq!(keys, vec!["farewell", "greeting", "submit"]);
}
#[test]
fn all_keys_empty_catalog() {
let catalog = StringCatalog::new();
assert!(catalog.all_keys().is_empty());
}
#[test]
fn missing_keys_none_missing() {
let catalog = multi_locale_catalog();
let missing = catalog.missing_keys("en", &["greeting", "farewell", "submit"]);
assert!(missing.is_empty());
}
#[test]
fn missing_keys_with_fallback() {
let catalog = multi_locale_catalog();
let missing = catalog.missing_keys("es", &["greeting", "farewell", "submit"]);
assert!(missing.is_empty(), "fallback should resolve submit");
}
#[test]
fn missing_keys_no_fallback() {
let mut catalog = StringCatalog::new();
let mut es = LocaleStrings::new();
es.insert("greeting", "Hola");
catalog.add_locale("es", es);
let missing = catalog.missing_keys("es", &["greeting", "farewell"]);
assert_eq!(missing, vec!["farewell"]);
}
#[test]
fn missing_keys_unknown_locale() {
let catalog = multi_locale_catalog();
let missing = catalog.missing_keys("de", &["greeting", "farewell", "submit"]);
assert!(missing.is_empty(), "fallback to en should cover all");
}
#[test]
fn coverage_report_structure() {
let catalog = multi_locale_catalog();
let report = catalog.coverage_report();
assert_eq!(report.total_keys, 3);
assert_eq!(report.locales.len(), 3);
let tags: Vec<&str> = report.locales.iter().map(|l| l.locale.as_str()).collect();
let mut sorted_tags = tags.clone();
sorted_tags.sort_unstable();
assert_eq!(tags, sorted_tags);
}
#[test]
fn coverage_report_with_fallback() {
let catalog = multi_locale_catalog();
let report = catalog.coverage_report();
for lc in &report.locales {
assert_eq!(
lc.present, 3,
"{} should have all 3 keys via fallback",
lc.locale
);
assert!(
lc.missing.is_empty(),
"{} should have no missing keys via fallback",
lc.locale
);
assert!(
(lc.coverage_percent - 100.0).abs() < f32::EPSILON,
"{} should be 100% coverage",
lc.locale
);
}
}
#[test]
fn coverage_report_without_fallback() {
let mut catalog = StringCatalog::new();
let mut en = LocaleStrings::new();
en.insert("a", "A");
en.insert("b", "B");
en.insert("c", "C");
catalog.add_locale("en", en);
let mut fr = LocaleStrings::new();
fr.insert("a", "A-fr");
catalog.add_locale("fr", fr);
let report = catalog.coverage_report();
assert_eq!(report.total_keys, 3);
let en_cov = report.locales.iter().find(|l| l.locale == "en").unwrap();
assert_eq!(en_cov.present, 3);
assert!(en_cov.missing.is_empty());
let fr_cov = report.locales.iter().find(|l| l.locale == "fr").unwrap();
assert_eq!(fr_cov.present, 1);
assert_eq!(fr_cov.missing, vec!["b", "c"]);
assert!((fr_cov.coverage_percent - 33.333_332).abs() < 0.01);
}
#[test]
fn coverage_report_empty_catalog() {
let catalog = StringCatalog::new();
let report = catalog.coverage_report();
assert_eq!(report.total_keys, 0);
assert!(report.locales.is_empty());
}
}