nfw-core 0.1.1

Blazing fast fullstack framework powered by NestForge
Documentation
use std::path::{Path, PathBuf};

use crate::routing::{Route, RouteMethod, RouteSegment};

pub struct RouteScanner {
    base_path: PathBuf,
}

impl RouteScanner {
    pub fn new(base_path: impl Into<PathBuf>) -> Self {
        Self {
            base_path: base_path.into(),
        }
    }

    pub async fn scan(&self) -> anyhow::Result<Vec<Route>> {
        let mut routes = Vec::new();
        let base_path = Path::new(&self.base_path);

        if !base_path.exists() {
            tracing::info!(
                "App directory does not exist, creating: {}",
                self.base_path.display()
            );
            std::fs::create_dir_all(base_path)?;
        }

        self.scan_directory(base_path, &mut routes, "", &Vec::new())?;

        tracing::info!("Scanned {} routes", routes.len());
        for route in &routes {
            tracing::debug!("Found route: {}", route);
        }

        Ok(routes)
    }

    fn scan_directory(
        &self,
        path: &Path,
        routes: &mut Vec<Route>,
        prefix: &str,
        segments: &[RouteSegment],
    ) -> anyhow::Result<()> {
        if !path.is_dir() {
            return Ok(());
        }

        let mut entries: Vec<_> = std::fs::read_dir(path)?.collect::<Result<_, _>>()?;
        entries.sort_by_key(|a| a.file_name());

        let mut new_segments = segments.to_vec();

        for entry in entries {
            let file_path = entry.path();
            let file_name = entry.file_name().to_string_lossy().to_string();

            if file_path.is_dir() {
                if file_name == "api" {
                    self.scan_api_directory(&file_path, routes, prefix, &new_segments)?;
                } else if file_name.starts_with('_') || file_name.starts_with('.') {
                    continue;
                } else {
                    let segment = self.parse_segment(&file_name);
                    if let Some(seg) = &segment {
                        new_segments.push(seg.clone());
                    }

                    let new_prefix = if prefix.is_empty() {
                        format!("/{}", file_name)
                    } else {
                        format!("{}/{}", prefix, file_name)
                    };
                    self.scan_directory(&file_path, routes, &new_prefix, &new_segments)?;

                    if segment.is_some() {
                        new_segments.pop();
                    }
                }
            } else if let Some(ext) = file_path.extension() {
                if ext == "tsx" || ext == "ts" || ext == "jsx" || ext == "js" {
                    if let Some(stem) = file_path.file_stem() {
                        let stem_str = stem.to_string_lossy();

                        if stem_str == "page" {
                            let route_path = self.build_path(prefix, segments);
                            routes.push(Route {
                                path: route_path,
                                method: RouteMethod::Get,
                                file_path: file_path.to_string_lossy().to_string(),
                                handler_name: format!("{}Page", self.sanitize_name(prefix)),
                                segments: segments.to_vec(),
                            });
                        } else if stem_str == "layout" {
                        } else if stem_str == "route" {
                            self.parse_route_file(&file_path, routes, prefix, segments)?;
                        } else if stem_str == "loading"
                            || stem_str == "error"
                            || stem_str == "not-found"
                        {
                        }
                    }
                }
            }
        }

        Ok(())
    }

    fn scan_api_directory(
        &self,
        path: &Path,
        routes: &mut Vec<Route>,
        prefix: &str,
        segments: &[RouteSegment],
    ) -> anyhow::Result<()> {
        let api_prefix = if prefix.is_empty() {
            "api".to_string()
        } else {
            format!("{}/api", prefix)
        };

        if !path.is_dir() {
            return Ok(());
        }

        let mut entries: Vec<_> = std::fs::read_dir(path)?.collect::<Result<_, _>>()?;
        entries.sort_by_key(|a| a.file_name());

        let mut new_segments = segments.to_vec();

        for entry in entries {
            let file_path = entry.path();
            let file_name = entry.file_name().to_string_lossy().to_string();

            if file_path.is_dir() {
                let segment = self.parse_segment(&file_name);
                if let Some(seg) = &segment {
                    new_segments.push(seg.clone());
                }

                let new_prefix = format!("{}/{}", api_prefix, file_name);
                self.scan_api_directory(&file_path, routes, &new_prefix, &new_segments)?;

                if segment.is_some() {
                    new_segments.pop();
                }
            } else if let Some(ext) = file_path.extension() {
                if ext == "ts" || ext == "js" {
                    if let Some(stem) = file_path.file_stem() {
                        if stem == "route" {
                            self.parse_route_file(&file_path, routes, &api_prefix, segments)?;
                        }
                    }
                }
            }
        }

        Ok(())
    }

