Skip to main content

linguini_config/
discovery.rs

1use crate::error::{ConfigError, ConfigResult};
2use crate::model::validate_locale_tag;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Eq, PartialEq)]
7pub struct SchemaFile {
8    pub path: PathBuf,
9    pub namespace: String,
10}
11
12#[derive(Debug, Clone, Eq, PartialEq)]
13pub struct LocaleFile {
14    pub path: PathBuf,
15    pub locale: String,
16    pub namespace: String,
17}
18
19pub fn discover_schema_files(root: impl AsRef<Path>) -> ConfigResult<Vec<SchemaFile>> {
20    let root = root.as_ref();
21    let mut files = Vec::new();
22    collect_schema_files(root, root, &mut files)?;
23    files.sort_by(|left, right| left.path.cmp(&right.path));
24    Ok(files)
25}
26
27pub fn discover_locale_files(root: impl AsRef<Path>) -> ConfigResult<Vec<LocaleFile>> {
28    let root = root.as_ref();
29    let mut files = Vec::new();
30    collect_locale_files(root, root, &mut files)?;
31    files.sort_by(|left, right| left.path.cmp(&right.path));
32    Ok(files)
33}
34
35pub fn locale_scope_chain(locale_root: impl AsRef<Path>, file: impl AsRef<Path>) -> Vec<PathBuf> {
36    let locale_root = locale_root.as_ref();
37    let file = file.as_ref();
38    let locale_name = file.file_name().unwrap_or_default();
39    let parent = file.parent().unwrap_or(locale_root);
40    let relative_parent = parent.strip_prefix(locale_root).unwrap_or(parent);
41    let mut paths = Vec::new();
42    let mut current = locale_root.to_path_buf();
43
44    paths.push(current.join(locale_name));
45
46    for component in relative_parent.components() {
47        current.push(component.as_os_str());
48        paths.push(current.join(locale_name));
49    }
50
51    paths
52}
53
54fn collect_schema_files(
55    root: &Path,
56    directory: &Path,
57    files: &mut Vec<SchemaFile>,
58) -> ConfigResult<()> {
59    for entry in read_directory(directory)? {
60        let path = entry.path();
61
62        if path.is_dir() {
63            collect_schema_files(root, &path, files)?;
64        } else if path.extension().and_then(|extension| extension.to_str()) == Some("lgs") {
65            files.push(SchemaFile {
66                namespace: schema_namespace(root, &path),
67                path,
68            });
69        }
70    }
71
72    Ok(())
73}
74
75fn collect_locale_files(
76    root: &Path,
77    directory: &Path,
78    files: &mut Vec<LocaleFile>,
79) -> ConfigResult<()> {
80    for entry in read_directory(directory)? {
81        let path = entry.path();
82
83        if path.is_dir() {
84            collect_locale_files(root, &path, files)?;
85        } else if path.extension().and_then(|extension| extension.to_str()) == Some("lgl") {
86            let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
87                continue;
88            };
89
90            validate_locale_tag(stem)?;
91            files.push(LocaleFile {
92                locale: stem.to_owned(),
93                namespace: locale_namespace(root, &path),
94                path,
95            });
96        }
97    }
98
99    Ok(())
100}
101
102fn read_directory(path: &Path) -> ConfigResult<Vec<fs::DirEntry>> {
103    fs::read_dir(path)
104        .map_err(|_| ConfigError::UnreadableDirectory(path.to_path_buf()))?
105        .collect::<Result<Vec<_>, _>>()
106        .map_err(|_| ConfigError::UnreadableDirectory(path.to_path_buf()))
107}
108
109fn schema_namespace(root: &Path, path: &Path) -> String {
110    namespace_from_path(root, path, true)
111}
112
113fn locale_namespace(root: &Path, path: &Path) -> String {
114    namespace_from_path(root, path, false)
115}
116
117fn namespace_from_path(root: &Path, path: &Path, include_file_stem: bool) -> String {
118    let relative = path.strip_prefix(root).unwrap_or(path);
119    let mut parts = Vec::new();
120
121    if let Some(parent) = relative.parent() {
122        parts.extend(
123            parent
124                .components()
125                .filter_map(|component| component.as_os_str().to_str().map(str::to_owned)),
126        );
127    }
128
129    if include_file_stem {
130        if let Some(stem) = relative.file_stem().and_then(|stem| stem.to_str()) {
131            parts.push(stem.to_owned());
132        }
133    }
134
135    parts.join(".")
136}
137
138#[cfg(test)]
139mod tests {
140    use super::{
141        discover_locale_files, discover_schema_files, locale_scope_chain, namespace_from_path,
142    };
143    use std::fs;
144    use std::path::Path;
145
146    #[test]
147    fn derives_schema_namespace_from_path() {
148        let namespace = namespace_from_path(
149            Path::new("linguini/schema"),
150            Path::new("linguini/schema/shop/delivery.lgs"),
151            true,
152        );
153
154        assert_eq!(namespace, "shop.delivery");
155    }
156
157    #[test]
158    fn derives_locale_namespace_from_parent_path() {
159        let namespace = namespace_from_path(
160            Path::new("linguini/locale"),
161            Path::new("linguini/locale/shop/forms/fruit/ru.lgl"),
162            false,
163        );
164
165        assert_eq!(namespace, "shop.forms.fruit");
166    }
167
168    #[test]
169    fn derives_locale_namespace_matching_schema_file_layout() {
170        let namespace = namespace_from_path(
171            Path::new("locales"),
172            Path::new("locales/shop/ru.lgl"),
173            false,
174        );
175
176        assert_eq!(namespace, "shop");
177    }
178
179    #[test]
180    fn builds_top_down_locale_scope_chain() {
181        let chain = locale_scope_chain("linguini/locale", "linguini/locale/shop/delivery/ru.lgl");
182
183        assert_eq!(
184            chain,
185            [
186                Path::new("linguini/locale/ru.lgl").to_path_buf(),
187                Path::new("linguini/locale/shop/ru.lgl").to_path_buf(),
188                Path::new("linguini/locale/shop/delivery/ru.lgl").to_path_buf(),
189            ]
190        );
191    }
192
193    #[test]
194    fn discovers_project_structure_namespaces_and_locales() {
195        let root = temp_root("discovers_project_structure_namespaces_and_locales");
196        let schema_root = root.join("schema");
197        let locale_root = root.join("locales");
198        fs::create_dir_all(schema_root.join("shop/forms")).expect("schema dirs");
199        fs::create_dir_all(locale_root.join("shop/forms/cart")).expect("locale dirs");
200        fs::write(schema_root.join("shop/forms/cart.lgs"), "cart()\n").expect("schema file");
201        fs::write(
202            locale_root.join("shop/forms/cart/en-US.lgl"),
203            "cart = Cart\n",
204        )
205        .expect("locale file");
206
207        let schemas = discover_schema_files(&schema_root).expect("schema discovery");
208        let locales = discover_locale_files(&locale_root).expect("locale discovery");
209
210        assert_eq!(schemas.len(), 1);
211        assert_eq!(schemas[0].namespace, "shop.forms.cart");
212        assert_eq!(locales.len(), 1);
213        assert_eq!(locales[0].locale, "en-US");
214        assert_eq!(locales[0].namespace, "shop.forms.cart");
215
216        fs::remove_dir_all(root).expect("remove temp project");
217    }
218
219    fn temp_root(name: &str) -> std::path::PathBuf {
220        let path =
221            std::env::temp_dir().join(format!("linguini-config-{name}-{}", std::process::id()));
222        let _ = fs::remove_dir_all(&path);
223        fs::create_dir_all(&path).expect("create temp root");
224        path
225    }
226}