use fluent_bundle::{FluentArgs, FluentBundle, FluentResource};
use parking_lot::RwLock;
use std::collections::HashMap;
use unic_langid::LanguageIdentifier;
static MANAGER: std::sync::OnceLock<RwLock<ManagerState>> = std::sync::OnceLock::new();
struct ManagerState {
locale: String,
resources: HashMap<String, HashMap<String, FluentResource>>,
lang_ids: HashMap<String, LanguageIdentifier>,
}
pub fn init_locale() {
MANAGER.get_or_init(|| {
let locale = resolve_locale();
let (resources, lang_ids) = load_resources();
RwLock::new(ManagerState {
locale,
resources,
lang_ids,
})
});
}
pub fn current_locale() -> String {
init_locale();
MANAGER.get().unwrap().read().locale.clone()
}
pub fn tr_args<'a>(id: &str, args: impl Into<FluentArgs<'a>>) -> String {
tr_impl(id, Some(&args.into()))
}
pub fn tr(id: &str) -> String {
tr_impl(id, None)
}
fn tr_impl(id: &str, args: Option<&FluentArgs<'_>>) -> String {
init_locale();
let manager = MANAGER.get().unwrap().read();
if let Some(result) = format_message(&manager, &manager.locale, id, args) {
return result;
}
if manager.locale != "en"
&& let Some(result) = format_message(&manager, "en", id, args)
{
return result;
}
id.to_string()
}
fn format_message(
manager: &ManagerState,
locale: &str,
id: &str,
args: Option<&FluentArgs<'_>>,
) -> Option<String> {
let res_map = manager.resources.get(locale)?;
let lang_id = manager.lang_ids.get(locale)?;
let mut bundle = FluentBundle::new(vec![lang_id.clone()]);
for resource in res_map.values() {
let _ = bundle.add_resource(resource);
}
let message = bundle.get_message(id)?;
let pattern = message.value()?;
let mut errors = vec![];
let result = bundle
.format_pattern(pattern, args, &mut errors)
.to_string();
Some(result)
}
fn resolve_locale() -> String {
if let Ok(locale) = std::env::var("INKLOG_LOCALE") {
let locale = locale.trim().to_string();
if !locale.is_empty() && is_valid_locale(&locale) {
return locale;
}
}
if let Some(sys_locale) = sys_locale::get_locale() {
let sys_locale = sys_locale.trim().to_string();
if !sys_locale.is_empty() && is_valid_locale(&sys_locale) {
return sys_locale;
}
}
"en".to_string()
}
fn is_valid_locale(locale: &str) -> bool {
if matches!(locale, "C" | "POSIX") {
return false;
}
let tag = locale.split('.').next().unwrap_or(locale);
tag.parse::<LanguageIdentifier>().is_ok()
}
fn load_resources() -> (
HashMap<String, HashMap<String, FluentResource>>,
HashMap<String, LanguageIdentifier>,
) {
let mut resources: HashMap<String, HashMap<String, FluentResource>> = HashMap::new();
let mut lang_ids: HashMap<String, LanguageIdentifier> = HashMap::new();
let locales_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
if !locales_dir.exists() {
return (resources, lang_ids);
}
let entries = match std::fs::read_dir(&locales_dir) {
Ok(e) => e,
Err(e) => {
eprintln!("[inklog] WARNING: failed to read locales dir: {e}");
return (resources, lang_ids);
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let locale_str = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
let lang_id: LanguageIdentifier = match locale_str.parse() {
Ok(id) => id,
Err(_) => {
eprintln!("[inklog] WARNING: invalid locale identifier: {locale_str}");
continue;
}
};
let mut locale_resources = HashMap::new();
if let Ok(ftl_entries) = std::fs::read_dir(&path) {
for ftl_entry in ftl_entries.flatten() {
let ftl_path = ftl_entry.path();
if ftl_path.extension().and_then(|e| e.to_str()) != Some("ftl") {
continue;
}
let source = match std::fs::read_to_string(&ftl_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"[inklog] WARNING: failed to read {}: {e}",
ftl_path.display()
);
continue;
}
};
let resource_name = ftl_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
match FluentResource::try_new(source) {
Ok(r) => {
locale_resources.insert(resource_name, r);
}
Err((_res, errs)) => {
for err in errs {
eprintln!(
"[inklog] WARNING: FTL parse error in {}: {err:?}",
ftl_path.display()
);
}
}
}
}
}
if !locale_resources.is_empty() {
resources.insert(locale_str.clone(), locale_resources);
lang_ids.insert(locale_str, lang_id);
}
}
(resources, lang_ids)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_locale_fallback() {
let locale = resolve_locale();
assert!(!locale.is_empty());
}
#[test]
fn test_current_locale_returns_valid_string() {
let locale = current_locale();
assert!(!locale.is_empty());
}
#[test]
fn test_tr_returns_translation_or_id() {
let result = tr("error-config_error");
assert!(!result.is_empty());
}
#[test]
fn test_tr_en_fallback_returns_english_text() {
let result = tr("error-io_error");
assert!(!result.is_empty());
if current_locale() == "en" {
assert_eq!(result, "IO error");
}
}
#[test]
fn test_tr_unknown_key_returns_id() {
let result = tr("nonexistent.key.that.does.not.exist");
assert_eq!(result, "nonexistent.key.that.does.not.exist");
}
#[test]
fn test_tr_log_level_names() {
let levels = [
("log_level-name_trace", "TRACE"),
("log_level-name_debug", "DEBUG"),
("log_level-name_info", "INFO"),
("log_level-name_warn", "WARN"),
("log_level-name_error", "ERROR"),
("log_level-name_fatal", "FATAL"),
];
for (key, expected_en) in levels {
let result = tr(key);
assert!(!result.is_empty(), "tr({}) returned empty", key);
if current_locale() == "en" {
assert_eq!(result, expected_en, "tr({}) en mismatch", key);
}
}
}
}