rok-cli 0.6.1

Developer CLI for rok-based Axum applications
//! Shared route parser — scans Rust source files for `.route()` declarations
//! and `routes! { ... }` macro invocations. Used by both `rok routes:list`
//! and the TUI Route Inventory tab.

use std::{fs, path::Path};

/// A single parsed route entry.
#[derive(Debug, Clone)]
pub struct RouteEntry {
    pub method: String,
    pub path: String,
    pub handler: String,
    pub name: Option<String>,
    pub controller: Option<String>,
    pub middleware: Vec<String>,
}

/// Scan `src/routes/` and return all discovered routes.
pub fn scan_routes_dir() -> anyhow::Result<Vec<RouteEntry>> {
    let routes_dir = Path::new("src/routes");
    if !routes_dir.exists() {
        return Ok(Vec::new());
    }

    let mut all_routes: Vec<RouteEntry> = Vec::new();

    let mut files: Vec<_> = fs::read_dir(routes_dir)?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("rs"))
        .collect();
    files.sort_by_key(|e| e.file_name());

    for entry in &files {
        let path = entry.path();
        let src = fs::read_to_string(&path)?;
        let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
        let file_path = path.display().to_string();
        extract_routes_from_file(&src, file_stem, &file_path, &mut all_routes);
    }

    Ok(all_routes)
}

fn extract_routes_from_file(src: &str, file_stem: &str, _file_path: &str, out: &mut Vec<RouteEntry>) {
    let mut current_nest: Vec<String> = Vec::new();
    let mut current_middleware: Vec<String> = Vec::new();

    for line in src.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with("//") {
            continue;
        }

        if let Some(mw) = parse_layer(trimmed) {
            current_middleware.push(mw);
            continue;
        }

        if let Some(prefix) = parse_nest(trimmed) {
            current_nest.push(prefix);
            continue;
        }

        if trimmed.contains(".route(") {
            extract_route(trimmed, file_stem, &current_nest, &current_middleware, out);
            continue;
        }

        if trimmed.starts_with("routes!") {
            extract_routes_macro(trimmed, file_stem, &current_nest, &current_middleware, out);
            continue;
        }

        if trimmed.starts_with("#[controller") || trimmed.starts_with("#[middleware") {
            continue;
        }
    }
}

fn parse_layer(s: &str) -> Option<String> {
    let s = s.trim();
    if s.starts_with(".layer(") && s.ends_with(')') {
        let inner = &s[7..s.len() - 1];
        if let Some(name) = inner.split('(').next() {
            let name = name.trim();
            if !name.is_empty() && !inner.starts_with('|') {
                return Some(name.to_string());
            }
        }
    }
    None
}

fn parse_nest(s: &str) -> Option<String> {
    let s = s.trim();
    if s.starts_with(".nest(\"") {
        let rest = &s[6..];
        if let Some(end_quote) = rest[1..].find('\"') {
            let prefix = &rest[1..=end_quote];
            return Some(prefix.to_string());
        }
    }
    None
}

fn extract_route(
    line: &str,
    file_stem: &str,
    nest_stack: &[String],
    middleware: &[String],
    out: &mut Vec<RouteEntry>,
) {
    let trimmed = line.trim();
    let inner = trimmed
        .trim_start_matches(".route(")
        .trim_end_matches(')')
        .trim_end_matches(',');

    let mut parts = inner.splitn(2, ',');
    let path = parts
        .next()
        .unwrap_or("")
        .trim()
        .trim_matches('"')
        .to_string();
    let handlers_str = parts.next().unwrap_or("").trim().to_string();

    let full_path = build_full_path(nest_stack, &path);
    let controller = determine_controller(nest_stack, file_stem);

    for (method, handler, name) in parse_method_handlers(&handlers_str) {
        out.push(RouteEntry {
            method,
            path: full_path.clone(),
            handler,
            name,
            controller: Some(controller.clone()),
            middleware: middleware.to_vec(),
        });
    }
}

