Skip to main content

es_fluent_manager_core/asset_localization/
module.rs

1use super::resource::{ModuleResourceSpec, ResourcePlanError};
2use es_fluent_shared::namespace::{NamespacePathError, ResolvedNamespace};
3use es_fluent_shared::registry::StaticFluentDomain;
4use std::collections::HashSet;
5use std::fmt;
6use unic_langid::LanguageIdentifier;
7
8/// A package-local Fluent domain and its namespace files.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub struct ModuleDomain {
11    /// Domain name from the owning crate's `i18n.toml`.
12    pub domain: StaticFluentDomain,
13    /// Namespaces configured for this domain.
14    pub namespaces: &'static [&'static str],
15}
16
17/// Static metadata describing an i18n module.
18///
19/// This single shape is shared by all managers (embedded, Bevy, and future
20/// third-party backends) so module discovery and routing can be standardized.
21#[derive(Debug, Eq, PartialEq)]
22pub struct ModuleData {
23    /// The unique module name (typically crate name).
24    pub name: &'static str,
25    /// The crate whose `i18n.toml` owns this module's domains.
26    pub owner: StaticFluentDomain,
27    /// Languages that this module can provide.
28    pub supported_languages: &'static [LanguageIdentifier],
29    /// Package-local domains and their namespace files.
30    pub domains: &'static [ModuleDomain],
31}
32
33impl ModuleData {
34    /// Returns the global/default canonical resource plan for this module.
35    ///
36    /// For each domain without namespaces, this plan requires `{domain}.ftl`.
37    /// With namespaces, the base file remains optional and every namespace is
38    /// required. Managers should prefer
39    /// [`crate::I18nModuleRegistration::resource_plan_for_language`] when a
40    /// registration provides a sparse per-language manifest plan.
41    pub fn resource_plan(&self) -> Vec<ModuleResourceSpec> {
42        self.try_resource_plan()
43            .expect("module metadata domains are statically validated")
44    }
45
46    /// Attempts to return the global/default canonical resource plan.
47    ///
48    /// Returns invalid namespace metadata as a typed error.
49    pub fn try_resource_plan(&self) -> Result<Vec<ModuleResourceSpec>, ResourcePlanError> {
50        let mut specs = Vec::new();
51        for domain in self.domains {
52            specs.extend(
53                super::resource::ResourcePlan::for_static_domain(domain.domain, domain.namespaces)?
54                    .into_specs(),
55            );
56        }
57        Ok(specs)
58    }
59
60    /// Returns whether this module defines the package-local domain.
61    pub fn owns_domain(&self, domain: StaticFluentDomain) -> bool {
62        self.domains
63            .iter()
64            .any(|candidate| candidate.domain == domain)
65    }
66}
67
68/// Validation failures for a discovered module registry.
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub enum ModuleRegistryError {
71    /// A module has an empty name.
72    EmptyModuleName,
73    /// A module has an empty owner.
74    EmptyOwner { module: String },
75    /// A module name appears more than once.
76    DuplicateModuleName { name: String },
77    /// An owner appears more than once.
78    DuplicateOwner { owner: String },
79    /// A module declares the same package-local domain more than once.
80    DuplicateDomain { module: String, domain: String },
81    /// A module declares the same language more than once.
82    DuplicateSupportedLanguage {
83        module: String,
84        language: LanguageIdentifier,
85    },
86    /// A module declares the same namespace more than once.
87    DuplicateNamespace {
88        module: String,
89        domain: String,
90        namespace: String,
91    },
92    /// A namespace entry is malformed.
93    InvalidNamespace {
94        module: String,
95        domain: String,
96        namespace: String,
97        details: NamespacePathError,
98    },
99}
100
101impl fmt::Display for ModuleRegistryError {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            Self::EmptyModuleName => write!(f, "module name must not be empty"),
105            Self::EmptyOwner { module } => {
106                write!(f, "module '{}' has an empty owner", module)
107            },
108            Self::DuplicateModuleName { name } => {
109                write!(f, "duplicate module name '{}'", name)
110            },
111            Self::DuplicateOwner { owner } => {
112                write!(f, "duplicate module owner '{}'", owner)
113            },
114            Self::DuplicateDomain { module, domain } => {
115                write!(
116                    f,
117                    "module '{}' declares duplicate domain '{}'",
118                    module, domain
119                )
120            },
121            Self::DuplicateSupportedLanguage { module, language } => write!(
122                f,
123                "module '{}' declares duplicate language '{}'",
124                module, language
125            ),
126            Self::DuplicateNamespace {
127                module,
128                domain,
129                namespace,
130            } => write!(
131                f,
132                "module '{}' domain '{}' declares duplicate namespace '{}'",
133                module, domain, namespace
134            ),
135            Self::InvalidNamespace {
136                module,
137                domain,
138                namespace,
139                details,
140            } => write!(
141                f,
142                "module '{}' domain '{}' has invalid namespace '{}': {}",
143                module, domain, namespace, details
144            ),
145        }
146    }
147}
148
149impl std::error::Error for ModuleRegistryError {}
150
151/// Validates module metadata discovered through inventory.
152///
153/// Contract:
154/// - `name` and `owner` must be non-empty and globally unique.
155/// - `supported_languages` must not contain duplicates.
156/// - Domains and namespaces must not contain duplicates within one module.
157/// - Namespaces use canonical forward-slash paths such as `ui` or `ui/button`.
158/// - Namespace paths must be relative, must not contain `.` or `..` segments,
159///   must not have leading or trailing whitespace, and must not include the
160///   `.ftl` suffix.
161pub fn validate_module_registry<'a>(
162    modules: impl IntoIterator<Item = &'a ModuleData>,
163) -> Result<(), Vec<ModuleRegistryError>> {
164    let mut errors = Vec::new();
165    let mut module_names = HashSet::new();
166    let mut module_owners = HashSet::new();
167
168    for data in modules {
169        if data.name.trim().is_empty() {
170            errors.push(ModuleRegistryError::EmptyModuleName);
171        } else if !module_names.insert(data.name) {
172            errors.push(ModuleRegistryError::DuplicateModuleName {
173                name: data.name.to_string(),
174            });
175        }
176
177        if data.owner.as_str().trim().is_empty() {
178            errors.push(ModuleRegistryError::EmptyOwner {
179                module: data.name.to_string(),
180            });
181        } else if !module_owners.insert(data.owner) {
182            errors.push(ModuleRegistryError::DuplicateOwner {
183                owner: data.owner.as_str().to_string(),
184            });
185        }
186
187        let mut seen_languages = HashSet::new();
188        for lang in data.supported_languages {
189            if !seen_languages.insert(lang.clone()) {
190                errors.push(ModuleRegistryError::DuplicateSupportedLanguage {
191                    module: data.name.to_string(),
192                    language: lang.clone(),
193                });
194            }
195        }
196
197        let mut seen_domains = HashSet::new();
198        for domain in data.domains {
199            if !seen_domains.insert(domain.domain) {
200                errors.push(ModuleRegistryError::DuplicateDomain {
201                    module: data.name.to_string(),
202                    domain: domain.domain.as_str().to_string(),
203                });
204            }
205
206            let mut seen_namespaces = HashSet::new();
207            for namespace in domain.namespaces {
208                let namespace = match ResolvedNamespace::new(*namespace) {
209                    Ok(namespace) => namespace,
210                    Err(details) => {
211                        errors.push(ModuleRegistryError::InvalidNamespace {
212                            module: data.name.to_string(),
213                            domain: domain.domain.as_str().to_string(),
214                            namespace: namespace.to_string(),
215                            details,
216                        });
217                        continue;
218                    },
219                };
220                if !seen_namespaces.insert(namespace.clone()) {
221                    errors.push(ModuleRegistryError::DuplicateNamespace {
222                        module: data.name.to_string(),
223                        domain: domain.domain.as_str().to_string(),
224                        namespace: namespace.to_string(),
225                    });
226                }
227            }
228        }
229    }
230
231    if errors.is_empty() {
232        Ok(())
233    } else {
234        Err(errors)
235    }
236}
237
238/// Common discovery contract for managers.
239///
240/// Any backend can iterate this inventory to discover registered modules.
241pub trait I18nModuleDescriptor: Send + Sync {
242    /// Returns static metadata for this module.
243    fn data(&self) -> &'static ModuleData;
244}
245
246/// A simple descriptor wrapper for metadata-only registrations.
247///
248/// This is used by asset-driven managers (e.g., Bevy) where runtime localization
249/// is handled by the host runtime rather than by `Localizer`.
250pub struct StaticModuleDescriptor {
251    data: &'static ModuleData,
252}
253
254impl StaticModuleDescriptor {
255    /// Creates a new metadata-only descriptor.
256    pub const fn new(data: &'static ModuleData) -> Self {
257        Self { data }
258    }
259}
260
261impl I18nModuleDescriptor for StaticModuleDescriptor {
262    fn data(&self) -> &'static ModuleData {
263        self.data
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use unic_langid::langid;
271
272    static VALID_MODULE: ModuleData = ModuleData {
273        name: "demo-module",
274        owner: crate::__macro::static_domain("demo-domain"),
275        supported_languages: &[langid!("en"), langid!("fr")],
276        domains: &[crate::ModuleDomain {
277            domain: crate::__macro::static_domain("demo-domain"),
278            namespaces: &["ui", "errors"],
279        }],
280    };
281    static DUPLICATE_MODULES: [&ModuleData; 2] = [
282        &ModuleData {
283            name: "dup-name",
284            owner: crate::__macro::static_domain("dup-domain-a"),
285            supported_languages: &[langid!("en"), langid!("en")],
286            domains: &[
287                crate::ModuleDomain {
288                    domain: crate::__macro::static_domain("dup-domain-a"),
289                    namespaces: &["ui", "ui"],
290                },
291                crate::ModuleDomain {
292                    domain: crate::__macro::static_domain("dup-domain-a"),
293                    namespaces: &[],
294                },
295            ],
296        },
297        &ModuleData {
298            name: "dup-name",
299            owner: crate::__macro::static_domain("dup-domain-a"),
300            supported_languages: &[],
301            domains: &[crate::ModuleDomain {
302                domain: crate::__macro::static_domain("dup-domain-a"),
303                namespaces: &["../bad"],
304            }],
305        },
306    ];
307
308    #[test]
309    fn module_data_resource_plan_uses_canonical_namespaced_paths() {
310        let plan = VALID_MODULE.resource_plan();
311        let keys: Vec<_> = plan.iter().map(|spec| spec.key.as_str()).collect();
312        let paths: Vec<_> = plan
313            .iter()
314            .map(|spec| spec.locale_relative_path.as_str())
315            .collect();
316
317        assert_eq!(
318            keys,
319            vec!["demo-domain", "demo-domain/ui", "demo-domain/errors"]
320        );
321        assert_eq!(
322            paths,
323            vec![
324                "demo-domain.ftl",
325                "demo-domain/ui.ftl",
326                "demo-domain/errors.ftl",
327            ]
328        );
329        assert!(!plan[0].required);
330        assert!(plan[1..].iter().all(|spec| spec.required));
331    }
332
333    #[test]
334    fn validate_module_registry_accepts_valid_metadata() {
335        assert!(validate_module_registry([&VALID_MODULE]).is_ok());
336    }
337
338    #[test]
339    fn validate_module_registry_reports_duplicates_and_invalid_namespaces() {
340        let errors =
341            validate_module_registry(DUPLICATE_MODULES).expect_err("invalid registry should fail");
342
343        assert!(errors.contains(&ModuleRegistryError::DuplicateModuleName {
344            name: "dup-name".to_string(),
345        }));
346        assert!(errors.contains(&ModuleRegistryError::DuplicateOwner {
347            owner: "dup-domain-a".to_string(),
348        }));
349        assert!(errors.contains(&ModuleRegistryError::DuplicateDomain {
350            module: "dup-name".to_string(),
351            domain: "dup-domain-a".to_string(),
352        }));
353        assert!(
354            errors.contains(&ModuleRegistryError::DuplicateSupportedLanguage {
355                module: "dup-name".to_string(),
356                language: langid!("en"),
357            })
358        );
359        assert!(errors.contains(&ModuleRegistryError::DuplicateNamespace {
360            module: "dup-name".to_string(),
361            domain: "dup-domain-a".to_string(),
362            namespace: "ui".to_string(),
363        }));
364        assert!(errors.iter().any(|error| matches!(
365            error,
366            ModuleRegistryError::InvalidNamespace { module, namespace, .. }
367                if module == "dup-name" && namespace == "../bad"
368        )));
369    }
370
371    #[test]
372    fn module_registry_error_messages_are_descriptive() {
373        let cases = [
374            (
375                ModuleRegistryError::EmptyModuleName,
376                "module name must not be empty",
377            ),
378            (
379                ModuleRegistryError::EmptyOwner {
380                    module: "demo".to_string(),
381                },
382                "module 'demo' has an empty owner",
383            ),
384            (
385                ModuleRegistryError::DuplicateModuleName {
386                    name: "demo".to_string(),
387                },
388                "duplicate module name 'demo'",
389            ),
390            (
391                ModuleRegistryError::DuplicateDomain {
392                    module: "demo".to_string(),
393                    domain: "demo".to_string(),
394                },
395                "module 'demo' declares duplicate domain 'demo'",
396            ),
397            (
398                ModuleRegistryError::DuplicateSupportedLanguage {
399                    module: "demo".to_string(),
400                    language: langid!("en"),
401                },
402                "module 'demo' declares duplicate language 'en'",
403            ),
404            (
405                ModuleRegistryError::DuplicateNamespace {
406                    module: "demo".to_string(),
407                    domain: "demo".to_string(),
408                    namespace: "ui".to_string(),
409                },
410                "module 'demo' domain 'demo' declares duplicate namespace 'ui'",
411            ),
412            (
413                ModuleRegistryError::InvalidNamespace {
414                    module: "demo".to_string(),
415                    domain: "demo".to_string(),
416                    namespace: "../ui".to_string(),
417                    details: NamespacePathError::CurrentOrParentSegment,
418                },
419                "module 'demo' domain 'demo' has invalid namespace '../ui': namespace path must not contain '.' or '..' segments",
420            ),
421        ];
422
423        for (error, expected) in cases {
424            assert_eq!(error.to_string(), expected);
425        }
426    }
427
428    #[test]
429    fn static_module_descriptor_returns_original_module_data() {
430        let descriptor = StaticModuleDescriptor::new(&VALID_MODULE);
431        assert_eq!(descriptor.data(), &VALID_MODULE);
432    }
433}