use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct I18nConfig {
pub default_locale: String,
pub supported_locales: Vec<String>,
pub fallback_chain: Vec<String>,
pub dir: String,
}
impl Default for I18nConfig {
fn default() -> Self {
Self {
default_locale: "en".to_owned(),
supported_locales: vec!["en".to_owned()],
fallback_chain: Vec::new(),
dir: "i18n".to_owned(),
}
}
}
impl I18nConfig {
#[must_use]
pub fn resolved_fallback_chain(&self) -> Vec<String> {
if self.fallback_chain.is_empty() {
return vec![self.default_locale.clone()];
}
let mut chain = self.fallback_chain.clone();
if !chain.iter().any(|l| l == &self.default_locale) {
chain.push(self.default_locale.clone());
}
chain
}
}
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error("i18n directory `{path}` could not be read: {source}")]
DirectoryRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("i18n default locale `{locale}` is missing — expected file at `{path}`")]
MissingDefaultLocale {
locale: String,
path: PathBuf,
},
#[error("failed to parse `{path}` at line {line}: {message}")]
Parse {
path: PathBuf,
line: usize,
message: String,
},
#[error("file `{path}` is not a recognizable locale tag")]
InvalidLocaleFilename {
path: PathBuf,
},
}
#[derive(Debug, Clone)]
pub struct Locale {
tag: String,
bundle: Option<Arc<Bundle>>,
}
impl Locale {
#[must_use]
pub fn new(tag: impl Into<String>) -> Self {
Self {
tag: tag.into(),
bundle: None,
}
}
#[must_use]
pub fn tag(&self) -> &str {
&self.tag
}
#[must_use]
pub fn with_bundle(mut self, bundle: Arc<Bundle>) -> Self {
self.bundle = Some(bundle);
self
}
#[must_use]
pub const fn bundle(&self) -> Option<&Arc<Bundle>> {
self.bundle.as_ref()
}
#[must_use]
pub fn t(&self, key: &str) -> String {
self.t_with(key, &[])
}
#[must_use]
pub fn t_with(&self, key: &str, args: &[(&str, &str)]) -> String {
self.bundle
.as_ref()
.map_or_else(|| key.to_owned(), |b| b.translate(&self.tag, key, args))
}
}
#[must_use]
pub fn negotiate<'a>(requested: &str, supported: &'a [String]) -> Option<&'a str> {
let normalized = requested.trim();
for s in supported {
if s.eq_ignore_ascii_case(normalized) {
return Some(s.as_str());
}
}
if let Some((primary, _)) = normalized.split_once('-') {
for s in supported {
if s.eq_ignore_ascii_case(primary) {
return Some(s.as_str());
}
}
}
None
}
#[must_use]
pub fn parse_accept_language<'a>(header: &str, supported: &'a [String]) -> Option<&'a str> {
let mut entries: Vec<(f32, &str)> = header
.split(',')
.filter_map(|raw| {
let mut parts = raw.split(';');
let tag = parts.next()?.trim();
if tag.is_empty() || tag == "*" {
return None;
}
let q = parts
.find_map(|p| {
let p = p.trim();
p.strip_prefix("q=").and_then(|v| v.parse::<f32>().ok())
})
.unwrap_or(1.0);
if q.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
return None;
}
Some((q, tag))
})
.collect();
entries.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
for (_, tag) in entries {
if let Some(matched) = negotiate(tag, supported) {
return Some(matched);
}
}
None
}
pub struct Bundle {
messages: HashMap<String, HashMap<String, String>>,
fallback_chain: Vec<String>,
default_locale: String,
supported_locales: Vec<String>,
miss_warnings: std::sync::Mutex<HashMap<(String, String), Instant>>,
warn_dedup_window: Duration,
miss_count: AtomicU64,
}
impl fmt::Debug for Bundle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Bundle")
.field("locales", &self.messages.keys())
.field("default_locale", &self.default_locale)
.field("supported_locales", &self.supported_locales)
.field("miss_count", &self.miss_count.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
impl Bundle {
pub fn load_from_dir(dir: &Path, config: &I18nConfig) -> Result<Self, LoadError> {
let mut messages: HashMap<String, HashMap<String, String>> = HashMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(LoadError::MissingDefaultLocale {
locale: config.default_locale.clone(),
path: dir.join(format!("{}.ftl", config.default_locale)),
});
}
Err(source) => {
return Err(LoadError::DirectoryRead {
path: dir.to_path_buf(),
source,
});
}
};
for entry in entries {
let entry = entry.map_err(|source| LoadError::DirectoryRead {
path: dir.to_path_buf(),
source,
})?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("ftl") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| LoadError::InvalidLocaleFilename { path: path.clone() })?
.to_owned();
let raw =
std::fs::read_to_string(&path).map_err(|source| LoadError::DirectoryRead {
path: path.clone(),
source,
})?;
let parsed = parse_ftl(&raw, &path)?;
messages.insert(stem, parsed);
}
let default_path = dir.join(format!("{}.ftl", config.default_locale));
if !messages.contains_key(&config.default_locale) {
return Err(LoadError::MissingDefaultLocale {
locale: config.default_locale.clone(),
path: default_path,
});
}
Ok(Self {
messages,
fallback_chain: config.resolved_fallback_chain(),
default_locale: config.default_locale.clone(),
supported_locales: config.supported_locales.clone(),
miss_warnings: std::sync::Mutex::new(HashMap::new()),
warn_dedup_window: Duration::from_secs(60),
miss_count: AtomicU64::new(0),
})
}
#[must_use]
pub fn from_messages(
messages: HashMap<String, HashMap<String, String>>,
config: &I18nConfig,
) -> Self {
Self {
messages,
fallback_chain: config.resolved_fallback_chain(),
default_locale: config.default_locale.clone(),
supported_locales: config.supported_locales.clone(),
miss_warnings: std::sync::Mutex::new(HashMap::new()),
warn_dedup_window: Duration::from_secs(60),
miss_count: AtomicU64::new(0),
}
}
#[must_use]
pub fn locales(&self) -> Vec<&str> {
self.messages.keys().map(String::as_str).collect()
}
#[must_use]
pub fn supported_locales(&self) -> &[String] {
&self.supported_locales
}
#[must_use]
pub fn default_locale(&self) -> &str {
&self.default_locale
}
#[must_use]
pub fn fallback_chain(&self) -> &[String] {
&self.fallback_chain
}
#[must_use]
pub fn miss_count(&self) -> u64 {
self.miss_count.load(Ordering::Relaxed)
}
pub fn translate(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String {
if let Some(template) = self.lookup_template(locale, key) {
return interpolate(template, args);
}
self.record_miss(locale, key);
format!("{{${key}}}")
}
fn lookup_template(&self, locale: &str, key: &str) -> Option<&str> {
if let Some(found) = self.messages.get(locale).and_then(|m| m.get(key)) {
return Some(found.as_str());
}
for fallback in &self.fallback_chain {
if fallback == locale {
continue;
}
if let Some(found) = self.messages.get(fallback).and_then(|m| m.get(key)) {
return Some(found.as_str());
}
}
None
}
fn record_miss(&self, locale: &str, key: &str) {
self.miss_count.fetch_add(1, Ordering::Relaxed);
let now = Instant::now();
let should_warn = {
let stale = now
.checked_sub(self.warn_dedup_window + Duration::from_secs(1))
.unwrap_or(now);
let miss_key = (locale.to_owned(), key.to_owned());
let mut guard = match self.miss_warnings.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let last_warned = guard.get(&miss_key).copied().unwrap_or(stale);
if now.duration_since(last_warned) >= self.warn_dedup_window {
guard.insert(miss_key, now);
true
} else {
false
}
};
if should_warn {
tracing::warn!(
target: "autumn::i18n",
locale = %locale,
key = %key,
"i18n key missing in requested and fallback locales",
);
}
}
}
fn parse_ftl(src: &str, path: &Path) -> Result<HashMap<String, String>, LoadError> {
let mut messages = HashMap::new();
let mut current_key: Option<String> = None;
let mut current_value = String::new();
let flush =
|messages: &mut HashMap<String, String>, key: &mut Option<String>, value: &mut String| {
if let Some(k) = key.take() {
messages.insert(k, std::mem::take(value).trim().to_owned());
}
};
for (idx, line) in src.lines().enumerate() {
let line_no = idx + 1;
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
continue;
}
if trimmed.is_empty() {
flush(&mut messages, &mut current_key, &mut current_value);
continue;
}
let starts_indented = line.starts_with(' ') || line.starts_with('\t');
if starts_indented {
if current_key.is_some() {
if !current_value.is_empty() {
current_value.push(' ');
}
current_value.push_str(trimmed);
continue;
}
return Err(LoadError::Parse {
path: path.to_path_buf(),
line: line_no,
message: format!("indented continuation has no preceding key: `{trimmed}`"),
});
}
flush(&mut messages, &mut current_key, &mut current_value);
let Some((raw_key, raw_value)) = trimmed.split_once('=') else {
return Err(LoadError::Parse {
path: path.to_path_buf(),
line: line_no,
message: format!("expected `key = value`, got: `{trimmed}`"),
});
};
let key = raw_key.trim();
if key.is_empty() {
return Err(LoadError::Parse {
path: path.to_path_buf(),
line: line_no,
message: "empty key before `=`".to_owned(),
});
}
current_key = Some(key.to_owned());
raw_value.trim().clone_into(&mut current_value);
}
flush(&mut messages, &mut current_key, &mut current_value);
Ok(messages)
}
fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(template.len());
let mut remaining = template;
while let Some(open) = remaining.find('{') {
out.push_str(&remaining[..open]);
let after_open = &remaining[open + 1..];
let Some(close_rel) = after_open.find('}') else {
out.push_str(&remaining[open..]);
return out;
};
let inside = after_open[..close_rel].trim();
let after_close = &after_open[close_rel + 1..];
match inside.strip_prefix('$') {
Some(var) if !var.is_empty() => {
let var = var.trim();
if let Some((_, val)) = args.iter().find(|(k, _)| *k == var) {
out.push_str(val);
} else {
out.push('{');
out.push_str(&after_open[..close_rel]);
out.push('}');
}
}
_ => {
out.push('{');
out.push_str(&after_open[..close_rel]);
out.push('}');
}
}
remaining = after_close;
}
out.push_str(remaining);
out
}
impl<S> FromRequestParts<S> for Locale
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let bundle = parts.extensions.get::<Arc<Bundle>>().cloned();
let supported: Vec<String> = bundle
.as_ref()
.map(|b| b.supported_locales.clone())
.unwrap_or_default();
let default = bundle
.as_ref()
.map_or_else(|| "en".to_owned(), |b| b.default_locale.clone());
let mut resolved = resolve_query_override(parts, &supported);
if resolved.is_none() {
resolved = resolve_from_session(parts, &supported).await;
}
if resolved.is_none() {
resolved = resolve_from_plain_cookie(parts, &supported);
}
if resolved.is_none() {
resolved = resolve_from_accept_language(parts, &supported);
}
let resolved = resolved.unwrap_or(default);
let mut locale = Self::new(resolved);
if let Some(bundle) = bundle {
locale = locale.with_bundle(bundle);
}
Ok(locale)
}
}
pub const LOCALE_SESSION_KEY: &str = "autumn_locale";
fn resolve_query_override(parts: &Parts, supported: &[String]) -> Option<String> {
let query = parts.uri.query()?;
for pair in query.split('&') {
if let Some(value) = pair.strip_prefix("locale=")
&& let Some(matched) = negotiate(value, supported)
{
return Some(matched.to_owned());
}
}
None
}
async fn resolve_from_session(parts: &Parts, supported: &[String]) -> Option<String> {
let session = parts.extensions.get::<crate::session::Session>().cloned()?;
let value = session.get(LOCALE_SESSION_KEY).await?;
negotiate(&value, supported).map(str::to_owned)
}
fn resolve_from_plain_cookie(parts: &Parts, supported: &[String]) -> Option<String> {
let cookie_header = parts
.headers
.get(axum::http::header::COOKIE)
.and_then(|h| h.to_str().ok())?;
for cookie in cookie_header.split(';') {
let cookie = cookie.trim();
if let Some(value) = cookie.strip_prefix("autumn_locale=")
&& let Some(matched) = negotiate(value, supported)
{
return Some(matched.to_owned());
}
}
None
}
fn resolve_from_accept_language(parts: &Parts, supported: &[String]) -> Option<String> {
let header = parts
.headers
.get(axum::http::header::ACCEPT_LANGUAGE)
.and_then(|h| h.to_str().ok())?;
parse_accept_language(header, supported).map(str::to_owned)
}
pub async fn set_locale_in_session(session: &crate::session::Session, locale: &str) {
session.insert(LOCALE_SESSION_KEY, locale).await;
}
#[must_use]
pub fn set_locale_cookie(locale: &str) -> String {
let locale = encode_locale_cookie_value(locale);
format!("autumn_locale={locale}; Path=/; Max-Age=31536000; SameSite=Lax")
}
fn encode_locale_cookie_value(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if is_locale_cookie_value_byte(byte) {
encoded.push(char::from(byte));
} else {
push_percent_encoded(&mut encoded, byte);
}
}
encoded
}
const fn is_locale_cookie_value_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
}
fn push_percent_encoded(output: &mut String, byte: u8) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
output.push('%');
output.push(char::from(HEX[(byte >> 4) as usize]));
output.push(char::from(HEX[(byte & 0x0f) as usize]));
}
pub use autumn_macros::t;
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, header};
fn build_parts(uri: &str, headers: &[(&str, &str)]) -> Parts {
let mut req = Request::builder().uri(uri);
for (k, v) in headers {
req = req.header(*k, *v);
}
let req = req.body(Body::empty()).unwrap();
let (parts, _) = req.into_parts();
parts
}
fn cfg(default: &str, supported: &[&str]) -> I18nConfig {
I18nConfig {
default_locale: default.to_owned(),
supported_locales: supported.iter().map(|s| (*s).to_owned()).collect(),
fallback_chain: vec![],
dir: "i18n".to_owned(),
}
}
fn bundle_with(locales: &[(&str, &[(&str, &str)])], cfg: &I18nConfig) -> Bundle {
let mut messages = HashMap::new();
for (loc, kvs) in locales {
let mut m = HashMap::new();
for (k, v) in *kvs {
m.insert((*k).to_owned(), (*v).to_owned());
}
messages.insert((*loc).to_owned(), m);
}
Bundle::from_messages(messages, cfg)
}
#[test]
fn i18n_config_default_is_english_only() {
let cfg = I18nConfig::default();
assert_eq!(cfg.default_locale, "en");
assert_eq!(cfg.supported_locales, vec!["en".to_owned()]);
assert_eq!(cfg.dir, "i18n");
}
#[test]
fn fallback_chain_defaults_to_default_locale() {
let cfg = cfg("en", &["en", "es"]);
assert_eq!(cfg.resolved_fallback_chain(), vec!["en".to_owned()]);
}
#[test]
fn fallback_chain_appends_default_when_missing() {
let mut cfg = cfg("en", &["en", "es", "pt-BR"]);
cfg.fallback_chain = vec!["pt".to_owned(), "es".to_owned()];
let chain = cfg.resolved_fallback_chain();
assert_eq!(
chain,
vec!["pt".to_owned(), "es".to_owned(), "en".to_owned()]
);
}
#[test]
fn fallback_chain_keeps_user_order_when_default_present() {
let mut cfg = cfg("en", &["en", "es"]);
cfg.fallback_chain = vec!["es".to_owned(), "en".to_owned()];
assert_eq!(
cfg.resolved_fallback_chain(),
vec!["es".to_owned(), "en".to_owned()]
);
}
#[test]
fn parse_ftl_basic_keys() {
let src = "welcome.title = Hi\ngreeting = Hello, { $name }!\n";
let parsed = parse_ftl(src, Path::new("test.ftl")).unwrap();
assert_eq!(parsed.get("welcome.title").map(String::as_str), Some("Hi"));
assert_eq!(
parsed.get("greeting").map(String::as_str),
Some("Hello, { $name }!")
);
}
#[test]
fn parse_ftl_skips_comments_and_blank_lines() {
let src = "# header comment\n\nwelcome = Hi\n\n# trailing\n";
let parsed = parse_ftl(src, Path::new("test.ftl")).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed.get("welcome").map(String::as_str), Some("Hi"));
}
#[test]
fn parse_ftl_supports_indented_continuation() {
let src = "long = first line\n second line\n third\n";
let parsed = parse_ftl(src, Path::new("test.ftl")).unwrap();
assert_eq!(
parsed.get("long").map(String::as_str),
Some("first line second line third")
);
}
#[test]
fn parse_ftl_rejects_missing_equals() {
let err = parse_ftl("oops no equals here\n", Path::new("test.ftl")).unwrap_err();
match err {
LoadError::Parse { line, .. } => assert_eq!(line, 1),
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn parse_ftl_rejects_orphan_continuation() {
let err = parse_ftl(" orphan continuation\n", Path::new("test.ftl")).unwrap_err();
assert!(matches!(err, LoadError::Parse { .. }));
}
#[test]
fn load_from_dir_errors_when_default_locale_missing() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("es.ftl"), "hi = Hola\n").unwrap();
let cfg = cfg("en", &["en", "es"]);
let err = Bundle::load_from_dir(tmp.path(), &cfg).unwrap_err();
match err {
LoadError::MissingDefaultLocale { locale, path } => {
assert_eq!(locale, "en");
assert!(path.ends_with("en.ftl"));
}
other => panic!("expected MissingDefaultLocale, got {other:?}"),
}
}
#[test]
fn load_from_dir_loads_all_ftl_files() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("en.ftl"), "hi = Hi\n").unwrap();
std::fs::write(tmp.path().join("es.ftl"), "hi = Hola\n").unwrap();
std::fs::write(tmp.path().join("README.md"), "ignore me").unwrap();
let cfg = cfg("en", &["en", "es"]);
let bundle = Bundle::load_from_dir(tmp.path(), &cfg).unwrap();
let mut locales = bundle.locales();
locales.sort_unstable();
assert_eq!(locales, vec!["en", "es"]);
}
#[test]
fn load_from_dir_propagates_parse_errors() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("en.ftl"), "no equals here\n").unwrap();
let cfg = cfg("en", &["en"]);
let err = Bundle::load_from_dir(tmp.path(), &cfg).unwrap_err();
assert!(matches!(err, LoadError::Parse { .. }));
}
#[test]
fn load_from_dir_missing_directory_is_treated_as_missing_default() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist");
let cfg = cfg("en", &["en"]);
let err = Bundle::load_from_dir(&missing, &cfg).unwrap_err();
assert!(matches!(err, LoadError::MissingDefaultLocale { .. }));
}
#[test]
fn translate_returns_translated_string() {
let cfg = cfg("en", &["en", "es"]);
let bundle = bundle_with(&[("en", &[("hi", "Hi")]), ("es", &[("hi", "Hola")])], &cfg);
assert_eq!(bundle.translate("es", "hi", &[]), "Hola");
assert_eq!(bundle.translate("en", "hi", &[]), "Hi");
}
#[test]
fn translate_falls_back_to_default_locale_on_miss() {
let cfg = cfg("en", &["en", "es"]);
let bundle = bundle_with(
&[
("en", &[("only_in_en", "english only")]),
("es", &[("hi", "Hola")]),
],
&cfg,
);
assert_eq!(bundle.translate("es", "only_in_en", &[]), "english only");
}
#[test]
fn translate_uses_explicit_fallback_chain() {
let mut cfg = cfg("en", &["en", "es", "pt-BR"]);
cfg.fallback_chain = vec!["pt".to_owned(), "es".to_owned(), "en".to_owned()];
let bundle = bundle_with(
&[
("en", &[]),
("pt", &[("howdy", "Olá")]),
("es", &[("howdy", "Hola")]),
],
&cfg,
);
assert_eq!(bundle.translate("pt-BR", "howdy", &[]), "Olá");
}
#[test]
fn translate_returns_marker_on_total_miss_and_records() {
let cfg = cfg("en", &["en"]);
let bundle = bundle_with(&[("en", &[])], &cfg);
let result = bundle.translate("en", "definitely.missing", &[]);
assert_eq!(result, "{$definitely.missing}");
assert_eq!(bundle.miss_count(), 1);
}
#[test]
fn translate_dedups_warnings_for_same_key() {
let cfg = cfg("en", &["en"]);
let bundle = bundle_with(&[("en", &[])], &cfg);
for _ in 0..5 {
let _ = bundle.translate("en", "missing", &[]);
}
assert_eq!(bundle.miss_count(), 5);
}
#[test]
fn translate_substitutes_named_args() {
let cfg = cfg("en", &["en"]);
let bundle = bundle_with(&[("en", &[("greeting", "Hello, { $name }!")])], &cfg);
assert_eq!(
bundle.translate("en", "greeting", &[("name", "Ada")]),
"Hello, Ada!"
);
}
#[test]
fn translate_leaves_unknown_args_visible() {
let cfg = cfg("en", &["en"]);
let bundle = bundle_with(&[("en", &[("greeting", "Hello, { $name }!")])], &cfg);
let out = bundle.translate("en", "greeting", &[]);
assert!(out.contains("{ $name }"), "got: {out}");
}
#[test]
fn negotiate_exact_match() {
let supported = vec!["en".to_owned(), "es".to_owned()];
assert_eq!(negotiate("es", &supported), Some("es"));
}
#[test]
fn negotiate_falls_back_to_primary_subtag() {
let supported = vec!["en".to_owned(), "es".to_owned()];
assert_eq!(negotiate("es-MX", &supported), Some("es"));
}
#[test]
fn negotiate_returns_none_when_unsupported() {
let supported = vec!["en".to_owned()];
assert_eq!(negotiate("ja", &supported), None);
}
#[test]
fn parse_accept_language_picks_highest_q() {
let supported = vec!["en".to_owned(), "es".to_owned()];
let header = "fr;q=0.9, es;q=0.8, en;q=0.7";
assert_eq!(parse_accept_language(header, &supported), Some("es"));
}
#[test]
fn parse_accept_language_skips_wildcard() {
let supported = vec!["en".to_owned(), "es".to_owned()];
assert_eq!(parse_accept_language("*", &supported), None);
}
#[test]
fn parse_accept_language_ignores_zero_quality_entries() {
let supported = vec!["en".to_owned(), "es".to_owned()];
assert_eq!(parse_accept_language("es;q=0, en;q=0", &supported), None);
}
#[tokio::test]
async fn locale_extractor_uses_query_override() {
let cfg = cfg("en", &["en", "es"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[])], &cfg));
let mut parts = build_parts("/?locale=es", &[(header::ACCEPT_LANGUAGE.as_str(), "en")]);
parts.extensions.insert(bundle.clone());
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "es");
}
#[tokio::test]
async fn locale_extractor_uses_cookie_when_no_query() {
let cfg = cfg("en", &["en", "es"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[])], &cfg));
let mut parts = build_parts(
"/",
&[
(header::COOKIE.as_str(), "autumn_locale=es; other=foo"),
(header::ACCEPT_LANGUAGE.as_str(), "en"),
],
);
parts.extensions.insert(bundle.clone());
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "es");
}
#[tokio::test]
async fn locale_extractor_negotiates_accept_language() {
let cfg = cfg("en", &["en", "es"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[])], &cfg));
let mut parts = build_parts(
"/",
&[(header::ACCEPT_LANGUAGE.as_str(), "es-MX,es;q=0.9,en;q=0.8")],
);
parts.extensions.insert(bundle.clone());
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "es");
}
#[tokio::test]
async fn locale_extractor_falls_through_to_default() {
let cfg = cfg("en", &["en", "es"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[])], &cfg));
let mut parts = build_parts(
"/?locale=ja",
&[(header::ACCEPT_LANGUAGE.as_str(), "ja-JP")],
);
parts.extensions.insert(bundle.clone());
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "en");
}
#[tokio::test]
async fn locale_extractor_resolution_order_is_query_then_cookie() {
let cfg = cfg("en", &["en", "es"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[])], &cfg));
let mut parts = build_parts(
"/?locale=es",
&[(header::COOKIE.as_str(), "autumn_locale=en")],
);
parts.extensions.insert(bundle.clone());
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "es");
}
#[test]
fn set_locale_cookie_format() {
let cookie = set_locale_cookie("es");
assert!(cookie.starts_with("autumn_locale=es"));
assert!(cookie.contains("Path=/"));
assert!(cookie.contains("SameSite=Lax"));
}
#[test]
fn set_locale_cookie_percent_encodes_cookie_delimiters() {
let cookie = set_locale_cookie("es; Secure; SameSite=None");
assert!(cookie.starts_with("autumn_locale=es%3B%20Secure%3B%20SameSite%3DNone;"));
assert!(!cookie.starts_with("autumn_locale=es; Secure;"));
}
#[tokio::test]
async fn locale_extractor_reads_signed_session_cookie() {
let cfg = cfg("en", &["en", "es"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[])], &cfg));
let session = crate::session::Session::new_for_test(
"test-id".to_owned(),
std::collections::HashMap::new(),
);
crate::i18n::set_locale_in_session(&session, "es").await;
let mut parts = build_parts("/", &[(axum::http::header::ACCEPT_LANGUAGE.as_str(), "en")]);
parts.extensions.insert(bundle.clone());
parts.extensions.insert(session);
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "es");
}
#[tokio::test]
async fn signed_session_locale_overrides_plain_cookie() {
let cfg = cfg("en", &["en", "es", "fr"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[]), ("fr", &[])], &cfg));
let session = crate::session::Session::new_for_test(
"test-id".to_owned(),
std::collections::HashMap::new(),
);
crate::i18n::set_locale_in_session(&session, "fr").await;
let mut parts = build_parts(
"/",
&[(axum::http::header::COOKIE.as_str(), "autumn_locale=es")],
);
parts.extensions.insert(bundle.clone());
parts.extensions.insert(session);
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "fr");
}
#[tokio::test]
async fn query_still_overrides_session() {
let cfg = cfg("en", &["en", "es", "fr"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[]), ("fr", &[])], &cfg));
let session = crate::session::Session::new_for_test(
"test-id".to_owned(),
std::collections::HashMap::new(),
);
crate::i18n::set_locale_in_session(&session, "fr").await;
let mut parts = build_parts("/?locale=es", &[]);
parts.extensions.insert(bundle.clone());
parts.extensions.insert(session);
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "es");
}
#[tokio::test]
async fn unsupported_session_locale_falls_through() {
let cfg = cfg("en", &["en", "es"]);
let bundle = Arc::new(bundle_with(&[("en", &[]), ("es", &[])], &cfg));
let session = crate::session::Session::new_for_test(
"test-id".to_owned(),
std::collections::HashMap::new(),
);
crate::i18n::set_locale_in_session(&session, "ja").await;
let mut parts = build_parts("/", &[(axum::http::header::ACCEPT_LANGUAGE.as_str(), "es")]);
parts.extensions.insert(bundle.clone());
parts.extensions.insert(session);
let locale = Locale::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(locale.tag(), "es");
}
#[test]
fn t_macro_basic_lookup() {
let cfg = cfg("en", &["en"]);
let bundle = Arc::new(bundle_with(&[("en", &[("hi", "Hi")])], &cfg));
let locale = Locale::new("en").with_bundle(bundle);
assert_eq!(t!(locale, "hi"), "Hi");
}
#[test]
fn t_macro_with_named_args() {
let cfg = cfg("en", &["en"]);
let bundle = Arc::new(bundle_with(&[("en", &[("g", "Hello, { $name }!")])], &cfg));
let locale = Locale::new("en").with_bundle(bundle);
assert_eq!(t!(locale, "g", name = "Ada"), "Hello, Ada!");
}
}