helpers.rs 433 lignes · 14427 octets
//! Helper functions for templates and services
//!
//! This module provides helper functions that can be used in Askama templates
//! and services for translation and localization.

use crate::i18n::service::I18nService;
use std::collections::HashMap;
use std::sync::Arc;

/// Translate a key using the I18nService
///
/// This is a helper function that can be called from templates.
/// In practice, templates will receive translated strings directly,
/// but this function can be used for dynamic translations.
pub async fn translate(service: &I18nService, key: &str, locale: &str) -> String {
    service.translate(key, locale).await
}

/// Format a date according to locale
///
/// # Arguments
///
/// * `date` - DateTime to format
/// * `locale` - Locale code (e.g., "fr", "fr_FR", "en", "en_US", "de", "de_DE", "es", "es_ES")
///
/// # Returns
///
/// Formatted date string according to locale conventions
///
/// # Examples
///
/// - fr_FR: "31/12/2024 14:30"
/// - en_US: "12/31/2024 02:30 PM"
/// - de_DE: "31.12.2024 14:30"
/// - es_ES: "31/12/2024 14:30"
pub fn format_date(date: &chrono::DateTime<chrono::Utc>, locale: &str) -> String {
    // T108 (FR-005.3): Regional variant fallback
    // Normalize locale code (remove region if present for matching)
    // This implements fallback: fr_CA -> fr, en_US -> en, etc.
    let locale_normalized = if locale.len() > 2 && locale.contains('_') {
        // Regional variant: extract base language
        &locale[..2]
    } else if locale.len() > 2 {
        // Might be a longer code without underscore, take first 2 chars
        &locale[..2]
    } else {
        // Already base language
        locale
    };

    match locale_normalized {
        "fr" => {
            // French format: DD/MM/YYYY HH:MM
            date.format("%d/%m/%Y %H:%M").to_string()
        }
        "de" => {
            // German format: DD.MM.YYYY HH:MM
            date.format("%d.%m.%Y %H:%M").to_string()
        }
        "es" | "it" | "pt" => {
            // Spanish/Italian/Portuguese format: DD/MM/YYYY HH:MM (same as French)
            date.format("%d/%m/%Y %H:%M").to_string()
        }
        "en" => {
            // English (US) format: MM/DD/YYYY HH:MM AM/PM
            date.format("%m/%d/%Y %I:%M %p").to_string()
        }
        _ => {
            // Default to English (US) format for unknown locales
            date.format("%m/%d/%Y %I:%M %p").to_string()
        }
    }
}

/// Format a number according to locale
///
/// # Arguments
///
/// * `number` - Number to format
/// * `locale` - Locale code (e.g., "fr", "fr_FR", "en", "en_US", "de", "de_DE", "es", "es_ES")
///
/// # Returns
///
/// Formatted number string according to locale conventions
///
/// # Examples
///
/// - fr_FR: "1 234,56" (space as thousand separator, comma as decimal)
/// - en_US: "1,234.56" (comma as thousand separator, dot as decimal)
/// - de_DE: "1.234,56" (dot as thousand separator, comma as decimal)
/// - es_ES: "1.234,56" (same as German)
pub fn format_number(number: f64, locale: &str) -> String {
    use num_format::{Locale, ToFormattedString};

    // T108 (FR-005.3): Regional variant fallback
    // Normalize locale code (remove region if present for matching)
    // This implements fallback: fr_CA -> fr, en_US -> en, etc.
    let locale_normalized = if locale.len() > 2 && locale.contains('_') {
        // Regional variant: extract base language
        &locale[..2]
    } else if locale.len() > 2 {
        // Might be a longer code without underscore, take first 2 chars
        &locale[..2]
    } else {
        // Already base language
        locale
    };

    // Map locale codes to num_format::Locale
    let num_locale = match locale_normalized {
        "fr" => Locale::fr,
        "de" => Locale::de,
        "es" => Locale::es,
        "it" => Locale::it,
        "pt" => Locale::pt,
        "en" => Locale::en,
        _ => Locale::en, // Default to English for unknown locales
    };

    // Format the number with proper locale-specific formatting
    // num-format handles thousand separators, but we need to handle decimal separator manually
    let integer_part = number.trunc() as i64;
    let formatted_integer = integer_part.to_formatted_string(&num_locale);

    // Format decimal part (always 2 decimal places)
    let decimal_part = (number.abs() % 1.0) * 100.0;
    let decimal_int = decimal_part.round() as u64;

    // Determine decimal separator based on locale
    let decimal_separator = match locale_normalized {
        "fr" | "de" | "es" | "it" | "pt" => ",",
        _ => ".", // English and default
    };

    // Handle case where number is exactly an integer (no decimal part)
    if decimal_int == 0 {
        format!("{}{}00", formatted_integer, decimal_separator)
    } else {
        format!(
            "{}{}{:02}",
            formatted_integer, decimal_separator, decimal_int
        )
    }
}

