rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
//! Locale detection and management.

use std::cell::RefCell;

use chrono::NaiveDate;

const DEFAULT_LANGUAGE: &str = "en-us";
const SUPPORTED_LANGUAGES: &[&str] = &[
    "en-us", "en-gb", "fr", "de", "es", "pt-br", "zh-hans", "ja", "ar",
];

thread_local! {
    static ACTIVE_LANGUAGE: RefCell<String> = RefCell::new(DEFAULT_LANGUAGE.to_owned());
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanguageInfo {
    pub code: String,
    pub name: String,
    pub name_local: String,
    pub bidi: bool,
}

#[must_use]
pub fn get_language() -> String {
    ACTIVE_LANGUAGE.with(|language| language.borrow().clone())
}

pub fn activate(language: &str) {
    ACTIVE_LANGUAGE.with(|active| {
        *active.borrow_mut() = language.to_owned();
    });
}

pub fn deactivate() {
    ACTIVE_LANGUAGE.with(|active| {
        *active.borrow_mut() = DEFAULT_LANGUAGE.to_owned();
    });
}

#[must_use]
pub fn get_supported_languages() -> Vec<String> {
    SUPPORTED_LANGUAGES
        .iter()
        .map(|language| (*language).to_owned())
        .collect()
}

#[must_use]
pub fn number_format(value: f64, decimal_places: usize, use_l10n: bool) -> String {
    let plain = format!("{:.*}", decimal_places, value);
    if !use_l10n {
        return plain;
    }

    let language = get_language();
    let (thousands_separator, decimal_separator) = separators_for_language(&language);
    let unsigned = plain.strip_prefix('-').unwrap_or(&plain);
    let sign = if plain.starts_with('-') { "-" } else { "" };
    let (integer_part, fractional_part) = unsigned.split_once('.').unwrap_or((unsigned, ""));

    let mut formatted = format!(
        "{sign}{}",
        group_integer_part(integer_part, thousands_separator)
    );
    if decimal_places > 0 {
        formatted.push(decimal_separator);
        formatted.push_str(fractional_part);
    }

    formatted
}

#[must_use]
pub fn date_format(date: &str, format_str: &str) -> String {
    NaiveDate::parse_from_str(date, "%Y-%m-%d")
        .map(|parsed| parsed.format(format_str).to_string())
        .unwrap_or_else(|_| date.to_owned())
}

#[must_use]
pub fn get_language_info(code: &str) -> LanguageInfo {
    match code.to_ascii_lowercase().as_str() {
        "en-us" => LanguageInfo {
            code: "en-us".to_owned(),
            name: "English (US)".to_owned(),
            name_local: "English (US)".to_owned(),
            bidi: false,
        },
        "en-gb" => LanguageInfo {
            code: "en-gb".to_owned(),
            name: "English (UK)".to_owned(),
            name_local: "English (UK)".to_owned(),
            bidi: false,
        },
        "fr" => LanguageInfo {
            code: "fr".to_owned(),
            name: "French".to_owned(),
            name_local: "Français".to_owned(),
            bidi: false,
        },
        "de" => LanguageInfo {
            code: "de".to_owned(),
            name: "German".to_owned(),
            name_local: "Deutsch".to_owned(),
            bidi: false,
        },
        "es" => LanguageInfo {
            code: "es".to_owned(),
            name: "Spanish".to_owned(),
            name_local: "Español".to_owned(),
            bidi: false,
        },
        "pt-br" => LanguageInfo {
            code: "pt-br".to_owned(),
            name: "Portuguese (Brazil)".to_owned(),
            name_local: "Português (Brasil)".to_owned(),
            bidi: false,
        },
        "zh-hans" => LanguageInfo {
            code: "zh-hans".to_owned(),
            name: "Chinese (Simplified)".to_owned(),
            name_local: "简体中文".to_owned(),
            bidi: false,
        },
        "ja" => LanguageInfo {
            code: "ja".to_owned(),
            name: "Japanese".to_owned(),
            name_local: "日本語".to_owned(),
            bidi: false,
        },
        "ar" => LanguageInfo {
            code: "ar".to_owned(),
            name: "Arabic".to_owned(),
            name_local: "العربية".to_owned(),
            bidi: true,
        },
        _ => LanguageInfo {
            code: code.to_owned(),
            name: code.to_owned(),
            name_local: code.to_owned(),
            bidi: false,
        },
    }
}

fn separators_for_language(language: &str) -> (char, char) {
    match language.to_ascii_lowercase().as_str() {
        "fr" => (' ', ','),
        "de" | "es" | "pt-br" => ('.', ','),
        "ar" => ('٬', '٫'),
        _ => (',', '.'),
    }
}

fn group_integer_part(integer_part: &str, separator: char) -> String {
    let mut reversed = String::with_capacity(integer_part.len() + integer_part.len() / 3);

    for (index, ch) in integer_part.chars().rev().enumerate() {
        if index > 0 && index % 3 == 0 {
            reversed.push(separator);
        }
        reversed.push(ch);
    }

    reversed.chars().rev().collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_language_is_returned_when_inactive() {
        deactivate();

        assert_eq!(get_language(), DEFAULT_LANGUAGE);
    }

    #[test]
    fn activate_updates_current_thread_language() {
        deactivate();
        activate("fr");

        assert_eq!(get_language(), "fr");

        deactivate();
        assert_eq!(get_language(), DEFAULT_LANGUAGE);
    }

    #[test]
    fn deactivate_restores_default_language() {
        activate("de");

        deactivate();

        assert_eq!(get_language(), DEFAULT_LANGUAGE);
    }

    #[test]
    fn language_state_is_thread_local() {
        deactivate();
        activate("es");

        let other = std::thread::spawn(get_language).join().unwrap();

        assert_eq!(get_language(), "es");
        assert_eq!(other, DEFAULT_LANGUAGE);

        deactivate();
    }

    #[test]
    fn supported_languages_are_hardcoded_common_values() {
        let supported = get_supported_languages();

        assert!(supported.contains(&DEFAULT_LANGUAGE.to_owned()));
        assert!(supported.contains(&"fr".to_owned()));
        assert!(supported.contains(&"ja".to_owned()));
    }

    #[test]
    fn test_number_format_with_l10n() {
        deactivate();
        activate("de");

        assert_eq!(number_format(12345.678, 2, true), "12.345,68");

        deactivate();
    }

    #[test]
    fn test_number_format_without_l10n() {
        activate("de");

        assert_eq!(number_format(12345.678, 2, false), "12345.68");

        deactivate();
    }

    #[test]
    fn test_language_info_english() {
        let info = get_language_info("en-us");

        assert_eq!(info.code, "en-us");
        assert_eq!(info.name, "English (US)");
        assert_eq!(info.name_local, "English (US)");
        assert!(!info.bidi);
    }

    #[test]
    fn date_format_reformats_iso_dates() {
        assert_eq!(date_format("2024-03-19", "%d/%m/%Y"), "19/03/2024");
    }

    #[test]
    fn unknown_language_info_falls_back_to_code() {
        let info = get_language_info("eo");

        assert_eq!(info.code, "eo");
        assert_eq!(info.name, "eo");
        assert_eq!(info.name_local, "eo");
        assert!(!info.bidi);
    }
}