Skip to main content

es_fluent_shared/
resource.rs

1//! Shared resource planning types used by managers and generation tooling.
2
3use 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/// Errors produced while building a module resource plan.
13#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
14pub enum ResourcePlanError {
15    /// The module domain is not a valid Fluent domain.
16    #[error("invalid Fluent domain '{domain}': {details}")]
17    InvalidDomain {
18        domain: String,
19        details: FluentIdentifierError,
20    },
21    /// The module domain is not a valid resource key segment.
22    #[error("invalid domain resource key '{key}': {details}")]
23    InvalidResourceKey {
24        /// Invalid resource key.
25        key: String,
26        /// Validation details.
27        details: ResourceKeyError,
28    },
29    /// A generated locale-relative resource path is invalid.
30    #[error("invalid locale-relative resource path '{path}': {details}")]
31    InvalidResourcePath {
32        /// Invalid locale-relative path.
33        path: String,
34        /// Validation details.
35        details: LocaleRelativeFtlPathError,
36    },
37    /// A namespace entry is not a valid locale-relative namespace path.
38    #[error("invalid namespace '{namespace}': {details}")]
39    InvalidNamespace {
40        namespace: String,
41        details: NamespacePathError,
42    },
43}
44
45/// Errors produced while discovering sparse resource plans from an assets tree.
46#[derive(Debug, thiserror::Error)]
47pub enum SparseAssetResourcePlanError {
48    /// The locale assets root could not be read.
49    #[error("Failed to read i18n directory at {path:?}: {source}")]
50    ReadAssetsRoot {
51        /// Assets root path.
52        path: PathBuf,
53        /// Filesystem error details.
54        source: std::io::Error,
55    },
56    /// A directory entry under the locale assets root could not be read.
57    #[error("Failed to read directory entry in {path:?}: {source}")]
58    ReadAssetsRootEntry {
59        /// Parent directory path.
60        path: PathBuf,
61        /// Filesystem error details.
62        source: std::io::Error,
63    },
64    /// A namespace directory could not be read.
65    #[error("Failed to read namespace directory {path:?}: {source}")]
66    ReadNamespaceDirectory {
67        /// Namespace directory path.
68        path: PathBuf,
69        /// Filesystem error details.
70        source: std::io::Error,
71    },
72    /// A namespace directory entry could not be read.
73    #[error("Failed to read directory entry in {path:?}: {source}")]
74    ReadNamespaceDirectoryEntry {
75        /// Parent directory path.
76        path: PathBuf,
77        /// Filesystem error details.
78        source: std::io::Error,
79    },
80    /// A locale directory name is not UTF-8.
81    #[error("Locale directory {path:?} contains a non-UTF-8 name")]
82    NonUtf8LocaleDirectory {
83        /// Locale directory path.
84        path: PathBuf,
85    },
86    /// A locale directory is not a valid canonical BCP-47 language identifier.
87    #[error("{}", format_locale_directory_error(raw_name, path, details))]
88    InvalidLocaleDirectory {
89        /// Raw directory name.
90        raw_name: String,
91        /// Locale directory path.
92        path: PathBuf,
93        /// Language parsing details.
94        details: CanonicalLanguageIdentifierError,
95    },
96    /// A namespace path could not be made relative to the namespace root.
97    #[error("Failed to derive namespace for asset {path:?} relative to {root:?}: {source}")]
98    NamespaceRelativePath {
99        /// FTL asset path.
100        path: PathBuf,
101        /// Namespace root path.
102        root: PathBuf,
103        /// Relative path error details.
104        source: std::path::StripPrefixError,
105    },
106    /// A namespace path contains a non-UTF-8 component.
107    #[error("Namespace path {path:?} contains non-UTF-8 components")]
108    NonUtf8NamespacePath {
109        /// Namespace path without the `.ftl` extension.
110        path: PathBuf,
111    },
112    /// A discovered namespace is not a canonical namespace path.
113    #[error("Discovered invalid namespace '{namespace}' in assets for crate '{domain}': {details}")]
114    InvalidNamespace {
115        /// Discovered namespace path.
116        namespace: String,
117        /// Module domain.
118        domain: String,
119        /// Namespace validation details.
120        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/// Errors produced while validating locale-relative Fluent resource paths.
146#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
147pub enum LocaleRelativeFtlPathError {
148    /// The path is empty.
149    #[error("path must not be empty")]
150    Empty,
151    /// The path is absolute.
152    #[error("path must be relative")]
153    Absolute,
154    /// The path contains a Windows path separator.
155    #[error("path must use '/' separators")]
156    Backslash,
157    /// The path does not end with `.ftl`.
158    #[error("path must end with .ftl")]
159    MissingFtlSuffix,
160    /// The path stem is not a valid locale-relative namespace-style path.
161    #[error("{0}")]
162    InvalidStem(#[from] NamespacePathError),
163}
164
165/// Error produced while validating a resource key.
166#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
167#[error("{0}")]
168pub struct ResourceKeyError(#[from] NamespacePathError);
169
170/// Stable key for a localized resource.
171///
172/// Keys use the canonical shape:
173/// - `{domain}` for base files
174/// - `{domain}/{namespace}` for namespaced files
175#[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    /// Validates and creates a resource key.
183    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    /// Validates and creates a resource key from static metadata.
190    #[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    /// Creates a base resource key from an already-validated static Fluent domain.
200    pub fn from_static_domain(domain: StaticFluentDomain) -> Self {
201        Self(domain.as_str().to_string())
202    }
203
204    /// Creates a namespaced resource key from already-validated domain and namespace values.
205    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    /// Returns the key as a string slice.
221    pub fn as_str(&self) -> &str {
222        &self.0
223    }
224
225    /// Returns the domain segment of the key.
226    pub fn domain(&self) -> &str {
227        self.0.split('/').next().unwrap_or(self.as_str())
228    }
229
230    /// Returns the domain segment as a validated Fluent domain.
231    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/// Locale-relative path to a Fluent resource file.
257///
258/// Paths use the canonical shape `{domain}.ftl` or
259/// `{domain}/{namespace}.ftl` and are relative to a locale root.
260#[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    /// Validates and creates a locale-relative Fluent resource path.
278    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    /// Validates and creates a locale-relative Fluent resource path from a static literal.
285    #[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    /// Returns the path as a string slice.
295    pub fn as_str(&self) -> &str {
296        &self.0
297    }
298
299    /// Returns the owned string.
300    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/// Canonical route for a crate resource before it is expanded for a locale.
356#[derive(Clone, Debug, Eq, Hash, PartialEq)]
357pub enum ResourceRoute {
358    /// The base crate resource, written as `{domain}.ftl`.
359    Base,
360    /// A namespaced crate resource, written under `{domain}/{namespace}.ftl`.
361    Namespaced(ResolvedNamespace),
362}
363
364impl ResourceRoute {
365    /// Creates a route from an optional resolved namespace.
366    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    /// Returns true when this route points at the base crate resource.
374    pub fn is_base(&self) -> bool {
375        matches!(self, Self::Base)
376    }
377
378    /// Builds the concrete module resource specification for a crate domain.
379    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    /// Builds the concrete module resource specification for a validated crate domain.
386    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/// Canonical description of a single localized resource file.
414#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)]
415pub struct ModuleResourceSpec {
416    /// Stable resource key used by managers (e.g., `my-crate`, `my-crate/ui`, `my-crate/ui/button`).
417    pub key: ResourceKey,
418    /// Path under a locale root (e.g., `my-crate.ftl`, `my-crate/ui.ftl`, `my-crate/ui/button.ftl`).
419    pub locale_relative_path: LocaleRelativeFtlPath,
420    /// Whether this resource is required for locale readiness.
421    pub required: bool,
422}
423
424impl ModuleResourceSpec {
425    /// Validates and creates a resource specification.
426    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    /// Creates a resource specification from validated parts.
446    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    /// Validates and creates the base domain resource specification.
459    pub fn try_base(domain: &str, required: bool) -> Result<Self, ResourcePlanError> {
460        Self::try_new(domain, format!("{domain}.ftl"), required)
461    }
462
463    /// Creates the base domain resource specification.
464    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    /// Creates the base domain resource specification from a validated static domain.
471    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    /// Validates and creates a namespaced resource specification.
490    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    /// Creates a namespaced resource specification.
503    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    /// Creates a namespaced resource specification from validated static metadata.
510    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    /// Returns the full path for a locale (e.g., `en/my-crate.ftl`).
538    pub fn locale_path(&self, lang: &LanguageIdentifier) -> String {
539        format!("{}/{}", lang, self.locale_relative_path)
540    }
541}
542
543/// Canonical resource plan for a module domain.
544#[derive(Clone, Debug, Eq, PartialEq)]
545pub struct ResourcePlan {
546    specs: Vec<ModuleResourceSpec>,
547}
548
549/// Sparse per-language resource plans discovered from a locale asset tree.
550#[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    /// Returns canonical language identifiers discovered in the assets tree.
559    pub fn languages(&self) -> &[LanguageIdentifier] {
560        &self.languages
561    }
562
563    /// Returns all namespace paths discovered across languages.
564    pub fn namespaces(&self) -> &[ResolvedNamespace] {
565        &self.namespaces
566    }
567
568    /// Returns sparse resource plans keyed by language identifier.
569    pub fn resource_specs_by_language(&self) -> &[(LanguageIdentifier, Vec<ModuleResourceSpec>)] {
570        &self.resource_specs_by_language
571    }
572
573    /// Converts the discovery result into its component vectors.
574    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    /// Builds the global/default canonical resource plan for a validated static domain.
591    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    /// Builds the global/default canonical resource plan for a domain.
644    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    /// Builds a sparse resource plan for a single locale.
682    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    /// Builds a sparse resource plan for a single locale from validated static metadata.
694    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    /// Discovers sparse per-language resource plans from an assets tree.
740    ///
741    /// `assets_root` must contain locale directories such as `en-US/`. Within
742    /// each locale, `{domain}.ftl` is the base resource and
743    /// `{domain}/{namespace}.ftl` entries are namespaced resources.
744    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    /// Returns the resource specs in this plan.
846    pub fn specs(&self) -> &[ModuleResourceSpec] {
847        &self.specs
848    }
849
850    /// Converts the plan into its resource specs.
851    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(&current_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
948/// Builds the global/default canonical resource plan for a domain.
949///
950/// Contract:
951/// - Without namespaces, `{domain}.ftl` is required.
952/// - With namespaces, `{domain}.ftl` remains an optional mixed-mode resource
953///   and `{domain}/{namespace}.ftl` entries are required.
954/// - Compile-time registrations may provide a sparse per-language plan through
955///   `I18nModuleRegistration::resource_plan_for_language`; managers should use
956///   that plan directly when it is available.
957pub 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/// Builds the global/default canonical resource plan for a domain.
965///
966/// Use [`try_resource_plan_for`] for dynamic namespace input that should return
967/// typed errors. This constructor is for static module metadata paths where
968/// invalid namespaces are programmer errors and recovery is not useful.
969#[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
978/// Returns required resource keys from a resource plan.
979pub 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
986/// Returns optional resource keys from a resource plan.
987pub 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
994/// Returns true when all required keys are present in the loaded set.
995pub 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}