Skip to main content

ferro_rs/validation/
bridge.rs

1//! Translation bridge for validation messages.
2//!
3//! Provides a decoupled callback mechanism for translating validation error
4//! messages. The validation module never depends on ferro-lang directly;
5//! instead, a `fn` pointer is registered once at app boot via
6//! [`register_validation_translator`].
7//!
8//! If no translator is registered, all rules fall back to hardcoded English.
9
10use std::sync::OnceLock;
11
12/// Callback signature for validation message translation.
13///
14/// - `key`: Translation key (e.g., `"validation.required"`)
15/// - `params`: Interpolation parameters (e.g., `[("attribute", "email"), ("min", "8")]`)
16/// - Returns: Translated message, or `None` if the key is not found (caller
17///   falls back to English)
18pub type TranslatorFn = fn(&str, &[(&str, &str)]) -> Option<String>;
19
20pub(crate) static VALIDATION_TRANSLATOR: OnceLock<TranslatorFn> = OnceLock::new();
21
22/// Register a translation function for validation messages.
23///
24/// Called once at app boot by the framework integration layer.
25/// If not called, all validation rules return hardcoded English messages.
26///
27/// Silently ignores double-registration (`OnceLock::set` returns `Err` if
28/// already set).
29pub fn register_validation_translator(f: TranslatorFn) {
30    let _ = VALIDATION_TRANSLATOR.set(f);
31}
32
33/// Attempt to translate a validation message.
34///
35/// Returns the translated message if a translator is registered and the key
36/// exists, otherwise returns `None` (caller falls back to English).
37pub(crate) fn translate_validation(key: &str, params: &[(&str, &str)]) -> Option<String> {
38    VALIDATION_TRANSLATOR.get().and_then(|f| f(key, params))
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    #[test]
46    fn test_translate_without_translator() {
47        // In a fresh process (or before any registration), translate_validation
48        // must return None so rules fall back to hardcoded English.
49        //
50        // Note: OnceLock is global and may already be set if another test in
51        // the same process registered a translator. This test verifies the
52        // function signature and None-propagation logic; full integration
53        // testing happens in Phase 62/63.
54        let result = translate_validation("validation.required", &[("attribute", "email")]);
55        // If no translator was registered yet, this is None.
56        // If another test set one, the function still compiles and runs correctly.
57        // The key assertion is that the function is callable with the expected types.
58        let _ = result;
59    }
60
61    #[test]
62    fn test_translator_fn_signature() {
63        // Verify a concrete function matches the TranslatorFn signature.
64        fn mock_translator(key: &str, _params: &[(&str, &str)]) -> Option<String> {
65            Some(format!("translated: {key}"))
66        }
67
68        let f: TranslatorFn = mock_translator;
69        let result = f("validation.required", &[("attribute", "name")]);
70        assert_eq!(result, Some("translated: validation.required".to_string()));
71    }
72
73    #[test]
74    fn test_register_double_registration_is_noop() {
75        fn first(_key: &str, _params: &[(&str, &str)]) -> Option<String> {
76            Some("first".to_string())
77        }
78        fn second(_key: &str, _params: &[(&str, &str)]) -> Option<String> {
79            Some("second".to_string())
80        }
81
82        // Register twice. The second call should be silently ignored
83        // because OnceLock only accepts the first value.
84        register_validation_translator(first);
85        register_validation_translator(second);
86
87        // If a translator is set, it must be the first one (or one
88        // set by a previous test in this process). Either way the
89        // second registration must not panic or replace the first.
90        if let Some(f) = VALIDATION_TRANSLATOR.get() {
91            let result = f("key", &[]);
92            // Must not be "second" — OnceLock ignores subsequent sets.
93            assert_ne!(result, Some("second".to_string()));
94        }
95    }
96}