    fn parse_route_file(
        &self,
        path: &Path,
        routes: &mut Vec<Route>,
        prefix: &str,
        segments: &[RouteSegment],
    ) -> anyhow::Result<()> {
        let content = std::fs::read_to_string(path)?;
        let route_path = self.build_path(prefix, segments);

        let methods = [
            ("GET", RouteMethod::Get),
            ("POST", RouteMethod::Post),
            ("PUT", RouteMethod::Put),
            ("DELETE", RouteMethod::Delete),
            ("PATCH", RouteMethod::Patch),
            ("OPTIONS", RouteMethod::Options),
            ("HEAD", RouteMethod::Head),
        ];

        for (method_name, method) in methods {
            if content.contains(&format!("export async function {}", method_name))
                || content.contains(&format!("export function {}", method_name))
                || (content.contains("export")
                    && content.contains(method_name)
                    && (content.contains("Request") || content.contains("Response")))
            {
                routes.push(Route {
                    path: route_path.clone(),
                    method,
                    file_path: path.to_string_lossy().to_string(),
                    handler_name: format!("{}Handler", method_name.to_lowercase()),
                    segments: segments.to_vec(),
                });
            }
        }

        if routes.is_empty() && content.contains("export") {
            routes.push(Route {
                path: route_path,
                method: RouteMethod::Get,
                file_path: path.to_string_lossy().to_string(),
                handler_name: "routeHandler".to_string(),
                segments: segments.to_vec(),
            });
        }

        Ok(())
    }

    pub fn parse_segment(&self, name: &str) -> Option<RouteSegment> {
        if name.is_empty() {
            return None;
        }
        if name.starts_with('[') && name.ends_with(']') {
            if name.starts_with("[[") && name.ends_with("]]") {
                let inner = &name[2..name.len() - 2];
                if let Some(stripped) = inner.strip_prefix("...") {
                    Some(RouteSegment::OptionalCatchAll(stripped.to_string()))
                } else {
                    Some(RouteSegment::Dynamic(inner.to_string()))
                }
            } else if name.contains("...") {
                let inner = &name[1..name.len() - 1];
                if let Some(stripped) = inner.strip_prefix("...") {
                    Some(RouteSegment::CatchAll(stripped.to_string()))
                } else {
                    Some(RouteSegment::Dynamic(inner.to_string()))
                }
            } else {
                let inner = &name[1..name.len() - 1];
                Some(RouteSegment::Dynamic(inner.to_string()))
            }
        } else {
            Some(RouteSegment::Static(name.to_string()))
        }
    }

    pub fn build_path(&self, prefix: &str, segments: &[RouteSegment]) -> String {
        let mut path = prefix.to_string();

        for segment in segments {
            if !path.ends_with('/') {
                path.push('/');
            }
            match segment {
                RouteSegment::Static(s) => path.push_str(s),
                RouteSegment::Dynamic(s) => {
                    path.push('{');
                    path.push_str(s);
                    path.push('}');
                }
                RouteSegment::CatchAll(s) => {
                    path.push('{');
                    path.push_str(s);
                    path.push('}');
                }
                RouteSegment::OptionalCatchAll(s) => {
                    path.push('{');
                    path.push_str(s);
                    path.push('}');
                }
            }
        }

        if path.is_empty() {
            path.push('/');
        }

        path
    }

    pub fn sanitize_name(&self, path: &str) -> String {
        path.replace('/', "")
            .replace('-', "_")
            .replace(['[', ']'], "")
            .replace("...", "")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_dynamic_segment() {
        let scanner = RouteScanner::new(".");
        assert!(matches!(
            scanner.parse_segment("[id]"),
            Some(RouteSegment::Dynamic(_))
        ));
        assert!(matches!(
            scanner.parse_segment("[slug]"),
            Some(RouteSegment::Dynamic(_))
        ));
    }

    #[test]
    fn test_catch_all_segment() {
        let scanner = RouteScanner::new(".");
        assert!(matches!(
            scanner.parse_segment("[...slug]"),
            Some(RouteSegment::CatchAll(_))
        ));
    }

    #[test]
    fn test_optional_catch_all_segment() {
        let scanner = RouteScanner::new(".");
        assert!(matches!(
            scanner.parse_segment("[[...slug]]"),
            Some(RouteSegment::OptionalCatchAll(_))
        ));
    }

    #[test]
    fn test_build_path() {
        let scanner = RouteScanner::new(".");
        let segments = vec![
            RouteSegment::Static("users".to_string()),
            RouteSegment::Dynamic("id".to_string()),
        ];
        assert_eq!(scanner.build_path("", &segments), "/users/{id}");
    }
}