1use crate::namespace_resolver::{
4 file_relative_namespace, file_stem_namespace, folder_namespace, folder_relative_namespace,
5};
6use darling::FromMeta;
7use std::{
8 borrow::Cow,
9 path::{Component, Path},
10};
11
12#[derive(Clone, Debug, Eq, Hash, PartialEq)]
14pub enum NamespaceRule {
15 Literal(Cow<'static, str>),
17 File,
19 FileRelative,
21 Folder,
23 FolderRelative,
25}
26
27impl NamespaceRule {
28 pub fn resolve(&self, file_path: &str, manifest_dir: Option<&Path>) -> String {
30 match self {
31 Self::Literal(value) => value.to_string(),
32 Self::File => file_stem_namespace(file_path),
33 Self::FileRelative => file_relative_namespace(file_path, manifest_dir),
34 Self::Folder => folder_namespace(file_path),
35 Self::FolderRelative => folder_relative_namespace(file_path, manifest_dir),
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 validate_namespace_path_rejects_unsafe_values() {
230 assert!(validate_namespace_path("ui/button").is_ok());
231 assert_eq!(
232 validate_namespace_path("").unwrap_err(),
233 "namespace must not be empty"
234 );
235 assert_eq!(
236 validate_namespace_path(" ui/button ").unwrap_err(),
237 "namespace must not have leading or trailing whitespace"
238 );
239 assert_eq!(
240 validate_namespace_path(r"ui\button").unwrap_err(),
241 "namespace must use '/' as path separator"
242 );
243 assert_eq!(
244 validate_namespace_path("ui//button").unwrap_err(),
245 "namespace path must not contain empty segments"
246 );
247 assert_eq!(
248 validate_namespace_path("../escape").unwrap_err(),
249 "namespace path must not contain '.' or '..' segments"
250 );
251 assert_eq!(
252 validate_namespace_path("/escape").unwrap_err(),
253 "namespace path must not contain empty segments"
254 );
255 assert_eq!(
256 validate_namespace_path("ui/button.ftl").unwrap_err(),
257 "namespace must not include file extension"
258 );
259 }
260}