greentic-x 0.4.13

Greentic-X CLI for catalog-driven composition, scaffolding, validation, and simulation.
Documentation
use std::collections::BTreeMap;
use std::sync::OnceLock;

use greentic_qa_lib::ResolvedI18nMap;

#[allow(dead_code)]
type Catalog = BTreeMap<String, String>;
#[allow(dead_code)]
type CatalogsByLocale = BTreeMap<String, Catalog>;

#[allow(dead_code)]
mod embedded {
    include!(concat!(env!("OUT_DIR"), "/embedded_i18n.rs"));
}

#[allow(dead_code)]
fn catalogs() -> &'static CatalogsByLocale {
    static CATALOGS: OnceLock<CatalogsByLocale> = OnceLock::new();
    CATALOGS.get_or_init(embedded::load_embedded_catalogs)
}

pub fn normalize_locale(raw: &str) -> String {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return "en".to_owned();
    }

    let candidate = trimmed
        .split(['.', '@'])
        .next()
        .unwrap_or_default()
        .replace('_', "-");

    if let Some(locale) = catalogs()
        .keys()
        .find(|locale| locale.eq_ignore_ascii_case(&candidate))
    {
        return locale.clone();
    }

    let base = candidate
        .split('-')
        .next()
        .unwrap_or_default()
        .to_ascii_lowercase();
    if let Some(locale) = catalogs()
        .keys()
        .find(|locale| locale.eq_ignore_ascii_case(&base))
    {
        return locale.clone();
    }

    "en".to_owned()
}

pub fn locale_from_env() -> Option<String> {
    for key in [
        "GX_LOCALE",
        "GREENTIC_LOCALE",
        "LC_ALL",
        "LC_MESSAGES",
        "LANG",
    ] {
        let Ok(raw) = std::env::var(key) else {
            continue;
        };
        if let Some(locale) = normalize_env_locale_candidate(&raw) {
            return Some(locale);
        }
    }
    None
}

pub fn resolve_locale(cli_locale: Option<&str>, doc_locale: Option<&str>) -> String {
    if let Some(locale) = cli_locale.filter(|value| !value.trim().is_empty()) {
        return normalize_locale(locale);
    }
    if let Some(locale) = doc_locale.filter(|value| !value.trim().is_empty()) {
        return normalize_locale(locale);
    }
    locale_from_env().unwrap_or_else(|| "en".to_owned())
}

#[allow(dead_code)]
pub fn tr(locale: &str, key: &str) -> String {
    let normalized = normalize_locale(locale);
    catalogs()
        .get(&normalized)
        .and_then(|catalog| catalog.get(key))
        .or_else(|| catalogs().get("en").and_then(|catalog| catalog.get(key)))
        .cloned()
        .unwrap_or_else(|| key.to_owned())
}

pub fn resolved_wizard_i18n(locale: &str) -> ResolvedI18nMap {
    let normalized = normalize_locale(locale);
    let mut resolved = catalogs().get("en").cloned().unwrap_or_default();
    if normalized != "en"
        && let Some(locale_catalog) = catalogs().get(&normalized)
    {
        resolved.extend(locale_catalog.clone());
    }
    resolved
}

fn normalize_env_locale_candidate(raw: &str) -> Option<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return None;
    }
    let base = trimmed.split(['.', '@']).next().unwrap_or_default().trim();
    if base.is_empty() || base.eq_ignore_ascii_case("C") || base.eq_ignore_ascii_case("POSIX") {
        return None;
    }
    Some(normalize_locale(base))
}

#[cfg(test)]
mod tests {
    use super::{
        normalize_env_locale_candidate, normalize_locale, resolve_locale, resolved_wizard_i18n, tr,
    };

    #[test]
    fn normalize_locale_maps_supported_prefixes() {
        assert_eq!(normalize_locale("nl-NL"), "nl");
        assert_eq!(normalize_locale("en_US"), "en");
        assert_eq!(normalize_locale("ar"), "ar");
        assert_eq!(normalize_locale("ar_EG"), "ar-EG");
    }

    #[test]
    fn normalize_env_candidate_strips_encoding_and_variant() {
        assert_eq!(
            normalize_env_locale_candidate("nl_NL.UTF-8@euro"),
            Some("nl".to_owned())
        );
        assert_eq!(
            normalize_env_locale_candidate("en_US.UTF-8"),
            Some("en".to_owned())
        );
    }

    #[test]
    fn normalize_env_candidate_ignores_posix() {
        assert_eq!(normalize_env_locale_candidate("C"), None);
        assert_eq!(normalize_env_locale_candidate("POSIX"), None);
    }

    #[test]
    fn resolve_locale_prefers_cli_then_doc() {
        assert_eq!(resolve_locale(Some("nl-NL"), Some("en")), "nl");
        assert_eq!(resolve_locale(None, Some("nl")), "nl");
        assert_eq!(resolve_locale(Some("ar"), Some("en")), "ar");
    }

    #[test]
    fn translations_load_from_embedded_catalogs() {
        assert_eq!(tr("en", "wizard.qa.title"), "GX Wizard");
        assert_eq!(tr("nl", "wizard.qa.title"), "GX Wizard");
        assert_eq!(tr("ar", "wizard.nav.exit"), "خروج");
    }

    #[test]
    fn translations_fallback_to_english_and_key() {
        assert_eq!(
            tr("en", "wizard.err.template_output_ext"),
            "template_output_path must end with .json"
        );
        assert_eq!(tr("nl", "missing.key"), "missing.key");
    }

    #[test]
    fn resolved_wizard_i18n_merges_english_fallback_with_locale_override() {
        let resolved = resolved_wizard_i18n("nl");
        assert_eq!(
            resolved.get("wizard.qa.title"),
            Some(&"GX Wizard".to_owned())
        );
        assert_eq!(
            resolved.get("wizard.field.solution_name"),
            Some(&"Naam van oplossing".to_owned())
        );
    }
}