1use std::collections::{BTreeMap, BTreeSet, HashSet};
4use std::path::{Path, PathBuf};
5
6use crate::CanonicalLanguageIdentifierError;
7use crate::LanguageIdentifier;
8use crate::fluent::{FluentDomain, FluentIdentifierError};
9use crate::namespace::{NamespacePathError, ResolvedNamespace};
10use crate::registry::StaticFluentDomain;
11
12#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
14pub enum ResourcePlanError {
15 #[error("invalid Fluent domain '{domain}': {details}")]
17 InvalidDomain {
18 domain: String,
19 details: FluentIdentifierError,
20 },
21 #[error("invalid domain resource key '{key}': {details}")]
23 InvalidResourceKey {
24 key: String,
26 details: ResourceKeyError,
28 },
29 #[error("invalid locale-relative resource path '{path}': {details}")]
31 InvalidResourcePath {
32 path: String,
34 details: LocaleRelativeFtlPathError,
36 },
37 #[error("invalid namespace '{namespace}': {details}")]
39 InvalidNamespace {
40 namespace: String,
41 details: NamespacePathError,
42 },
43}
44
45#[derive(Debug, thiserror::Error)]
47pub enum SparseAssetResourcePlanError {
48 #[error("Failed to read i18n directory at {path:?}: {source}")]
50 ReadAssetsRoot {
51 path: PathBuf,
53 source: std::io::Error,
55 },
56 #[error("Failed to read directory entry in {path:?}: {source}")]
58 ReadAssetsRootEntry {
59 path: PathBuf,
61 source: std::io::Error,
63 },
64 #[error("Failed to read namespace directory {path:?}: {source}")]
66 ReadNamespaceDirectory {
67 path: PathBuf,
69 source: std::io::Error,
71 },
72 #[error("Failed to read directory entry in {path:?}: {source}")]
74 ReadNamespaceDirectoryEntry {
75 path: PathBuf,
77 source: std::io::Error,
79 },
80 #[error("Locale directory {path:?} contains a non-UTF-8 name")]
82 NonUtf8LocaleDirectory {
83 path: PathBuf,
85 },
86 #[error("{}", format_locale_directory_error(raw_name, path, details))]
88 InvalidLocaleDirectory {
89 raw_name: String,
91 path: PathBuf,
93 details: CanonicalLanguageIdentifierError,
95 },
96 #[error("Failed to derive namespace for asset {path:?} relative to {root:?}: {source}")]
98 NamespaceRelativePath {
99 path: PathBuf,
101 root: PathBuf,
103 source: std::path::StripPrefixError,
105 },
106 #[error("Namespace path {path:?} contains non-UTF-8 components")]
108 NonUtf8NamespacePath {
109 path: PathBuf,
111 },
112 #[error("Discovered invalid namespace '{namespace}' in assets for crate '{domain}': {details}")]
114 InvalidNamespace {
115 namespace: String,
117 domain: String,
119 details: NamespacePathError,
121 },
122}
123
124fn format_locale_directory_error(
125 raw_name: &str,
126 path: &Path,
127 details: &CanonicalLanguageIdentifierError,
128) -> String {
129 match details {
130 CanonicalLanguageIdentifierError::Invalid { source, .. } => format!(
131 "Locale directory '{raw_name}' under \"{}\" is not a valid BCP-47 identifier: {source}",
132 path.display()
133 ),
134 CanonicalLanguageIdentifierError::IcuInvalid { details, .. } => format!(
135 "Locale directory '{raw_name}' under \"{}\" could not be parsed as an ICU locale: {details}",
136 path.display()
137 ),
138 CanonicalLanguageIdentifierError::NonCanonical { canonical, .. } => format!(
139 "Locale directory '{raw_name}' under \"{}\" must use canonical BCP-47 form '{canonical}'",
140 path.display()
141 ),
142 }
143}
144
145#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
147pub enum LocaleRelativeFtlPathError {
148 #[error("path must not be empty")]
150 Empty,
151 #[error("path must be relative")]
153 Absolute,
154 #[error("path must use '/' separators")]
156 Backslash,
157 #[error("path must end with .ftl")]
159 MissingFtlSuffix,
160 #[error("{0}")]
162 InvalidStem(#[from] NamespacePathError),
163}
164
165#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
167#[error("{0}")]
168pub struct ResourceKeyError(#[from] NamespacePathError);
169
170#[derive(
176 Clone, Debug, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
177)]
178#[as_ref(str)]
179pub struct ResourceKey(String);
180
181impl ResourceKey {
182 pub fn try_new(key: impl Into<String>) -> Result<Self, ResourceKeyError> {
184 let key = key.into();
185 crate::namespace::validate_namespace_path_typed(&key)?;
186 Ok(Self(key))
187 }
188
189 #[allow(
191 clippy::panic,
192 reason = "static metadata uses literal keys; use try_new for dynamic input"
193 )]
194 pub fn from_static_path(key: &'static str) -> Self {
195 Self::try_new(key)
196 .unwrap_or_else(|error| panic!("invalid static resource key '{key}': {error}"))
197 }
198
199 pub fn from_static_domain(domain: StaticFluentDomain) -> Self {
201 Self(domain.as_str().to_string())
202 }
203
204 pub fn from_static_domain_and_namespace(
206 domain: StaticFluentDomain,
207 namespace: &ResolvedNamespace,
208 ) -> Self {
209 Self(format!("{}/{namespace}", domain.as_str()))
210 }
211
212 fn from_domain(domain: &FluentDomain) -> Self {
213 Self(domain.as_str().to_string())
214 }
215
216 fn from_domain_and_namespace(domain: &FluentDomain, namespace: &ResolvedNamespace) -> Self {
217 Self(format!("{}/{namespace}", domain.as_str()))
218 }
219
220 pub fn as_str(&self) -> &str {
222 &self.0
223 }
224
225 pub fn domain(&self) -> &str {
227 self.0.split('/').next().unwrap_or(self.as_str())
228 }
229
230 pub fn domain_name(&self) -> FluentDomain {
232 FluentDomain::try_new(self.domain())
233 .expect("resource key validation should guarantee a valid domain segment")
234 }
235}
236
237impl serde::Serialize for ResourceKey {
238 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
239 where
240 S: serde::Serializer,
241 {
242 serializer.serialize_str(self.as_str())
243 }
244}
245
246impl<'de> serde::Deserialize<'de> for ResourceKey {
247 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
248 where
249 D: serde::Deserializer<'de>,
250 {
251 let value = <String as serde::Deserialize>::deserialize(deserializer)?;
252 Self::try_new(value).map_err(serde::de::Error::custom)
253 }
254}
255
256#[derive(
261 Clone,
262 Debug,
263 derive_more::AsRef,
264 derive_more::Deref,
265 derive_more::Display,
266 Eq,
267 Hash,
268 Ord,
269 PartialEq,
270 PartialOrd,
271)]
272#[as_ref(str)]
273#[deref(forward)]
274pub struct LocaleRelativeFtlPath(String);
275
276impl LocaleRelativeFtlPath {
277 pub fn try_new(path: impl Into<String>) -> Result<Self, LocaleRelativeFtlPathError> {
279 let path = path.into();
280 validate_locale_relative_ftl_path(&path)?;
281 Ok(Self(path))
282 }
283
284 #[allow(
286 clippy::panic,
287 reason = "static metadata uses literal paths; use try_new for dynamic input"
288 )]
289 pub fn from_static_path(path: &'static str) -> Self {
290 Self::try_new(path)
291 .unwrap_or_else(|error| panic!("invalid locale-relative FTL path '{path}': {error}"))
292 }
293
294 pub fn as_str(&self) -> &str {
296 &self.0
297 }
298
299 pub fn into_string(self) -> String {
301 self.0
302 }
303}
304
305impl PartialEq<&str> for LocaleRelativeFtlPath {
306 fn eq(&self, other: &&str) -> bool {
307 self.as_str() == *other
308 }
309}
310
311impl PartialEq<LocaleRelativeFtlPath> for &str {
312 fn eq(&self, other: &LocaleRelativeFtlPath) -> bool {
313 *self == other.as_str()
314 }
315}
316
317impl serde::Serialize for LocaleRelativeFtlPath {
318 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
319 where
320 S: serde::Serializer,
321 {
322 serializer.serialize_str(self.as_str())
323 }
324}
325
326impl<'de> serde::Deserialize<'de> for LocaleRelativeFtlPath {
327 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
328 where
329 D: serde::Deserializer<'de>,
330 {
331 let value = <String as serde::Deserialize>::deserialize(deserializer)?;
332 Self::try_new(value).map_err(serde::de::Error::custom)
333 }
334}
335
336fn validate_locale_relative_ftl_path(path: &str) -> Result<(), LocaleRelativeFtlPathError> {
337 if path.is_empty() {
338 return Err(LocaleRelativeFtlPathError::Empty);
339 }
340 if path.starts_with('/') {
341 return Err(LocaleRelativeFtlPathError::Absolute);
342 }
343 if path.contains('\\') {
344 return Err(LocaleRelativeFtlPathError::Backslash);
345 }
346
347 let Some(stem) = path.strip_suffix(".ftl") else {
348 return Err(LocaleRelativeFtlPathError::MissingFtlSuffix);
349 };
350
351 crate::namespace::validate_namespace_path_typed(stem)?;
352 Ok(())
353}
354
355#[derive(Clone, Debug, Eq, Hash, PartialEq)]
357pub enum ResourceRoute {
358 Base,
360 Namespaced(ResolvedNamespace),
362}
363
364impl ResourceRoute {
365 pub fn from_namespace(namespace: Option<ResolvedNamespace>) -> Self {
367 match namespace {
368 Some(namespace) => Self::Namespaced(namespace),
369 None => Self::Base,
370 }
371 }
372
373 pub fn is_base(&self) -> bool {
375 matches!(self, Self::Base)
376 }
377
378 pub fn resource_spec(&self, domain: &str, required: bool) -> ModuleResourceSpec {
380 let domain =
381 FluentDomain::try_new(domain).expect("resource route domain should be validated");
382 self.resource_spec_for_domain(&domain, required)
383 }
384
385 pub fn resource_spec_for_static_domain(
387 &self,
388 domain: StaticFluentDomain,
389 required: bool,
390 ) -> ModuleResourceSpec {
391 match self {
392 Self::Base => ModuleResourceSpec::base_for_static_domain(domain, required),
393 Self::Namespaced(namespace) => {
394 ModuleResourceSpec::namespaced_for_static_domain(domain, namespace, required)
395 },
396 }
397 }
398
399 fn resource_spec_for_domain(
400 &self,
401 domain: &FluentDomain,
402 required: bool,
403 ) -> ModuleResourceSpec {
404 match self {
405 Self::Base => ModuleResourceSpec::base_for_domain(domain, required),
406 Self::Namespaced(namespace) => {
407 ModuleResourceSpec::namespaced_for_domain(domain, namespace, required)
408 },
409 }
410 }
411}
412
413#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)]
415pub struct ModuleResourceSpec {
416 pub key: ResourceKey,
418 pub locale_relative_path: LocaleRelativeFtlPath,
420 pub required: bool,
422}
423
424impl ModuleResourceSpec {
425 pub fn try_new(
427 key: impl Into<String>,
428 locale_relative_path: impl Into<String>,
429 required: bool,
430 ) -> Result<Self, ResourcePlanError> {
431 let key = key.into();
432 let locale_relative_path = locale_relative_path.into();
433 Ok(Self {
434 key: ResourceKey::try_new(key.clone())
435 .map_err(|details| ResourcePlanError::InvalidResourceKey { key, details })?,
436 locale_relative_path: LocaleRelativeFtlPath::try_new(locale_relative_path.clone())
437 .map_err(|details| ResourcePlanError::InvalidResourcePath {
438 path: locale_relative_path,
439 details,
440 })?,
441 required,
442 })
443 }
444
445 pub fn new(
447 key: ResourceKey,
448 locale_relative_path: LocaleRelativeFtlPath,
449 required: bool,
450 ) -> Self {
451 Self {
452 key,
453 locale_relative_path,
454 required,
455 }
456 }
457
458 pub fn try_base(domain: &str, required: bool) -> Result<Self, ResourcePlanError> {
460 Self::try_new(domain, format!("{domain}.ftl"), required)
461 }
462
463 pub fn base(domain: &str, required: bool) -> Self {
465 let domain =
466 FluentDomain::try_new(domain).expect("base resource domain should be validated");
467 Self::base_for_domain(&domain, required)
468 }
469
470 pub fn base_for_static_domain(domain: StaticFluentDomain, required: bool) -> Self {
472 Self::new(
473 ResourceKey::from_static_domain(domain),
474 LocaleRelativeFtlPath::try_new(format!("{}.ftl", domain.as_str()))
475 .expect("static domain should produce a valid locale-relative FTL path"),
476 required,
477 )
478 }
479
480 fn base_for_domain(domain: &FluentDomain, required: bool) -> Self {
481 Self::new(
482 ResourceKey::from_domain(domain),
483 LocaleRelativeFtlPath::try_new(format!("{}.ftl", domain.as_str()))
484 .expect("domain should produce a valid locale-relative FTL path"),
485 required,
486 )
487 }
488
489 pub fn try_namespaced(
491 domain: &str,
492 namespace: &ResolvedNamespace,
493 required: bool,
494 ) -> Result<Self, ResourcePlanError> {
495 Self::try_new(
496 format!("{domain}/{namespace}"),
497 format!("{domain}/{namespace}.ftl"),
498 required,
499 )
500 }
501
502 pub fn namespaced(domain: &str, namespace: &ResolvedNamespace, required: bool) -> Self {
504 let domain =
505 FluentDomain::try_new(domain).expect("namespaced resource domain should be validated");
506 Self::namespaced_for_domain(&domain, namespace, required)
507 }
508
509 pub fn namespaced_for_static_domain(
511 domain: StaticFluentDomain,
512 namespace: &ResolvedNamespace,
513 required: bool,
514 ) -> Self {
515 Self::new(
516 ResourceKey::from_static_domain_and_namespace(domain, namespace),
517 LocaleRelativeFtlPath::try_new(format!("{}/{namespace}.ftl", domain.as_str())).expect(
518 "static domain and namespace should produce a valid locale-relative FTL path",
519 ),
520 required,
521 )
522 }
523
524 fn namespaced_for_domain(
525 domain: &FluentDomain,
526 namespace: &ResolvedNamespace,
527 required: bool,
528 ) -> Self {
529 Self::new(
530 ResourceKey::from_domain_and_namespace(domain, namespace),
531 LocaleRelativeFtlPath::try_new(format!("{}/{namespace}.ftl", domain.as_str()))
532 .expect("domain and namespace should produce a valid locale-relative FTL path"),
533 required,
534 )
535 }
536
537 pub fn locale_path(&self, lang: &LanguageIdentifier) -> String {
539 format!("{}/{}", lang, self.locale_relative_path)
540 }
541}
542
543#[derive(Clone, Debug, Eq, PartialEq)]
545pub struct ResourcePlan {
546 specs: Vec<ModuleResourceSpec>,
547}
548
549#[derive(Clone, Debug, Eq, PartialEq)]
551pub struct SparseAssetResourcePlans {
552 languages: Vec<LanguageIdentifier>,
553 namespaces: Vec<ResolvedNamespace>,
554 resource_specs_by_language: Vec<(LanguageIdentifier, Vec<ModuleResourceSpec>)>,
555}
556
557impl SparseAssetResourcePlans {
558 pub fn languages(&self) -> &[LanguageIdentifier] {
560 &self.languages
561 }
562
563 pub fn namespaces(&self) -> &[ResolvedNamespace] {
565 &self.namespaces
566 }
567
568 pub fn resource_specs_by_language(&self) -> &[(LanguageIdentifier, Vec<ModuleResourceSpec>)] {
570 &self.resource_specs_by_language
571 }
572
573 pub fn into_parts(
575 self,
576 ) -> (
577 Vec<LanguageIdentifier>,
578 Vec<ResolvedNamespace>,
579 Vec<(LanguageIdentifier, Vec<ModuleResourceSpec>)>,
580 ) {
581 (
582 self.languages,
583 self.namespaces,
584 self.resource_specs_by_language,
585 )
586 }
587}
588
589impl ResourcePlan {
590 pub fn for_static_domain(
592 domain: StaticFluentDomain,
593 namespaces: &[&str],
594 ) -> Result<Self, ResourcePlanError> {
595 let mut resolved_namespaces = Vec::with_capacity(namespaces.len());
596 let mut seen = HashSet::new();
597
598 for namespace in namespaces {
599 let namespace = ResolvedNamespace::new(*namespace).map_err(|details| {
600 ResourcePlanError::InvalidNamespace {
601 namespace: (*namespace).to_string(),
602 details,
603 }
604 })?;
605
606 if seen.insert(namespace.clone()) {
607 resolved_namespaces.push(namespace);
608 }
609 }
610
611 Ok(Self::from_static_domain_and_namespaces(
612 domain,
613 &resolved_namespaces,
614 namespaces.is_empty(),
615 ))
616 }
617
618 fn from_static_domain_and_namespaces(
619 domain: StaticFluentDomain,
620 namespaces: &[ResolvedNamespace],
621 base_required: bool,
622 ) -> Self {
623 if namespaces.is_empty() {
624 return Self {
625 specs: vec![ModuleResourceSpec::base_for_static_domain(
626 domain,
627 base_required,
628 )],
629 };
630 }
631
632 let mut specs = Vec::with_capacity(namespaces.len() + 1);
633 specs.push(ModuleResourceSpec::base_for_static_domain(domain, false));
634 for namespace in namespaces {
635 specs.push(ModuleResourceSpec::namespaced_for_static_domain(
636 domain, namespace, true,
637 ));
638 }
639
640 Self { specs }
641 }
642
643 pub fn for_domain(domain: &str, namespaces: &[&str]) -> Result<Self, ResourcePlanError> {
645 let domain =
646 FluentDomain::try_new(domain).map_err(|details| ResourcePlanError::InvalidDomain {
647 domain: domain.to_string(),
648 details,
649 })?;
650
651 if namespaces.is_empty() {
652 return Ok(Self {
653 specs: vec![ModuleResourceSpec::base_for_domain(&domain, true)],
654 });
655 }
656
657 let mut specs = Vec::with_capacity(namespaces.len() + 1);
658 specs.push(ModuleResourceSpec::base_for_domain(&domain, false));
659
660 let mut seen = HashSet::new();
661 for namespace in namespaces {
662 let namespace = ResolvedNamespace::new(*namespace).map_err(|details| {
663 ResourcePlanError::InvalidNamespace {
664 namespace: (*namespace).to_string(),
665 details,
666 }
667 })?;
668
669 if !seen.insert(namespace.clone()) {
670 continue;
671 }
672
673 specs.push(ModuleResourceSpec::namespaced_for_domain(
674 &domain, &namespace, true,
675 ));
676 }
677
678 Ok(Self { specs })
679 }
680
681 pub fn sparse_for_domain(
683 domain: &str,
684 has_base_resource: bool,
685 namespaces: &[ResolvedNamespace],
686 base_required: bool,
687 ) -> Self {
688 let domain =
689 FluentDomain::try_new(domain).expect("sparse resource domain should be validated");
690 Self::sparse_for_validated_domain(&domain, has_base_resource, namespaces, base_required)
691 }
692
693 pub fn sparse_for_static_domain(
695 domain: StaticFluentDomain,
696 has_base_resource: bool,
697 namespaces: &[ResolvedNamespace],
698 base_required: bool,
699 ) -> Self {
700 let mut specs = Vec::with_capacity(namespaces.len() + usize::from(has_base_resource));
701
702 if has_base_resource {
703 specs.push(ModuleResourceSpec::base_for_static_domain(
704 domain,
705 base_required,
706 ));
707 }
708
709 for namespace in namespaces {
710 specs.push(ModuleResourceSpec::namespaced_for_static_domain(
711 domain, namespace, true,
712 ));
713 }
714
715 Self { specs }
716 }
717
718 fn sparse_for_validated_domain(
719 domain: &FluentDomain,
720 has_base_resource: bool,
721 namespaces: &[ResolvedNamespace],
722 base_required: bool,
723 ) -> Self {
724 let mut specs = Vec::with_capacity(namespaces.len() + usize::from(has_base_resource));
725
726 if has_base_resource {
727 specs.push(ModuleResourceSpec::base_for_domain(domain, base_required));
728 }
729
730 for namespace in namespaces {
731 specs.push(ModuleResourceSpec::namespaced_for_domain(
732 domain, namespace, true,
733 ));
734 }
735
736 Self { specs }
737 }
738
739 pub fn sparse_from_assets(
745 domain: &str,
746 assets_root: &Path,
747 ) -> Result<SparseAssetResourcePlans, SparseAssetResourcePlanError> {
748 let entries = std::fs::read_dir(assets_root).map_err(|source| {
749 SparseAssetResourcePlanError::ReadAssetsRoot {
750 path: assets_root.to_path_buf(),
751 source,
752 }
753 })?;
754
755 let mut namespaces = BTreeSet::new();
756 let mut languages_with_base_file = BTreeSet::new();
757 let mut discovered_languages = BTreeSet::new();
758 let mut namespaces_by_language: BTreeMap<LanguageIdentifier, BTreeSet<ResolvedNamespace>> =
759 BTreeMap::new();
760
761 for entry in entries {
762 let entry =
763 entry.map_err(|source| SparseAssetResourcePlanError::ReadAssetsRootEntry {
764 path: assets_root.to_path_buf(),
765 source,
766 })?;
767 let path = entry.path();
768 if !path.is_dir() {
769 continue;
770 }
771
772 let raw_name = path
773 .file_name()
774 .and_then(|value| value.to_str())
775 .ok_or_else(|| SparseAssetResourcePlanError::NonUtf8LocaleDirectory {
776 path: path.clone(),
777 })?;
778 let canonical_lang =
779 crate::parse_canonical_language_identifier(raw_name).map_err(|details| {
780 SparseAssetResourcePlanError::InvalidLocaleDirectory {
781 raw_name: raw_name.to_string(),
782 path: path.clone(),
783 details,
784 }
785 })?;
786
787 let base_path = path.join(format!("{domain}.ftl"));
788 let namespace_root = path.join(domain);
789 let has_base_file = base_path.exists();
790 let discovered_namespaces = if namespace_root.is_dir() {
791 discover_namespaces(domain, &namespace_root)?
792 } else {
793 BTreeSet::new()
794 };
795
796 if has_base_file || !discovered_namespaces.is_empty() {
797 discovered_languages.insert(canonical_lang.clone());
798 }
799 if has_base_file {
800 languages_with_base_file.insert(canonical_lang.clone());
801 }
802 for namespace in discovered_namespaces {
803 namespaces.insert(namespace.clone());
804 namespaces_by_language
805 .entry(canonical_lang.clone())
806 .or_default()
807 .insert(namespace);
808 }
809 }
810
811 let namespaces: Vec<ResolvedNamespace> = namespaces.into_iter().collect();
812 let languages: Vec<LanguageIdentifier> = discovered_languages.into_iter().collect();
813 let mut resource_specs_by_language = Vec::with_capacity(languages.len());
814
815 for lang in &languages {
816 if namespaces.is_empty() {
817 let plan = Self::sparse_for_domain(domain, true, &[], true);
818 resource_specs_by_language.push((lang.clone(), plan.into_specs()));
819 continue;
820 }
821
822 let resolved_namespaces = namespaces_by_language
823 .get(lang)
824 .into_iter()
825 .flat_map(|entries| entries.iter())
826 .cloned()
827 .collect::<Vec<_>>();
828
829 let plan = Self::sparse_for_domain(
830 domain,
831 languages_with_base_file.contains(lang),
832 &resolved_namespaces,
833 false,
834 );
835 resource_specs_by_language.push((lang.clone(), plan.into_specs()));
836 }
837
838 Ok(SparseAssetResourcePlans {
839 languages,
840 namespaces,
841 resource_specs_by_language,
842 })
843 }
844
845 pub fn specs(&self) -> &[ModuleResourceSpec] {
847 &self.specs
848 }
849
850 pub fn into_specs(self) -> Vec<ModuleResourceSpec> {
852 self.specs
853 }
854}
855
856fn namespace_from_relative_ftl_path(
857 domain: &str,
858 namespace_root: &Path,
859 path: &Path,
860) -> Result<Option<ResolvedNamespace>, SparseAssetResourcePlanError> {
861 if !path.is_file() {
862 return Ok(None);
863 }
864
865 if path.extension().and_then(|ext| ext.to_str()) != Some("ftl") {
866 return Ok(None);
867 }
868
869 let relative_path = path.strip_prefix(namespace_root).map_err(|source| {
870 SparseAssetResourcePlanError::NamespaceRelativePath {
871 path: path.to_path_buf(),
872 root: namespace_root.to_path_buf(),
873 source,
874 }
875 })?;
876 let relative_without_extension = relative_path.with_extension("");
877 let mut components = Vec::new();
878
879 for component in relative_without_extension.components() {
880 let value = component.as_os_str().to_str().ok_or_else(|| {
881 SparseAssetResourcePlanError::NonUtf8NamespacePath {
882 path: relative_without_extension.clone(),
883 }
884 })?;
885 components.push(value.to_string());
886 }
887
888 if components.is_empty() {
889 return Ok(None);
890 }
891
892 let namespace = components.join("/");
893 ResolvedNamespace::new(namespace.clone())
894 .map(Some)
895 .map_err(|details| SparseAssetResourcePlanError::InvalidNamespace {
896 namespace,
897 domain: domain.to_string(),
898 details,
899 })
900}
901
902fn discover_namespaces(
903 domain: &str,
904 namespace_root: &Path,
905) -> Result<BTreeSet<ResolvedNamespace>, SparseAssetResourcePlanError> {
906 let mut namespaces = BTreeSet::new();
907 let mut pending = vec![namespace_root.to_path_buf()];
908
909 while let Some(current_dir) = pending.pop() {
910 let entries = std::fs::read_dir(¤t_dir).map_err(|source| {
911 SparseAssetResourcePlanError::ReadNamespaceDirectory {
912 path: current_dir.clone(),
913 source,
914 }
915 })?;
916
917 for entry in entries {
918 let entry = entry.map_err(|source| {
919 SparseAssetResourcePlanError::ReadNamespaceDirectoryEntry {
920 path: current_dir.clone(),
921 source,
922 }
923 })?;
924 let path = entry.path();
925
926 if path.is_dir() {
927 pending.push(path);
928 continue;
929 }
930
931 if let Some(namespace) =
932 namespace_from_relative_ftl_path(domain, namespace_root, &path)?
933 {
934 namespaces.insert(namespace);
935 }
936 }
937 }
938
939 Ok(namespaces)
940}
941
942impl From<ResourcePlan> for Vec<ModuleResourceSpec> {
943 fn from(plan: ResourcePlan) -> Self {
944 plan.into_specs()
945 }
946}
947
948pub fn try_resource_plan_for(
958 domain: &str,
959 namespaces: &[&str],
960) -> Result<Vec<ModuleResourceSpec>, ResourcePlanError> {
961 ResourcePlan::for_domain(domain, namespaces).map(ResourcePlan::into_specs)
962}
963
964#[allow(
970 clippy::panic,
971 reason = "static metadata uses literal namespaces; use try_resource_plan_for for dynamic input"
972)]
973pub fn resource_plan_for(domain: &str, namespaces: &[&str]) -> Vec<ModuleResourceSpec> {
974 try_resource_plan_for(domain, namespaces)
975 .unwrap_or_else(|error| panic!("resource_plan_for received {error}"))
976}
977
978pub fn required_resource_keys_from_plan(plan: &[ModuleResourceSpec]) -> HashSet<ResourceKey> {
980 plan.iter()
981 .filter(|spec| spec.required)
982 .map(|spec| spec.key.clone())
983 .collect()
984}
985
986pub fn optional_resource_keys_from_plan(plan: &[ModuleResourceSpec]) -> HashSet<ResourceKey> {
988 plan.iter()
989 .filter(|spec| !spec.required)
990 .map(|spec| spec.key.clone())
991 .collect()
992}
993
994pub fn locale_is_ready(
996 required_keys: &HashSet<ResourceKey>,
997 loaded_keys: &HashSet<ResourceKey>,
998) -> bool {
999 required_keys.iter().all(|key| loaded_keys.contains(key))
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use crate::namespace::NamespacePathError;
1006
1007 #[test]
1008 fn resource_key_explicit_constructors_preserve_key_and_domain() {
1009 let dynamic = ResourceKey::try_new("demo/ui").expect("dynamic key");
1010 let static_key = ResourceKey::from_static_path("demo/errors");
1011
1012 assert_eq!(dynamic.as_str(), "demo/ui");
1013 assert_eq!(dynamic.domain(), "demo");
1014 assert_eq!(dynamic.domain_name().as_str(), "demo");
1015 assert_eq!(dynamic.as_ref(), "demo/ui");
1016 assert_eq!(static_key.to_string(), "demo/errors");
1017 }
1018
1019 #[test]
1020 fn resource_key_rejects_noncanonical_key_shapes() {
1021 assert_eq!(
1022 ResourceKey::try_new("../demo").expect_err("parent segment"),
1023 ResourceKeyError(NamespacePathError::CurrentOrParentSegment)
1024 );
1025 assert_eq!(
1026 ResourceKey::try_new("demo.ftl").expect_err("file suffix"),
1027 ResourceKeyError(NamespacePathError::FileExtension)
1028 );
1029 assert_eq!(
1030 ResourceKey::try_new("demo//ui").expect_err("empty segment"),
1031 ResourceKeyError(NamespacePathError::EmptySegment)
1032 );
1033 }
1034
1035 #[test]
1036 fn locale_relative_ftl_path_validates_canonical_resource_paths() {
1037 let path = LocaleRelativeFtlPath::try_new("demo/ui.ftl").expect("path");
1038 assert_eq!(path.as_str(), "demo/ui.ftl");
1039 assert_eq!(path.to_string(), "demo/ui.ftl");
1040 assert_eq!(path, "demo/ui.ftl");
1041 assert_eq!(
1042 LocaleRelativeFtlPath::from_static_path("demo/static.ftl").as_str(),
1043 "demo/static.ftl"
1044 );
1045
1046 assert_eq!(
1047 LocaleRelativeFtlPath::try_new("").expect_err("empty path"),
1048 LocaleRelativeFtlPathError::Empty
1049 );
1050 assert_eq!(
1051 LocaleRelativeFtlPath::try_new("/demo.ftl").expect_err("absolute path"),
1052 LocaleRelativeFtlPathError::Absolute
1053 );
1054 assert_eq!(
1055 LocaleRelativeFtlPath::try_new("demo\\ui.ftl").expect_err("backslash path"),
1056 LocaleRelativeFtlPathError::Backslash
1057 );
1058 assert_eq!(
1059 LocaleRelativeFtlPath::try_new("demo/ui").expect_err("missing suffix"),
1060 LocaleRelativeFtlPathError::MissingFtlSuffix
1061 );
1062 assert!(matches!(
1063 LocaleRelativeFtlPath::try_new("demo/../ui.ftl"),
1064 Err(LocaleRelativeFtlPathError::InvalidStem(
1065 NamespacePathError::CurrentOrParentSegment
1066 ))
1067 ));
1068 }
1069
1070 #[test]
1071 fn module_resource_spec_try_new_reports_invalid_parts() {
1072 assert!(matches!(
1073 ModuleResourceSpec::try_new("../demo", "demo.ftl", true),
1074 Err(ResourcePlanError::InvalidResourceKey {
1075 key,
1076 details: ResourceKeyError(NamespacePathError::CurrentOrParentSegment),
1077 }) if key == "../demo"
1078 ));
1079 assert!(matches!(
1080 ModuleResourceSpec::try_new("demo", "demo", true),
1081 Err(ResourcePlanError::InvalidResourcePath {
1082 path,
1083 details: LocaleRelativeFtlPathError::MissingFtlSuffix,
1084 }) if path == "demo"
1085 ));
1086 }
1087
1088 #[test]
1089 fn resource_plan_for_handles_base_and_namespaced_resources() {
1090 let base_plan = resource_plan_for("demo", &[]);
1091 assert_eq!(base_plan.len(), 1);
1092 assert_eq!(base_plan[0].key.as_str(), "demo");
1093 assert_eq!(base_plan[0].locale_relative_path, "demo.ftl");
1094 let en_us = "en-US".parse().expect("language id");
1095 assert_eq!(base_plan[0].locale_path(&en_us), "en-US/demo.ftl");
1096 assert!(base_plan[0].required);
1097
1098 let namespaced_plan = resource_plan_for("demo", &["ui", "ui", "errors"]);
1099 let keys: Vec<_> = namespaced_plan
1100 .iter()
1101 .map(|spec| spec.key.as_str())
1102 .collect();
1103 assert_eq!(keys, vec!["demo", "demo/ui", "demo/errors"]);
1104 assert!(!namespaced_plan[0].required);
1105 assert_eq!(namespaced_plan[0].locale_relative_path, "demo.ftl");
1106 assert!(namespaced_plan[1].required);
1107 assert!(
1108 namespaced_plan
1109 .iter()
1110 .all(|spec| spec.locale_relative_path.ends_with(".ftl"))
1111 );
1112 }
1113
1114 #[test]
1115 fn resource_plan_api_exposes_specs_and_sparse_plans() {
1116 let plan = ResourcePlan::for_domain("demo", &["ui"]).expect("resource plan");
1117 assert_eq!(plan.specs()[0], ModuleResourceSpec::base("demo", false));
1118 assert_eq!(
1119 plan.specs()[1].key,
1120 ResourceKey::from_static_path("demo/ui")
1121 );
1122
1123 let namespace = ResolvedNamespace::new("errors/forms").expect("namespace");
1124 let sparse = ResourcePlan::sparse_for_domain("demo", true, &[namespace], false);
1125 let specs = sparse.into_specs();
1126
1127 assert_eq!(
1128 specs,
1129 vec![
1130 ModuleResourceSpec::base("demo", false),
1131 ModuleResourceSpec::new(
1132 ResourceKey::from_static_path("demo/errors/forms"),
1133 LocaleRelativeFtlPath::from_static_path("demo/errors/forms.ftl"),
1134 true
1135 ),
1136 ]
1137 );
1138 }
1139
1140 #[test]
1141 fn sparse_from_assets_discovers_canonical_language_resource_plans() {
1142 let temp = tempfile::tempdir().expect("tempdir");
1143 let assets = temp.path();
1144 std::fs::create_dir_all(assets.join("en-US/demo")).expect("create en assets");
1145 std::fs::create_dir_all(assets.join("fr/demo/forms")).expect("create fr assets");
1146 std::fs::write(assets.join("en-US/demo.ftl"), "hello = Hello").expect("write en base");
1147 std::fs::write(assets.join("en-US/demo/ui.ftl"), "title = UI").expect("write en ui");
1148 std::fs::write(assets.join("fr/demo/forms/button.ftl"), "button = Bouton")
1149 .expect("write fr namespace");
1150 std::fs::write(assets.join("fr/demo/ignore.txt"), "ignored").expect("write ignored");
1151
1152 let plans = ResourcePlan::sparse_from_assets("demo", assets).expect("plans");
1153
1154 assert_eq!(
1155 plans
1156 .languages()
1157 .iter()
1158 .map(ToString::to_string)
1159 .collect::<Vec<_>>(),
1160 vec!["en-US", "fr"]
1161 );
1162 assert_eq!(
1163 plans
1164 .namespaces()
1165 .iter()
1166 .map(ResolvedNamespace::as_str)
1167 .collect::<Vec<_>>(),
1168 vec!["forms/button", "ui"]
1169 );
1170 let specs_by_language = plans
1171 .resource_specs_by_language()
1172 .iter()
1173 .map(|(language, specs)| (language.to_string(), specs.clone()))
1174 .collect::<Vec<_>>();
1175 assert_eq!(
1176 specs_by_language,
1177 vec![
1178 (
1179 "en-US".to_string(),
1180 vec![
1181 ModuleResourceSpec::base("demo", false),
1182 ModuleResourceSpec::namespaced(
1183 "demo",
1184 &ResolvedNamespace::new("ui").expect("ui namespace"),
1185 true
1186 ),
1187 ]
1188 ),
1189 (
1190 "fr".to_string(),
1191 vec![ModuleResourceSpec::namespaced(
1192 "demo",
1193 &ResolvedNamespace::new("forms/button").expect("forms namespace"),
1194 true
1195 ),]
1196 ),
1197 ]
1198 );
1199 }
1200
1201 #[test]
1202 fn sparse_from_assets_requires_base_when_no_namespaces_exist() {
1203 let temp = tempfile::tempdir().expect("tempdir");
1204 let assets = temp.path();
1205 std::fs::create_dir_all(assets.join("en")).expect("create en assets");
1206 std::fs::write(assets.join("en/demo.ftl"), "hello = Hello").expect("write en base");
1207
1208 let plans = ResourcePlan::sparse_from_assets("demo", assets).expect("plans");
1209
1210 let specs_by_language = plans
1211 .resource_specs_by_language()
1212 .iter()
1213 .map(|(language, specs)| (language.to_string(), specs.clone()))
1214 .collect::<Vec<_>>();
1215 assert_eq!(
1216 specs_by_language,
1217 vec![(
1218 "en".to_string(),
1219 vec![ModuleResourceSpec::base("demo", true)]
1220 )]
1221 );
1222 }
1223
1224 #[test]
1225 fn sparse_from_assets_rejects_noncanonical_locale_directories() {
1226 let temp = tempfile::tempdir().expect("tempdir");
1227 let assets = temp.path();
1228 std::fs::create_dir_all(assets.join("en-us")).expect("create invalid locale");
1229 std::fs::write(assets.join("en-us/demo.ftl"), "hello = Hello").expect("write base");
1230
1231 let error = ResourcePlan::sparse_from_assets("demo", assets).expect_err("invalid locale");
1232
1233 assert!(matches!(
1234 error,
1235 SparseAssetResourcePlanError::InvalidLocaleDirectory { ref raw_name, .. }
1236 if raw_name == "en-us"
1237 ));
1238 assert!(
1239 error
1240 .to_string()
1241 .contains("must use canonical BCP-47 form 'en-US'")
1242 );
1243 }
1244
1245 #[test]
1246 fn sparse_from_assets_rejects_invalid_namespaces() {
1247 let temp = tempfile::tempdir().expect("tempdir");
1248 let assets = temp.path();
1249 std::fs::create_dir_all(assets.join("en/demo")).expect("create en assets");
1250 std::fs::write(assets.join("en/demo/bad.ftl.ftl"), "bad = Bad")
1251 .expect("write invalid namespace");
1252
1253 let error =
1254 ResourcePlan::sparse_from_assets("demo", assets).expect_err("invalid namespace");
1255
1256 assert!(matches!(
1257 error,
1258 SparseAssetResourcePlanError::InvalidNamespace {
1259 namespace,
1260 domain,
1261 details: NamespacePathError::FileExtension,
1262 } if namespace == "bad.ftl" && domain == "demo"
1263 ));
1264 }
1265
1266 #[test]
1267 #[should_panic(expected = "resource_plan_for received invalid namespace")]
1268 fn resource_plan_for_rejects_invalid_namespaces() {
1269 let _ = resource_plan_for("demo", &["../outside"]);
1270 }
1271
1272 #[test]
1273 fn try_resource_plan_for_reports_invalid_namespaces() {
1274 let err = try_resource_plan_for("demo", &["../outside"])
1275 .expect_err("invalid namespace should fail");
1276
1277 assert_eq!(
1278 err,
1279 ResourcePlanError::InvalidNamespace {
1280 namespace: "../outside".to_string(),
1281 details: NamespacePathError::CurrentOrParentSegment
1282 }
1283 );
1284 }
1285
1286 #[test]
1287 fn try_resource_plan_for_reports_invalid_domain_without_panicking() {
1288 let err = try_resource_plan_for("../demo", &[]).expect_err("invalid domain should fail");
1289
1290 assert!(matches!(
1291 err,
1292 ResourcePlanError::InvalidDomain { domain, .. } if domain == "../demo"
1293 ));
1294 }
1295
1296 #[test]
1297 fn resource_plan_uses_resolved_namespace_keys() {
1298 let plan = resource_plan_for("demo", &["ui/button"]);
1299
1300 assert_eq!(plan[1].key, ResourceKey::from_static_path("demo/ui/button"));
1301 assert_eq!(plan[1].locale_relative_path, "demo/ui/button.ftl");
1302 }
1303
1304 #[test]
1305 fn required_and_optional_keys_reflect_plan_membership() {
1306 let plan = vec![
1307 ModuleResourceSpec::new(
1308 ResourceKey::from_static_path("demo"),
1309 LocaleRelativeFtlPath::from_static_path("demo.ftl"),
1310 true,
1311 ),
1312 ModuleResourceSpec::new(
1313 ResourceKey::from_static_path("demo/optional"),
1314 LocaleRelativeFtlPath::from_static_path("demo/optional.ftl"),
1315 false,
1316 ),
1317 ];
1318
1319 let required = required_resource_keys_from_plan(&plan);
1320 let optional = optional_resource_keys_from_plan(&plan);
1321
1322 assert!(required.contains(&ResourceKey::from_static_path("demo")));
1323 assert!(!required.contains(&ResourceKey::from_static_path("demo/optional")));
1324 assert!(optional.contains(&ResourceKey::from_static_path("demo/optional")));
1325 assert!(!optional.contains(&ResourceKey::from_static_path("demo")));
1326
1327 let loaded = HashSet::from([ResourceKey::from_static_path("demo")]);
1328 assert!(locale_is_ready(&required, &loaded));
1329
1330 let unloaded = HashSet::new();
1331 assert!(!locale_is_ready(&required, &unloaded));
1332 }
1333}