1use darling::FromMeta;
3use std::{
4 borrow::Cow,
5 path::{Component, Path},
6};
7
8#[derive(Clone, Debug, Eq, Hash, PartialEq)]
10pub enum NamespaceRule {
11 Literal(Cow<'static, str>),
13 File,
15 FileRelative,
17 Folder,
19 FolderRelative,
21}
22
23impl NamespaceRule {
24 pub fn resolve(&self, file_path: &str, manifest_dir: Option<&Path>) -> String {
26 match self {
27 Self::Literal(value) => value.to_string(),
28 Self::File => crate::namespace_resolver::file_stem_namespace(file_path),
29 Self::FileRelative => {
30 crate::namespace_resolver::file_relative_namespace(file_path, manifest_dir)
31 },
32 Self::Folder => crate::namespace_resolver::folder_namespace(file_path),
33 Self::FolderRelative => {
34 crate::namespace_resolver::folder_relative_namespace(file_path, manifest_dir)
35 },
36 }
37 }
38}
39
40pub fn validate_namespace_path(namespace: &str) -> Result<(), &'static str> {
42 let trimmed = namespace.trim();
43 if trimmed.is_empty() {
44 return Err("namespace must not be empty");
45 }
46 if namespace != trimmed {
47 return Err("namespace must not have leading or trailing whitespace");
48 }
49 if trimmed.contains('\\') {
50 return Err("namespace must use '/' as path separator");
51 }
52 if trimmed.split('/').any(|segment| segment.is_empty()) {
53 return Err("namespace path must not contain empty segments");
54 }
55 if trimmed
56 .split('/')
57 .any(|segment| matches!(segment, "." | ".."))
58 {
59 return Err("namespace path must not contain '.' or '..' segments");
60 }
61 if Path::new(trimmed)
62 .components()
63 .any(|component| matches!(component, Component::RootDir | Component::Prefix(_)))
64 {
65 return Err("namespace must be a relative path");
66 }
67 if trimmed.ends_with(".ftl") {
68 return Err("namespace must not include file extension");
69 }
70
71 Ok(())
72}
73
74impl FromMeta for NamespaceRule {
75 fn from_meta(item: &syn::Meta) -> darling::Result<Self> {
76 match item {
77 syn::Meta::NameValue(nv) => {
78 if let syn::Expr::Lit(syn::ExprLit {
79 lit: syn::Lit::Str(s),
80 ..
81 }) = &nv.value
82 {
83 Ok(Self::Literal(Cow::Owned(s.value())))
84 } else if let syn::Expr::Path(path) = &nv.value {
85 if path.path.is_ident("file") {
86 Ok(Self::File)
87 } else if path.path.is_ident("folder") {
88 Ok(Self::Folder)
89 } else {
90 Err(darling::Error::custom(
91 "expected string literal, 'file', or 'folder' identifier",
92 ))
93 }
94 } else if let syn::Expr::Call(call) = &nv.value {
95 parse_relative_namespace(call)
96 } else {
97 Err(darling::Error::unexpected_type(
98 "expected string literal, 'file', or 'folder'",
99 ))
100 }
101 },
102 syn::Meta::List(list) => {
103 let expr: syn::Expr = syn::parse2(list.tokens.clone()).map_err(|_| {
104 darling::Error::custom(
105 "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
106 )
107 })?;
108
109 match expr {
110 syn::Expr::Path(path) => {
111 if path.path.is_ident("file") {
112 Ok(Self::File)
113 } else if path.path.is_ident("folder") {
114 Ok(Self::Folder)
115 } else {
116 Err(darling::Error::custom(
117 "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
118 ))
119 }
120 },
121 syn::Expr::Call(call) => parse_relative_namespace(&call),
122 syn::Expr::Lit(syn::ExprLit {
123 lit: syn::Lit::Str(lit),
124 ..
125 }) => Ok(Self::Literal(Cow::Owned(lit.value()))),
126 _ => Err(darling::Error::custom(
127 "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
128 )),
129 }
130 },
131 _ => Err(darling::Error::unsupported_format(
132 "expected namespace = \"value\", namespace = file|folder, or namespace = file(relative)|folder(relative)",
133 )),
134 }
135 }
136}
137
138fn parse_relative_namespace(call: &syn::ExprCall) -> darling::Result<NamespaceRule> {
139 let Some((target, arg)) = parse_single_ident_call(call) else {
140 return Err(darling::Error::custom(
141 "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
142 ));
143 };
144
145 match (target.as_str(), arg.as_str()) {
146 ("file", "relative") => Ok(NamespaceRule::FileRelative),
147 ("folder", "relative") => Ok(NamespaceRule::FolderRelative),
148 _ => Err(darling::Error::custom(
149 "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
150 )),
151 }
152}
153
154fn parse_single_ident_call(call: &syn::ExprCall) -> Option<(String, String)> {
155 let syn::Expr::Path(target_path) = call.func.as_ref() else {
156 return None;
157 };
158 if call.args.len() != 1 {
159 return None;
160 }
161 let arg = call.args.first()?;
162 let syn::Expr::Path(arg_path) = arg else {
163 return None;
164 };
165 let target = target_path.path.get_ident()?.to_string();
166 let arg = arg_path.path.get_ident()?.to_string();
167 Some((target, arg))
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use syn::parse_quote;
174
175 #[test]
176 fn literal_namespace_parses_and_resolves() {
177 let meta: syn::Meta = parse_quote!(namespace = "my_namespace");
178 let ns = NamespaceRule::from_meta(&meta).unwrap();
179 assert!(matches!(ns, NamespaceRule::Literal(ref s) if s == "my_namespace"));
180 assert_eq!(ns.resolve("/some/path/lib.rs", None), "my_namespace");
181 }
182
183 #[test]
184 fn literal_namespace_constructor_accepts_static_str() {
185 let ns = NamespaceRule::Literal(Cow::Borrowed("ui"));
186 assert_eq!(ns.resolve("/some/path/lib.rs", None), "ui");
187 }
188
189 #[test]
190 fn file_and_folder_variants_parse() {
191 let file_meta: syn::Meta = parse_quote!(namespace = file);
192 assert!(matches!(
193 NamespaceRule::from_meta(&file_meta).unwrap(),
194 NamespaceRule::File
195 ));
196
197 let folder_meta: syn::Meta = parse_quote!(namespace(folder(relative)));
198 assert!(matches!(
199 NamespaceRule::from_meta(&folder_meta).unwrap(),
200 NamespaceRule::FolderRelative
201 ));
202 }
203
204 #[test]
205 fn namespace_rule_resolves_relative_variants() {
206 assert_eq!(
207 NamespaceRule::FileRelative.resolve("src/ui/button.rs", None),
208 "ui/button"
209 );
210 assert_eq!(
211 NamespaceRule::FolderRelative.resolve("src/ui/button.rs", None),
212 "ui"
213 );
214 }
215
216 #[test]
217 fn relative_namespace_resolution_normalizes_parent_segments() {
218 assert_eq!(
219 NamespaceRule::FileRelative.resolve("src/ui/../button.rs", None),
220 "button"
221 );
222 assert_eq!(
223 NamespaceRule::FolderRelative.resolve("src/ui/../forms/button.rs", None),
224 "forms"
225 );
226 }
227
228 #[test]
229 fn namespace_rule_parses_list_and_name_value_relative_forms() {
230 let file_relative_meta: syn::Meta = parse_quote!(namespace = file(relative));
231 assert!(matches!(
232 NamespaceRule::from_meta(&file_relative_meta).unwrap(),
233 NamespaceRule::FileRelative
234 ));
235
236 let folder_meta: syn::Meta = parse_quote!(namespace(folder));
237 assert!(matches!(
238 NamespaceRule::from_meta(&folder_meta).unwrap(),
239 NamespaceRule::Folder
240 ));
241
242 let literal_meta: syn::Meta = parse_quote!(namespace("ui/list"));
243 assert!(matches!(
244 NamespaceRule::from_meta(&literal_meta).unwrap(),
245 NamespaceRule::Literal(ref value) if value == "ui/list"
246 ));
247 }
248
249 #[test]
250 fn validate_namespace_path_rejects_unsafe_values() {
251 assert!(validate_namespace_path("ui/button").is_ok());
252 assert_eq!(
253 validate_namespace_path("").unwrap_err(),
254 "namespace must not be empty"
255 );
256 assert_eq!(
257 validate_namespace_path(" ui/button ").unwrap_err(),
258 "namespace must not have leading or trailing whitespace"
259 );
260 assert_eq!(
261 validate_namespace_path(r"ui\button").unwrap_err(),
262 "namespace must use '/' as path separator"
263 );
264 assert_eq!(
265 validate_namespace_path("ui//button").unwrap_err(),
266 "namespace path must not contain empty segments"
267 );
268 assert_eq!(
269 validate_namespace_path("../escape").unwrap_err(),
270 "namespace path must not contain '.' or '..' segments"
271 );
272 assert_eq!(
273 validate_namespace_path("/escape").unwrap_err(),
274 "namespace path must not contain empty segments"
275 );
276 assert_eq!(
277 validate_namespace_path("ui/button.ftl").unwrap_err(),
278 "namespace must not include file extension"
279 );
280 }
281
282 #[test]
283 fn namespace_rule_rejects_unsupported_meta_shapes() {
284 let unsupported_format: syn::Meta = parse_quote!(namespace);
285 assert!(NamespaceRule::from_meta(&unsupported_format).is_err());
286
287 let unknown_name_value_path: syn::Meta = parse_quote!(namespace = module);
288 assert!(NamespaceRule::from_meta(&unknown_name_value_path).is_err());
289
290 let unsupported_name_value_literal: syn::Meta = parse_quote!(namespace = 42);
291 assert!(NamespaceRule::from_meta(&unsupported_name_value_literal).is_err());
292
293 let unknown_list_path: syn::Meta = parse_quote!(namespace(module));
294 assert!(NamespaceRule::from_meta(&unknown_list_path).is_err());
295
296 let unsupported_list_literal: syn::Meta = parse_quote!(namespace(42));
297 assert!(NamespaceRule::from_meta(&unsupported_list_literal).is_err());
298 }
299
300 #[test]
301 fn relative_namespace_calls_require_supported_single_ident_arguments() {
302 let unknown_target: syn::Meta = parse_quote!(namespace(module(relative)));
303 assert!(NamespaceRule::from_meta(&unknown_target).is_err());
304
305 let unknown_argument: syn::Meta = parse_quote!(namespace(file(crate_root)));
306 assert!(NamespaceRule::from_meta(&unknown_argument).is_err());
307
308 let multiple_arguments: syn::Meta = parse_quote!(namespace(file(relative, extra)));
309 assert!(NamespaceRule::from_meta(&multiple_arguments).is_err());
310
311 let literal_argument: syn::Meta = parse_quote!(namespace(file("relative")));
312 assert!(NamespaceRule::from_meta(&literal_argument).is_err());
313 }
314}