csaf-crud 1.4.17

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! UI internationalization — 46 European languages.
//!
//! The interface chrome — navigation, buttons, labels, headings, and
//! validation/error text — is translated at render time via [`t`]. CSAF
//! document content and user-entered data are NOT translated; they keep
//! their source language/values, as does the JSON API (`/api/v1/**`), whose
//! field names and Problem Details bodies are a stable machine contract.
//!
//! Implemented per `skills/languages-europe-rust` (the recipe proven in the
//! shipped ndaal SBOM Auditor): a pinned-plus-alphabetical [`Lang`] enum,
//! one embedded `key<TAB>text` TSV per language under `src/i18n_data/`,
//! and English fallback for any key a translation has not filled — so a
//! missing key degrades to English, never to a blank or a panic.
//!
//! The active language is resolved per request by [`from_parts`]: an
//! explicit `csaf_lang` cookie (set by the in-page switcher, `GET
//! /lang/{code}`) wins; absent that, the browser's `Accept-Language` header
//! is honoured; the final fallback is English.

use std::collections::HashMap;
use std::sync::OnceLock;

use http::header::{ACCEPT_LANGUAGE, COOKIE};
use http::request::Parts;

mod lang;
#[cfg(test)]
mod tests;

pub use lang::Lang;

/// The cookie the language switcher sets to pin a language.
pub const COOKIE_NAME: &str = "csaf_lang";

/// A single language's `key -> text` table.
type Table = HashMap<&'static str, &'static str>;

/// Every language's TSV, embedded at compile time (the single-binary
/// promise holds). Order matches [`Lang::all`].
static TSV: [(&str, &str); 49] = [
    ("en", include_str!("../i18n_data/en.tsv")),
    ("de", include_str!("../i18n_data/de.tsv")),
    ("fr", include_str!("../i18n_data/fr.tsv")),
    ("es", include_str!("../i18n_data/es.tsv")),
    ("it", include_str!("../i18n_data/it.tsv")),
    ("sq", include_str!("../i18n_data/sq.tsv")),
    ("hy", include_str!("../i18n_data/hy.tsv")),
    ("az", include_str!("../i18n_data/az.tsv")),
    ("be", include_str!("../i18n_data/be.tsv")),
    ("bs", include_str!("../i18n_data/bs.tsv")),
    ("bg", include_str!("../i18n_data/bg.tsv")),
    ("ca", include_str!("../i18n_data/ca.tsv")),
    ("zh", include_str!("../i18n_data/zh.tsv")),
    ("hr", include_str!("../i18n_data/hr.tsv")),
    ("cs", include_str!("../i18n_data/cs.tsv")),
    ("da", include_str!("../i18n_data/da.tsv")),
    ("nl", include_str!("../i18n_data/nl.tsv")),
    ("et", include_str!("../i18n_data/et.tsv")),
    ("fi", include_str!("../i18n_data/fi.tsv")),
    ("ka", include_str!("../i18n_data/ka.tsv")),
    ("el", include_str!("../i18n_data/el.tsv")),
    ("hi", include_str!("../i18n_data/hi.tsv")),
    ("hu", include_str!("../i18n_data/hu.tsv")),
    ("is", include_str!("../i18n_data/is.tsv")),
    ("ga", include_str!("../i18n_data/ga.tsv")),
    ("la", include_str!("../i18n_data/la.tsv")),
    ("lv", include_str!("../i18n_data/lv.tsv")),
    ("lt", include_str!("../i18n_data/lt.tsv")),
    ("smj", include_str!("../i18n_data/smj.tsv")),
    ("lb", include_str!("../i18n_data/lb.tsv")),
    ("mk", include_str!("../i18n_data/mk.tsv")),
    ("mt", include_str!("../i18n_data/mt.tsv")),
    ("cnr", include_str!("../i18n_data/cnr.tsv")),
    ("se", include_str!("../i18n_data/se.tsv")),
    ("no", include_str!("../i18n_data/no.tsv")),
    ("nn", include_str!("../i18n_data/nn.tsv")),
    ("pl", include_str!("../i18n_data/pl.tsv")),
    ("pt", include_str!("../i18n_data/pt.tsv")),
    ("ro", include_str!("../i18n_data/ro.tsv")),
    ("rm", include_str!("../i18n_data/rm.tsv")),
    ("ru", include_str!("../i18n_data/ru.tsv")),
    ("sr", include_str!("../i18n_data/sr.tsv")),
    ("sk", include_str!("../i18n_data/sk.tsv")),
    ("sl", include_str!("../i18n_data/sl.tsv")),
    ("sma", include_str!("../i18n_data/sma.tsv")),
    ("sv", include_str!("../i18n_data/sv.tsv")),
    ("tr", include_str!("../i18n_data/tr.tsv")),
    ("uk", include_str!("../i18n_data/uk.tsv")),
    ("ur", include_str!("../i18n_data/ur.tsv")),
];

