use anyhow::Context;
use include_dir::{Dir, include_dir};
use once_cell::sync::Lazy;
use std::collections::BTreeMap;
use std::sync::RwLock;
use unic_langid::LanguageIdentifier;
pub type Map = BTreeMap<String, String>;
static OPERATOR_CLI_I18N: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/i18n/operator_cli");
static CURRENT_LOCALE: Lazy<RwLock<String>> = Lazy::new(|| RwLock::new(select_locale(None)));
pub fn select_locale(cli_locale: Option<&str>) -> String {
let supported = supported_locales();
if let Some(cli) = cli_locale
&& let Some(found) = resolve_supported(cli, &supported)
{
return found;
}
for env_key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Ok(raw) = std::env::var(env_key)
&& let Some(found) = resolve_supported(&raw, &supported)
{
return found;
}
}
if let Some(raw) = sys_locale::get_locale()
&& let Some(found) = resolve_supported(&raw, &supported)
{
return found;
}
"en".to_string()
}
pub fn set_locale(locale: impl Into<String>) {
let raw = locale.into();
let normalized = normalize_locale_tag(&raw).unwrap_or(raw);
if let Ok(mut guard) = CURRENT_LOCALE.write() {
*guard = normalized;
}
}
pub fn current_locale() -> String {
CURRENT_LOCALE
.read()
.map(|value| value.clone())
.unwrap_or_else(|_| select_locale(None))
}
pub fn tr(key: &str, fallback: &str) -> String {
tr_for_locale(key, fallback, ¤t_locale())
}
pub fn trf(key: &str, fallback: &str, args: &[&str]) -> String {
let mut rendered = tr(key, fallback);
for value in args {
rendered = rendered.replacen("{}", value, 1);
}
rendered
}
pub fn tr_for_locale(key: &str, fallback: &str, locale: &str) -> String {
match load_cli(locale) {
Ok(map) => map
.get(key)
.cloned()
.unwrap_or_else(|| fallback.to_string()),
Err(_) => fallback.to_string(),
}
}
pub fn load_cli(locale: &str) -> anyhow::Result<Map> {
for candidate in locale_candidates(locale) {
if let Some(file) = OPERATOR_CLI_I18N.get_file(&candidate) {
let raw = file.contents_utf8().ok_or_else(|| {
anyhow::anyhow!("operator cli i18n file is not valid UTF-8: {candidate}")
})?;
return serde_json::from_str(raw)
.with_context(|| format!("parse embedded operator cli i18n map {candidate}"));
}
}
Ok(Map::new())
}
fn locale_candidates(locale: &str) -> Vec<String> {
let mut out = Vec::new();
let mut push_candidate = |candidate: String| {
if !out.iter().any(|existing| existing == &candidate) {
out.push(candidate);
}
};
let trimmed = locale.trim();
if !trimmed.is_empty() {
push_candidate(format!("{}.json", trimmed));
if let Some(primary) = normalize_locale_tag(trimmed) {
push_candidate(format!("{}.json", primary));
if let Some(base) = base_language(&primary) {
push_candidate(format!("{}.json", base));
}
}
}
push_candidate("en.json".to_string());
out
}
fn normalize_locale_tag(raw: &str) -> Option<String> {
let mut cleaned = raw.trim();
if cleaned.is_empty() {
return None;
}
if cleaned.eq_ignore_ascii_case("c") || cleaned.eq_ignore_ascii_case("posix") {
return None;
}
if let Some((head, _)) = cleaned.split_once('.') {
cleaned = head;
}
if let Some((head, _)) = cleaned.split_once('@') {
cleaned = head;
}
if cleaned.eq_ignore_ascii_case("c") || cleaned.eq_ignore_ascii_case("posix") {
return None;
}
let normalized = cleaned.replace('_', "-");
normalized
.parse::<LanguageIdentifier>()
.ok()
.map(|value| value.to_string())
}
fn base_language(tag: &str) -> Option<String> {
tag.split('-')
.next()
.map(|value| value.to_ascii_lowercase())
}
fn resolve_supported(candidate: &str, supported: &[String]) -> Option<String> {
let normalized = normalize_locale_tag(candidate)?;
if supported.iter().any(|value| value == &normalized) {
return Some(normalized);
}
let base = base_language(&normalized)?;
if supported.iter().any(|value| value == &base) {
return Some(base);
}
None
}
fn supported_locales() -> Vec<String> {
let mut out = OPERATOR_CLI_I18N
.files()
.filter_map(|file| {
file.path()
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(".json"))
.map(|name| name.to_string())
})
.collect::<Vec<_>>();
out.sort();
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefers_requested_locale_before_english() {
let map = load_cli("de-DE").expect("load de locale");
assert_eq!(
map.get("cli.common.answer_yes_no").map(String::as_str),
Some("bitte mit y oder n antworten")
);
}
#[test]
fn normalize_locale_tag_handles_common_system_forms() {
assert_eq!(
normalize_locale_tag("en_US.UTF-8").as_deref(),
Some("en-US")
);
assert_eq!(normalize_locale_tag("de_DE@euro").as_deref(), Some("de-DE"));
assert_eq!(normalize_locale_tag("es").as_deref(), Some("es"));
}
#[test]
fn normalize_locale_tag_rejects_c_posix() {
assert_eq!(normalize_locale_tag("C"), None);
assert_eq!(normalize_locale_tag("POSIX"), None);
assert_eq!(normalize_locale_tag("C.UTF-8"), None);
}
#[test]
fn locale_candidates_deduplicate_and_fall_back_to_english() {
assert_eq!(
locale_candidates("de-DE"),
vec![
"de-DE.json".to_string(),
"de.json".to_string(),
"en.json".to_string()
]
);
assert_eq!(locale_candidates("en"), vec!["en.json".to_string()]);
}
#[test]
fn resolve_supported_falls_back_to_base_language() {
let supported = vec!["de".to_string(), "en".to_string()];
assert_eq!(
resolve_supported("de-DE.UTF-8", &supported),
Some("de".to_string())
);
assert_eq!(resolve_supported("fr-FR", &supported), None);
}
#[test]
fn translation_helpers_use_fallbacks_and_substitutions() {
assert_eq!(tr_for_locale("missing.key", "fallback", "en"), "fallback");
assert_eq!(
trf("missing.key", "hello {} {}", &["demo", "team"]),
"hello demo team"
);
}
}