greentic-bundle 0.5.6

Greentic bundle authoring CLI scaffold with embedded i18n and answer-document contracts.
Documentation
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::sync::{OnceLock, RwLock};

use anyhow::{Result, anyhow};
use unic_langid::LanguageIdentifier;

include!(concat!(env!("OUT_DIR"), "/embedded_i18n.rs"));

static CATALOGS: OnceLock<BTreeMap<String, BTreeMap<String, String>>> = OnceLock::new();
static ACTIVE_LOCALE: OnceLock<RwLock<String>> = OnceLock::new();
static SUPPORTED_LOCALES: OnceLock<Vec<String>> = OnceLock::new();

pub fn init(locale: Option<String>) {
    let resolved = select_locale(locale, &supported_locales());
    let state = ACTIVE_LOCALE.get_or_init(|| RwLock::new("en".to_string()));
    if let Ok(mut slot) = state.write() {
        *slot = resolved;
    }
}

pub fn tr(key: &str) -> String {
    tr_for(&current_locale(), key)
}

pub fn tr_for(locale: &str, key: &str) -> String {
    let catalogs = catalogs();
    if let Some(message) = translated_message(catalogs, locale, key) {
        return message.to_string();
    }
    key.to_string()
}

pub fn trf(key: &str, args: &[(&str, &str)]) -> String {
    trf_for(&current_locale(), key, args)
}

pub fn trf_for(locale: &str, key: &str, args: &[(&str, &str)]) -> String {
    let rendered = tr_for(locale, key);
    if args.is_empty() {
        return rendered;
    }
    render_template(&rendered, args)
}

pub fn current_locale() -> String {
    ACTIVE_LOCALE
        .get_or_init(|| RwLock::new("en".to_string()))
        .read()
        .map(|value| value.clone())
        .unwrap_or_else(|_| "en".to_string())
}

pub fn supported_locales() -> Vec<String> {
    SUPPORTED_LOCALES
        .get_or_init(|| {
            let mut locales = catalogs().keys().cloned().collect::<Vec<_>>();
            locales.sort();
            locales
        })
        .clone()
}

pub fn select_locale(cli_locale: Option<String>, supported: &[String]) -> String {
    fn resolve(candidate: &str, supported: &[String]) -> Option<String> {
        let normalized = normalize_locale(candidate)?;
        if supported.iter().any(|entry| entry == &normalized) {
            return Some(normalized);
        }
        let base = base_language(&normalized)?;
        if supported.iter().any(|entry| entry == &base) {
            return Some(base);
        }
        None
    }

    if let Some(cli) = cli_locale.as_deref()
        && let Some(found) = resolve(cli, supported)
    {
        return found;
    }

    if let Some(env_locale) = detect_env_locale()
        && let Some(found) = resolve(&env_locale, supported)
    {
        return found;
    }

    if let Some(system_locale) = detect_system_locale()
        && let Some(found) = resolve(&system_locale, supported)
    {
        return found;
    }

    "en".to_string()
}

pub fn cli_locale_from_argv(argv: &[OsString]) -> Option<String> {
    let mut iter = argv.iter().skip(1);
    while let Some(arg) = iter.next() {
        let text = arg.to_string_lossy();
        if let Some(value) = text.strip_prefix("--locale=") {
            return Some(value.to_string());
        }
        if text == "--locale" {
            return iter.next().map(|value| value.to_string_lossy().to_string());
        }
    }
    None
}

pub fn detect_env_locale() -> Option<String> {
    for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
        if let Ok(value) = std::env::var(key) {
            let trimmed = value.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }
    None
}

pub fn detect_system_locale() -> Option<String> {
    sys_locale::get_locale()
}

pub fn normalize_locale(raw: &str) -> Option<String> {
    let mut cleaned = raw.trim();
    if cleaned.is_empty() {
        return None;
    }
    if let Some((head, _)) = cleaned.split_once('.') {
        cleaned = head;
    }
    if let Some((head, _)) = cleaned.split_once('@') {
        cleaned = head;
    }
    let cleaned = cleaned.replace('_', "-");
    cleaned
        .parse::<LanguageIdentifier>()
        .ok()
        .map(|locale| locale.to_string())
}

pub fn base_language(tag: &str) -> Option<String> {
    tag.split('-')
        .next()
        .map(|value| value.to_ascii_lowercase())
}

