From 6a86d3f053afd6d6f40187a1cf4de4c868c10d95 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 22 Jul 2026 22:21:28 +0700 Subject: [PATCH] feat(cli): add cora trace and cora arch subcommands - cora trace : BFS path tracing with --direction (outgoing/incoming) and --depth control. Uses edges table (typed) with call_graph fallback. - cora arch: Architecture overview showing module symbol counts, edge type distribution. - 3 new graph tests (trace outgoing/incoming, arch overview). - 708 tests pass, 0 clippy warnings. --- src/index/graph.rs | 371 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 131 ++++++++++++++++ 2 files changed, 502 insertions(+) diff --git a/src/index/graph.rs b/src/index/graph.rs index 35f7e27..edf23c6 100644 --- a/src/index/graph.rs +++ b/src/index/graph.rs @@ -249,6 +249,296 @@ pub struct ImpactNode { pub depth: u32, } +/// Trace the execution path from a symbol — BFS over call_graph + edges. +/// +/// Returns a tree-like structure showing outgoing call chains. +pub fn trace_path( + conn: &Connection, + project_id: i64, + symbol_name: &str, + depth: u32, + direction: TraceDirection, +) -> anyhow::Result> { + let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + let mut result = Vec::new(); + let mut current_level = vec![(symbol_name.to_string(), 0u32)]; + + visited.insert(symbol_name.to_string()); + + while !current_level.is_empty() { + let mut next_level = Vec::new(); + + for (sym, d) in ¤t_level { + if *d >= depth { + continue; + } + + let neighbors = match direction { + TraceDirection::Outgoing => find_callees_edges(conn, project_id, sym, 100)?, + TraceDirection::Incoming => find_callers_edges(conn, project_id, sym, 100)?, + }; + + for neighbor in neighbors { + let name = match direction { + TraceDirection::Outgoing => neighbor.target, + TraceDirection::Incoming => neighbor.source, + }; + + let is_new = visited.insert(name.clone()); + let node = TraceNode { + symbol: name.clone(), + file: neighbor.file, + line: neighbor.line, + kind: neighbor.kind, + depth: d + 1, + }; + + if is_new { + next_level.push((name.clone(), d + 1)); + } + result.push(node); + } + } + + current_level = next_level; + } + + result.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.file.cmp(&b.file))); + Ok(result) +} + +/// Trace direction: follow outgoing calls or incoming callers. +#[derive(Debug, Clone, Copy)] +pub enum TraceDirection { + Outgoing, + Incoming, +} + +/// A trace node with edge kind info. +#[derive(Debug, Clone, serde::Serialize)] +pub struct TraceNode { + pub symbol: String, + pub file: String, + pub line: u32, + pub kind: String, + pub depth: u32, +} + +/// Find outgoing edges from a symbol (uses edges table, falls back to call_graph). +fn find_callees_edges( + conn: &Connection, + project_id: i64, + symbol_name: &str, + limit: usize, +) -> anyhow::Result> { + let pattern = format!("%{symbol_name}%"); + + // Try edges table first (has typed relationships) + let mut stmt = conn.prepare( + "SELECT source, kind, target, file, line + FROM edges + WHERE source LIKE ?1 AND project_id = ?2 + LIMIT ?3", + )?; + + let rows: Vec = stmt + .query_map( + rusqlite::params![pattern, project_id, limit as i64], + |row| { + Ok(EdgeRow { + source: row.get(0)?, + kind: row.get(1)?, + target: row.get(2)?, + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + }) + }, + )? + .filter_map(|r| r.ok()) + .collect(); + + if !rows.is_empty() { + return Ok(rows); + } + + // Fallback to call_graph + let mut stmt = conn.prepare( + "SELECT caller, 'CALLS', callee, file, line + FROM call_graph + WHERE caller LIKE ?1 AND project_id = ?2 + LIMIT ?3", + )?; + + let rows: Vec = stmt + .query_map( + rusqlite::params![pattern, project_id, limit as i64], + |row| { + Ok(EdgeRow { + source: row.get(0)?, + kind: row.get(1)?, + target: row.get(2)?, + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + }) + }, + )? + .filter_map(|r| r.ok()) + .collect(); + + Ok(rows) +} + +/// Find incoming edges to a symbol (uses edges table, falls back to call_graph). +fn find_callers_edges( + conn: &Connection, + project_id: i64, + symbol_name: &str, + limit: usize, +) -> anyhow::Result> { + let pattern = format!("%{symbol_name}%"); + + let mut stmt = conn.prepare( + "SELECT source, kind, target, file, line + FROM edges + WHERE target LIKE ?1 AND project_id = ?2 + LIMIT ?3", + )?; + + let rows: Vec = stmt + .query_map( + rusqlite::params![pattern, project_id, limit as i64], + |row| { + Ok(EdgeRow { + source: row.get(0)?, + kind: row.get(1)?, + target: row.get(2)?, + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + }) + }, + )? + .filter_map(|r| r.ok()) + .collect(); + + if !rows.is_empty() { + return Ok(rows); + } + + let mut stmt = conn.prepare( + "SELECT caller, 'CALLS', callee, file, line + FROM call_graph + WHERE callee LIKE ?1 AND project_id = ?2 + LIMIT ?3", + )?; + + let rows: Vec = stmt + .query_map( + rusqlite::params![pattern, project_id, limit as i64], + |row| { + Ok(EdgeRow { + source: row.get(0)?, + kind: row.get(1)?, + target: row.get(2)?, + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + }) + }, + )? + .filter_map(|r| r.ok()) + .collect(); + + Ok(rows) +} + +/// Raw edge row from the database. +struct EdgeRow { + source: String, + kind: String, + target: String, + file: String, + line: u32, +} + +/// Architecture overview: module statistics and edge density. +pub fn arch_overview(conn: &Connection, project_id: i64) -> anyhow::Result { + // Module = directory component of file path (e.g., "src/index" from "src/index/mod.rs") + let mut stmt = conn.prepare( + "SELECT + CASE + WHEN instr(file, '/') > 0 THEN substr(file, 1, instr(file, '/') - 1) + ELSE file + END AS module, + COUNT(*) AS symbol_count, + COUNT(DISTINCT kind) AS edge_types + FROM symbols + WHERE project_id = ?1 + GROUP BY module + ORDER BY symbol_count DESC", + )?; + + let modules: Vec = stmt + .query_map(rusqlite::params![project_id], |row| { + Ok(ModuleInfo { + name: row.get(0)?, + symbol_count: row.get::<_, i64>(1)? as usize, + edge_types: row.get::<_, i64>(2)? as usize, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + // Edge type distribution + let edge_counts = get_edge_kind_counts(conn, project_id); + + Ok(ArchOverview { + modules, + edge_counts, + }) +} + +/// Count edges by kind. +fn get_edge_kind_counts(conn: &Connection, project_id: i64) -> Vec<(String, i64)> { + let mut counts = Vec::new(); + + if let Ok(mut s) = conn.prepare( + "SELECT kind, COUNT(*) FROM edges WHERE project_id = ?1 GROUP BY kind ORDER BY COUNT(*) DESC", + ) { + if let Ok(rows) = s.query_map(rusqlite::params![project_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) { + counts.extend(rows.flatten()); + } + } + + if counts.is_empty() { + if let Ok(mut s) = + conn.prepare("SELECT 'CALLS', COUNT(*) FROM call_graph WHERE project_id = ?1") + { + if let Ok(rows) = s.query_map(rusqlite::params![project_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) { + counts.extend(rows.flatten()); + } + } + } + + counts +} + +/// Architecture overview result. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ArchOverview { + pub modules: Vec, + pub edge_counts: Vec<(String, i64)>, +} + +/// Module-level statistics. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ModuleInfo { + pub name: String, + pub symbol_count: usize, + pub edge_types: usize, +} #[cfg(test)] mod tests { use super::*; @@ -366,4 +656,85 @@ mod tests { assert!(impact.iter().any(|n| n.symbol == "b" && n.depth == 1)); assert!(impact.iter().any(|n| n.symbol == "a" && n.depth == 2)); } + + #[test] + fn test_trace_path_outgoing() { + let conn = mem_conn(); + let pid = test_project(&conn); + + // a→b→c, a→d + for (caller, callee) in [("a", "b"), ("b", "c"), ("a", "d")] { + store_edges( + &conn, + &[CallEdge { + caller: caller.into(), + callee: callee.into(), + file: "x.rs".into(), + line: 1, + }], + pid, + ) + .unwrap(); + } + + let nodes = trace_path(&conn, pid, "a", 2, TraceDirection::Outgoing).unwrap(); + let syms: Vec<&str> = nodes.iter().map(|n| n.symbol.as_str()).collect(); + assert!(syms.contains(&"b")); + assert!(syms.contains(&"c")); + assert!(syms.contains(&"d")); + } + + #[test] + fn test_trace_path_incoming() { + let conn = mem_conn(); + let pid = test_project(&conn); + + for (caller, callee) in [("a", "c"), ("b", "c")] { + store_edges( + &conn, + &[CallEdge { + caller: caller.into(), + callee: callee.into(), + file: "y.rs".into(), + line: 1, + }], + pid, + ) + .unwrap(); + } + + let nodes = trace_path(&conn, pid, "c", 1, TraceDirection::Incoming).unwrap(); + let syms: Vec<&str> = nodes.iter().map(|n| n.symbol.as_str()).collect(); + assert!(syms.contains(&"a")); + assert!(syms.contains(&"b")); + } + + #[test] + fn test_arch_overview() { + let conn = mem_conn(); + let pid = test_project(&conn); + + store_edges( + &conn, + &[CallEdge { + caller: "main".into(), + callee: "run".into(), + file: "src/main.rs".into(), + line: 1, + }], + pid, + ) + .unwrap(); + + // Insert a symbol so arch has data + conn.execute( + "INSERT INTO symbols (name, kind, file, line, project_id) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params!["main", "function", "src/main.rs", 1i64, pid], + ) + .unwrap(); + + let overview = arch_overview(&conn, pid).unwrap(); + assert_eq!(overview.modules.len(), 1); + assert_eq!(overview.modules[0].name, "src"); + } } diff --git a/src/main.rs b/src/main.rs index 5592609..15df50c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -162,6 +162,31 @@ enum Command { json: bool, }, + /// Trace execution paths through the codebase + Trace { + /// Symbol name to start tracing from + symbol: String, + + /// Trace direction: outgoing (callees) or incoming (callers) + #[clap(long, value_parser = ["outgoing", "incoming"], default_value = "outgoing")] + direction: String, + + /// Traversal depth (max hops) + #[clap(long, default_value = "3")] + depth: u32, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + + /// Show architecture overview (modules, edge types, structure) + Arch { + /// Output as JSON + #[clap(long)] + json: bool, + }, + /// Find tests affected by changed files Affected { /// Changed files (space-separated). If empty, reads from git diff. @@ -799,6 +824,112 @@ async fn main() -> Result<()> { 0 } + Command::Trace { + symbol, + direction, + depth, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = crate::data_dir::graph_db_path(); + if !db_path.exists() { + eprintln!("{}", "No index found. Run `cora index` first.".yellow()); + std::process::exit(1); + } + let conn = index::open_global_index()?; + let project_id = index::ensure_project(&conn, &project_root)?; + + let dir = match direction.as_str() { + "incoming" => index::graph::TraceDirection::Incoming, + _ => index::graph::TraceDirection::Outgoing, + }; + + let nodes = index::graph::trace_path(&conn, project_id, &symbol, depth, dir)?; + + if json { + println!("{}", serde_json::to_string_pretty(&nodes)?); + } else if nodes.is_empty() { + eprintln!("{}", format!("No trace results for '{symbol}'.").yellow()); + } else { + let dir_label = match dir { + index::graph::TraceDirection::Outgoing => "calls", + index::graph::TraceDirection::Incoming => "called by", + }; + println!( + "{}", + format!( + "Trace from '{}' ({}, {} nodes):", + symbol, + dir_label, + nodes.len() + ) + .cyan() + ); + println!("{}", "─".repeat(60).dimmed()); + let mut prev_depth = 0u32; + for node in &nodes { + if node.depth != prev_depth { + prev_depth = node.depth; + println!(" {}", format!("depth {}", node.depth).blue().bold()); + } + let indent = " ".repeat(node.depth as usize + 1); + let kind_tag = format!("[{}]", node.kind).dimmed(); + println!( + " {}{} {} {}:{}", + indent, + node.symbol.white().bold(), + kind_tag, + node.file.dimmed(), + node.line + ); + } + } + 0 + } + + Command::Arch { json } => { + let project_root = std::env::current_dir()?; + let db_path = crate::data_dir::graph_db_path(); + if !db_path.exists() { + eprintln!("{}", "No index found. Run `cora index` first.".yellow()); + std::process::exit(1); + } + let conn = index::open_global_index()?; + let project_id = index::ensure_project(&conn, &project_root)?; + + let overview = index::graph::arch_overview(&conn, project_id)?; + + if json { + println!("{}", serde_json::to_string_pretty(&overview)?); + } else { + println!("{}", "ARCHITECTURE OVERVIEW".cyan().bold()); + println!("{}", "─".repeat(50).dimmed()); + println!(); + println!( + " {} {} {}", + "MODULE".bold(), + "SYMBOLS".bold(), + "EDGE TYPES".bold() + ); + println!(" {}", "─".repeat(45).dimmed()); + for m in &overview.modules { + println!( + " {:<30} {:>6} {:>10}", + m.name, m.symbol_count, m.edge_types + ); + } + println!(); + if !overview.edge_counts.is_empty() { + println!(" {}", "EDGE DISTRIBUTION".cyan().bold()); + println!(" {}", "─".repeat(45).dimmed()); + for (kind, count) in &overview.edge_counts { + println!(" {:<20} {:>6}", kind, count); + } + } + } + 0 + } + Command::Affected { files, stdin,