/// Generate a translated validation message
///
/// This helper function can be used in services to generate translated validation messages.
/// If I18nService and locale are not provided, it returns the default English message.
///
/// # Arguments
///
/// * `key` - Translation key (e.g., "error.validation_field.email_format")
/// * `locale` - Optional locale code (e.g., "fr", "en")
/// * `i18n_service` - Optional I18nService instance
/// * `vars` - Optional HashMap of variables for interpolation
/// * `default_message` - Default message to use if translation is not available
///
/// # Returns
///
/// The translated message, or the default message if translation is not available
pub async fn translate_validation_message(
    key: &str,
    locale: Option<&str>,
    i18n_service: Option<&Arc<I18nService>>,
    vars: Option<&HashMap<String, String>>,
    default_message: &str,
) -> String {
    if let (Some(service), Some(loc)) = (i18n_service, locale) {
        if let Some(variables) = vars {
            service.translate_with_vars(key, loc, variables).await
        } else {
            service.translate(key, loc).await
        }
    } else {
        default_message.to_string()
    }
}

/// Generate a translated validation message for email format error
pub async fn validation_email_format(
    locale: Option<&str>,
    i18n_service: Option<&Arc<I18nService>>,
) -> String {
    translate_validation_message(
        "error.validation_field.email_format",
        locale,
        i18n_service,
        None,
        "Invalid email format",
    )
    .await
}

/// Generate a translated validation message for username minimum length error
pub async fn validation_username_min_length(
    min: usize,
    locale: Option<&str>,
    i18n_service: Option<&Arc<I18nService>>,
) -> String {
    let mut vars = HashMap::new();
    vars.insert("min".to_string(), min.to_string());

    translate_validation_message(
        "error.validation_field.username_min_length",
        locale,
        i18n_service,
        Some(&vars),
        &format!("Username must contain at least {} characters", min),
    )
    .await
}

