1use darling::FromMeta;
3use std::{
4 borrow::Cow,
5 path::{Component, Path},
6};
7
8use crate::resource::{ResourceKey, ResourceKeyError};
9
10#[derive(Clone, Copy, Debug, Eq, thiserror::Error, PartialEq)]
12pub enum NamespacePathError {
13 #[error("namespace must not be empty")]
15 Empty,
16 #[error("namespace must not have leading or trailing whitespace")]
18 OuterWhitespace,
19 #[error("namespace must use '/' as path separator")]
21 BackslashSeparator,
22 #[error("namespace path must not contain empty segments")]
24 EmptySegment,
25 #[error("namespace path must not contain '.' or '..' segments")]
27 CurrentOrParentSegment,
28 #[error("namespace must be a relative path")]
30 AbsolutePath,
31 #[error("namespace must not include file extension")]
33 FileExtension,
34}
35
36impl NamespacePathError {
37 pub fn details(self) -> &'static str {
39 match self {
40 Self::Empty => "namespace must not be empty",
41 Self::OuterWhitespace => "namespace must not have leading or trailing whitespace",
42 Self::BackslashSeparator => "namespace must use '/' as path separator",
43 Self::EmptySegment => "namespace path must not contain empty segments",
44 Self::CurrentOrParentSegment => "namespace path must not contain '.' or '..' segments",
45 Self::AbsolutePath => "namespace must be a relative path",
46 Self::FileExtension => "namespace must not include file extension",
47 }
48 }
49}
50
51#[derive(
53 Clone, Debug, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
54)]
55#[as_ref(str)]
56pub struct ResolvedNamespace(Cow<'static, str>);
57
58impl ResolvedNamespace {
59 pub fn new(namespace: impl Into<String>) -> Result<Self, NamespacePathError> {
61 let namespace = namespace.into();
62 validate_namespace_path_typed(&namespace)?;
63 Ok(Self(Cow::Owned(namespace)))
64 }
65
66 pub(crate) const fn from_static_unchecked(namespace: &'static str) -> Self {
68 Self(Cow::Borrowed(namespace))
69 }
70
71 pub fn as_str(&self) -> &str {
73 self.0.as_ref()
74 }
75
76 pub fn try_resource_key(&self, domain: &str) -> Result<ResourceKey, ResourceKeyError> {
78 ResourceKey::try_new(format!("{domain}/{}", self.as_str()))
79 }
80}
81
82impl PartialEq<&str> for ResolvedNamespace {
83 fn eq(&self, other: &&str) -> bool {
84 self.as_str() == *other
85 }
86}
87
88impl PartialEq<str> for ResolvedNamespace {
89 fn eq(&self, other: &str) -> bool {
90 self.as_str() == other
91 }
92}
93
94impl PartialEq<ResolvedNamespace> for &str {
95 fn eq(&self, other: &ResolvedNamespace) -> bool {
96 *self == other.as_str()
97 }
98}
99
100#[derive(Clone, Debug, Eq, Hash, PartialEq)]
102pub enum NamespaceRule {
103 Literal(ResolvedNamespace),
105 File,
107 FileRelative,
109 Folder,
111 FolderRelative,
113}
114
115impl NamespaceRule {
116 pub fn literal(namespace: impl Into<String>) -> Result<Self, NamespacePathError> {
118 ResolvedNamespace::new(namespace).map(Self::Literal)
119 }
120
121 pub fn resolve(&self, file_path: &str, manifest_dir: Option<&Path>) -> String {
123 match self {
124 Self::Literal(value) => value.to_string(),
125 Self::File => crate::namespace_resolver::file_stem_namespace(file_path),
126 Self::FileRelative => {
127 crate::namespace_resolver::file_relative_namespace(file_path, manifest_dir)
128 },
129 Self::Folder => crate::namespace_resolver::folder_namespace(file_path),
130 Self::FolderRelative => {
131 crate::namespace_resolver::folder_relative_namespace(file_path, manifest_dir)
132 },
133 }
134 }
135
136 pub fn try_resolve(
138 &self,
139 file_path: &str,
140 manifest_dir: Option<&Path>,
141 ) -> Result<ResolvedNamespace, NamespacePathError> {
142 ResolvedNamespace::new(self.resolve(file_path, manifest_dir))
143 }
144}
145
146pub fn validate_namespace_path(namespace: &str) -> Result<(), &'static str> {
148 validate_namespace_path_typed(namespace).map_err(NamespacePathError::details)
149}
150
151pub fn validate_namespace_path_typed(namespace: &str) -> Result<(), NamespacePathError> {
153 let trimmed = namespace.trim();
154 if trimmed.is_empty() {
155 return Err(NamespacePathError::Empty);
156 }
157 if namespace != trimmed {
158 return Err(NamespacePathError::OuterWhitespace);
159 }
160 if trimmed.contains('\\') {
161 return Err(NamespacePathError::BackslashSeparator);
162 }
163 if trimmed.split('/').any(|segment| segment.is_empty()) {
164 return Err(NamespacePathError::EmptySegment);
165 }
166 if trimmed
167 .split('/')
168 .any(|segment| matches!(segment, "." | ".."))
169 {
170 return Err(NamespacePathError::CurrentOrParentSegment);
171 }
172 if Path::new(trimmed)
173 .components()
174 .any(|component| matches!(component, Component::RootDir | Component::Prefix(_)))
175 {
176 return Err(NamespacePathError::AbsolutePath);
177 }
178 if trimmed.ends_with(".ftl") {
179 return Err(NamespacePathError::FileExtension);
180 }
181
182 Ok(())
183}
184
185impl FromMeta for NamespaceRule {
186 fn from_meta(item: &syn::Meta) -> darling::Result<Self> {
187 match item {
188 syn::Meta::NameValue(nv) => {
189 if let syn::Expr::Lit(syn::ExprLit {
190 lit: syn::Lit::Str(s),
191 ..
192 }) = &nv.value
193 {
194 ResolvedNamespace::new(s.value())
195 .map(Self::Literal)
196 .map_err(|error| darling::Error::custom(error.to_string()).with_span(s))
197 } else if let syn::Expr::Path(path) = &nv.value {
198 parse_namespace_ident(path)
199 } else {
200 Err(darling::Error::unexpected_type(
201 "expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative'",
202 ))
203 }
204 },
205 syn::Meta::List(_) => Err(darling::Error::unsupported_format(
206 "expected namespace = \"value\", namespace = file, namespace = file_relative, namespace = folder, or namespace = folder_relative",
207 )),
208 _ => Err(darling::Error::unsupported_format(
209 "expected namespace = \"value\", namespace = file, namespace = file_relative, namespace = folder, or namespace = folder_relative",
210 )),
211 }
212 }
213}
214
215fn parse_namespace_ident(path: &syn::ExprPath) -> darling::Result<NamespaceRule> {
216 let Some(ident) = path.path.get_ident() else {
217 return Err(expected_namespace_value_error());
218 };
219
220 match ident.to_string().as_str() {
221 "file" => Ok(NamespaceRule::File),
222 "file_relative" => Ok(NamespaceRule::FileRelative),
223 "folder" => Ok(NamespaceRule::Folder),
224 "folder_relative" => Ok(NamespaceRule::FolderRelative),
225 _ => Err(darling::Error::custom(
226 "expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative' identifier",
227 )),
228 }
229}
230
231fn expected_namespace_value_error() -> darling::Error {
232 darling::Error::custom(
233 "expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative' identifier",
234 )
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use syn::parse_quote;
241
242 #[test]
243 fn literal_namespace_parses_and_resolves() {
244 let meta: syn::Meta = parse_quote!(namespace = "my_namespace");
245 let ns = NamespaceRule::from_meta(&meta).unwrap();
246 assert!(matches!(ns, NamespaceRule::Literal(ref s) if s == "my_namespace"));
247 assert_eq!(ns.resolve("/some/path/lib.rs", None), "my_namespace");
248 }
249
250 #[test]
251 fn literal_namespace_constructor_accepts_static_str() {
252 let ns = NamespaceRule::literal("ui").expect("valid namespace");
253 assert_eq!(ns.resolve("/some/path/lib.rs", None), "ui");
254 }
255
256 #[test]
257 fn file_and_folder_variants_parse() {
258 let file_meta: syn::Meta = parse_quote!(namespace = file);
259 assert!(matches!(
260 NamespaceRule::from_meta(&file_meta).unwrap(),
261 NamespaceRule::File
262 ));
263
264 let file_relative_meta: syn::Meta = parse_quote!(namespace = file_relative);
265 assert!(matches!(
266 NamespaceRule::from_meta(&file_relative_meta).unwrap(),
267 NamespaceRule::FileRelative
268 ));
269
270 let folder_meta: syn::Meta = parse_quote!(namespace = folder);
271 assert!(matches!(
272 NamespaceRule::from_meta(&folder_meta).unwrap(),
273 NamespaceRule::Folder
274 ));
275
276 let folder_relative_meta: syn::Meta = parse_quote!(namespace = folder_relative);
277 assert!(matches!(
278 NamespaceRule::from_meta(&folder_relative_meta).unwrap(),
279 NamespaceRule::FolderRelative
280 ));
281 }
282
283 #[test]
284 fn namespace_rule_resolves_relative_variants() {
285 assert_eq!(
286 NamespaceRule::FileRelative.resolve("src/ui/button.rs", None),
287 "ui/button"
288 );
289 assert_eq!(
290 NamespaceRule::FolderRelative.resolve("src/ui/button.rs", None),
291 "ui"
292 );
293 }
294
295 #[test]
296 fn resolved_namespace_builds_resource_keys() {
297 let namespace = ResolvedNamespace::new("ui/button").unwrap();
298
299 assert_eq!(namespace.as_str(), "ui/button");
300 assert_eq!(
301 namespace.try_resource_key("demo").unwrap().as_str(),
302 "demo/ui/button"
303 );
304 assert_eq!(namespace.to_string(), "ui/button");
305 }
306
307 #[test]
308 fn namespace_rule_try_resolve_validates_output() {
309 let ns = NamespaceRule::FileRelative
310 .try_resolve("src/ui/button.rs", None)
311 .unwrap();
312 assert_eq!(ns.as_str(), "ui/button");
313
314 let err = NamespaceRule::literal("../escape").unwrap_err();
315 assert_eq!(err, NamespacePathError::CurrentOrParentSegment);
316 assert_eq!(
317 err.details(),
318 "namespace path must not contain '.' or '..' segments"
319 );
320 }
321
322 #[test]
323 fn relative_namespace_resolution_normalizes_parent_segments() {
324 assert_eq!(
325 NamespaceRule::FileRelative.resolve("src/ui/../button.rs", None),
326 "button"
327 );
328 assert_eq!(
329 NamespaceRule::FolderRelative.resolve("src/ui/../forms/button.rs", None),
330 "forms"
331 );
332 }
333
334 #[test]
335 fn validate_namespace_path_rejects_unsafe_values() {
336 assert!(validate_namespace_path("ui/button").is_ok());
337 assert_eq!(
338 validate_namespace_path_typed("../escape").unwrap_err(),
339 NamespacePathError::CurrentOrParentSegment
340 );
341 assert_eq!(
342 validate_namespace_path("").unwrap_err(),
343 "namespace must not be empty"
344 );
345 assert_eq!(
346 validate_namespace_path(" ui/button ").unwrap_err(),
347 "namespace must not have leading or trailing whitespace"
348 );
349 assert_eq!(
350 validate_namespace_path(r"ui\button").unwrap_err(),
351 "namespace must use '/' as path separator"
352 );
353 assert_eq!(
354 validate_namespace_path("ui//button").unwrap_err(),
355 "namespace path must not contain empty segments"
356 );
357 assert_eq!(
358 validate_namespace_path("../escape").unwrap_err(),
359 "namespace path must not contain '.' or '..' segments"
360 );
361 assert_eq!(
362 validate_namespace_path("/escape").unwrap_err(),
363 "namespace path must not contain empty segments"
364 );
365 assert_eq!(
366 validate_namespace_path("ui/button.ftl").unwrap_err(),
367 "namespace must not include file extension"
368 );
369 }
370
371 #[test]
372 fn namespace_rule_rejects_unsupported_meta_shapes() {
373 let unsupported_format: syn::Meta = parse_quote!(namespace);
374 assert!(NamespaceRule::from_meta(&unsupported_format).is_err());
375
376 let unknown_name_value_path: syn::Meta = parse_quote!(namespace = module);
377 assert!(NamespaceRule::from_meta(&unknown_name_value_path).is_err());
378
379 let unsupported_name_value_literal: syn::Meta = parse_quote!(namespace = 42);
380 assert!(NamespaceRule::from_meta(&unsupported_name_value_literal).is_err());
381
382 let unknown_target: syn::Meta = parse_quote!(namespace(module(relative)));
383 assert!(NamespaceRule::from_meta(&unknown_target).is_err());
384
385 let list_folder: syn::Meta = parse_quote!(namespace(folder));
386 assert!(NamespaceRule::from_meta(&list_folder).is_err());
387
388 let list_literal: syn::Meta = parse_quote!(namespace("ui/list"));
389 assert!(NamespaceRule::from_meta(&list_literal).is_err());
390
391 let unsupported_name_value_call: syn::Meta = parse_quote!(namespace = file(relative));
392 assert!(NamespaceRule::from_meta(&unsupported_name_value_call).is_err());
393
394 let multiple_arguments: syn::Meta = parse_quote!(namespace(file(relative, extra)));
395 assert!(NamespaceRule::from_meta(&multiple_arguments).is_err());
396 }
397}