1use fluent_bundle::FluentResource;
4use std::collections::HashSet;
5use std::fmt;
6use std::sync::Arc;
7use unic_langid::LanguageIdentifier;
8
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct ResourceKey(String);
16
17impl ResourceKey {
18 pub fn new(key: impl Into<String>) -> Self {
20 Self(key.into())
21 }
22
23 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27
28 pub fn domain(&self) -> &str {
30 self.0.split('/').next().unwrap_or(self.as_str())
31 }
32}
33
34impl AsRef<str> for ResourceKey {
35 fn as_ref(&self) -> &str {
36 self.as_str()
37 }
38}
39
40impl From<String> for ResourceKey {
41 fn from(value: String) -> Self {
42 Self::new(value)
43 }
44}
45
46impl From<&str> for ResourceKey {
47 fn from(value: &str) -> Self {
48 Self::new(value.to_string())
49 }
50}
51
52impl fmt::Display for ResourceKey {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 f.write_str(self.as_str())
55 }
56}
57
58#[derive(Debug)]
63pub struct ModuleData {
64 pub name: &'static str,
66 pub domain: &'static str,
68 pub supported_languages: &'static [LanguageIdentifier],
70 pub namespaces: &'static [&'static str],
75}
76
77impl ModuleData {
78 pub fn resource_plan(&self) -> Vec<ModuleResourceSpec> {
80 resource_plan_for(self.domain, self.namespaces)
81 }
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
86pub enum ModuleRegistryError {
87 EmptyModuleName,
89 EmptyDomain { module: String },
91 DuplicateModuleName { name: String },
93 DuplicateDomain { domain: String },
95 DuplicateSupportedLanguage {
97 module: String,
98 language: LanguageIdentifier,
99 },
100 DuplicateNamespace { module: String, namespace: String },
102 InvalidNamespace {
104 module: String,
105 namespace: String,
106 details: &'static str,
107 },
108}
109
110impl fmt::Display for ModuleRegistryError {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::EmptyModuleName => write!(f, "module name must not be empty"),
114 Self::EmptyDomain { module } => {
115 write!(f, "module '{}' has an empty domain", module)
116 },
117 Self::DuplicateModuleName { name } => {
118 write!(f, "duplicate module name '{}'", name)
119 },
120 Self::DuplicateDomain { domain } => {
121 write!(f, "duplicate module domain '{}'", domain)
122 },
123 Self::DuplicateSupportedLanguage { module, language } => write!(
124 f,
125 "module '{}' declares duplicate language '{}'",
126 module, language
127 ),
128 Self::DuplicateNamespace { module, namespace } => write!(
129 f,
130 "module '{}' declares duplicate namespace '{}'",
131 module, namespace
132 ),
133 Self::InvalidNamespace {
134 module,
135 namespace,
136 details,
137 } => write!(
138 f,
139 "module '{}' has invalid namespace '{}': {}",
140 module, namespace, details
141 ),
142 }
143 }
144}
145
146impl std::error::Error for ModuleRegistryError {}
147
148pub fn validate_module_registry<'a>(
156 modules: impl IntoIterator<Item = &'a ModuleData>,
157) -> Result<(), Vec<ModuleRegistryError>> {
158 let mut errors = Vec::new();
159 let mut module_names = HashSet::new();
160 let mut module_domains = HashSet::new();
161
162 for data in modules {
163 if data.name.trim().is_empty() {
164 errors.push(ModuleRegistryError::EmptyModuleName);
165 } else if !module_names.insert(data.name) {
166 errors.push(ModuleRegistryError::DuplicateModuleName {
167 name: data.name.to_string(),
168 });
169 }
170
171 if data.domain.trim().is_empty() {
172 errors.push(ModuleRegistryError::EmptyDomain {
173 module: data.name.to_string(),
174 });
175 } else if !module_domains.insert(data.domain) {
176 errors.push(ModuleRegistryError::DuplicateDomain {
177 domain: data.domain.to_string(),
178 });
179 }
180
181 let mut seen_languages = HashSet::new();
182 for lang in data.supported_languages {
183 if !seen_languages.insert(lang.clone()) {
184 errors.push(ModuleRegistryError::DuplicateSupportedLanguage {
185 module: data.name.to_string(),
186 language: lang.clone(),
187 });
188 }
189 }
190
191 let mut seen_namespaces = HashSet::new();
192 for namespace in data.namespaces {
193 let trimmed = namespace.trim();
194 if trimmed.is_empty() {
195 errors.push(ModuleRegistryError::InvalidNamespace {
196 module: data.name.to_string(),
197 namespace: namespace.to_string(),
198 details: "namespace must not be empty",
199 });
200 continue;
201 }
202 if trimmed.contains('/') {
203 errors.push(ModuleRegistryError::InvalidNamespace {
204 module: data.name.to_string(),
205 namespace: namespace.to_string(),
206 details: "namespace must not contain '/'",
207 });
208 }
209 if trimmed.ends_with(".ftl") {
210 errors.push(ModuleRegistryError::InvalidNamespace {
211 module: data.name.to_string(),
212 namespace: namespace.to_string(),
213 details: "namespace must not include file extension",
214 });
215 }
216 if !seen_namespaces.insert(trimmed) {
217 errors.push(ModuleRegistryError::DuplicateNamespace {
218 module: data.name.to_string(),
219 namespace: trimmed.to_string(),
220 });
221 }
222 }
223 }
224
225 if errors.is_empty() {
226 Ok(())
227 } else {
228 Err(errors)
229 }
230}
231
232#[derive(Clone, Debug, Eq, PartialEq)]
234pub struct ModuleResourceSpec {
235 pub key: ResourceKey,
237 pub locale_relative_path: String,
239 pub required: bool,
241}
242
243impl ModuleResourceSpec {
244 pub fn locale_path(&self, lang: &LanguageIdentifier) -> String {
246 format!("{}/{}", lang, self.locale_relative_path)
247 }
248}
249
250fn module_resource_spec(
251 key: impl Into<ResourceKey>,
252 locale_relative_path: impl Into<String>,
253 required: bool,
254) -> ModuleResourceSpec {
255 ModuleResourceSpec {
256 key: key.into(),
257 locale_relative_path: locale_relative_path.into(),
258 required,
259 }
260}
261
262pub fn resource_plan_for(domain: &str, namespaces: &[&str]) -> Vec<ModuleResourceSpec> {
269 if namespaces.is_empty() {
270 return vec![module_resource_spec(
271 ResourceKey::new(domain.to_string()),
272 format!("{domain}.ftl"),
273 true,
274 )];
275 }
276
277 let mut plan = Vec::with_capacity(namespaces.len() + 1);
278 plan.push(module_resource_spec(
279 ResourceKey::new(domain.to_string()),
280 format!("{domain}.ftl"),
281 false,
282 ));
283
284 let mut seen = HashSet::new();
285 for namespace in namespaces {
286 if !seen.insert(*namespace) {
287 continue;
288 }
289
290 plan.push(module_resource_spec(
291 ResourceKey::new(format!("{domain}/{namespace}")),
292 format!("{domain}/{namespace}.ftl"),
293 true,
294 ));
295 }
296
297 plan
298}
299
300pub fn required_resource_keys_from_plan(plan: &[ModuleResourceSpec]) -> HashSet<ResourceKey> {
302 plan.iter()
303 .filter(|spec| spec.required)
304 .map(|spec| spec.key.clone())
305 .collect()
306}
307
308pub fn optional_resource_keys_from_plan(plan: &[ModuleResourceSpec]) -> HashSet<ResourceKey> {
310 plan.iter()
311 .filter(|spec| !spec.required)
312 .map(|spec| spec.key.clone())
313 .collect()
314}
315
316pub fn locale_is_ready(
318 required_keys: &HashSet<ResourceKey>,
319 loaded_keys: &HashSet<ResourceKey>,
320) -> bool {
321 required_keys.iter().all(|key| loaded_keys.contains(key))
322}
323
324#[derive(Clone, Debug, Default)]
326pub struct LocaleLoadReport {
327 required_keys: HashSet<ResourceKey>,
328 optional_keys: HashSet<ResourceKey>,
329 loaded_keys: HashSet<ResourceKey>,
330 errors: Vec<ResourceLoadError>,
331}
332
333impl LocaleLoadReport {
334 pub fn from_plan(plan: &[ModuleResourceSpec]) -> Self {
336 Self::from_specs(plan.iter())
337 }
338
339 pub fn from_specs<'a>(specs: impl IntoIterator<Item = &'a ModuleResourceSpec>) -> Self {
341 let mut required_keys = HashSet::new();
342 let mut optional_keys = HashSet::new();
343
344 for spec in specs {
345 if spec.required {
346 required_keys.insert(spec.key.clone());
347 } else {
348 optional_keys.insert(spec.key.clone());
349 }
350 }
351
352 Self {
353 required_keys,
354 optional_keys,
355 loaded_keys: HashSet::new(),
356 errors: Vec::new(),
357 }
358 }
359
360 pub fn mark_loaded(&mut self, key: ResourceKey) {
362 self.loaded_keys.insert(key);
363 }
364
365 pub fn record_error(&mut self, error: ResourceLoadError) {
367 self.loaded_keys.remove(error.key());
368 self.errors.push(error);
369 }
370
371 pub fn required_keys(&self) -> &HashSet<ResourceKey> {
373 &self.required_keys
374 }
375
376 pub fn optional_keys(&self) -> &HashSet<ResourceKey> {
378 &self.optional_keys
379 }
380
381 pub fn loaded_keys(&self) -> &HashSet<ResourceKey> {
383 &self.loaded_keys
384 }
385
386 pub fn errors(&self) -> &[ResourceLoadError] {
388 &self.errors
389 }
390
391 pub fn missing_required_keys(&self) -> HashSet<ResourceKey> {
393 self.required_keys
394 .iter()
395 .filter(|key| !self.loaded_keys.contains(*key))
396 .cloned()
397 .collect()
398 }
399
400 pub fn has_required_errors(&self) -> bool {
402 self.errors.iter().any(ResourceLoadError::is_required)
403 }
404
405 pub fn is_ready(&self) -> bool {
407 locale_is_ready(&self.required_keys, &self.loaded_keys) && !self.has_required_errors()
408 }
409}
410
411#[derive(Clone, Debug, Eq, PartialEq)]
413pub enum ResourceLoadError {
414 Missing {
416 key: ResourceKey,
417 path: String,
418 required: bool,
419 },
420 InvalidUtf8 {
422 key: ResourceKey,
423 path: String,
424 required: bool,
425 details: String,
426 },
427 Parse {
429 key: ResourceKey,
430 path: String,
431 required: bool,
432 details: String,
433 },
434 Load {
436 key: ResourceKey,
437 path: String,
438 required: bool,
439 details: String,
440 },
441}
442
443impl ResourceLoadError {
444 pub fn missing(spec: &ModuleResourceSpec) -> Self {
446 Self::Missing {
447 key: spec.key.clone(),
448 path: spec.locale_relative_path.clone(),
449 required: spec.required,
450 }
451 }
452
453 pub fn load(spec: &ModuleResourceSpec, details: impl Into<String>) -> Self {
455 Self::Load {
456 key: spec.key.clone(),
457 path: spec.locale_relative_path.clone(),
458 required: spec.required,
459 details: details.into(),
460 }
461 }
462
463 pub fn key(&self) -> &ResourceKey {
465 match self {
466 Self::Missing { key, .. }
467 | Self::InvalidUtf8 { key, .. }
468 | Self::Parse { key, .. }
469 | Self::Load { key, .. } => key,
470 }
471 }
472
473 pub fn is_required(&self) -> bool {
475 match self {
476 Self::Missing { required, .. }
477 | Self::InvalidUtf8 { required, .. }
478 | Self::Parse { required, .. }
479 | Self::Load { required, .. } => *required,
480 }
481 }
482}
483
484impl fmt::Display for ResourceLoadError {
485 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486 match self {
487 Self::Missing {
488 key,
489 path,
490 required,
491 } => write!(
492 f,
493 "missing {} resource '{}' at '{}'",
494 if *required { "required" } else { "optional" },
495 key,
496 path
497 ),
498 Self::InvalidUtf8 {
499 key,
500 path,
501 required,
502 details,
503 } => write!(
504 f,
505 "invalid UTF-8 in {} resource '{}' at '{}': {}",
506 if *required { "required" } else { "optional" },
507 key,
508 path,
509 details
510 ),
511 Self::Parse {
512 key,
513 path,
514 required,
515 details,
516 } => write!(
517 f,
518 "failed to parse {} resource '{}' at '{}': {}",
519 if *required { "required" } else { "optional" },
520 key,
521 path,
522 details
523 ),
524 Self::Load {
525 key,
526 path,
527 required,
528 details,
529 } => write!(
530 f,
531 "failed to load {} resource '{}' at '{}': {}",
532 if *required { "required" } else { "optional" },
533 key,
534 path,
535 details
536 ),
537 }
538 }
539}
540
541impl std::error::Error for ResourceLoadError {}
542
543pub fn parse_fluent_resource_bytes(
545 spec: &ModuleResourceSpec,
546 bytes: &[u8],
547) -> Result<Arc<FluentResource>, ResourceLoadError> {
548 let content =
549 String::from_utf8(bytes.to_vec()).map_err(|e| ResourceLoadError::InvalidUtf8 {
550 key: spec.key.clone(),
551 path: spec.locale_relative_path.clone(),
552 required: spec.required,
553 details: e.to_string(),
554 })?;
555
556 parse_fluent_resource_content(spec, content)
557}
558
559pub fn parse_fluent_resource_content(
561 spec: &ModuleResourceSpec,
562 content: String,
563) -> Result<Arc<FluentResource>, ResourceLoadError> {
564 FluentResource::try_new(content)
565 .map(Arc::new)
566 .map_err(|(_, errs)| ResourceLoadError::Parse {
567 key: spec.key.clone(),
568 path: spec.locale_relative_path.clone(),
569 required: spec.required,
570 details: format!("{errs:?}"),
571 })
572}
573
574pub trait I18nModuleDescriptor: Send + Sync {
578 fn data(&self) -> &'static ModuleData;
580}
581
582pub struct StaticModuleDescriptor {
587 data: &'static ModuleData,
588}
589
590impl StaticModuleDescriptor {
591 pub const fn new(data: &'static ModuleData) -> Self {
593 Self { data }
594 }
595}
596
597impl I18nModuleDescriptor for StaticModuleDescriptor {
598 fn data(&self) -> &'static ModuleData {
599 self.data
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use std::collections::HashSet;
607 use unic_langid::langid;
608
609 static SUPPORTED: &[LanguageIdentifier] = &[langid!("en-US"), langid!("fr")];
610 static NAMESPACES: &[&str] = &["ui", "errors"];
611 static DATA: ModuleData = ModuleData {
612 name: "test-module",
613 domain: "test-domain",
614 supported_languages: SUPPORTED,
615 namespaces: NAMESPACES,
616 };
617
618 #[test]
619 fn static_descriptor_new_and_data_round_trip() {
620 let module = StaticModuleDescriptor::new(&DATA);
621 let data = module.data();
622
623 assert_eq!(data.name, "test-module");
624 assert_eq!(data.domain, "test-domain");
625 assert_eq!(data.supported_languages, SUPPORTED);
626 assert_eq!(data.namespaces, NAMESPACES);
627 }
628
629 #[test]
630 fn resource_key_helpers_return_expected_shapes() {
631 let key = ResourceKey::new("app/ui");
632 assert_eq!(key.as_str(), "app/ui");
633 assert_eq!(key.domain(), "app");
634 assert_eq!(key.to_string(), "app/ui");
635 }
636
637 #[test]
638 fn resource_plan_without_namespaces_requires_base_file() {
639 let plan = resource_plan_for("app", &[]);
640 assert_eq!(
641 plan,
642 vec![ModuleResourceSpec {
643 key: ResourceKey::new("app"),
644 locale_relative_path: "app.ftl".to_string(),
645 required: true
646 }]
647 );
648 }
649
650 #[test]
651 fn resource_plan_with_namespaces_requires_namespace_files() {
652 let plan = resource_plan_for("app", &["ui", "errors"]);
653 assert_eq!(
654 plan,
655 vec![
656 ModuleResourceSpec {
657 key: ResourceKey::new("app"),
658 locale_relative_path: "app.ftl".to_string(),
659 required: false
660 },
661 ModuleResourceSpec {
662 key: ResourceKey::new("app/ui"),
663 locale_relative_path: "app/ui.ftl".to_string(),
664 required: true
665 },
666 ModuleResourceSpec {
667 key: ResourceKey::new("app/errors"),
668 locale_relative_path: "app/errors.ftl".to_string(),
669 required: true
670 }
671 ]
672 );
673 assert_eq!(plan[1].locale_path(&langid!("en-US")), "en-US/app/ui.ftl");
674 }
675
676 #[test]
677 fn resource_plan_deduplicates_duplicate_namespaces() {
678 let plan = resource_plan_for("app", &["ui", "ui"]);
679 assert_eq!(plan.len(), 2);
680 assert_eq!(plan[1].key, ResourceKey::new("app/ui"));
681 }
682
683 #[test]
684 fn locale_is_ready_requires_all_required_keys() {
685 let plan = resource_plan_for("app", &["ui", "errors"]);
686 let required = required_resource_keys_from_plan(&plan);
687 let optional = optional_resource_keys_from_plan(&plan);
688
689 assert_eq!(optional, HashSet::from([ResourceKey::new("app")]));
690
691 let ready_loaded =
692 HashSet::from([ResourceKey::new("app/ui"), ResourceKey::new("app/errors")]);
693 assert!(locale_is_ready(&required, &ready_loaded));
694
695 let missing_required = HashSet::from([ResourceKey::new("app/ui")]);
696 assert!(!locale_is_ready(&required, &missing_required));
697 }
698
699 #[test]
700 fn locale_load_report_tracks_errors_and_readiness() {
701 let plan = resource_plan_for("app", &["ui"]);
702 let mut report = LocaleLoadReport::from_plan(&plan);
703
704 report.mark_loaded(ResourceKey::new("app/ui"));
705 report.record_error(ResourceLoadError::load(&plan[0], "file watcher error"));
706
707 assert!(report.is_ready());
708 assert_eq!(
709 report.required_keys(),
710 &HashSet::from([ResourceKey::new("app/ui")])
711 );
712 assert_eq!(
713 report.optional_keys(),
714 &HashSet::from([ResourceKey::new("app")])
715 );
716 assert!(report.loaded_keys().contains(&ResourceKey::new("app/ui")));
717 assert_eq!(report.missing_required_keys(), HashSet::new());
718 }
719
720 #[test]
721 fn validate_module_registry_rejects_duplicates_and_invalid_namespaces() {
722 static DUP_LANGUAGE: &[LanguageIdentifier] = &[langid!("en"), langid!("en")];
723 static INVALID_NAMESPACES: &[&str] = &["ui", "ui", "", "errors.ftl", "bad/path"];
724 static BAD_DATA: ModuleData = ModuleData {
725 name: "test-module",
726 domain: "test-domain",
727 supported_languages: DUP_LANGUAGE,
728 namespaces: INVALID_NAMESPACES,
729 };
730 static DUP_DOMAIN: ModuleData = ModuleData {
731 name: "other-module",
732 domain: "test-domain",
733 supported_languages: SUPPORTED,
734 namespaces: &[],
735 };
736
737 let errs = validate_module_registry([&DATA, &BAD_DATA, &DUP_DOMAIN])
738 .expect_err("validation should fail");
739 assert!(errs.iter().any(|err| matches!(
740 err,
741 ModuleRegistryError::DuplicateModuleName { name } if name == "test-module"
742 )));
743 assert!(errs.iter().any(|err| matches!(
744 err,
745 ModuleRegistryError::DuplicateDomain { domain } if domain == "test-domain"
746 )));
747 assert!(errs.iter().any(|err| matches!(
748 err,
749 ModuleRegistryError::DuplicateSupportedLanguage { module, .. } if module == "test-module"
750 )));
751 assert!(errs.iter().any(|err| matches!(
752 err,
753 ModuleRegistryError::DuplicateNamespace { module, namespace } if module == "test-module" && namespace == "ui"
754 )));
755 }
756
757 #[test]
758 fn module_data_resource_plan_delegates_to_shared_builder() {
759 let plan = DATA.resource_plan();
760 let direct = resource_plan_for(DATA.domain, DATA.namespaces);
761 assert_eq!(plan, direct);
762 }
763
764 #[test]
765 fn parse_fluent_resource_content_reports_parse_errors() {
766 let spec = ModuleResourceSpec {
767 key: ResourceKey::new("app/ui"),
768 locale_relative_path: "app/ui.ftl".to_string(),
769 required: true,
770 };
771
772 let err = parse_fluent_resource_content(&spec, "broken = {".to_string())
773 .expect_err("invalid fluent should fail");
774 assert!(matches!(
775 err,
776 ResourceLoadError::Parse { required: true, .. }
777 ));
778 }
779
780 #[test]
781 fn parse_fluent_resource_bytes_reports_utf8_errors() {
782 let spec = ModuleResourceSpec {
783 key: ResourceKey::new("app/ui"),
784 locale_relative_path: "app/ui.ftl".to_string(),
785 required: false,
786 };
787
788 let err =
789 parse_fluent_resource_bytes(&spec, &[0xFF, 0xFE]).expect_err("invalid utf-8 bytes");
790 assert!(matches!(
791 err,
792 ResourceLoadError::InvalidUtf8 {
793 required: false,
794 ..
795 }
796 ));
797 }
798}