/// Generate a translated validation message for password minimum length error
pub async fn validation_password_min_length(
    min: usize,
    locale: Option<&str>,
    i18n_service: Option<&Arc<I18nService>>,
) -> String {
    let mut vars = HashMap::new();
    vars.insert("min".to_string(), min.to_string());

    translate_validation_message(
        "error.validation_field.password_min_length",
        locale,
        i18n_service,
        Some(&vars),
        &format!("Password must contain at least {} characters", min),
    )
    .await
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{TimeZone, Utc};

    #[test]
    fn test_format_date() {
        // Test with a fixed date: 2024-12-31 14:30:00 UTC
        let date = Utc.with_ymd_and_hms(2024, 12, 31, 14, 30, 0).unwrap();

        // Test French format
        let fr_formatted = format_date(&date, "fr");
        assert!(
            fr_formatted.contains("31/12/2024"),
            "French format should contain DD/MM/YYYY"
        );
        assert!(
            fr_formatted.contains("14:30"),
            "French format should contain 24-hour time"
        );

        // Test German format
        let de_formatted = format_date(&date, "de");
        assert!(
            de_formatted.contains("31.12.2024"),
            "German format should contain DD.MM.YYYY"
        );
        assert!(
            de_formatted.contains("14:30"),
            "German format should contain 24-hour time"
        );

        // Test Spanish format
        let es_formatted = format_date(&date, "es");
        assert!(
            es_formatted.contains("31/12/2024"),
            "Spanish format should contain DD/MM/YYYY"
        );

        // Test English format
        let en_formatted = format_date(&date, "en");
        assert!(
            en_formatted.contains("12/31/2024"),
            "English format should contain MM/DD/YYYY"
        );
        assert!(
            en_formatted.contains("PM") || en_formatted.contains("AM"),
            "English format should contain AM/PM"
        );

        // Test Italian format
        let it_formatted = format_date(&date, "it");
        assert!(
            it_formatted.contains("31/12/2024"),
            "Italian format should contain DD/MM/YYYY"
        );
        assert!(
            it_formatted.contains("14:30"),
            "Italian format should contain 24-hour time"
        );

        // Test invalid locale (should default to English)
        let invalid_formatted = format_date(&date, "xx");
        assert!(
            invalid_formatted.contains("12/31/2024"),
            "Invalid locale should default to English format"
        );
    }

    #[test]
    fn test_format_number() {
        let number = 1234.56;

        // Test French format: 1 234,56 (uses non-breaking space as thousand separator)
        let fr_formatted = format_number(number, "fr");
        println!("French formatted: '{}'", fr_formatted);
        assert!(
            fr_formatted.contains(","),
            "French format should use comma as decimal separator, got: {}",
            fr_formatted
        );
        // French uses non-breaking space (U+00A0) as thousand separator, so check for "1" and "234" or "1234"
        assert!(
            fr_formatted.starts_with("1"),
            "French format should start with '1', got: {}",
            fr_formatted
        );
        assert!(
            fr_formatted.contains("234") || fr_formatted.replace('\u{00A0}', "").contains("1234"),
            "French format should contain number parts, got: {}",
            fr_formatted
        );

        // Test German format: 1.234,56
        let de_formatted = format_number(number, "de");
        println!("German formatted: '{}'", de_formatted);
        assert!(
            de_formatted.contains(","),
            "German format should use comma as decimal separator, got: {}",
            de_formatted
        );
        assert!(
            de_formatted.contains("."),
            "German format should use dot as thousand separator, got: {}",
            de_formatted
        );

        // Test Spanish format: 1.234,56 (same as German)
        let es_formatted = format_number(number, "es");
        println!("Spanish formatted: '{}'", es_formatted);
        assert!(
            es_formatted.contains(","),
            "Spanish format should use comma as decimal separator, got: {}",
            es_formatted
        );
        assert!(
            es_formatted.contains("."),
            "Spanish format should use dot as thousand separator, got: {}",
            es_formatted
        );

        // Test English format: 1,234.56
        let en_formatted = format_number(number, "en");
        println!("English formatted: '{}'", en_formatted);
        assert!(
            en_formatted.contains("."),
            "English format should use dot as decimal separator, got: {}",
            en_formatted
        );
        assert!(
            en_formatted.contains(","),
            "English format should use comma as thousand separator, got: {}",
            en_formatted
        );

        // Test Italian format: 1.234,56 (same as German/Spanish)
        let it_formatted = format_number(number, "it");
        println!("Italian formatted: '{}'", it_formatted);
        assert!(
            it_formatted.contains(","),
            "Italian format should use comma as decimal separator, got: {}",
            it_formatted
        );
        assert!(
            it_formatted.contains("."),
            "Italian format should use dot as thousand separator, got: {}",
            it_formatted
        );

        // Test invalid locale (should default to English)
        let invalid_formatted = format_number(number, "xx");
        println!("Invalid locale formatted: '{}'", invalid_formatted);
        assert!(
            invalid_formatted.contains("."),
            "Invalid locale should default to English format, got: {}",
            invalid_formatted
        );
    }

    #[test]
    fn test_format_date_locale_normalization() {
        let date = Utc.with_ymd_and_hms(2024, 12, 31, 14, 30, 0).unwrap();

        // Test that fr_FR and fr produce the same result
        let fr_short = format_date(&date, "fr");
        let fr_long = format_date(&date, "fr_FR");
        assert_eq!(
            fr_short, fr_long,
            "fr and fr_FR should produce the same format"
        );

        // Test that en_US and en produce the same result
        let en_short = format_date(&date, "en");
        let en_long = format_date(&date, "en_US");
        assert_eq!(
            en_short, en_long,
            "en and en_US should produce the same format"
        );
    }

    #[test]
    fn test_format_number_locale_normalization() {
        let number = 1234.56;

        // Test that fr_FR and fr produce the same result
        let fr_short = format_number(number, "fr");
        let fr_long = format_number(number, "fr_FR");
        assert_eq!(
            fr_short, fr_long,
            "fr and fr_FR should produce the same format"
        );

        // Test that en_US and en produce the same result
        let en_short = format_number(number, "en");
        let en_long = format_number(number, "en_US");
        assert_eq!(
            en_short, en_long,
            "en and en_US should produce the same format"
        );
    }
}