use std::collections::BTreeMap;
use std::fmt;
use std::sync::{Arc, RwLock};
use serde::de::{MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
const LAST_RESORT_LOCALE: &str = "en";
#[derive(Clone, Debug)]
pub struct LocaleScope {
tag: Arc<RwLock<String>>,
chain: Arc<[String]>,
default_locale: Arc<str>,
}
impl LocaleScope {
#[must_use]
pub fn new(tag: impl Into<String>, chain: Vec<String>) -> Self {
Self {
tag: Arc::new(RwLock::new(tag.into())),
chain: if chain.is_empty() {
effective_chain()
} else {
chain.into()
},
default_locale: default_locale_snapshot(),
}
}
#[must_use]
pub fn with_default_locale(
tag: impl Into<String>,
chain: Vec<String>,
default_locale: &str,
) -> Self {
Self {
default_locale: Arc::from(default_locale),
..Self::new(tag, chain)
}
}
#[must_use]
pub fn for_tag(tag: impl Into<String>) -> Self {
Self {
tag: Arc::new(RwLock::new(tag.into())),
chain: effective_chain(),
default_locale: default_locale_snapshot(),
}
}
#[must_use]
pub fn default_locale(&self) -> &str {
&self.default_locale
}
#[must_use]
pub fn tag(&self) -> String {
self.tag
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn set_tag(&self, tag: impl Into<String>) {
let mut guard = self
.tag
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = tag.into();
}
#[must_use]
pub fn chain(&self) -> &[String] {
&self.chain
}
}
tokio::task_local! {
static ACTIVE_LOCALE: LocaleScope;
}
struct LocaleDefaults {
default_locale: Arc<str>,
chain: Arc<[String]>,
}
static DEFAULTS: RwLock<Option<LocaleDefaults>> = RwLock::new(None);
pub fn install_locale_defaults(default_locale: &str, chain: Vec<String>) {
let mut guard = DEFAULTS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = Some(LocaleDefaults {
default_locale: Arc::from(default_locale),
chain: chain.into(),
});
}
#[must_use]
pub fn fallback_chain_snapshot() -> Vec<String> {
DEFAULTS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map_or_else(Vec::new, |d| d.chain.to_vec())
}
#[must_use]
pub fn default_locale_snapshot() -> Arc<str> {
DEFAULTS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map_or_else(
|| Arc::from(LAST_RESORT_LOCALE),
|d| Arc::clone(&d.default_locale),
)
}
fn effective_chain() -> Arc<[String]> {
let guard = DEFAULTS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match guard.as_ref() {
Some(d) if !d.chain.is_empty() => Arc::clone(&d.chain),
Some(d) => Arc::from([d.default_locale.to_string()]),
None => Arc::from([LAST_RESORT_LOCALE.to_owned()]),
}
}
#[must_use]
pub fn ambient_locale() -> Option<String> {
ACTIVE_LOCALE.try_with(LocaleScope::tag).ok()
}
pub fn publish_ambient_locale(tag: &str) {
let _ = ACTIVE_LOCALE.try_with(|scope| scope.set_tag(tag));
}
#[must_use]
pub fn ambient_locale_scope() -> Option<LocaleScope> {
ACTIVE_LOCALE.try_with(Clone::clone).ok()
}
pub async fn with_locale<F>(tag: impl Into<String>, fut: F) -> F::Output
where
F: std::future::Future,
{
ACTIVE_LOCALE.scope(LocaleScope::for_tag(tag), fut).await
}
pub async fn with_locale_chain<F>(tag: impl Into<String>, chain: Vec<String>, fut: F) -> F::Output
where
F: std::future::Future,
{
ACTIVE_LOCALE.scope(LocaleScope::new(tag, chain), fut).await
}
pub async fn with_locale_scope<F>(scope: LocaleScope, fut: F) -> F::Output
where
F: std::future::Future,
{
ACTIVE_LOCALE.scope(scope, fut).await
}
pub fn with_locale_sync<R>(tag: impl Into<String>, f: impl FnOnce() -> R) -> R {
ACTIVE_LOCALE.sync_scope(LocaleScope::for_tag(tag), f)
}
pub fn with_locale_chain_sync<R>(
tag: impl Into<String>,
chain: Vec<String>,
f: impl FnOnce() -> R,
) -> R {
ACTIVE_LOCALE.sync_scope(LocaleScope::new(tag, chain), f)
}
pub fn with_locale_scope_sync<R>(scope: LocaleScope, f: impl FnOnce() -> R) -> R {
ACTIVE_LOCALE.sync_scope(scope, f)
}
#[must_use]
pub fn write_locale() -> String {
if let Some(tag) = ambient_locale() {
return tag;
}
scoped_or_global_default_locale()
}
#[must_use]
pub fn scoped_or_global_default_locale() -> String {
ACTIVE_LOCALE
.try_with(|scope| scope.default_locale.to_string())
.unwrap_or_else(|_| default_locale_snapshot().to_string())
}
#[derive(Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "db", derive(diesel::AsExpression, diesel::FromSqlRow))]
#[cfg_attr(feature = "db", diesel(sql_type = diesel::sql_types::Text))]
pub struct Translated {
values: BTreeMap<String, String>,
}
impl Translated {
#[must_use]
pub const fn new() -> Self {
Self {
values: BTreeMap::new(),
}
}
#[must_use]
pub fn from_pairs<K, V, I>(pairs: I) -> Self
where
K: Into<String>,
V: Into<String>,
I: IntoIterator<Item = (K, V)>,
{
Self {
values: pairs
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
}
}
#[must_use]
pub fn for_active(value: impl Into<String>) -> Self {
Self::from_pairs([(write_locale(), value.into())])
}
#[must_use]
pub fn get(&self, locale: &str) -> Option<&str> {
self.values.get(locale).map(String::as_str)
}
pub fn set(&mut self, locale: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.values.insert(locale.into(), value.into());
self
}
pub fn set_active(&mut self, value: impl Into<String>) -> &mut Self {
self.set(write_locale(), value)
}
pub fn remove(&mut self, locale: &str) -> Option<String> {
self.values.remove(locale)
}
pub fn merge_from(&mut self, other: &Self) -> &mut Self {
for (locale, value) in &other.values {
self.values.insert(locale.clone(), value.clone());
}
self
}
#[must_use]
pub fn resolve(&self) -> Option<&str> {
ACTIVE_LOCALE
.try_with(|scope| self.lookup(Some(&scope.tag()), &scope.chain))
.unwrap_or_else(|_| self.lookup(None, &effective_chain()))
}
#[must_use]
pub fn resolve_in(&self, locale: &str) -> Option<&str> {
ACTIVE_LOCALE
.try_with(|scope| self.lookup(Some(locale), &scope.chain))
.unwrap_or_else(|_| self.lookup(Some(locale), &effective_chain()))
}
fn lookup<'a>(&'a self, tag: Option<&str>, chain: &[String]) -> Option<&'a str> {
if let Some(tag) = tag
&& let Some(found) = self.values.get(tag)
{
return Some(found.as_str());
}
for fallback in chain {
if Some(fallback.as_str()) == tag {
continue;
}
if let Some(found) = self.values.get(fallback) {
return Some(found.as_str());
}
}
None
}
#[must_use]
pub fn available_locales(&self) -> Vec<&str> {
self.values.keys().map(String::as_str).collect()
}
#[must_use]
pub fn is_translated(&self, locale: &str) -> bool {
self.values.contains_key(locale)
}
#[must_use]
pub fn len(&self) -> usize {
self.values.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.values.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
#[must_use]
pub fn encode_column(&self) -> String {
serde_json::to_string(&self.values).unwrap_or_else(|_| "{}".to_owned())
}
#[must_use]
pub fn decode_column(raw: &str, default_locale: &str) -> Self {
if raw.is_empty() {
return Self::new();
}
if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(raw) {
let mut values = BTreeMap::new();
for (k, v) in obj {
match v {
serde_json::Value::String(s) => {
values.insert(k, s);
}
_ => return Self::from_pairs([(default_locale, raw)]),
}
}
return Self { values };
}
Self::from_pairs([(default_locale, raw)])
}
}
impl fmt::Display for Translated {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.resolve().unwrap_or(""))
}
}
impl fmt::Debug for Translated {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_map().entries(self.values.iter()).finish()
}
}
impl<K: Into<String>, V: Into<String>> FromIterator<(K, V)> for Translated {
fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
Self::from_pairs(iter)
}
}
impl Serialize for Translated {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_map(self.values.iter())
}
}
impl<'de> Deserialize<'de> for Translated {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct TranslatedVisitor;
impl<'de> Visitor<'de> for TranslatedVisitor {
type Value = Translated;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
"a map of locale tag to translated value, e.g. {\"en\": \"Hello\"} \
(a bare string is refused: it would replace every other locale)",
)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(Translated::new())
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(Translated::new())
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
let mut values = BTreeMap::new();
while let Some((k, v)) = map.next_entry::<String, String>()? {
values.insert(k, v);
}
Ok(Translated { values })
}
}
deserializer.deserialize_map(TranslatedVisitor)
}
}
#[cfg(feature = "db")]
mod db {
use diesel::backend::Backend;
use diesel::deserialize::{self, FromSql};
use diesel::serialize::{self, IsNull, Output, ToSql};
use diesel::sql_types::Text;
use super::{Translated, scoped_or_global_default_locale};
impl ToSql<Text, diesel::sqlite::Sqlite> for Translated {
fn to_sql<'b>(
&'b self,
out: &mut Output<'b, '_, diesel::sqlite::Sqlite>,
) -> serialize::Result {
out.set_value(self.encode_column());
Ok(IsNull::No)
}
}
impl ToSql<Text, diesel::pg::Pg> for Translated {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> serialize::Result {
use std::io::Write as _;
out.write_all(self.encode_column().as_bytes())?;
Ok(IsNull::No)
}
}
impl<DB> FromSql<Text, DB> for Translated
where
DB: Backend,
String: FromSql<Text, DB>,
{
fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
let raw = String::from_sql(bytes)?;
Ok(Self::decode_column(
&raw,
&scoped_or_global_default_locale(),
))
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TranslatableColumnDescriptor {
pub model: &'static str,
pub table: &'static str,
pub column: &'static str,
}
inventory::collect!(TranslatableColumnDescriptor);
#[must_use]
pub fn registered_translatable_columns() -> Vec<&'static TranslatableColumnDescriptor> {
inventory::iter::<TranslatableColumnDescriptor>
.into_iter()
.collect()
}
#[must_use]
pub fn translatable_columns_for_table(table: &str) -> Vec<&'static str> {
registered_translatable_columns()
.iter()
.filter(|d| d.table == table)
.map(|d| d.column)
.collect()
}
#[cfg(test)]
mod tests {
use super::super::{
Translated, fallback_chain_snapshot, install_locale_defaults, with_locale,
with_locale_chain_sync,
};
fn chain(items: &[&str]) -> Vec<String> {
items.iter().map(|s| (*s).to_owned()).collect()
}
static DEFAULTS_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_defaults_guard<R>(f: impl FnOnce() -> R) -> R {
let _guard = DEFAULTS_GUARD
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f()
}
#[test]
fn set_stores_an_independent_value_per_locale() {
let mut t = Translated::new();
t.set("en", "Hello");
t.set("es", "Hola");
assert_eq!(t.get("en"), Some("Hello"));
assert_eq!(t.get("es"), Some("Hola"));
assert_eq!(t.get("fr"), None);
}
#[test]
fn updating_one_locale_leaves_the_others_untouched() {
let mut t = Translated::new();
t.set("en", "Hello");
t.set("es", "Hola");
t.set("en", "Hello again");
assert_eq!(t.get("en"), Some("Hello again"));
assert_eq!(t.get("es"), Some("Hola"));
}
#[test]
fn resolve_uses_the_ambient_locale() {
let mut t = Translated::new();
t.set("en", "Hello");
t.set("es", "Hola");
let got = with_locale_chain_sync("es", chain(&["en"]), || t.resolve().map(str::to_owned));
assert_eq!(got.as_deref(), Some("Hola"));
}
#[tokio::test]
async fn resolve_uses_the_ambient_locale_across_an_await() {
let mut t = Translated::new();
t.set("en", "Hello");
t.set("fr", "Bonjour");
let got = with_locale("fr", async { t.resolve().map(str::to_owned) }).await;
assert_eq!(got.as_deref(), Some("Bonjour"));
}
#[test]
fn resolve_walks_the_fallback_chain_when_the_active_locale_is_missing() {
let mut t = Translated::new();
t.set("en", "Hello");
let got = with_locale_chain_sync("fr", chain(&["en"]), || t.resolve().map(str::to_owned));
assert_eq!(got.as_deref(), Some("Hello"));
}
#[test]
fn resolve_honours_chain_order() {
let mut t = Translated::new();
t.set("en", "Hello");
t.set("pt", "Ola");
let got = with_locale_chain_sync("pt-BR", chain(&["pt", "en"]), || {
t.resolve().map(str::to_owned)
});
assert_eq!(got.as_deref(), Some("Ola"), "first chain link wins");
}
#[test]
fn resolve_returns_none_when_the_chain_is_exhausted() {
let mut t = Translated::new();
t.set("de", "Hallo");
let got = with_locale_chain_sync("fr", chain(&["en"]), || t.resolve().map(str::to_owned));
assert_eq!(got, None, "exhausted chain is None, never a panic");
}
#[test]
fn display_renders_the_empty_string_when_unresolved() {
let t = Translated::new();
let rendered = with_locale_chain_sync("fr", chain(&["en"]), || format!("[{t}]"));
assert_eq!(rendered, "[]");
}
#[test]
fn resolution_outside_any_request_scope_never_panics() {
let mut t = Translated::new();
t.set("en", "Hello");
let _ = t.resolve();
assert!(t.resolve_in("en").is_some());
}
#[test]
fn resolve_in_takes_an_explicit_locale_over_the_ambient_one() {
let mut t = Translated::new();
t.set("en", "Hello");
t.set("es", "Hola");
let got = with_locale_chain_sync("es", chain(&["en"]), || {
t.resolve_in("en").map(str::to_owned)
});
assert_eq!(got.as_deref(), Some("Hello"));
}
#[test]
fn available_locales_are_reported_in_a_stable_order() {
let mut t = Translated::new();
t.set("es", "Hola");
t.set("en", "Hello");
assert_eq!(t.available_locales(), vec!["en", "es"]);
assert!(t.is_translated("es"));
assert!(!t.is_translated("de"));
assert_eq!(t.len(), 2);
assert!(!t.is_empty());
}
#[test]
fn remove_drops_one_locale_only() {
let mut t = Translated::new();
t.set("en", "Hello");
t.set("es", "Hola");
assert_eq!(t.remove("en").as_deref(), Some("Hello"));
assert_eq!(t.available_locales(), vec!["es"]);
}
#[test]
fn json_round_trips_losslessly() {
let mut t = Translated::new();
t.set("en", "Hello");
t.set("es", "Hola");
let json = serde_json::to_string(&t).expect("serialize");
let back: Translated = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
back, t,
"serde must be lossless — version history depends on it"
);
assert!(json.contains("\"es\""));
}
#[test]
fn deserializing_a_bare_string_is_refused() {
let err = serde_json::from_str::<Translated>("\"Hola\"").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("locale tag"), "{msg}");
}
#[test]
fn merge_from_overwrites_only_the_locales_it_carries() {
let mut stored = Translated::from_pairs([("en", "Hello"), ("es", "Hola")]);
let incoming = Translated::from_pairs([("es", "Hola de nuevo"), ("fr", "Bonjour")]);
stored.merge_from(&incoming);
assert_eq!(stored.get("en"), Some("Hello"), "untouched locale survives");
assert_eq!(stored.get("es"), Some("Hola de nuevo"));
assert_eq!(stored.get("fr"), Some("Bonjour"));
assert_eq!(incoming.available_locales(), vec!["es", "fr"]);
}
#[test]
fn assignment_replaces_the_whole_container() {
let stored = Translated::from_pairs([("en", "Hello"), ("es", "Hola")]);
let incoming = Translated::from_pairs([("es", "Hola de nuevo")]);
let after = incoming;
assert_eq!(stored.get("en"), Some("Hello"));
assert_eq!(after.get("en"), None, "assignment is replace, not merge");
}
#[test]
fn an_unrelated_json_string_map_decodes_as_a_container_without_losing_values() {
let t = Translated::decode_column("{\"host\":\"db1\"}", "en");
assert_eq!(t.get("host"), Some("db1"), "values are never dropped");
let rendered = with_locale_chain_sync("en", chain(&["en"]), || t.to_string());
assert_eq!(rendered, "");
}
#[test]
fn an_object_with_a_non_string_value_is_still_read_as_text() {
let t = Translated::decode_column("{\"en\":42}", "en");
assert_eq!(t.get("en"), Some("{\"en\":42}"));
}
#[test]
fn every_writable_locale_tag_survives_a_column_round_trip() {
for tag in [
"en",
"es",
"es-MX",
"zh-Hant-TW",
"pt-BR",
"sgn-BE-FR",
"x-private",
"i-klingon",
"en_US",
"host",
"qaai",
"SOME_APP_CONVENTION",
] {
let mut written = Translated::new();
written.set(tag, "value");
let decoded = Translated::decode_column(&written.encode_column(), "en");
assert_eq!(
decoded.get(tag),
Some("value"),
"`{tag}` was writable but did not decode back"
);
assert_eq!(decoded, written, "round trip must be lossless for `{tag}`");
}
}
#[test]
fn a_write_with_no_configuration_is_readable_back() {
with_defaults_guard(|| {
let mut t = Translated::new();
t.set_active("Hello");
assert_eq!(
t.resolve(),
Some("Hello"),
"the write-side last resort must be reachable from the read side"
);
assert_eq!(t.to_string(), "Hello");
});
}
#[test]
fn publishing_a_locale_refines_the_scope_in_place() {
let t = Translated::from_pairs([("en", "Hello"), ("es", "Hola")]);
let got = with_locale_chain_sync("en", chain(&["en"]), || {
super::super::publish_ambient_locale("es");
t.resolve().map(str::to_owned)
});
assert_eq!(got.as_deref(), Some("Hola"));
}
#[test]
fn publishing_outside_a_scope_is_inert() {
super::super::publish_ambient_locale("es");
}
#[test]
fn an_empty_chain_falls_back_to_the_process_default() {
with_defaults_guard(|| {
let t = Translated::from_pairs([("en", "Hello")]);
let got = with_locale_chain_sync("de", Vec::new(), || t.resolve().map(str::to_owned));
assert_eq!(got.as_deref(), Some("Hello"));
});
}
#[test]
fn decoding_a_legacy_plain_text_column_keeps_the_value() {
let t = Translated::decode_column("Hello", "en");
assert_eq!(t.get("en"), Some("Hello"));
assert!(!t.encode_column().is_empty());
}
#[test]
fn decoding_a_json_object_of_non_strings_falls_back_to_plain_text() {
let t = Translated::decode_column("{\"en\":42}", "en");
assert_eq!(t.get("en"), Some("{\"en\":42}"));
}
#[test]
fn decoding_an_empty_column_yields_no_translations() {
assert!(Translated::decode_column("", "en").is_empty());
assert!(Translated::decode_column("{}", "en").is_empty());
}
#[test]
fn encode_column_emits_a_json_object() {
let mut t = Translated::new();
t.set("en", "Hello");
assert_eq!(t.encode_column(), "{\"en\":\"Hello\"}");
}
#[test]
fn column_decoding_prefers_the_scopes_default_locale_over_the_global() {
with_defaults_guard(|| {
install_locale_defaults("de", chain(&["de"]));
let scope = super::LocaleScope::with_default_locale("fr", chain(&["fr"]), "fr");
let decoded = super::with_locale_scope_sync(scope, || {
let default = super::scoped_or_global_default_locale();
let t = Translated::decode_column("Bonjour le monde", &default);
(default, t.resolve().map(str::to_owned))
});
assert_eq!(decoded.0, "fr", "the scope's default wins inside a request");
assert_eq!(
decoded.1.as_deref(),
Some("Bonjour le monde"),
"so the legacy value resolves instead of rendering empty"
);
assert_eq!(super::scoped_or_global_default_locale(), "de");
install_locale_defaults("en", chain(&["en"]));
});
}
#[test]
fn installed_defaults_keep_the_default_locale_independent_of_the_chain_tail() {
with_defaults_guard(|| {
install_locale_defaults("fr", chain(&["fr", "en"]));
assert_eq!(fallback_chain_snapshot(), chain(&["fr", "en"]));
assert_eq!(&*super::super::default_locale_snapshot(), "fr");
assert_eq!(super::super::write_locale(), "fr");
let legacy =
Translated::decode_column("Bonjour", &super::super::default_locale_snapshot());
assert_eq!(legacy.get("fr"), Some("Bonjour"));
install_locale_defaults("en", chain(&["en"]));
});
}
#[test]
fn for_active_seeds_the_ambient_locale() {
let t = with_locale_chain_sync("es", chain(&["en"]), || Translated::for_active("Hola"));
assert_eq!(t.get("es"), Some("Hola"));
}
}