Skip to main content

es_fluent_manager_core/localization/
registry.rs

1use super::I18nModuleRegistration;
2use crate::asset_localization::ModuleData;
3use std::collections::HashMap;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6pub enum ModuleRegistrationKind {
7    MetadataOnly,
8    RuntimeLocalizer,
9}
10
11impl std::fmt::Display for ModuleRegistrationKind {
12    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13        match self {
14            Self::MetadataOnly => f.write_str("metadata-only"),
15            Self::RuntimeLocalizer => f.write_str("runtime-localizer"),
16        }
17    }
18}
19
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub enum ModuleDiscoveryError {
22    InvalidMetadata(crate::asset_localization::ModuleRegistryError),
23    InconsistentModuleMetadata {
24        name: String,
25        owner: String,
26    },
27    DuplicateModuleRegistration {
28        name: String,
29        owner: String,
30        kind: ModuleRegistrationKind,
31        count: usize,
32    },
33}
34
35impl std::fmt::Display for ModuleDiscoveryError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::InvalidMetadata(error) => write!(f, "{error}"),
39            Self::InconsistentModuleMetadata { name, owner } => write!(
40                f,
41                "module '{name}' (owner '{owner}') has mismatched metadata between registrations",
42            ),
43            Self::DuplicateModuleRegistration {
44                name,
45                owner,
46                kind,
47                count,
48            } => write!(
49                f,
50                "module '{name}' (owner '{owner}') has {count} duplicate {kind} registrations",
51            ),
52        }
53    }
54}
55
56impl std::error::Error for ModuleDiscoveryError {
57    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
58        match self {
59            Self::InvalidMetadata(error) => Some(error),
60            Self::InconsistentModuleMetadata { .. } => None,
61            Self::DuplicateModuleRegistration { .. } => None,
62        }
63    }
64}
65
66#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
67struct RegistrationCounts {
68    metadata_only: usize,
69    runtime_localizer: usize,
70}
71
72#[derive(Clone, Copy, Debug, Default)]
73struct IdentityInspection {
74    chosen_data: Option<&'static ModuleData>,
75    metadata_only: Option<&'static ModuleData>,
76    runtime_localizer: Option<&'static ModuleData>,
77    counts: RegistrationCounts,
78}
79
80fn validate_single_module_data(data: &'static ModuleData, errors: &mut Vec<ModuleDiscoveryError>) {
81    if let Err(validation_errors) = crate::asset_localization::validate_module_registry([data]) {
82        errors.extend(
83            validation_errors
84                .into_iter()
85                .map(ModuleDiscoveryError::InvalidMetadata),
86        );
87    }
88}
89
90/// Validates discovered registrations strictly and returns either the validated
91/// module list or the collected registry/discovery errors.
92///
93/// Strict validation still allows one metadata-only registration plus one
94/// runtime-localizer registration for the same exact (`name`, `owner`)
95/// identity, because that pairing is used intentionally by some integrations.
96/// It rejects repeated registrations of the same kind for one identity and all
97/// metadata validation failures reported by `validate_module_registry()`. Exact
98/// metadata/runtime pairs are preserved so integrations can filter the
99/// registration kind they need.
100pub fn try_filter_module_registry(
101    modules: impl IntoIterator<Item = &'static dyn I18nModuleRegistration>,
102) -> Result<Vec<&'static dyn I18nModuleRegistration>, Vec<ModuleDiscoveryError>> {
103    let modules = modules.into_iter().collect::<Vec<_>>();
104    let inspections = inspect_module_registry(&modules);
105    let discovered_data = inspections
106        .values()
107        .filter_map(|inspection| inspection.chosen_data)
108        .collect::<Vec<_>>();
109
110    let mut errors = Vec::new();
111    if let Err(validation_errors) =
112        crate::asset_localization::validate_module_registry(discovered_data.iter().copied())
113    {
114        errors.extend(
115            validation_errors
116                .into_iter()
117                .map(ModuleDiscoveryError::InvalidMetadata),
118        );
119    }
120
121    for ((name, owner), inspection) in inspections {
122        if let (Some(metadata_only), Some(runtime_localizer)) =
123            (inspection.metadata_only, inspection.runtime_localizer)
124            && metadata_only != runtime_localizer
125        {
126            errors.push(ModuleDiscoveryError::InconsistentModuleMetadata {
127                name: name.to_string(),
128                owner: owner.to_string(),
129            });
130
131            if inspection.chosen_data != Some(metadata_only) {
132                validate_single_module_data(metadata_only, &mut errors);
133            }
134            if inspection.chosen_data != Some(runtime_localizer) {
135                validate_single_module_data(runtime_localizer, &mut errors);
136            }
137        }
138
139        let counts = inspection.counts;
140        if counts.metadata_only > 1 {
141            errors.push(ModuleDiscoveryError::DuplicateModuleRegistration {
142                name: name.to_string(),
143                owner: owner.to_string(),
144                kind: ModuleRegistrationKind::MetadataOnly,
145                count: counts.metadata_only,
146            });
147        }
148        if counts.runtime_localizer > 1 {
149            errors.push(ModuleDiscoveryError::DuplicateModuleRegistration {
150                name: name.to_string(),
151                owner: owner.to_string(),
152                kind: ModuleRegistrationKind::RuntimeLocalizer,
153                count: counts.runtime_localizer,
154            });
155        }
156    }
157
158    if errors.is_empty() {
159        Ok(modules)
160    } else {
161        Err(errors)
162    }
163}
164
165fn inspect_module_registry(
166    modules: &[&'static dyn I18nModuleRegistration],
167) -> HashMap<(&'static str, &'static str), IdentityInspection> {
168    let mut inspections: HashMap<(&'static str, &'static str), IdentityInspection> = HashMap::new();
169
170    for module in modules {
171        let data = module.data();
172        let inspection = inspections
173            .entry((data.name, data.owner.as_str()))
174            .or_default();
175        inspection.chosen_data.get_or_insert(data);
176
177        match module.registration_kind() {
178            ModuleRegistrationKind::MetadataOnly => {
179                inspection.counts.metadata_only += 1;
180                inspection.metadata_only.get_or_insert(data);
181            },
182            ModuleRegistrationKind::RuntimeLocalizer => {
183                inspection.counts.runtime_localizer += 1;
184                inspection.runtime_localizer.get_or_insert(data);
185            },
186        }
187    }
188
189    inspections
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::asset_localization::{I18nModuleDescriptor, StaticModuleDescriptor};
196    use crate::localization::{I18nModule, LocalizationError, Localizer};
197    use std::error::Error as _;
198    use unic_langid::{LanguageIdentifier, langid};
199
200    static REGISTRY_TEST_LANGUAGES: &[LanguageIdentifier] = &[langid!("en")];
201    static REGISTRY_TEST_DATA: ModuleData = ModuleData {
202        name: "registry-test",
203        owner: crate::__macro::static_domain("registry-domain"),
204        supported_languages: REGISTRY_TEST_LANGUAGES,
205        domains: &[crate::ModuleDomain {
206            domain: crate::__macro::static_domain("registry-domain"),
207            namespaces: &[],
208        }],
209    };
210    static REGISTRY_INVALID_DATA: ModuleData = ModuleData {
211        name: "registry-invalid",
212        owner: crate::__macro::static_domain("registry-invalid"),
213        supported_languages: &[],
214        domains: &[crate::ModuleDomain {
215            domain: crate::__macro::static_domain("registry-invalid"),
216            namespaces: &[" ../escape "],
217        }],
218    };
219    static REGISTRY_METADATA: StaticModuleDescriptor =
220        StaticModuleDescriptor::new(&REGISTRY_TEST_DATA);
221    static REGISTRY_METADATA_TWO: StaticModuleDescriptor =
222        StaticModuleDescriptor::new(&REGISTRY_TEST_DATA);
223    static REGISTRY_INVALID_METADATA: StaticModuleDescriptor =
224        StaticModuleDescriptor::new(&REGISTRY_INVALID_DATA);
225
226    struct RegistryRuntimeModule;
227    struct RegistryRuntimeModuleTwo;
228    struct RegistryNoopLocalizer;
229
230    impl Localizer for RegistryNoopLocalizer {
231        fn select_language(&self, _lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
232            Ok(())
233        }
234
235        fn localize<'a>(
236            &self,
237            _key: crate::StaticFluentMessageKey,
238            _args: Option<&crate::FluentArgumentMap<'a>>,
239        ) -> Option<String> {
240            None
241        }
242    }
243
244    impl I18nModuleDescriptor for RegistryRuntimeModule {
245        fn data(&self) -> &'static ModuleData {
246            &REGISTRY_TEST_DATA
247        }
248    }
249
250    impl I18nModule for RegistryRuntimeModule {
251        fn create_localizer(&self) -> Box<dyn Localizer> {
252            Box::new(RegistryNoopLocalizer)
253        }
254    }
255
256    impl I18nModuleDescriptor for RegistryRuntimeModuleTwo {
257        fn data(&self) -> &'static ModuleData {
258            &REGISTRY_TEST_DATA
259        }
260    }
261
262    impl I18nModule for RegistryRuntimeModuleTwo {
263        fn create_localizer(&self) -> Box<dyn Localizer> {
264            Box::new(RegistryNoopLocalizer)
265        }
266    }
267
268    static REGISTRY_RUNTIME: RegistryRuntimeModule = RegistryRuntimeModule;
269    static REGISTRY_RUNTIME_TWO: RegistryRuntimeModuleTwo = RegistryRuntimeModuleTwo;
270
271    #[test]
272    fn inspect_module_registry_records_counts_for_each_registration_kind() {
273        let modules: [&'static dyn I18nModuleRegistration; 2] = [
274            &REGISTRY_METADATA as &dyn I18nModuleRegistration,
275            &REGISTRY_RUNTIME as &dyn I18nModuleRegistration,
276        ];
277
278        let inspections = inspect_module_registry(&modules);
279        let inspection = inspections
280            .get(&("registry-test", "registry-domain"))
281            .expect("module identity should be inspected");
282
283        assert_eq!(inspection.chosen_data, Some(&REGISTRY_TEST_DATA));
284        assert_eq!(inspection.metadata_only, Some(&REGISTRY_TEST_DATA));
285        assert_eq!(inspection.runtime_localizer, Some(&REGISTRY_TEST_DATA));
286        assert_eq!(
287            inspection.counts,
288            RegistrationCounts {
289                metadata_only: 1,
290                runtime_localizer: 1,
291            }
292        );
293    }
294
295    #[test]
296    fn try_filter_module_registry_allows_empty_and_exact_metadata_runtime_pair() {
297        let empty: [&'static dyn I18nModuleRegistration; 0] = [];
298        assert!(
299            try_filter_module_registry(empty)
300                .expect("empty registry is valid")
301                .is_empty()
302        );
303
304        let filtered = try_filter_module_registry([
305            &REGISTRY_METADATA as &dyn I18nModuleRegistration,
306            &REGISTRY_RUNTIME as &dyn I18nModuleRegistration,
307        ])
308        .expect("exact metadata and runtime registrations should be valid");
309
310        assert_eq!(filtered.len(), 2);
311    }
312
313    #[test]
314    fn try_filter_module_registry_reports_duplicate_registration_kinds() {
315        let metadata_errors = match try_filter_module_registry([
316            &REGISTRY_METADATA as &dyn I18nModuleRegistration,
317            &REGISTRY_METADATA_TWO as &dyn I18nModuleRegistration,
318        ]) {
319            Ok(_) => panic!("duplicate metadata registrations should fail"),
320            Err(errors) => errors,
321        };
322        assert!(metadata_errors.iter().any(|error| {
323            matches!(
324                error,
325                ModuleDiscoveryError::DuplicateModuleRegistration {
326                    kind: ModuleRegistrationKind::MetadataOnly,
327                    count: 2,
328                    ..
329                }
330            )
331        }));
332
333        let runtime_errors = match try_filter_module_registry([
334            &REGISTRY_RUNTIME as &dyn I18nModuleRegistration,
335            &REGISTRY_RUNTIME_TWO as &dyn I18nModuleRegistration,
336        ]) {
337            Ok(_) => panic!("duplicate runtime registrations should fail"),
338            Err(errors) => errors,
339        };
340        assert!(runtime_errors.iter().any(|error| {
341            matches!(
342                error,
343                ModuleDiscoveryError::DuplicateModuleRegistration {
344                    kind: ModuleRegistrationKind::RuntimeLocalizer,
345                    count: 2,
346                    ..
347                }
348            )
349        }));
350    }
351
352    #[test]
353    fn validate_single_module_data_wraps_registry_validation_errors() {
354        let mut errors = Vec::new();
355        validate_single_module_data(&REGISTRY_INVALID_DATA, &mut errors);
356
357        let invalid = errors
358            .iter()
359            .find(|error| matches!(error, ModuleDiscoveryError::InvalidMetadata(_)))
360            .expect("invalid metadata should be wrapped");
361        assert!(invalid.source().is_some());
362        assert!(invalid.to_string().contains("namespace"));
363    }
364
365    #[test]
366    fn module_discovery_errors_format_diagnostics_and_sources() {
367        assert_eq!(
368            ModuleRegistrationKind::MetadataOnly.to_string(),
369            "metadata-only"
370        );
371        assert_eq!(
372            ModuleRegistrationKind::RuntimeLocalizer.to_string(),
373            "runtime-localizer"
374        );
375
376        let invalid = match try_filter_module_registry([
377            &REGISTRY_INVALID_METADATA as &dyn I18nModuleRegistration
378        ]) {
379            Ok(_) => panic!("invalid metadata should fail"),
380            Err(mut errors) => errors.remove(0),
381        };
382        assert!(invalid.source().is_some());
383
384        let inconsistent = ModuleDiscoveryError::InconsistentModuleMetadata {
385            name: "name".to_string(),
386            owner: "owner".to_string(),
387        };
388        assert_eq!(
389            inconsistent.to_string(),
390            "module 'name' (owner 'owner') has mismatched metadata between registrations"
391        );
392        assert!(inconsistent.source().is_none());
393
394        let duplicate = ModuleDiscoveryError::DuplicateModuleRegistration {
395            name: "name".to_string(),
396            owner: "owner".to_string(),
397            kind: ModuleRegistrationKind::RuntimeLocalizer,
398            count: 3,
399        };
400        assert_eq!(
401            duplicate.to_string(),
402            "module 'name' (owner 'owner') has 3 duplicate runtime-localizer registrations"
403        );
404        assert!(duplicate.source().is_none());
405    }
406}