fn extract_routes_macro(
    line: &str,
    file_stem: &str,
    nest_stack: &[String],
    middleware: &[String],
    out: &mut Vec<RouteEntry>,
) {
    let body_start = match line.find('{') {
        Some(i) => i + 1,
        None => return,
    };
    let body_end = match line.rfind('}') {
        Some(i) => i,
        None => return,
    };
    if body_start >= body_end {
        return;
    }

    let body = &line[body_start..body_end];

    for entry in split_top_level(body) {
        let entry = entry.trim();
        if entry.is_empty() {
            continue;
        }

        if let Some(rest) = entry.strip_prefix("resource ") {
            let (resource_path, resource_handler) = parse_resource_statement(rest);
            let ctrl = resource_handler
                .trim()
                .trim_end_matches("Controller")
                .to_lowercase();
            let base_path = build_full_path(nest_stack, &resource_path);
            for (method, action, subpath) in resource_methods(&resource_path) {
                let full_path = format!("{}{}", base_path, subpath);
                out.push(RouteEntry {
                    method: method.to_string(),
                    path: full_path,
                    handler: format!("{}::{}", resource_handler, action),
                    name: Some(format!("{}.{}", ctrl, action)),
                    controller: Some(determine_controller(nest_stack, file_stem)),
                    middleware: middleware.to_vec(),
                });
            }
            continue;
        }

        if entry.starts_with("group ") {
            continue;
        }

        if let Some(method) = entry.split_whitespace().next() {
            if let Some(m) = match_method(method) {
                let after_method = entry.trim_start_matches(method).trim();
                let path_str = after_method.trim_matches('"');
                let end_quote = match path_str.find('"') {
                    Some(i) => i,
                    None => continue,
                };
                let path_val = &path_str[..end_quote];
                let rest = after_method[end_quote + 1..].trim();

                let rest = rest.strip_prefix("=> ").unwrap_or(rest).trim();

                let (handler, name) = if let Some(as_idx) = rest.rfind(" as ") {
                    let handler = rest[..as_idx].trim();
                    let name_part = rest[as_idx + 4..].trim().trim_matches('"');
                    (handler.to_string(), Some(name_part.to_string()))
                } else {
                    (rest.to_string(), None)
                };

                let full_path = build_full_path(nest_stack, path_val);
                out.push(RouteEntry {
                    method: m.to_string(),
                    path: full_path,
                    handler,
                    name,
                    controller: Some(determine_controller(nest_stack, file_stem)),
                    middleware: middleware.to_vec(),
                });
            }
        }
    }
}

fn match_method(s: &str) -> Option<&'static str> {
    match s {
        "GET" => Some("GET"),
        "POST" => Some("POST"),
        "PUT" => Some("PUT"),
        "PATCH" => Some("PATCH"),
        "DELETE" => Some("DELETE"),
        "HEAD" => Some("HEAD"),
        "OPTIONS" => Some("OPTIONS"),
        _ => None,
    }
}

fn parse_resource_statement(s: &str) -> (String, String) {
    let s = s.trim();
    let path_end = match s.find('"') {
        Some(i) => i,
        None => return (String::new(), s.to_string()),
    };
    let path = s[..=path_end].trim_matches('"').to_string();
    let rest = s[path_end + 1..].trim();
    let rest = rest.strip_prefix("=> ").unwrap_or(rest).trim();
    let handler = rest.split_whitespace().next().unwrap_or(rest).to_string();
    (path, handler)
}

fn resource_methods(_path: &str) -> Vec<(&'static str, &'static str, &'static str)> {
    vec![
        ("GET", "index", ""),
        ("GET", "create", "/create"),
        ("POST", "store", ""),
        ("GET", "show", "/:id"),
        ("GET", "edit", "/:id/edit"),
        ("PUT", "update", "/:id"),
        ("DELETE", "destroy", "/:id"),
    ]
}

fn build_full_path(nest: &[String], route_path: &str) -> String {
    let prefix = nest.join("/");
    let route_path = route_path.trim_start_matches('/');
    if prefix.is_empty() {
        format!("/{}", route_path)
    } else if route_path.is_empty() {
        format!("/{}", prefix)
    } else {
        format!("/{}/{}", prefix, route_path)
    }
}

fn determine_controller(nest: &[String], file_stem: &str) -> String {
    if let Some(last) = nest.last() {
        last.trim_start_matches('/').to_string()
    } else {
        file_stem.to_string()
    }
}

fn parse_method_handlers(s: &str) -> Vec<(String, String, Option<String>)> {
    let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
    let mut result = Vec::new();

    let mut remaining = s.trim().to_string();
    for method in &methods {
        let prefix = format!("{method}(");
        while let Some(pos) = remaining.find(&prefix) {
            let after = &remaining[pos + prefix.len()..];
            let end = match after.find(')') {
                Some(e) => e,
                None => break,
            };
            let handler = after[..end].trim().to_string();
            result.push((method.to_uppercase(), handler, None));
            remaining = remaining[pos + prefix.len() + end + 1..].to_string();
        }
    }

    if result.is_empty() {
        result.push(("*".to_string(), s.trim().to_string(), None));
    }

    result
}

fn split_top_level(s: &str) -> Vec<String> {
    let mut parts = Vec::new();
    let mut depth = 0usize;
    let mut start = 0usize;

    for (i, ch) in s.char_indices() {
        match ch {
            '{' | '[' | '(' => depth += 1,
            '}' | ']' | ')' => depth = depth.saturating_sub(1),
            ',' if depth == 0 => {
                parts.push(s[start..i].to_string());
                start = i + 1;
            }
            _ => {}
        }
    }

    if start < s.len() {
        let last = s[start..].trim();
        if !last.is_empty() {
            parts.push(last.to_string());
        }
    }

    parts
}