use std::fs;
use std::path::{Path, PathBuf};
#[cfg(test)]
use std::sync::Mutex;
use std::sync::OnceLock;
use fluent_langneg::{NegotiationStrategy, negotiate_languages};
use unic_langid::LanguageIdentifier;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TextDirection {
Ltr,
Rtl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum LocaleSource {
CliFlag,
Persisted,
System,
Default,
}
impl LocaleSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::CliFlag => "cli",
Self::Persisted => "persisted",
Self::System => "system",
Self::Default => "default",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Language {
En,
PtBr,
}
impl Language {
pub const AVAILABLE: &'static [Language] = &[Language::En, Language::PtBr];
pub const fn as_str(self) -> &'static str {
match self {
Self::En => "en",
Self::PtBr => "pt-BR",
}
}
pub const fn fallback(self) -> Language {
match self {
Self::En => Self::En,
Self::PtBr => Self::En,
}
}
pub const fn direction(self) -> TextDirection {
TextDirection::Ltr
}
pub fn from_tag(tag: &str) -> Option<Language> {
match tag {
"en" | "en-US" | "en-GB" | "en-AU" | "en-CA" | "en-IE" | "en-IN" | "en-IL" => {
Some(Self::En)
}
"pt-BR" | "pt-br" => Some(Self::PtBr),
other => {
let n = normalize_bcp47_tag(other);
match n.as_str() {
"en" | "en-US" | "en-GB" => Some(Self::En),
"pt-BR" => Some(Self::PtBr),
_ => None,
}
}
}
}
pub fn from_langid(id: &LanguageIdentifier) -> Option<Language> {
let tag = id.to_string();
Self::from_tag(&tag).or_else(|| {
let lang = id.language.as_str();
match lang {
"en" => Some(Self::En),
"pt" => Some(Self::PtBr),
_ => None,
}
})
}
pub fn language_identifier(self) -> Result<LanguageIdentifier, crate::error::AtomwriteError> {
self.as_str()
.parse()
.map_err(|e| crate::error::AtomwriteError::InternalError {
reason: format!("built-in locale tag {:?} is not BCP 47: {e}", self.as_str()),
})
}
}
#[derive(Debug, Clone)]
pub struct LocaleState {
pub language: Language,
pub source: LocaleSource,
pub system_raw: Option<String>,
pub detection_failed: bool,
pub preference_path: Option<PathBuf>,
pub persisted_tag: Option<String>,
}
static RESOLVED: OnceLock<LocaleState> = OnceLock::new();
pub fn normalize_bcp47_tag(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return String::new();
}
let base = trimmed.split(['.', '@']).next().unwrap_or(trimmed).trim();
if base.eq_ignore_ascii_case("C") || base.eq_ignore_ascii_case("POSIX") {
return String::new();
}
base.replace('_', "-")
}
pub fn parse_langid(raw: &str) -> Option<LanguageIdentifier> {
let tag = normalize_bcp47_tag(raw);
if tag.is_empty() {
return None;
}
tag.parse().ok()
}
pub fn available_tags() -> &'static [&'static str] {
&["en", "pt-BR"]
}
pub fn negotiate_to_language(requested_tags: &[&str]) -> Language {
let requested: Vec<fluent_langneg::LanguageIdentifier> = requested_tags
.iter()
.filter_map(|t| {
let n = normalize_bcp47_tag(t);
if n.is_empty() {
None
} else {
n.parse().ok()
}
})
.collect();
let available: Vec<fluent_langneg::LanguageIdentifier> = available_tags()
.iter()
.filter_map(|t| t.parse().ok())
.collect();
let Ok(default) = "en".parse::<fluent_langneg::LanguageIdentifier>() else {
return Language::En;
};
if requested.is_empty() {
return Language::En;
}
let supported = negotiate_languages(
&requested,
&available,
Some(&default),
NegotiationStrategy::Lookup,
);
supported
.first()
.and_then(|id| {
let tag = id.to_string();
Language::from_tag(&tag).or_else(|| {
match id.language.as_str() {
"pt" => Some(Language::PtBr),
"en" => Some(Language::En),
_ => None,
}
})
})
.unwrap_or(Language::En)
}
include!("locale_persist.inc.rs");
pub fn resolve_locale(
cli_override: Option<&str>,
system_raw: Option<String>,
pref_path: Option<PathBuf>,
) -> LocaleState {
let persisted_tag = pref_path
.as_ref()
.and_then(|p| read_persisted_preference(p));
if let Some(raw) = cli_override.map(str::trim).filter(|s| !s.is_empty()) {
let language = negotiate_to_language(&[raw]);
return LocaleState {
language,
source: LocaleSource::CliFlag,
system_raw,
detection_failed: false,
preference_path: pref_path,
persisted_tag,
};
}
if let Some(ref tag) = persisted_tag {
let language = negotiate_to_language(&[tag.as_str()]);
return LocaleState {
language,
source: LocaleSource::Persisted,
system_raw,
detection_failed: false,
preference_path: pref_path,
persisted_tag,
};
}
if let Some(ref raw) = system_raw {
if let Some(id) = parse_langid(raw) {
let tag = id.to_string();
let language = negotiate_to_language(&[tag.as_str()]);
return LocaleState {
language,
source: LocaleSource::System,
system_raw,
detection_failed: false,
preference_path: pref_path,
persisted_tag: None,
};
}
return LocaleState {
language: Language::En,
source: LocaleSource::Default,
system_raw,
detection_failed: true,
preference_path: pref_path,
persisted_tag: None,
};
}
LocaleState {
language: Language::En,
source: LocaleSource::Default,
system_raw: None,
detection_failed: true,
preference_path: pref_path,
persisted_tag: None,
}
}
pub fn detect_system_locale() -> Option<String> {
sys_locale::get_locale()
}
pub fn init_locale(cli_override: Option<&str>) -> Language {
let system_raw = detect_system_locale();
let pref = preference_path();
let state = resolve_locale(cli_override, system_raw, pref);
let language = state.language;
rust_i18n::set_locale(language.as_str());
if RESOLVED.set(state).is_err() {
tracing::debug!("locale already resolved; keeping the first state");
}
language
}
pub fn resolved_state() -> Option<&'static LocaleState> {
RESOLVED.get()
}
pub fn resolved_language() -> Language {
RESOLVED.get().map(|s| s.language).unwrap_or(Language::En)
}
#[cfg(test)]
static LOCALE_TEST_MUTEX: Mutex<()> = Mutex::new(());
#[cfg(test)]
pub struct LocaleTestGuard {
_lock: std::sync::MutexGuard<'static, ()>,
prev: &'static str,
}
#[cfg(test)]
impl LocaleTestGuard {
pub fn set(language: Language) -> Self {
let lock = LOCALE_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
rust_i18n::set_locale(language.as_str());
Self {
_lock: lock,
prev: "en",
}
}
}
#[cfg(test)]
impl Drop for LocaleTestGuard {
fn drop(&mut self) {
rust_i18n::set_locale(self.prev);
}
}
#[cfg(test)]
pub fn set_locale_for_test(language: Language) {
let _g = LOCALE_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
rust_i18n::set_locale(language.as_str());
}
pub fn parse_cli_locale(raw: &str) -> Result<String, String> {
let tag = normalize_bcp47_tag(raw);
if tag.is_empty() {
return Err(format!(
"invalid locale {raw:?}; available: {}",
available_tags().join(", ")
));
}
let parsed = parse_langid(&tag);
if parsed.is_none() {
return Err(format!(
"invalid locale {raw:?}; available: {}",
available_tags().join(", ")
));
}
if let Some(id) = parsed {
let lang = id.language.as_str();
if !matches!(lang, "en" | "pt") {
return Err(format!(
"unsupported locale {raw:?} (language {lang}); available: {}",
available_tags().join(", ")
));
}
}
Ok(negotiate_to_language(&[&tag]).as_str().to_string())
}
pub fn log_resolved_locale() {
match resolved_state() {
Some(state) => {
if state.detection_failed {
tracing::debug!(
system_raw = ?state.system_raw,
fallback = state.language.as_str(),
"locale OS detection missing or unparsable; using fallback"
);
}
tracing::debug!(
locale = state.language.as_str(),
source = state.source.as_str(),
system_raw = ?state.system_raw,
persisted = ?state.persisted_tag,
"locale resolved"
);
}
None => {
tracing::debug!("locale state not initialized");
}
}
}
#[cfg(test)]
include!("locale_tests.inc.rs");