linguini_config/
discovery.rs1use 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}