use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::path::Path;
use std::sync::{LazyLock, Mutex, OnceLock};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Lang {
#[default]
Ru,
En,
Ext(&'static str),
}
const REFERENCE: Lang = Lang::En;
static INTERN: LazyLock<Mutex<HashSet<&str>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
fn intern(code: &str) -> &'static str {
let mut set = INTERN.lock().expect("language-code interner poisoned");
if let Some(s) = set.get(code) {
return s;
}
let leaked: &'static str = Box::leak(code.to_string().into_boxed_str());
set.insert(leaked);
leaked
}
static WARNED_MISSING: LazyLock<Mutex<HashSet<(Lang, String)>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
fn warn_missing_key_once(lang: Lang, key: &str) {
let mut warned = WARNED_MISSING.lock().expect("warned-keys set poisoned");
if warned.insert((lang, key.to_string())) {
tracing::warn!(
lang = lang.code(),
key,
"i18n: key missing from every bundle in the chain — the key itself (a slug) reaches the output"
);
}
}
impl Lang {
pub const ALL: &[Lang] = &[Lang::Ru, Lang::En];
pub fn code(self) -> &'static str {
match self {
Lang::Ru => "ru",
Lang::En => "en",
Lang::Ext(c) => c,
}
}
pub fn from_code(code: &str) -> Lang {
match code {
"ru" => Lang::Ru,
"en" => Lang::En,
other => Lang::Ext(intern(other)),
}
}
pub fn all() -> Vec<Lang> {
all_from(registry())
}
pub fn label(self) -> &'static str {
let fallback = match self {
Lang::Ru => "Русский", Lang::En => "English",
Lang::Ext(code) => code,
};
locale_exact(self)
.and_then(|l| l.get("ui.lang.name"))
.filter(|s| !s.is_empty()) .unwrap_or(fallback)
}
fn bundle_src(self) -> &'static str {
match self {
Lang::Ru => include_str!("../../locales/ru.json"),
Lang::En => include_str!("../../locales/en.json"),
Lang::Ext(_) => unreachable!("an external language has no baked-in source"),
}
}
}
impl Serialize for Lang {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.code())
}
}
impl<'de> Deserialize<'de> for Lang {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let code = String::deserialize(d)?;
Ok(Lang::from_code(&code))
}
}
fn all_from(reg: &HashMap<Lang, &'static Locale>) -> Vec<Lang> {
let mut v: Vec<Lang> = reg.keys().copied().collect();
sort_langs(&mut v);
v
}
fn sort_langs(v: &mut [Lang]) {
v.sort_by_key(|l| match l {
Lang::Ru => (0u8, ""),
Lang::En => (1u8, ""),
Lang::Ext(c) => (2u8, *c),
});
}
pub fn lang_for_locale(locale: Option<&str>) -> Lang {
let primary = locale.and_then(|l| l.split(['-', '_', '.']).next());
match primary.map(str::to_ascii_lowercase).as_deref() {
Some("ru") => Lang::Ru,
_ => Lang::En,
}
}
pub fn detect_os_language() -> Lang {
lang_for_locale(sys_locale::get_locale().as_deref())
}
pub struct Locale {
lang: Lang,
map: HashMap<String, String>,
fallback: Option<Lang>,
}
impl Locale {
fn from_map(lang: Lang, mut map: HashMap<String, String>) -> Locale {
let fallback = map.remove("_fallback").map(|c| Lang::from_code(c.trim()));
Locale {
lang,
map,
fallback,
}
}
pub fn lang(&self) -> Lang {
self.lang
}
#[cfg(test)]
pub fn has_key(&self, key: &str) -> bool {
self.map.contains_key(key)
}
pub fn t<'a>(&'a self, key: &'a str) -> &'a str {
if let Some(v) = self.map.get(key) {
return v;
}
if let Some(fb) = self.fallback
&& fb != self.lang
&& let Some(v) = locale(fb).map.get(key)
{
return v;
}
if self.lang != REFERENCE
&& self.fallback != Some(REFERENCE)
&& let Some(v) = locale(REFERENCE).map.get(key)
{
return v;
}
warn_missing_key_once(self.lang, key);
key
}
pub fn get(&'static self, key: &str) -> Option<&'static str> {
self.map.get(key).map(|s| s.as_str())
}
pub fn tf(&self, key: &str, args: &[(&str, &str)]) -> String {
let (out, unused) = substitute(self.t(key), args);
debug_assert!(
unused.is_empty(),
"tf(\"{key}\"): arguments not found in the template: {unused:?} — was a placeholder renamed?"
);
out
}
}
fn substitute<'a>(template: &str, args: &'a [(&'a str, &'a str)]) -> (String, Vec<&'a str>) {
let mut used = vec![false; args.len()];
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
match after.find('}') {
Some(close) if !after[..close].contains('{') && !after[..close].is_empty() => {
let name = &after[..close];
match args.iter().position(|(k, _)| *k == name) {
Some(idx) => {
out.push_str(args[idx].1);
used[idx] = true;
}
None => {
out.push('{');
out.push_str(name);
out.push('}');
}
}
rest = &after[close + 1..];
}
_ => {
out.push('{');
rest = after;
}
}
}
out.push_str(rest);
let unused = args
.iter()
.zip(&used)
.filter(|(_, u)| !**u)
.map(|((k, _), _)| *k)
.collect();
(out, unused)
}
const FALLBACK_META_KEY: &str = "_fallback";
fn placeholders(s: &str) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
let mut rest = s;
while let Some(i) = rest.find('{') {
if let Some(j) = rest[i..].find('}') {
out.insert(rest[i + 1..i + j].to_string());
rest = &rest[i + j + 1..];
} else {
break;
}
}
out
}
fn is_valid_lang_code(code: &str) -> bool {
let bytes = code.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_lowercase() || *bytes.last().unwrap() == b'-' {
return false;
}
bytes.windows(2).all(|w| w != b"--")
&& bytes
.iter()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
}
fn json_to_map(src: &str) -> Result<HashMap<String, String>, String> {
let raw: HashMap<String, serde_json::Value> =
serde_json::from_str(src).map_err(|e| format!("JSON parse error: {e}"))?;
let mut map = HashMap::with_capacity(raw.len());
for (k, v) in raw {
let text = match v {
serde_json::Value::String(s) => s,
serde_json::Value::Array(a) => {
let mut parts = Vec::with_capacity(a.len());
for x in &a {
match x.as_str() {
Some(s) => parts.push(s),
None => return Err(format!("key {k}: array element is not a string")),
}
}
parts.join(" ")
}
other => {
return Err(format!("key {k}: expected a string or array, got {other}"));
}
};
map.insert(k, text);
}
Ok(map)
}
fn builtin_map(lang: Lang) -> HashMap<String, String> {
json_to_map(lang.bundle_src()).unwrap_or_else(|e| panic!("built-in bundle {lang:?}: {e}"))
}
fn build_builtin_registry() -> HashMap<Lang, &'static Locale> {
Lang::ALL
.iter()
.map(|&lang| {
let loc: &'static Locale =
Box::leak(Box::new(Locale::from_map(lang, builtin_map(lang))));
(lang, loc)
})
.collect()
}
static BUILTIN: LazyLock<HashMap<Lang, &Locale>> = LazyLock::new(build_builtin_registry);
static REGISTRY: OnceLock<HashMap<Lang, &Locale>> = OnceLock::new();
fn overlay_external(maps: &mut HashMap<Lang, HashMap<String, String>>, dir: &Path) -> Vec<String> {
let mut warnings = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return warnings,
};
let ref_placeholders: HashMap<String, std::collections::BTreeSet<String>> = maps
.get(&REFERENCE)
.map(|m| {
m.iter()
.map(|(k, v)| (k.clone(), placeholders(v)))
.collect()
})
.unwrap_or_default();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(OsStr::to_str) != Some("json") {
continue;
}
let Some(stem) = path.file_stem().and_then(OsStr::to_str) else {
continue;
};
if !is_valid_lang_code(stem) {
warnings.push(format!(
"external locale {}: invalid name (a language code is lowercase Latin letters/digits/hyphens, starting with a letter), skipping",
path.display()
));
continue;
}
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
warnings.push(format!(
"external locale {}: read failed ({e}), skipping",
path.display()
));
continue;
}
};
let ext_map = match json_to_map(&src) {
Ok(m) => m,
Err(e) => {
warnings.push(format!(
"external locale {}: parsing failed ({e}), skipping",
path.display()
));
continue;
}
};
warnings.extend(validate_external_map(&path, &ext_map, &ref_placeholders));
let lang = Lang::from_code(stem);
let count = ext_map.len();
let is_new = !maps.contains_key(&lang);
let target = maps.entry(lang).or_default();
for (k, v) in ext_map {
target.insert(k, v); }
if is_new {
tracing::info!(
lang = stem,
keys = count,
"external locale: added a new language (missing keys fall back to the ru reference)"
);
} else {
tracing::info!(
lang = stem,
keys = count,
"external locale: overrode keys of the built-in bundle"
);
}
}
warnings
}
fn validate_external_map(
path: &Path,
ext_map: &HashMap<String, String>,
ref_placeholders: &HashMap<String, std::collections::BTreeSet<String>>,
) -> Vec<String> {
let mut warnings = Vec::new();
for (k, v) in ext_map {
if k == FALLBACK_META_KEY {
continue;
}
match ref_placeholders.get(k) {
None => warnings.push(format!(
"external locale {} key {k}: not in the reference (a typo? the key overrides nothing)",
path.display()
)),
Some(want) => {
let got = placeholders(v);
if &got != want {
warnings.push(format!(
"external locale {} key {k}: placeholder set diverges from the reference (want {want:?}, got {got:?}) — will break tf substitution",
path.display()
));
}
}
}
}
warnings
}
#[must_use]
pub fn init(dir: &Path) -> Vec<String> {
let mut maps: HashMap<Lang, HashMap<String, String>> =
Lang::ALL.iter().map(|&l| (l, builtin_map(l))).collect();
let warnings = overlay_external(&mut maps, dir);
let registry: HashMap<Lang, &'static Locale> = maps
.into_iter()
.map(|(lang, map)| {
let loc: &'static Locale = Box::leak(Box::new(Locale::from_map(lang, map)));
(lang, loc)
})
.collect();
if REGISTRY.set(registry).is_err() {
return Vec::new();
}
warnings
}
fn registry() -> &'static HashMap<Lang, &'static Locale> {
match REGISTRY.get() {
Some(r) => r,
None => &BUILTIN,
}
}
pub fn locale(lang: Lang) -> &'static Locale {
let reg = registry();
reg.get(&lang)
.or_else(|| reg.get(&REFERENCE))
.copied()
.expect("the reference locale (en) is always present in the registry")
}
fn locale_exact(lang: Lang) -> Option<&'static Locale> {
registry().get(&lang).copied()
}
pub fn export_bundle(lang: Lang) -> String {
if let Lang::Ru | Lang::En = lang {
return lang.bundle_src().to_string();
}
let reference = locale(REFERENCE);
let target = locale(lang);
let mut keys: Vec<&String> = reference.map.keys().collect();
keys.sort();
let obj: serde_json::Map<String, serde_json::Value> = keys
.into_iter()
.map(|k| {
(
k.clone(),
serde_json::Value::String(target.t(k).to_string()),
)
})
.collect();
serde_json::to_string_pretty(&serde_json::Value::Object(obj)).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_bundles_parse() {
for &lang in Lang::ALL {
let _ = locale(lang);
}
}
#[test]
fn lang_for_locale_maps_primary_subtag() {
assert_eq!(lang_for_locale(Some("ru")), Lang::Ru);
assert_eq!(lang_for_locale(Some("ru-RU")), Lang::Ru);
assert_eq!(lang_for_locale(Some("ru_RU.UTF-8")), Lang::Ru);
assert_eq!(lang_for_locale(Some("RU")), Lang::Ru);
assert_eq!(lang_for_locale(Some("en-US")), Lang::En);
assert_eq!(lang_for_locale(Some("de-DE")), Lang::En);
assert_eq!(lang_for_locale(Some("")), Lang::En);
assert_eq!(lang_for_locale(None), Lang::En);
}
#[test]
fn key_sets_match_across_languages() {
let ref_keys: std::collections::BTreeSet<&String> = locale(REFERENCE).map.keys().collect();
for &lang in Lang::ALL {
if lang == REFERENCE {
continue;
}
let keys: std::collections::BTreeSet<&String> = locale(lang).map.keys().collect();
let missing: Vec<_> = ref_keys.difference(&keys).collect();
let extra: Vec<_> = keys.difference(&ref_keys).collect();
assert!(
missing.is_empty() && extra.is_empty(),
"{lang:?}: missing {missing:?}, extra {extra:?}"
);
}
}
#[test]
fn placeholder_sets_match_across_languages() {
for (key, val) in &locale(REFERENCE).map {
let want = placeholders(val);
for &lang in Lang::ALL {
if lang == REFERENCE {
continue;
}
let got = placeholders(locale(lang).t(key));
assert_eq!(want, got, "key {key}: {lang:?} placeholders diverge");
}
}
}
#[test]
fn en_bundle_has_no_cyrillic() {
for (key, val) in &locale(Lang::En).map {
let cyr = val.chars().find(|&c| {
('а'..='я').contains(&c) || ('А'..='Я').contains(&c) || c == 'ё' || c == 'Ё'
});
assert!(
cyr.is_none(),
"key {key}: Cyrillic in the en translation: {val:?}"
);
}
}
#[test]
fn fallback_to_reference_then_key() {
let en = locale(Lang::En);
assert_eq!(en.t("no.such.key.exists"), "no.such.key.exists");
}
#[test]
fn tf_substitutes_named_placeholders() {
for &lang in Lang::ALL {
let s = locale(lang).tf("selfmodel.age.days", &[("n", "3")]);
assert!(s.contains('3') && !s.contains("{n}"), "{lang:?}: {s}");
}
}
fn dotted_literals_in_src() -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
visit(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut out);
out
}
fn scan(s: &str, out: &mut std::collections::BTreeSet<String>) {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let Some(p) = s[i..].find('"') else { break };
let start = i + p + 1; let (j, dots) = key_run(bytes, start);
if j < bytes.len()
&& bytes[j] as char == '"'
&& dots >= 1
&& bytes[j - 1] as char != '.'
{
out.insert(s[start..j].to_string());
}
i = j; }
}
fn key_run(bytes: &[u8], start: usize) -> (usize, usize) {
let mut j = start;
let mut dots = 0usize;
while j < bytes.len() {
let c = bytes[j] as char;
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' {
j += 1;
} else if c == '.' {
dots += 1;
j += 1;
} else {
break;
}
}
(j, dots)
}
fn visit(dir: &Path, out: &mut std::collections::BTreeSet<String>) {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
visit(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
scan(&std::fs::read_to_string(&path).unwrap_or_default(), out);
}
}
}
fn bundle_prefixes() -> std::collections::BTreeSet<String> {
locale(REFERENCE)
.map
.keys()
.filter_map(|k| k.split('.').next().map(str::to_string))
.collect()
}
#[test]
fn all_bundle_key_references_in_code_exist() {
const NON_KEY: &[&str] = &["defaults.json", "python.webc", "notes.md", "setup.msi"];
let prefixes = bundle_prefixes();
let ru = locale(Lang::Ru);
let missing: Vec<String> = dotted_literals_in_src()
.into_iter()
.filter(|k| {
prefixes.contains(k.split('.').next().unwrap()) && !NON_KEY.contains(&k.as_str())
})
.filter(|k| !ru.has_key(k))
.collect();
assert!(
missing.is_empty(),
"keys exist in code but are missing from the bundle: {missing:?}"
);
}
#[test]
fn bundle_keys_are_not_dead() {
const DYNAMIC_PREFIX: &[&str] = &["ui.tool.label."];
let literals = dotted_literals_in_src();
let dead: Vec<&String> = locale(REFERENCE)
.map
.keys()
.filter(|k| !literals.contains(*k))
.filter(|k| !DYNAMIC_PREFIX.iter().any(|p| k.starts_with(p)))
.collect();
assert!(
dead.is_empty(),
"bundle keys aren't used anywhere in code (dead?): {dead:?}"
);
}
#[test]
fn every_withheld_image_string_tells_the_model_not_to_describe_it() {
const FAMILY: &[&str] = &[
"loop.images_no_vision",
"loop.images_dropped",
"loop.image_not_shown_no_vision",
"loop.image_not_shown_dropped",
"tool.mcp.images_off",
"tool.python_exec.files.not_shown_off",
"tool.python_exec.files.not_shown_cap",
"tool.python_exec.files.svg",
];
for (lang, needle) in [(Lang::En, "do not describe"), (Lang::Ru, "не описывай")] {
let loc = locale(lang);
for key in FAMILY {
let text = loc.t(key);
assert!(
text.contains(needle),
"{lang:?} {key} must tell the model not to describe what it \
has not seen, got: {text:?}"
);
}
}
}
fn builtin_maps() -> HashMap<Lang, HashMap<String, String>> {
Lang::ALL.iter().map(|&l| (l, builtin_map(l))).collect()
}
#[test]
fn json_to_map_parses_string_and_array() {
let m = json_to_map(r#"{"a":"one","b":["two","three"]}"#).unwrap();
assert_eq!(m["a"], "one");
assert_eq!(m["b"], "two three"); }
#[test]
fn json_to_map_rejects_bad_value() {
assert!(json_to_map(r#"{"a":42}"#).is_err());
assert!(json_to_map("{ битый").is_err());
}
fn duplicate_keys(src: &str) -> Vec<String> {
struct Dups;
impl<'de> serde::de::Visitor<'de> for Dups {
type Value = Vec<String>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a JSON object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<Self::Value, A::Error> {
let mut seen = HashSet::new();
let mut dups = Vec::new();
while let Some((k, _)) = map.next_entry::<String, serde::de::IgnoredAny>()? {
if !seen.insert(k.clone()) {
dups.push(k);
}
}
Ok(dups)
}
}
let mut de = serde_json::Deserializer::from_str(src);
serde::de::Deserializer::deserialize_map(&mut de, Dups).expect("bundle JSON parses")
}
#[test]
fn builtin_bundles_have_no_duplicate_keys() {
assert_eq!(duplicate_keys(r#"{"a":"1","b":"2","a":"3"}"#), ["a"]);
for &lang in Lang::ALL.iter() {
let dups = duplicate_keys(lang.bundle_src());
assert!(
dups.is_empty(),
"{lang:?}: duplicate bundle keys — the later value silently wins: {dups:?}"
);
}
}
#[test]
fn overlay_external_overrides_builtin_per_key() {
let dir = tempfile::tempdir().unwrap();
let key = "ui.feed.role.user"; std::fs::write(
dir.path().join("en.json"),
format!("{{\"{key}\": \"OVERRIDDEN\"}}"),
)
.unwrap();
let mut maps = builtin_maps();
let before_other = maps[&Lang::En].len();
overlay_external(&mut maps, dir.path());
assert_eq!(maps[&Lang::En][key], "OVERRIDDEN");
assert_eq!(maps[&Lang::En].len(), before_other);
assert_ne!(maps[&Lang::Ru][key], "OVERRIDDEN");
}
#[test]
fn overlay_external_adds_new_language() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("de.json"),
r#"{"ui.lang.name": "Deutsch", "ui.feed.role.user": "DU"}"#,
)
.unwrap();
let mut maps = builtin_maps();
overlay_external(&mut maps, dir.path());
let de = Lang::Ext("de");
assert!(maps.contains_key(&de));
assert_eq!(maps[&de]["ui.feed.role.user"], "DU");
assert!(!maps[&de].contains_key("ui.feed.role.assistant"));
}
#[test]
fn overlay_external_skips_malformed_and_bad_names() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("en.json"), "{ битый json").unwrap(); std::fs::write(dir.path().join("DE.json"), r#"{"x":"y"}"#).unwrap(); std::fs::write(dir.path().join("readme.txt"), "not json").unwrap(); let mut maps = builtin_maps();
let en_before = maps[&Lang::En].clone();
overlay_external(&mut maps, dir.path());
assert_eq!(maps[&Lang::En], en_before);
assert_eq!(maps.len(), Lang::ALL.len());
}
#[test]
fn overlay_external_missing_dir_is_noop() {
let mut maps = builtin_maps();
let before = maps.clone();
overlay_external(&mut maps, Path::new("no/such/locales/dir"));
assert_eq!(maps.len(), before.len());
}
#[test]
fn sort_langs_orders_ru_en_then_ext_by_code() {
let mut v = vec![Lang::Ext("uk"), Lang::En, Lang::Ext("de"), Lang::Ru];
sort_langs(&mut v);
assert_eq!(
v,
vec![Lang::Ru, Lang::En, Lang::Ext("de"), Lang::Ext("uk")]
);
}
#[test]
fn substitute_is_single_pass_no_cascade() {
let (out, unused) = substitute("{a}{b}", &[("a", "{b}"), ("b", "X")]);
assert_eq!(out, "{b}X");
assert!(unused.is_empty());
}
#[test]
fn substitute_leaves_unknown_placeholder_verbatim() {
let (out, unused) = substitute("{x} {a} {", &[("a", "1")]);
assert_eq!(out, "{x} 1 {"); assert!(unused.is_empty());
}
#[test]
fn substitute_reports_unused_args() {
let (_out, unused) = substitute("{a}", &[("a", "1"), ("z", "2")]);
assert_eq!(unused, vec!["z"]);
}
#[test]
fn tf_does_not_cascade_on_real_key() {
let ru = locale(Lang::Ru);
let s = ru.tf("selfmodel.maintenance_wrapper", &[("core", "A {n} B")]);
assert!(s.contains("A {n} B"), "{s}");
}
#[test]
fn is_valid_lang_code_accepts_bcp47_and_rejects_junk() {
for ok in ["de", "en", "pt-br", "zh-tw", "sr-latn", "x9"] {
assert!(is_valid_lang_code(ok), "should accept: {ok}");
}
for bad in ["", "EN", "e n", "-de", "de-", "d--e", "1de", "de.json"] {
assert!(!is_valid_lang_code(bad), "should reject: {bad}");
}
}
#[test]
fn from_map_extracts_fallback_meta() {
let mut m = HashMap::new();
m.insert("_fallback".to_string(), "en".to_string());
m.insert("k".to_string(), "v".to_string());
let loc = Locale::from_map(Lang::Ext("de"), m);
assert_eq!(loc.fallback, Some(Lang::En));
assert!(!loc.map.contains_key("_fallback")); assert_eq!(loc.map.get("k").map(String::as_str), Some("v"));
}
#[test]
fn external_fallback_meta_drives_t_before_reference() {
let mut m = HashMap::new();
m.insert("_fallback".to_string(), "en".to_string());
let de = Locale::from_map(Lang::Ext("de"), m);
let key = "ui.feed.role.user"; assert_eq!(de.t(key), locale(Lang::En).t(key));
assert_ne!(de.t(key), locale(Lang::Ru).t(key));
}
#[test]
fn label_filters_empty_override() {
let mut m = HashMap::new();
m.insert("ui.lang.name".to_string(), String::new());
let loc: &'static Locale = Box::leak(Box::new(Locale::from_map(Lang::Ext("de"), m)));
assert_eq!(loc.get("ui.lang.name").filter(|s| !s.is_empty()), None);
}
#[test]
fn overlay_validation_is_non_fatal() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("de.json"),
r#"{"totally.unknown.key":"x","selfmodel.age.days":"vor {tagen} Tagen"}"#,
)
.unwrap();
let mut maps = builtin_maps();
overlay_external(&mut maps, dir.path());
let de = &maps[&Lang::Ext("de")];
assert_eq!(de.get("totally.unknown.key").map(String::as_str), Some("x"));
assert!(de.contains_key("selfmodel.age.days")); }
#[test]
fn overlay_accepts_bcp47_named_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pt-br.json"),
r#"{"ui.lang.name":"Português (BR)"}"#,
)
.unwrap();
let mut maps = builtin_maps();
overlay_external(&mut maps, dir.path());
assert!(maps.contains_key(&Lang::Ext("pt-br")));
}
#[test]
fn export_builtin_is_raw_source() {
assert_eq!(export_bundle(Lang::Ru), Lang::Ru.bundle_src());
assert_eq!(export_bundle(Lang::En), Lang::En.bundle_src());
}
#[test]
fn export_unknown_code_yields_reference_template() {
let json = export_bundle(Lang::Ext("zz"));
let obj: HashMap<String, String> = serde_json::from_str(&json).unwrap();
let en = locale(Lang::En);
assert_eq!(obj.len(), en.map.len());
assert_eq!(obj.get("ui.lang.name"), en.map.get("ui.lang.name"));
}
#[test]
fn code_and_from_code_round_trip() {
assert_eq!(Lang::from_code("ru"), Lang::Ru);
assert_eq!(Lang::from_code("en"), Lang::En);
assert_eq!(Lang::from_code("de"), Lang::Ext("de"));
for l in [Lang::Ru, Lang::En, Lang::Ext("de")] {
assert_eq!(Lang::from_code(l.code()), l);
}
}
#[test]
fn lang_serde_round_trips_codes() {
for (lang, json) in [(Lang::Ru, "\"ru\""), (Lang::En, "\"en\"")] {
assert_eq!(serde_json::to_string(&lang).unwrap(), json);
assert_eq!(serde_json::from_str::<Lang>(json).unwrap(), lang);
}
let de: Lang = serde_json::from_str("\"de\"").unwrap();
assert_eq!(de, Lang::Ext("de"));
assert_eq!(serde_json::to_string(&de).unwrap(), "\"de\"");
}
#[test]
fn label_ext_falls_back_to_code_without_bundle() {
assert_eq!(Lang::Ext("de").label(), "de");
}
#[test]
fn loaded_external_language_registers_and_self_names() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("de.json"),
r#"{"ui.lang.name":"Deutsch","ui.feed.role.user":"DU"}"#,
)
.unwrap();
let mut maps = builtin_maps();
overlay_external(&mut maps, dir.path());
let reg: HashMap<Lang, &'static Locale> = maps
.into_iter()
.map(|(lang, map)| {
let loc: &'static Locale = Box::leak(Box::new(Locale::from_map(lang, map)));
(lang, loc)
})
.collect();
let de = Lang::Ext("de");
assert_eq!(all_from(®), vec![Lang::Ru, Lang::En, de]);
assert_eq!(reg[&de].get("ui.lang.name"), Some("Deutsch"));
assert_eq!(reg[&de].t("ui.feed.role.user"), "DU");
assert_eq!(
reg[&de].t("ui.feed.role.assistant"),
reg[&Lang::En].t("ui.feed.role.assistant")
);
assert_ne!(
reg[&de].t("ui.feed.role.assistant"),
reg[&Lang::Ru].t("ui.feed.role.assistant")
);
}
}