1use std::path::{Path, PathBuf};
2
3use crate::routing::{Route, RouteMethod, RouteSegment};
4
5pub struct RouteScanner {
6 base_path: PathBuf,
7}
8
9impl RouteScanner {
10 pub fn new(base_path: impl Into<PathBuf>) -> Self {
11 Self {
12 base_path: base_path.into(),
13 }
14 }
15
16 pub async fn scan(&self) -> anyhow::Result<Vec<Route>> {
17 let mut routes = Vec::new();
18 let base_path = Path::new(&self.base_path);
19
20 if !base_path.exists() {
21 tracing::info!(
22 "App directory does not exist, creating: {}",
23 self.base_path.display()
24 );
25 std::fs::create_dir_all(base_path)?;
26 }
27
28 self.scan_directory(base_path, &mut routes, "", &Vec::new())?;
29
30 tracing::info!("Scanned {} routes", routes.len());
31 for route in &routes {
32 tracing::debug!("Found route: {}", route);
33 }
34
35 Ok(routes)
36 }
37
38 fn scan_directory(
39 &self,
40 path: &Path,
41 routes: &mut Vec<Route>,
42 prefix: &str,
43 segments: &[RouteSegment],
44 ) -> anyhow::Result<()> {
45 if !path.is_dir() {
46 return Ok(());
47 }
48
49 let mut entries: Vec<_> = std::fs::read_dir(path)?.collect::<Result<_, _>>()?;
50 entries.sort_by_key(|a| a.file_name());
51
52 let mut new_segments = segments.to_vec();
53
54 for entry in entries {
55 let file_path = entry.path();
56 let file_name = entry.file_name().to_string_lossy().to_string();
57
58 if file_path.is_dir() {
59 if file_name == "api" {
60 self.scan_api_directory(&file_path, routes, prefix, &new_segments)?;
61 } else if file_name.starts_with('_') || file_name.starts_with('.') {
62 continue;
63 } else {
64 let segment = self.parse_segment(&file_name);
65 if let Some(seg) = &segment {
66 new_segments.push(seg.clone());
67 }
68
69 let new_prefix = if prefix.is_empty() {
70 format!("/{}", file_name)
71 } else {
72 format!("{}/{}", prefix, file_name)
73 };
74 self.scan_directory(&file_path, routes, &new_prefix, &new_segments)?;
75
76 if segment.is_some() {
77 new_segments.pop();
78 }
79 }
80 } else if let Some(ext) = file_path.extension() {
81 if ext == "tsx" || ext == "ts" || ext == "jsx" || ext == "js" {
82 if let Some(stem) = file_path.file_stem() {
83 let stem_str = stem.to_string_lossy();
84
85 if stem_str == "page" {
86 let route_path = self.build_path(prefix, segments);
87 routes.push(Route {
88 path: route_path,
89 method: RouteMethod::Get,
90 file_path: file_path.to_string_lossy().to_string(),
91 handler_name: format!("{}Page", self.sanitize_name(prefix)),
92 segments: segments.to_vec(),
93 });
94 } else if stem_str == "layout" {
95 } else if stem_str == "route" {
96 self.parse_route_file(&file_path, routes, prefix, segments)?;
97 } else if stem_str == "loading"
98 || stem_str == "error"
99 || stem_str == "not-found"
100 {
101 }
102 }
103 }
104 }
105 }
106
107 Ok(())
108 }
109
110 fn scan_api_directory(
111 &self,
112 path: &Path,
113 routes: &mut Vec<Route>,
114 prefix: &str,
115 segments: &[RouteSegment],
116 ) -> anyhow::Result<()> {
117 let api_prefix = if prefix.is_empty() {
118 "api".to_string()
119 } else {
120 format!("{}/api", prefix)
121 };
122
123 if !path.is_dir() {
124 return Ok(());
125 }
126
127 let mut entries: Vec<_> = std::fs::read_dir(path)?.collect::<Result<_, _>>()?;
128 entries.sort_by_key(|a| a.file_name());
129
130 let mut new_segments = segments.to_vec();
131
132 for entry in entries {
133 let file_path = entry.path();
134 let file_name = entry.file_name().to_string_lossy().to_string();
135
136 if file_path.is_dir() {
137 let segment = self.parse_segment(&file_name);
138 if let Some(seg) = &segment {
139 new_segments.push(seg.clone());
140 }
141
142 let new_prefix = format!("{}/{}", api_prefix, file_name);
143 self.scan_api_directory(&file_path, routes, &new_prefix, &new_segments)?;
144
145 if segment.is_some() {
146 new_segments.pop();
147 }
148 } else if let Some(ext) = file_path.extension() {
149 if ext == "ts" || ext == "js" {
150 if let Some(stem) = file_path.file_stem() {
151 if stem == "route" {
152 self.parse_route_file(&file_path, routes, &api_prefix, segments)?;
153 }
154 }
155 }
156 }
157 }
158
159 Ok(())
160 }
161
162 fn parse_route_file(
163 &self,
164 path: &Path,
165 routes: &mut Vec<Route>,
166 prefix: &str,
167 segments: &[RouteSegment],
168 ) -> anyhow::Result<()> {
169 let content = std::fs::read_to_string(path)?;
170 let route_path = self.build_path(prefix, segments);
171
172 let methods = [
173 ("GET", RouteMethod::Get),
174 ("POST", RouteMethod::Post),
175 ("PUT", RouteMethod::Put),
176 ("DELETE", RouteMethod::Delete),
177 ("PATCH", RouteMethod::Patch),
178 ("OPTIONS", RouteMethod::Options),
179 ("HEAD", RouteMethod::Head),
180 ];
181
182 for (method_name, method) in methods {
183 if content.contains(&format!("export async function {}", method_name))
184 || content.contains(&format!("export function {}", method_name))
185 || (content.contains("export")
186 && content.contains(method_name)
187 && (content.contains("Request") || content.contains("Response")))
188 {
189 routes.push(Route {
190 path: route_path.clone(),
191 method,
192 file_path: path.to_string_lossy().to_string(),
193 handler_name: format!("{}Handler", method_name.to_lowercase()),
194 segments: segments.to_vec(),
195 });
196 }
197 }
198
199 if routes.is_empty() && content.contains("export") {
200 routes.push(Route {
201 path: route_path,
202 method: RouteMethod::Get,
203 file_path: path.to_string_lossy().to_string(),
204 handler_name: "routeHandler".to_string(),
205 segments: segments.to_vec(),
206 });
207 }
208
209 Ok(())
210 }
211
212 pub fn parse_segment(&self, name: &str) -> Option<RouteSegment> {
213 if name.is_empty() {
214 return None;
215 }
216 if name.starts_with('[') && name.ends_with(']') {
217 if name.starts_with("[[") && name.ends_with("]]") {
218 let inner = &name[2..name.len() - 2];
219 if let Some(stripped) = inner.strip_prefix("...") {
220 Some(RouteSegment::OptionalCatchAll(stripped.to_string()))
221 } else {
222 Some(RouteSegment::Dynamic(inner.to_string()))
223 }
224 } else if name.contains("...") {
225 let inner = &name[1..name.len() - 1];
226 if let Some(stripped) = inner.strip_prefix("...") {
227 Some(RouteSegment::CatchAll(stripped.to_string()))
228 } else {
229 Some(RouteSegment::Dynamic(inner.to_string()))
230 }
231 } else {
232 let inner = &name[1..name.len() - 1];
233 Some(RouteSegment::Dynamic(inner.to_string()))
234 }
235 } else {
236 Some(RouteSegment::Static(name.to_string()))
237 }
238 }
239
240 pub fn build_path(&self, prefix: &str, segments: &[RouteSegment]) -> String {
241 let mut path = prefix.to_string();
242
243 for segment in segments {
244 if !path.ends_with('/') {
245 path.push('/');
246 }
247 match segment {
248 RouteSegment::Static(s) => path.push_str(s),
249 RouteSegment::Dynamic(s) => {
250 path.push('{');
251 path.push_str(s);
252 path.push('}');
253 }
254 RouteSegment::CatchAll(s) => {
255 path.push('{');
256 path.push_str(s);
257 path.push('}');
258 }
259 RouteSegment::OptionalCatchAll(s) => {
260 path.push('{');
261 path.push_str(s);
262 path.push('}');
263 }
264 }
265 }
266
267 if path.is_empty() {
268 path.push('/');
269 }
270
271 path
272 }
273
274 pub fn sanitize_name(&self, path: &str) -> String {
275 path.replace('/', "")
276 .replace('-', "_")
277 .replace(['[', ']'], "")
278 .replace("...", "")
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_dynamic_segment() {
288 let scanner = RouteScanner::new(".");
289 assert!(matches!(
290 scanner.parse_segment("[id]"),
291 Some(RouteSegment::Dynamic(_))
292 ));
293 assert!(matches!(
294 scanner.parse_segment("[slug]"),
295 Some(RouteSegment::Dynamic(_))
296 ));
297 }
298
299 #[test]
300 fn test_catch_all_segment() {
301 let scanner = RouteScanner::new(".");
302 assert!(matches!(
303 scanner.parse_segment("[...slug]"),
304 Some(RouteSegment::CatchAll(_))
305 ));
306 }
307
308 #[test]
309 fn test_optional_catch_all_segment() {
310 let scanner = RouteScanner::new(".");
311 assert!(matches!(
312 scanner.parse_segment("[[...slug]]"),
313 Some(RouteSegment::OptionalCatchAll(_))
314 ));
315 }
316
317 #[test]
318 fn test_build_path() {
319 let scanner = RouteScanner::new(".");
320 let segments = vec![
321 RouteSegment::Static("users".to_string()),
322 RouteSegment::Dynamic("id".to_string()),
323 ];
324 assert_eq!(scanner.build_path("", &segments), "/users/{id}");
325 }
326}