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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub struct ModuleDomain {
11 pub domain: StaticFluentDomain,
13 pub namespaces: &'static [&'static str],
15}
16
17#[derive(Debug, Eq, PartialEq)]
22pub struct ModuleData {
23 pub name: &'static str,
25 pub owner: StaticFluentDomain,
27 pub supported_languages: &'static [LanguageIdentifier],
29 pub domains: &'static [ModuleDomain],
31}
32
33impl ModuleData {
34 pub fn resource_plan(&self) -> Vec<ModuleResourceSpec> {
42 self.try_resource_plan()
43 .expect("module metadata domains are statically validated")
44 }
45
46 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 pub fn owns_domain(&self, domain: StaticFluentDomain) -> bool {
62 self.domains
63 .iter()
64 .any(|candidate| candidate.domain == domain)
65 }
66}
67
68#[derive(Clone, Debug, Eq, PartialEq)]
70pub enum ModuleRegistryError {
71 EmptyModuleName,
73 EmptyOwner { module: String },
75 DuplicateModuleName { name: String },
77 DuplicateOwner { owner: String },
79 DuplicateDomain { module: String, domain: String },
81 DuplicateSupportedLanguage {
83 module: String,
84 language: LanguageIdentifier,
85 },
86 DuplicateNamespace {
88 module: String,
89 domain: String,
90 namespace: String,
91 },
92 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
151pub 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
238pub trait I18nModuleDescriptor: Send + Sync {
242 fn data(&self) -> &'static ModuleData;
244}
245
246pub struct StaticModuleDescriptor {
251 data: &'static ModuleData,
252}
253
254impl StaticModuleDescriptor {
255 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}