use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[cfg(test)]
use std::sync::Mutex;
use fluent_langneg::{negotiate_languages, NegotiationStrategy};
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,
EnvVar,
Persisted,
System,
Default,
}
impl LocaleSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::CliFlag => "cli",
Self::EnvVar => "env",
Self::Persisted => "persisted",
Self::System => "system",
Self::Default => "default",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Idioma {
En,
PtBr,
}
impl Idioma {
pub const AVAILABLE: &'static [Idioma] = &[Idioma::En, Idioma::PtBr];
pub const fn as_str(self) -> &'static str {
match self {
Self::En => "en",
Self::PtBr => "pt-BR",
}
}
pub const fn fallback(self) -> Idioma {
match self {
Self::En => Self::En,
Self::PtBr => Self::En,
}
}
pub const fn direcao(self) -> TextDirection {
TextDirection::Ltr
}
pub fn from_tag(tag: &str) -> Option<Idioma> {
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<Idioma> {
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) -> LanguageIdentifier {
self.as_str()
.parse()
.expect("Idioma::as_str is always valid BCP 47")
}
}
#[derive(Debug, Clone)]
pub struct LocaleState {
pub idioma: Idioma,
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_idioma(requested_tags: &[&str]) -> Idioma {
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 default: fluent_langneg::LanguageIdentifier = "en"
.parse()
.expect("en is valid BCP 47");
if requested.is_empty() {
return Idioma::En;
}
let supported = negotiate_languages(
&requested,
&available,
Some(&default),
NegotiationStrategy::Lookup,
);
supported
.first()
.and_then(|id| {
let tag = id.to_string();
Idioma::from_tag(&tag).or_else(|| {
match id.language.as_str() {
"pt" => Some(Idioma::PtBr),
"en" => Some(Idioma::En),
_ => None,
}
})
})
.unwrap_or(Idioma::En)
}
pub fn preference_path() -> Option<PathBuf> {
crate::storage::config_dir().map(|d| d.join("locale"))
}
pub fn read_persisted_preference(path: &Path) -> Option<String> {
let text = fs::read_to_string(path).ok()?;
let tag = text.lines().next()?.trim();
if tag.is_empty() {
return None;
}
let idioma = negotiate_to_idioma(&[tag]);
Idioma::from_tag(tag)
.or({
Some(idioma)
})
.map(|i| i.as_str().to_string())
}
pub fn write_persisted_preference(idioma: Idioma) -> std::io::Result<PathBuf> {
let path = preference_path().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"cannot resolve XDG config directory for atomwrite",
)
})?;
if let Some(parent) = path.parent() {
crate::storage::ensure_dir(parent)?;
}
{
let mut f = fs::File::create(&path)?;
writeln!(f, "{}", idioma.as_str())?;
f.flush()?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(path)
}
pub fn clear_persisted_preference() -> std::io::Result<Option<PathBuf>> {
let Some(path) = preference_path() else {
return Ok(None);
};
match fs::remove_file(&path) {
Ok(()) => Ok(Some(path)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Some(path)),
Err(e) => Err(e),
}
}
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 idioma = negotiate_to_idioma(&[raw]);
return LocaleState {
idioma,
source: LocaleSource::CliFlag,
system_raw,
detection_failed: false,
preference_path: pref_path,
persisted_tag,
};
}
if let Some(ref tag) = persisted_tag {
let idioma = negotiate_to_idioma(&[tag.as_str()]);
return LocaleState {
idioma,
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 idioma = negotiate_to_idioma(&[tag.as_str()]);
return LocaleState {
idioma,
source: LocaleSource::System,
system_raw,
detection_failed: false,
preference_path: pref_path,
persisted_tag: None,
};
}
return LocaleState {
idioma: Idioma::En,
source: LocaleSource::Default,
system_raw,
detection_failed: true,
preference_path: pref_path,
persisted_tag: None,
};
}
LocaleState {
idioma: Idioma::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>) -> Idioma {
let system_raw = detect_system_locale();
let pref = preference_path();
let state = resolve_locale(cli_override, system_raw, pref);
let idioma = state.idioma;
rust_i18n::set_locale(idioma.as_str());
let _ = RESOLVED.set(state);
idioma
}
pub fn resolved_state() -> Option<&'static LocaleState> {
RESOLVED.get()
}
pub fn resolved_idioma() -> Idioma {
RESOLVED
.get()
.map(|s| s.idioma)
.unwrap_or(Idioma::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(idioma: Idioma) -> Self {
let lock = LOCALE_TEST_MUTEX
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev = resolved_idioma().as_str();
let prev = if prev.is_empty() { "en" } else { prev };
let _ = prev;
rust_i18n::set_locale(idioma.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(idioma: Idioma) {
let _g = LOCALE_TEST_MUTEX
.lock()
.unwrap_or_else(|e| e.into_inner());
rust_i18n::set_locale(idioma.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 idioma = negotiate_to_idioma(&[&tag]);
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(", ")
));
}
}
let _ = idioma;
Ok(negotiate_to_idioma(&[&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.idioma.as_str(),
"locale OS detection missing or unparsable; using fallback"
);
}
tracing::debug!(
locale = state.idioma.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");