use std::{fs, path::Path};
#[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>,
}
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, ¤t_nest, ¤t_middleware, out);
continue;
}
if trimmed.starts_with("routes!") {
extract_routes_macro(trimmed, file_stem, ¤t_nest, ¤t_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
}