Skip to main content

nfw_core/routing/
scanner.rs

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}