Skip to main content

es_fluent_shared/resource/
discovery.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3
4use crate::LanguageIdentifier;
5use crate::namespace::ResolvedNamespace;
6
7use super::{ModuleResourceSpec, ResourcePlan, SparseAssetResourcePlanError};
8
9/// Sparse per-language resource plans discovered from a locale asset tree.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct SparseAssetResourcePlans {
12    languages: Vec<LanguageIdentifier>,
13    namespaces: Vec<ResolvedNamespace>,
14    resource_specs_by_language: Vec<(LanguageIdentifier, Vec<ModuleResourceSpec>)>,
15}
16
17impl SparseAssetResourcePlans {
18    /// Returns canonical language identifiers discovered in the assets tree.
19    pub fn languages(&self) -> &[LanguageIdentifier] {
20        &self.languages
21    }
22
23    /// Returns all namespace paths discovered across languages.
24    pub fn namespaces(&self) -> &[ResolvedNamespace] {
25        &self.namespaces
26    }
27
28    /// Returns sparse resource plans keyed by language identifier.
29    pub fn resource_specs_by_language(&self) -> &[(LanguageIdentifier, Vec<ModuleResourceSpec>)] {
30        &self.resource_specs_by_language
31    }
32
33    /// Converts the discovery result into its component vectors.
34    pub fn into_parts(
35        self,
36    ) -> (
37        Vec<LanguageIdentifier>,
38        Vec<ResolvedNamespace>,
39        Vec<(LanguageIdentifier, Vec<ModuleResourceSpec>)>,
40    ) {
41        (
42            self.languages,
43            self.namespaces,
44            self.resource_specs_by_language,
45        )
46    }
47}
48
49impl ResourcePlan {
50    /// Discovers sparse per-language resource plans from an assets tree.
51    ///
52    /// `assets_root` must contain locale directories such as `en-US/`. Within
53    /// each locale, `{domain}.ftl` is the base resource and
54    /// `{domain}/{namespace}.ftl` entries are namespaced resources.
55    pub fn sparse_from_assets(
56        domain: &str,
57        assets_root: &Path,
58    ) -> Result<SparseAssetResourcePlans, SparseAssetResourcePlanError> {
59        let entries = std::fs::read_dir(assets_root).map_err(|source| {
60            SparseAssetResourcePlanError::ReadAssetsRoot {
61                path: assets_root.to_path_buf(),
62                source,
63            }
64        })?;
65
66        let mut namespaces = BTreeSet::new();
67        let mut languages_with_base_file = BTreeSet::new();
68        let mut discovered_languages = BTreeSet::new();
69        let mut namespaces_by_language: BTreeMap<LanguageIdentifier, BTreeSet<ResolvedNamespace>> =
70            BTreeMap::new();
71
72        for entry in entries {
73            let entry =
74                entry.map_err(|source| SparseAssetResourcePlanError::ReadAssetsRootEntry {
75                    path: assets_root.to_path_buf(),
76                    source,
77                })?;
78            let path = entry.path();
79            if !path.is_dir() {
80                continue;
81            }
82
83            let raw_name = path
84                .file_name()
85                .and_then(|value| value.to_str())
86                .ok_or_else(|| SparseAssetResourcePlanError::NonUtf8LocaleDirectory {
87                    path: path.clone(),
88                })?;
89            let canonical_lang =
90                crate::parse_canonical_language_identifier(raw_name).map_err(|details| {
91                    SparseAssetResourcePlanError::InvalidLocaleDirectory {
92                        raw_name: raw_name.to_string(),
93                        path: path.clone(),
94                        details,
95                    }
96                })?;
97
98            let base_path = path.join(format!("{domain}.ftl"));
99            let namespace_root = path.join(domain);
100            let has_base_file = base_path.exists();
101            let discovered_namespaces = if namespace_root.is_dir() {
102                discover_namespaces(domain, &namespace_root)?
103            } else {
104                BTreeSet::new()
105            };
106
107            if has_base_file || !discovered_namespaces.is_empty() {
108                discovered_languages.insert(canonical_lang.clone());
109            }
110            if has_base_file {
111                languages_with_base_file.insert(canonical_lang.clone());
112            }
113            for namespace in discovered_namespaces {
114                namespaces.insert(namespace.clone());
115                namespaces_by_language
116                    .entry(canonical_lang.clone())
117                    .or_default()
118                    .insert(namespace);
119            }
120        }
121
122        let namespaces: Vec<ResolvedNamespace> = namespaces.into_iter().collect();
123        let languages: Vec<LanguageIdentifier> = discovered_languages.into_iter().collect();
124        let mut resource_specs_by_language = Vec::with_capacity(languages.len());
125
126        for lang in &languages {
127            if namespaces.is_empty() {
128                let plan = Self::sparse_for_domain(domain, true, &[], true);
129                resource_specs_by_language.push((lang.clone(), plan.into_specs()));
130                continue;
131            }
132
133            let resolved_namespaces = namespaces_by_language
134                .get(lang)
135                .into_iter()
136                .flat_map(|entries| entries.iter())
137                .cloned()
138                .collect::<Vec<_>>();
139
140            let plan = Self::sparse_for_domain(
141                domain,
142                languages_with_base_file.contains(lang),
143                &resolved_namespaces,
144                false,
145            );
146            resource_specs_by_language.push((lang.clone(), plan.into_specs()));
147        }
148
149        Ok(SparseAssetResourcePlans {
150            languages,
151            namespaces,
152            resource_specs_by_language,
153        })
154    }
155}
156
157fn namespace_from_relative_ftl_path(
158    domain: &str,
159    namespace_root: &Path,
160    path: &Path,
161) -> Result<Option<ResolvedNamespace>, SparseAssetResourcePlanError> {
162    if !path.is_file() {
163        return Ok(None);
164    }
165
166    if path.extension().and_then(|ext| ext.to_str()) != Some("ftl") {
167        return Ok(None);
168    }
169
170    let relative_path = path.strip_prefix(namespace_root).map_err(|source| {
171        SparseAssetResourcePlanError::NamespaceRelativePath {
172            path: path.to_path_buf(),
173            root: namespace_root.to_path_buf(),
174            source,
175        }
176    })?;
177    let relative_without_extension = relative_path.with_extension("");
178    let mut components = Vec::new();
179
180    for component in relative_without_extension.components() {
181        let value = component.as_os_str().to_str().ok_or_else(|| {
182            SparseAssetResourcePlanError::NonUtf8NamespacePath {
183                path: relative_without_extension.clone(),
184            }
185        })?;
186        components.push(value.to_string());
187    }
188
189    if components.is_empty() {
190        return Ok(None);
191    }
192
193    let namespace = components.join("/");
194    ResolvedNamespace::new(namespace.clone())
195        .map(Some)
196        .map_err(|details| SparseAssetResourcePlanError::InvalidNamespace {
197            namespace,
198            domain: domain.to_string(),
199            details,
200        })
201}
202
203fn discover_namespaces(
204    domain: &str,
205    namespace_root: &Path,
206) -> Result<BTreeSet<ResolvedNamespace>, SparseAssetResourcePlanError> {
207    let mut namespaces = BTreeSet::new();
208    let mut pending = vec![namespace_root.to_path_buf()];
209
210    while let Some(current_dir) = pending.pop() {
211        let entries = std::fs::read_dir(&current_dir).map_err(|source| {
212            SparseAssetResourcePlanError::ReadNamespaceDirectory {
213                path: current_dir.clone(),
214                source,
215            }
216        })?;
217
218        for entry in entries {
219            let entry = entry.map_err(|source| {
220                SparseAssetResourcePlanError::ReadNamespaceDirectoryEntry {
221                    path: current_dir.clone(),
222                    source,
223                }
224            })?;
225            let path = entry.path();
226
227            if path.is_dir() {
228                pending.push(path);
229                continue;
230            }
231
232            if let Some(namespace) =
233                namespace_from_relative_ftl_path(domain, namespace_root, &path)?
234            {
235                namespaces.insert(namespace);
236            }
237        }
238    }
239
240    Ok(namespaces)
241}