#[derive(Debug, Clone, PartialEq)]
pub struct Language {
pub code: String,
pub region: Option<String>,
pub quality: f32,
}
impl Language {
pub fn new(code: impl Into<String>) -> Self {
Self {
code: code.into(),
region: None,
quality: 1.0,
}
}
pub fn with_region(code: impl Into<String>, region: impl Into<String>) -> Self {
Self {
code: code.into(),
region: Some(region.into()),
quality: 1.0,
}
}
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split(';').collect();
let lang_part = parts.first()?.trim();
let (code, region) = if let Some((c, r)) = lang_part.split_once('-') {
(c.to_lowercase(), Some(r.to_uppercase()))
} else {
(lang_part.to_lowercase(), None)
};
let mut quality = 1.0;
for param in parts.iter().skip(1) {
let param = param.trim();
if let Some((key, value)) = param.split_once('=')
&& key.trim() == "q"
&& let Ok(q) = value.trim().parse::<f32>()
{
if !q.is_finite() {
return None;
}
quality = q.clamp(0.0, 1.0);
}
}
Some(Self {
code,
region,
quality,
})
}
pub fn matches(&self, other: &Language) -> bool {
if self.code == "*" || other.code == "*" {
return true;
}
if self.code != other.code {
return false;
}
match (&self.region, &other.region) {
(None, _) | (_, None) => true,
(Some(r1), Some(r2)) => r1 == r2,
}
}
pub fn tag(&self) -> String {
match &self.region {
Some(region) => format!("{}-{}", self.code, region),
None => self.code.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct LanguageNegotiator {
fallback: Language,
}
impl LanguageNegotiator {
pub fn new() -> Self {
Self {
fallback: Language::new("en"),
}
}
pub fn with_fallback(fallback: Language) -> Self {
Self { fallback }
}
pub fn negotiate(&self, accept_language: &str, available: &[Language]) -> Language {
let mut requested = self.parse_accept_language(accept_language);
requested.sort_by(|a, b| {
b.quality
.partial_cmp(&a.quality)
.unwrap_or(std::cmp::Ordering::Equal)
});
for req in &requested {
for avail in available {
if req.matches(avail) {
return avail.clone();
}
}
}
self.fallback.clone()
}
pub fn parse_accept_language(&self, header: &str) -> Vec<Language> {
header
.split(',')
.filter_map(|s| Language::parse(s.trim()))
.collect()
}
pub fn find_all_matches(&self, accept_language: &str, available: &[Language]) -> Vec<Language> {
let mut requested = self.parse_accept_language(accept_language);
requested.sort_by(|a, b| {
b.quality
.partial_cmp(&a.quality)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut matches = Vec::new();
for req in &requested {
for avail in available {
if req.matches(avail) && !matches.iter().any(|m: &Language| m.code == avail.code) {
matches.push(avail.clone());
}
}
}
matches
}
}
impl Default for LanguageNegotiator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn test_language_parse() {
let lang = Language::parse("en-US;q=0.9").unwrap();
assert_eq!(lang.code, "en");
assert_eq!(lang.region, Some("US".to_string()));
assert_eq!(lang.quality, 0.9);
}
#[test]
fn test_language_matches() {
let en_us = Language::with_region("en", "US");
let en = Language::new("en");
assert!(en_us.matches(&en));
assert!(en.matches(&en_us));
}
#[test]
fn test_negotiate() {
let negotiator = LanguageNegotiator::new();
let available = vec![Language::new("en"), Language::new("fr")];
let result = negotiator.negotiate("fr, en;q=0.9", &available);
assert_eq!(result.code, "fr");
}
#[test]
fn test_negotiate_fallback() {
let negotiator = LanguageNegotiator::new();
let available = vec![Language::new("en"), Language::new("fr")];
let result = negotiator.negotiate("de", &available);
assert_eq!(result.code, "en");
}
#[test]
fn test_find_all_matches() {
let negotiator = LanguageNegotiator::new();
let available = vec![
Language::new("en"),
Language::new("fr"),
Language::new("ja"),
];
let matches = negotiator.find_all_matches("en, fr, de", &available);
assert_eq!(matches.len(), 2);
}
#[rstest]
#[case("en-US;q=NaN")]
#[case("en-US;q=inf")]
#[case("en-US;q=-inf")]
fn test_parse_rejects_non_finite_quality(#[case] input: &str) {
let result = Language::parse(input);
assert_eq!(result, None, "Non-finite quality value should be rejected");
}
#[rstest]
fn test_negotiate_does_not_panic_on_nan_quality() {
let negotiator = LanguageNegotiator::new();
let available = vec![Language::new("en"), Language::new("fr")];
let result = negotiator.negotiate("en;q=NaN, fr;q=0.9", &available);
assert_eq!(result.code, "fr");
}
#[rstest]
fn test_find_all_matches_does_not_panic_on_nan_quality() {
let negotiator = LanguageNegotiator::new();
let available = vec![Language::new("en"), Language::new("fr")];
let matches = negotiator.find_all_matches("en;q=NaN, fr;q=0.9", &available);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].code, "fr");
}
}