use http::header::{ACCEPT_LANGUAGE, COOKIE};
use http::request::Parts;
mod admin;
mod csaf;
mod home;
mod info;
mod nav;
mod settings;
pub const COOKIE_NAME: &str = "csaf_lang";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Lang {
#[default]
En,
De,
Fr,
Es,
It,
}
impl Lang {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::En => "en",
Self::De => "de",
Self::Fr => "fr",
Self::Es => "es",
Self::It => "it",
}
}
#[must_use]
pub const fn endonym(self) -> &'static str {
match self {
Self::En => "English",
Self::De => "Deutsch",
Self::Fr => "Français",
Self::Es => "Español",
Self::It => "Italiano",
}
}
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
let primary = code.split(['-', '_']).next().unwrap_or(code);
match primary.to_ascii_lowercase().as_str() {
"en" => Some(Self::En),
"de" => Some(Self::De),
"fr" => Some(Self::Fr),
"es" => Some(Self::Es),
"it" => Some(Self::It),
_ => None,
}
}
#[must_use]
pub const fn all() -> [Self; 5] {
[Self::En, Self::De, Self::Fr, Self::Es, Self::It]
}
}
#[must_use]
pub fn from_parts(parts: &Parts) -> Lang {
let cookie = parts.headers.get(COOKIE).and_then(|v| v.to_str().ok());
let accept = parts
.headers
.get(ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok());
resolve(cookie, accept)
}
#[must_use]
pub fn resolve(cookie_header: Option<&str>, accept_language: Option<&str>) -> Lang {
if let Some(lang) = cookie_header.and_then(lang_from_cookie) {
return lang;
}
if let Some(lang) = accept_language.and_then(lang_from_accept) {
return lang;
}
Lang::En
}
fn lang_from_cookie(header: &str) -> Option<Lang> {
header.split(';').find_map(|pair| {
let (name, value) = pair.split_once('=')?;
(name.trim() == COOKIE_NAME).then(|| Lang::from_code(value.trim()))?
})
}
fn lang_from_accept(header: &str) -> Option<Lang> {
header.split(',').find_map(|item| {
let mut params = item.split(';');
let tag = params.next().unwrap_or(item).trim();
let excluded = params.any(|param| {
param
.trim()
.strip_prefix("q=")
.is_some_and(|q| q.trim().parse::<f32>() == Ok(0.0))
});
if excluded {
return None;
}
Lang::from_code(tag)
})
}
#[must_use]
pub fn t(lang: Lang, key: &'static str) -> &'static str {
match lookup(key) {
Some((en, de, fr, es, it)) => match lang {
Lang::En => en,
Lang::De => de,
Lang::Fr => fr,
Lang::Es => es,
Lang::It => it,
},
None => key,
}
}
pub(crate) type Entry = (
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
);
fn lookup(key: &str) -> Option<Entry> {
nav::tr(key)
.or_else(|| home::tr(key))
.or_else(|| csaf::tr(key))
.or_else(|| admin::tr(key))
.or_else(|| info::tr(key))
.or_else(|| settings::tr(key))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_and_from_code_round_trip() {
for lang in Lang::all() {
assert_eq!(Lang::from_code(lang.code()), Some(lang));
}
}
#[test]
fn from_code_is_case_insensitive_and_takes_primary_subtag() {
assert_eq!(Lang::from_code("DE"), Some(Lang::De));
assert_eq!(Lang::from_code("de-DE"), Some(Lang::De));
assert_eq!(Lang::from_code("fr_CA"), Some(Lang::Fr));
assert_eq!(Lang::from_code("pt"), None);
assert_eq!(Lang::from_code(""), None);
}
#[test]
fn cookie_beats_accept_language() {
assert_eq!(resolve(Some("csaf_lang=de"), Some("fr")), Lang::De);
}
#[test]
fn accept_language_used_when_no_cookie() {
assert_eq!(resolve(None, Some("es,en;q=0.5")), Lang::Es);
}
#[test]
fn accept_language_excludes_q_zero_languages() {
assert_eq!(resolve(None, Some("de;q=0, fr")), Lang::Fr);
}
#[test]
fn unresolvable_headers_fall_back_to_english() {
assert_eq!(resolve(None, None), Lang::En);
assert_eq!(resolve(Some("other=1"), Some("xx-XX")), Lang::En);
}
#[test]
fn cookie_parsing_finds_the_pair_among_others() {
assert_eq!(resolve(Some("a=1; csaf_lang=it; b=2"), None), Lang::It);
}
#[test]
fn malformed_cookie_pairs_do_not_panic_or_match() {
assert_eq!(resolve(Some("csaf_lang; =de; ==="), None), Lang::En);
}
#[test]
fn unknown_key_returns_the_key_itself() {
assert_eq!(t(Lang::De, "no.such.key"), "no.such.key");
}
#[test]
fn nav_keys_resolve_in_all_five_languages() {
for key in [
"nav.dashboard",
"nav.csaf",
"nav.language",
"nav.info_about",
] {
for lang in Lang::all() {
assert_ne!(t(lang, key), key, "{key} missing a {lang:?} translation");
}
}
}
}