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" ); } }
GitRust