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}