/// The parsed per-language tables, built once on first use.
fn tables() -> &'static HashMap<&'static str, Table> {
    static TABLES: OnceLock<HashMap<&'static str, Table>> = OnceLock::new();
    TABLES.get_or_init(|| {
        TSV.iter()
            .map(|&(code, src)| (code, parse_tsv(src)))
            .collect()
    })
}

/// Parse a `key<TAB>text` TSV: one entry per line, skipping blank lines and
/// `#` comments (the per-file provenance header). Split on the FIRST tab.
fn parse_tsv(src: &'static str) -> Table {
    src.lines()
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .filter_map(|line| line.split_once('\t'))
        .collect()
}

/// Translate a stable chrome `key` into the given language.
///
/// The language's own table first, then English (the fallback for any key a
/// machine translation has not filled), then the raw key verbatim — a
/// visible, debuggable marker for a developer typo, never a panic.
#[must_use]
pub fn t(lang: Lang, key: &'static str) -> &'static str {
    let tables = tables();
    if let Some(text) = tables.get(lang.code()).and_then(|t| t.get(key).copied()) {
        return text;
    }
    if let Some(text) = tables.get("en").and_then(|t| t.get(key).copied()) {
        return text;
    }
    key
}

/// Every key the English source-of-truth table defines.
///
/// For tests and tooling that sweep the full key set (the L×N coverage
/// matrix, the bundled-font glyph-coverage suite); not used on the render
/// path.
#[must_use]
pub fn translation_keys() -> Vec<&'static str> {
    tables()
        .get("en")
        .map(|table| table.keys().copied().collect())
        .unwrap_or_default()
}

/// Resolve the UI language for a request from its headers.
#[must_use]
pub fn from_parts(parts: &Parts) -> Lang {
    // HTTP/2 clients — Chrome included — may split cookie pairs across
    // SEVERAL `cookie` header fields (RFC 9113 §8.2.3 "cookie crumbling"),
    // so `get(COOKIE)` (first field only) silently drops any pair in a
    // later field. Join all fields with "; ", the semantic recombination
    // RFC 9113 prescribes.
    let cookie = parts
        .headers
        .get_all(COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok())
        .collect::<Vec<_>>()
        .join("; ");
    let cookie = (!cookie.is_empty()).then_some(cookie.as_str());
    let accept = parts
        .headers
        .get(ACCEPT_LANGUAGE)
        .and_then(|v| v.to_str().ok());
    resolve(cookie, accept)
}

/// Resolve the language from raw `Cookie` and `Accept-Language` header
/// values: the `csaf_lang` cookie wins, then `Accept-Language`, then English.
#[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
}

/// Find a `csaf_lang=<code>` pair in a `Cookie` header value.
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()))?
    })
}

/// Pick the first supported language listed in an `Accept-Language` header.
///
/// Honours client order (which conventionally matches `q`-weight order); the
/// `q=` weights themselves are not parsed for ordering — order is a faithful
/// proxy and avoids a float-sort. `*` (any) is ignored so it falls through to
/// English. The one weight value that IS honoured is `q=0`: per RFC 9110
/// §12.5.4 it means "not acceptable", so a language explicitly excluded by
/// the client is skipped even if it appears first in list order.
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)
    })
}