pub fn locale_chain(locale: &str) -> Vec<String> {
    let Some(normalized) = normalize_locale(locale) else {
        return vec!["en".to_string()];
    };

    let mut out = vec![normalized.clone()];
    if let Some(base) = base_language(&normalized)
        && base != normalized
    {
        out.push(base);
    }
    if !out.iter().any(|entry| entry == "en") {
        out.push("en".to_string());
    }
    out
}

fn catalogs() -> &'static BTreeMap<String, BTreeMap<String, String>> {
    CATALOGS.get_or_init(load_embedded_catalogs)
}

pub fn load_catalog(locale: &str) -> Result<BTreeMap<String, String>> {
    for candidate in locale_chain(locale) {
        if let Some(catalog) = catalogs().get(&candidate) {
            return Ok(catalog.clone());
        }
    }
    Err(anyhow!(
        "{}",
        trf("errors.i18n.missing_locale", &[("locale", locale)])
    ))
}

fn translated_message<'a>(
    catalogs: &'a BTreeMap<String, BTreeMap<String, String>>,
    locale: &str,
    key: &str,
) -> Option<&'a str> {
    let normalized = normalize_locale(locale)?;
    if let Some(message) = catalogs
        .get(&normalized)
        .and_then(|catalog| catalog.get(key))
        .map(String::as_str)
    {
        return Some(message);
    }

    let base = base_language(&normalized);
    if let Some(base) = base.as_deref()
        && base != normalized
        && let Some(message) = catalogs
            .get(base)
            .and_then(|catalog| catalog.get(key))
            .map(String::as_str)
    {
        return Some(message);
    }

    if normalized != "en" && base.as_deref() != Some("en") {
        return catalogs
            .get("en")
            .and_then(|catalog| catalog.get(key))
            .map(String::as_str);
    }

    None
}

fn render_template(template: &str, args: &[(&str, &str)]) -> String {
    let mut out = String::with_capacity(template.len());
    let mut rest = template;

    while let Some(start) = rest.find('{') {
        out.push_str(&rest[..start]);
        let Some(end_offset) = rest[start + 1..].find('}') else {
            out.push_str(&rest[start..]);
            return out;
        };

        let end = start + 1 + end_offset;
        let key = &rest[start + 1..end];
        if let Some((_, value)) = args.iter().find(|(name, _)| *name == key) {
            out.push_str(value);
        } else {
            out.push_str(&rest[start..=end]);
        }
        rest = &rest[end + 1..];
    }

    out.push_str(rest);
    out
}

#[cfg(test)]
mod tests {
    use super::{
        base_language, cli_locale_from_argv, locale_chain, normalize_locale, select_locale, tr_for,
    };

    #[test]
    fn normalizes_locale_tags() {
        assert_eq!(normalize_locale("EN_us.UTF-8"), Some("en-US".to_string()));
        assert_eq!(normalize_locale("de_DE@euro"), Some("de-DE".to_string()));
        assert_eq!(normalize_locale("nl"), Some("nl".to_string()));
    }

    #[test]
    fn falls_back_to_en_language() {
        assert_eq!(locale_chain("en-US"), vec!["en-US", "en"]);
        assert_eq!(locale_chain("zz-ZZ"), vec!["zz-ZZ", "zz", "en"]);
    }

    #[test]
    fn returns_english_translation_for_unknown_locale() {
        assert_eq!(
            tr_for("zz-ZZ", "cli.root.about"),
            "Greentic bundle authoring CLI scaffold"
        );
    }

    #[test]
    fn reads_locale_from_cli_args() {
        let argv = [
            "greentic-bundle".into(),
            "--locale".into(),
            "nl-NL".into(),
            "wizard".into(),
        ];
        assert_eq!(cli_locale_from_argv(&argv), Some("nl-NL".to_string()));
    }

    #[test]
    fn selects_locale_with_base_language_fallback() {
        let supported = vec!["en".to_string(), "en-GB".to_string(), "ar".to_string()];
        assert_eq!(
            select_locale(Some("ar-SA".to_string()), &supported),
            "ar".to_string()
        );
        assert_eq!(
            select_locale(Some("en-AU".to_string()), &supported),
            "en".to_string()
        );
    }

    #[test]
    fn extracts_base_language() {
        assert_eq!(base_language("en-GB"), Some("en".to_string()));
    }
}