csaf-crud 1.4.15

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 (English / German / French / Spanish / Italian).
//!
//! 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.
//!
//! 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.
//!
//! Ported from the same mechanism in the sibling `ndaal_public_bsi_grundschutz_oscal_viewer`
//! repo (`src/i18n.rs`, its `feat(i18n)` branch, already extended there to
//! five languages) rather than designing a new one. Two deliberate
//! deviations from that source, both consequences of this crate rendering
//! HTML via `format!` instead of Askama templates:
//!   - [`t`] takes `Lang` by value, not `&Lang` — the source needed `&Lang`
//!     only because Askama passes template fields by reference.
//!   - Translation tables are split one-per-route-module
//!     (`i18n::{nav,home,csaf,admin,info,settings}`) instead of one 1831-line
//!     file — this repo's own file-length gate (rust-doctor `oversized_unit`,
//!     500 lines) would flag the source's single-file shape.

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

mod admin;
mod csaf;
mod home;
mod info;
mod nav;
mod settings;

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

/// A supported user-interface language.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Lang {
    /// English (the default / fallback).
    #[default]
    En,
    /// German (Deutsch).
    De,
    /// French (Français).
    Fr,
    /// Spanish (Español).
    Es,
    /// Italian (Italiano).
    It,
}

impl Lang {
    /// The BCP-47 / ISO-639-1 code (`en`/`de`/`fr`/`es`/`it`) — also the `<html lang>` value.
    #[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",
        }
    }

    /// The language's own name (endonym), for the switcher menu.
    #[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",
        }
    }

    /// Parse a language code (case-insensitively, primary subtag only), or
    /// `None` for an unsupported language.
    #[must_use]
    pub fn from_code(code: &str) -> Option<Self> {
        // Take the primary subtag (`de-DE` -> `de`) and lower-case it.
        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,
        }
    }

    /// All supported languages, in switcher-menu order.
    #[must_use]
    pub const fn all() -> [Self; 5] {
        [Self::En, Self::De, Self::Fr, Self::Es, Self::It]
    }
}

/// Resolve the UI language for a request from its headers.
#[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)
}

/// 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)
    })
}

/// Translate a stable chrome `key` into the given language.
///
/// Unknown keys return the key verbatim (a visible, debuggable marker rather
/// than a panic) — keys are compile-time string literals at call sites, so
/// this only fires on a developer typo.
#[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,
    }
}

/// The `(en, de, fr, es, it)` texts for a key, or `None` if unknown.
///
/// `pub(crate)` so each domain module's own completeness tests (added as
/// each is populated) can call [`t`] and inspect table shape uniformly.
pub(crate) type Entry = (
    &'static str,
    &'static str,
    &'static str,
    &'static str,
    &'static str,
);

/// Look a key up across the per-route-module translation tables.
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() {
        // The seam-level check for the one domain Phase 1 fully populates.
        // Each further-populated domain module carries its own exhaustive
        // per-table completeness test as it lands.
        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");
            }
        }
    }
}