playwright-ast-coverage 0.1.6

Static Playwright AST coverage for Next.js App Router routes
use anyhow::Result;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

const PAGE_STEMS: &[&str] = &["page"];
const PAGE_EXTS: &[&str] = &["tsx", "ts", "jsx", "js"];

pub struct Route {
    pub file: PathBuf,
    pub pattern: String,
}

pub fn collect_routes(frontend_root: &Path) -> Result<Vec<Route>> {
    if !frontend_root.exists() {
        return Ok(Vec::new());
    }

    let mut routes = Vec::new();
    for entry in WalkDir::new(frontend_root)
        .into_iter()
        .filter_map(|entry| entry.ok())
    {
        let path = entry.path();
        if !path.is_file() {
            continue;
        }

        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        if !PAGE_STEMS.contains(&stem) || !PAGE_EXTS.contains(&ext) {
            continue;
        }

        if let Ok(relative) = path.strip_prefix(frontend_root) {
            routes.push(Route {
                file: path.to_path_buf(),
                pattern: path_to_route_pattern(relative),
            });
        }
    }

    routes.sort_by(|a, b| a.pattern.cmp(&b.pattern).then_with(|| a.file.cmp(&b.file)));
    Ok(routes)
}

pub fn path_to_route_pattern(relative: &Path) -> String {
    let dir: PathBuf = relative
        .parent()
        .map(|path| path.to_path_buf())
        .unwrap_or_default();

    let mut segments = Vec::new();
    for component in dir.components() {
        let std::path::Component::Normal(segment) = component else {
            continue;
        };
        let segment = segment.to_str().unwrap_or("");

        if segment.starts_with('@') || (segment.starts_with('(') && segment.ends_with(')')) {
            continue;
        }

        if segment.starts_with("[[...") && segment.ends_with("]]") {
            segments.push("**".to_string());
            continue;
        }

        if segment.starts_with("[...") && segment.ends_with(']') {
            segments.push("*".to_string());
            continue;
        }

        if segment.starts_with('[') && segment.ends_with(']') {
            segments.push(format!(":{}", &segment[1..segment.len() - 1]));
            continue;
        }

        segments.push(segment.to_string());
    }

    if segments.is_empty() {
        "/".to_string()
    } else {
        format!("/{}", segments.join("/"))
    }
}

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

    #[test]
    fn root_page_maps_to_slash() {
        let p = Path::new("page.tsx");
        assert_eq!(path_to_route_pattern(p), "/");
    }

    #[test]
    fn route_group_is_skipped() {
        let p = Path::new("(user)/user/[idOrUsername]/page.tsx");
        assert_eq!(path_to_route_pattern(p), "/user/:idOrUsername");
    }

    #[test]
    fn slug_dynamic_segment() {
        let p = Path::new("communities/[slug]/settings/page.tsx");
        assert_eq!(path_to_route_pattern(p), "/communities/:slug/settings");
    }

    #[test]
    fn catch_all_maps_to_wildcard() {
        let p = Path::new("[...rest]/page.tsx");
        assert_eq!(path_to_route_pattern(p), "/*");
    }

    #[test]
    fn static_nested_path() {
        let p = Path::new("communities/page.tsx");
        assert_eq!(path_to_route_pattern(p), "/communities");
    }

    #[test]
    fn collect_frontend_routes_finds_pages() {
        let routes: Vec<String> = collect_routes(&fixture_path(&["routes", "collect"]))
            .unwrap()
            .into_iter()
            .map(|route| route.pattern)
            .collect();
        assert!(routes.contains(&"/communities".to_string()));
        assert!(routes.contains(&"/communities/:slug".to_string()));
        assert!(routes.contains(&"/user/:id".to_string()));
    }

    #[test]
    fn collect_frontend_routes_sorts_duplicate_patterns_by_file() {
        let routes = collect_routes(&fixture_path(&["routes", "sort-duplicates"])).unwrap();
        assert_eq!(routes[0].pattern, "/same");
        assert!(routes[0].file <= routes[1].file);
    }

    #[test]
    fn collect_frontend_routes_missing_root_returns_empty() {
        let routes = collect_routes(&fixture_path(&["routes", "missing"])).unwrap();
        assert!(routes.is_empty());
    }

    #[test]
    fn absolute_path_components_are_ignored() {
        let p = Path::new("/communities/page.tsx");
        assert_eq!(path_to_route_pattern(p), "/communities");
    }
}