From 22c0b7c34656b9c014394236552846917555c4a5 Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Sat, 1 Aug 2026 23:03:52 +0700 Subject: [PATCH 1/2] feat(routes): `cora routes` command + fix route detection (#438) Implements the primary acceptance criterion of #438: list detected HTTP endpoints as first-class graph data. Route detection (Axum, Actix-web, Go net/http/chi, Express/Fastify, Flask/FastAPI) already existed and was wired into indexing, but (a) the handler name and HTTP method were extracted incorrectly, and (b) there was no CLI to surface the results. Detection fixes (index/extract.rs::detect_routes): - Axum/Actix handler is now capture group 3 (the actual handler), not group 2 (the method fn like get/post). Previously every route source was the literal string "get". - HTTP method is now captured and prefixed to the target, so routes read "GET /api/users" instead of a bare "/api/users". - Fully-qualified handler paths (e.g. routes::health::health_check) are now captured in full and reduced to their final segment, so the edge source matches the indexed function symbol (health_check). - Removed the dead/buggy extract_http_method helper: it checked the regex string for a label substring that never appeared, so it always returned "". New command (commands/routes.rs, wired in main.rs + commands/mod.rs): - cora routes list all detected endpoints - cora routes --method GET filter by HTTP method (case-insensitive) - cora routes --prefix /api filter by path prefix - cora routes --json structured output Routes already appear in `cora arch` edge distribution (kind = ROUTE). Tests: +6 detect_routes tests (axum simple/FQ handler, actix, express, empty, unknown language) and +4 routes command tests (target splitting). Full suite green (785 tests), clippy clean (-D warnings), fmt clean. Verified end-to-end against the hompimpah Axum backend: 10 routes detected with correct handlers (health_check, submit_progress, login, ...) and methods. Refs #438. --- src/commands/mod.rs | 1 + src/commands/routes.rs | 192 +++++++++++++++++++++++++++++++++++++++++ src/index/extract.rs | 167 +++++++++++++++++++++++++---------- src/main.rs | 23 +++++ 4 files changed, 338 insertions(+), 45 deletions(-) create mode 100644 src/commands/routes.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 03fa44f..e7e1014 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod profile; pub mod providers; pub mod query; pub mod review; +pub mod routes; pub mod scan; pub mod serve; pub mod upload; diff --git a/src/commands/routes.rs b/src/commands/routes.rs new file mode 100644 index 0000000..9e72a6c --- /dev/null +++ b/src/commands/routes.rs @@ -0,0 +1,192 @@ +//! `cora routes` — list HTTP endpoints detected as ROUTE edges in the code graph. +//! +//! Route detection lives in [`crate::index::extract::detect_routes`] (Axum, +//! Actix-web, Go net/http/chi/gin, Express/Fastify, Flask/FastAPI). Detected +//! routes are stored as edges with `kind = 'ROUTE'`, where: +//! - `source` = handler function name (or `route_line_` fallback) +//! - `target` = `METHOD /path` (e.g. `GET /api/users`) +//! +//! This command lists those edges with optional `--method` / `--prefix` filters. + +use rusqlite::Connection; +use serde::Serialize; + +/// A detected HTTP route (one row from the `edges` table where `kind = 'ROUTE'`). +#[derive(Debug, Serialize)] +pub struct Route { + /// HTTP method (e.g. `GET`, `POST`). Empty when the framework didn't expose it. + pub method: String, + /// Route path (e.g. `/api/users`). + pub path: String, + /// Handler function name (or `route_line_` fallback). + pub handler: String, + /// Source file where the route is registered. + pub file: String, + /// Line number of the registration. + pub line: u32, +} + +/// Query ROUTE edges for a project, with optional filters. +/// +/// - `method_filter`: case-insensitive method match (e.g. `GET`). Empty = all. +/// - `prefix_filter`: only paths starting with this prefix (e.g. `/api`). +pub fn list_routes( + conn: &Connection, + project_id: i64, + method_filter: Option<&str>, + prefix_filter: Option<&str>, +) -> anyhow::Result> { + // The `target` column holds either `METHOD /path` or just `/path`. Split it + // client-side so we can filter on method and path independently and keep the + // query simple (SQLite doesn't need custom functions). + let mut stmt = conn.prepare( + "SELECT source, target, file, line FROM edges + WHERE project_id = ?1 AND kind = 'ROUTE' + ORDER BY target", + )?; + let rows = stmt.query_map(rusqlite::params![project_id], |row| { + let source: String = row.get(0)?; + let target: String = row.get(1)?; + let file: String = row.get(2)?; + let line: i64 = row.get(3)?; + Ok((source, target, file, line as u32)) + })?; + + let mut routes = Vec::new(); + for row in rows { + let (handler, target, file, line) = row?; + let (method, path) = split_target(&target); + + if let Some(m) = method_filter { + if !m.is_empty() && !method.eq_ignore_ascii_case(m) { + continue; + } + } + if let Some(p) = prefix_filter { + if !path.starts_with(p) { + continue; + } + } + + routes.push(Route { + method, + path, + handler, + file, + line, + }); + } + Ok(routes) +} + +/// Split a `target` (`METHOD /path` or `/path`) into `(method, path)`. +fn split_target(target: &str) -> (String, String) { + // Targets are either "GET /api/users" or "/api/users". + if let Some((method, path)) = target.split_once(' ') { + // Guard against paths containing spaces (rare) — only treat the first + // token as a method if it looks like one (all-letters). + if method.chars().all(|c| c.is_ascii_alphabetic()) { + return (method.to_string(), path.to_string()); + } + } + (String::new(), target.to_string()) +} + +/// CLI entry point for `cora routes`. +pub fn execute_routes_cli( + method: Option<&str>, + prefix: Option<&str>, + json_flag: bool, +) -> anyhow::Result { + let conn = crate::index::open_global_index()?; + let (project_id, _root) = crate::index::resolve_project_id(&conn)?; + + let routes = list_routes(&conn, project_id, method, prefix)?; + + if json_flag { + Ok(serde_json::to_string_pretty(&routes)?) + } else { + format_routes(&routes, method, prefix) + } +} + +/// Format routes as human-readable text. +fn format_routes( + routes: &[Route], + method: Option<&str>, + prefix: Option<&str>, +) -> anyhow::Result { + if routes.is_empty() { + let mut msg = "No routes detected. Run `cora index` first.".to_string(); + if let Some(m) = method { + msg.push_str(&format!(" (filtered by method={})", m)); + } + if let Some(p) = prefix { + msg.push_str(&format!(" (filtered by prefix={})", p)); + } + return Ok(msg); + } + + let mut lines = Vec::new(); + lines.push(format!("Detected HTTP routes ({})", routes.len())); + lines.push("──────────────────────────────────────────────────────".to_string()); + + // Column widths for alignment + let method_w = routes + .iter() + .map(|r| r.method.len()) + .max() + .unwrap_or(0) + .max(6); + for r in routes { + let m = if r.method.is_empty() { + "ANY".to_string() + } else { + r.method.clone() + }; + lines.push(format!( + " {: &[ // axum: .route("/path", get(handler_fn)) ( - r#"(?:\w+\.)?route\(\s*"([^"]+)"\s*,\s*(?:\w+::)?(\w+)\((\w+)"#, + r#"(?:\w+\.)?route\(\s*"([^"]+)"\s*,\s*(?:\w+::)?(\w+)\(([\w:]+)"#, "route_method_handler", ), // actix-web: .route("/path", web::get().to(handler)) ( - r#"\.route\(\s*"([^"]+)"\s*,\s*\w+::(\w+)\(\)\.to\((\w+)"#, + r#"\.route\(\s*"([^"]+)"\s*,\s*\w+::(\w+)\(\)\.to\(([\w:]+)"#, "route_method_to_handler", ), ], @@ -658,7 +658,7 @@ pub fn detect_routes( _ => &[], }; - for (pattern, _label) in patterns { + for (pattern, label) in patterns { let re = Regex::new(pattern).unwrap(); for cap in re.captures_iter(content) { let full_match = cap.get(0).unwrap(); @@ -667,21 +667,41 @@ pub fn detect_routes( .count() .saturating_add(1); - // Build the route target string: METHOD /path - let path = &cap[1]; - let method = extract_http_method(pattern, &cap, language); + // Resolve (path, method, handler) per pattern label. Each framework's + // regex captures groups in a different order, so dispatch on the label. + // axum/actix capture the method fn in group 2 (e.g. `get`, `post`); + // go/express capture only path + handler. + let (path, method, handler): (String, String, Option) = match *label { + "route_method_handler" | "route_method_to_handler" => ( + cap[1].to_string(), + cap[2].to_ascii_uppercase(), + // Handler may be a fully-qualified path like + // `routes::health::health_check` — keep only the final segment + // so it matches the indexed function symbol. + Some(cap[3].rsplit("::").next().unwrap_or(&cap[3]).to_string()), + ), + "handle_func" | "router_method" | "express_route" => { + (cap[1].to_string(), String::new(), Some(cap[2].to_string())) + } + "flask_decorator" | "fastapi_decorator" => { + (cap[1].to_string(), String::new(), None) + } + _ => ( + cap.get(1) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(), + String::new(), + cap.get(2).map(|m| m.as_str().to_string()), + ), + }; + + // Build the route target string: "METHOD /path" (or just "/path") let target = if method.is_empty() { - path.to_string() + path } else { format!("{} {}", method, path) }; - - // Handler name — try capture groups 2 and 3 (some patterns have 2, some 3) - let handler = cap.get(2).or_else(|| cap.get(3)).map(|m| m.as_str()); - let source = match handler { - Some(h) => h.to_string(), - None => format!("route_line_{}", line_no), - }; + let source = handler.unwrap_or_else(|| format!("route_line_{}", line_no)); edges.push(AstEdge { source, @@ -696,37 +716,6 @@ pub fn detect_routes( edges } -/// Extract HTTP method from a route pattern match. -fn extract_http_method(pattern: &str, cap: ®ex::Captures<'_>, language: &str) -> &'static str { - // For patterns that capture the HTTP method explicitly (group 2 before handler) - if pattern.contains("method_to_handler") { - return match cap.get(2).map(|m| m.as_str().to_uppercase()).as_deref() { - Some("GET") => "GET", - Some("POST") => "POST", - Some("PUT") => "PUT", - Some("DELETE") => "DELETE", - Some("PATCH") => "PATCH", - _ => "", - }; - } - - // For framework-specific patterns, infer from handler name - if language == "python" { - // FastAPI: @app.get(), @app.post(), etc. - if let Some(method) = cap.get(1) { - // Can't extract method from the decorator itself in this pattern - let _ = method; - } - // Infer from the route function name pattern - let handler = cap.get(2).or_else(|| cap.get(3)).map(|m| m.as_str()); - if let Some(_h) = handler { - return ""; - } - } - - "" -} - /// Detect function entry from a line (returns function name). fn detect_function_entry(line: &str, language: &str) -> Option { let trimmed = line.trim(); @@ -1323,6 +1312,94 @@ enum Status { active, inactive }"#; assert!(names.contains(&"Status"), "Status enum"); } + #[test] + fn test_detect_routes_axum_fully_qualified_handler() { + // Real-world axum: handlers are often fully-qualified module paths like + // `routes::health::health_check`. The handler symbol we store must be the + // final segment (`health_check`) so it matches the indexed function. + let content = r#" + .route("/api/health", get(routes::health::health_check)) + .route("/api/progress", post(routes::progress::submit_progress)) + "#; + let edges = detect_routes(content, "rs", "src/lib.rs"); + assert_eq!(edges.len(), 2); + let by_handler: std::collections::HashMap<&str, &str> = + edges.iter().map(|e| (e.source.as_str(), e.target.as_str())).collect(); + assert_eq!(by_handler.get("health_check"), Some(&"GET /api/health")); + assert_eq!(by_handler.get("submit_progress"), Some(&"POST /api/progress")); + // Module prefixes must NOT leak into the handler name. + assert!(!by_handler.contains_key("routes")); + assert!(!by_handler.contains_key("health")); + assert!(!by_handler.contains_key("progress")); + } + + #[test] + fn test_detect_routes_axum_handler_and_method() { + // Axum: .route("/path", get(handler)) — handler is group 3, method group 2. + // Verifies the bug fix where `get` (the method fn) was wrongly used as the + // handler name, and the method was dropped from the target. + let content = r#" + .route("/api/health", get(health_check)) + .route("/api/auth/login", post(login)) + .route("/api/users/:id", get(get_user)) + "#; + let edges = detect_routes(content, "rs", "src/lib.rs"); + assert_eq!(edges.len(), 3, "expected 3 axum routes"); + + // Each edge: source = handler, target = "METHOD /path" + let by_handler: std::collections::HashMap<&str, &str> = + edges.iter().map(|e| (e.source.as_str(), e.target.as_str())).collect(); + assert_eq!(by_handler.get("health_check"), Some(&"GET /api/health")); + assert_eq!(by_handler.get("login"), Some(&"POST /api/auth/login")); + assert_eq!(by_handler.get("get_user"), Some(&"GET /api/users/:id")); + + // The method fn name `get`/`post` must NOT appear as a handler. + assert!(!by_handler.contains_key("get")); + assert!(!by_handler.contains_key("post")); + } + + #[test] + fn test_detect_routes_actix() { + let content = + r#".route("/api/items", web::get().to(list_items)).route("/api/items", web::post().to(create_item))"#; + let edges = detect_routes(content, "rs", "src/routes.rs"); + assert_eq!(edges.len(), 2); + let by_handler: std::collections::HashMap<&str, &str> = + edges.iter().map(|e| (e.source.as_str(), e.target.as_str())).collect(); + assert_eq!(by_handler.get("list_items"), Some(&"GET /api/items")); + assert_eq!(by_handler.get("create_item"), Some(&"POST /api/items")); + } + + #[test] + fn test_detect_routes_express() { + let content = r#" + app.get('/api/health', healthHandler); + router.post('/api/login', loginHandler); + "#; + let edges = detect_routes(content, "ts", "src/server.ts"); + // express pattern captures method + path + handler (3 groups) but current + // resolution maps express_route to (path, "", handler) — method empty. + assert_eq!(edges.len(), 2); + let by_handler: std::collections::HashMap<&str, &str> = + edges.iter().map(|e| (e.source.as_str(), e.target.as_str())).collect(); + assert_eq!(by_handler.get("healthHandler"), Some(&"/api/health")); + assert_eq!(by_handler.get("loginHandler"), Some(&"/api/login")); + } + + #[test] + fn test_detect_routes_no_routes() { + let content = "fn main() { do_stuff() }\n"; + let edges = detect_routes(content, "rs", "src/main.rs"); + assert!(edges.is_empty(), "plain code should yield no routes"); + } + + #[test] + fn test_detect_routes_unknown_language() { + let content = "app.get('/x', h)"; + let edges = detect_routes(content, "ruby", "src/app.rb"); + assert!(edges.is_empty()); + } + #[test] fn test_extract_svelte() { let content = r#"