use rusqlite::Connection;
#[derive(Debug, Clone)]
pub struct QueryPattern {
pub source: String,
pub direction: QueryDirection,
pub target: String,
}
#[derive(Debug, Clone, Copy)]
pub enum QueryDirection {
Outgoing,
Incoming,
}
pub fn parse_query(input: &str) -> anyhow::Result<QueryPattern> {
let input = input.trim();
if input.contains("->") {
let parts: Vec<&str> = input.splitn(2, "->").collect();
if parts.len() != 2 {
anyhow::bail!("Invalid query pattern: expected 'SOURCE -> TARGET'");
}
Ok(QueryPattern {
source: parts[0].trim().to_string(),
direction: QueryDirection::Outgoing,
target: parts[1].trim().to_string(),
})
} else if input.contains("<-") {
let parts: Vec<&str> = input.splitn(2, "<-").collect();
if parts.len() != 2 {
anyhow::bail!("Invalid query pattern: expected 'TARGET <- SOURCE'");
}
Ok(QueryPattern {
source: parts[1].trim().to_string(),
direction: QueryDirection::Incoming,
target: parts[0].trim().to_string(),
})
} else if input.is_empty() {
anyhow::bail!("Empty query pattern");
} else {
Ok(QueryPattern {
source: input.to_string(),
direction: QueryDirection::Outgoing,
target: "*".to_string(),
})
}
}
pub fn execute_query(
pattern: &QueryPattern,
project_id: i64,
conn: &Connection,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let results = match pattern.direction {
QueryDirection::Outgoing => execute_outgoing(pattern, project_id, conn, limit)?,
QueryDirection::Incoming => execute_incoming(pattern, project_id, conn, limit)?,
};
Ok(serde_json::json!(results))
}
fn execute_outgoing(
pattern: &QueryPattern,
project_id: i64,
conn: &Connection,
limit: usize,
) -> anyhow::Result<Vec<serde_json::Value>> {
if pattern.source == "*" {
let mut stmt = conn.prepare(
"SELECT name, kind, file, line FROM symbols
WHERE project_id = ?1 AND kind IN ('function', 'procedure')
LIMIT ?2",
)?;
let rows = stmt.query_map(rusqlite::params![project_id, limit as i64], |row| {
let name: String = row.get(0)?;
let kind: String = row.get(1)?;
let file: String = row.get(2)?;
let line: i64 = row.get(3)?;
Ok(serde_json::json!({
"symbol": name,
"kind": kind,
"file": file,
"line": line,
}))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
} else if pattern.target == "*" {
let callees = crate::index::graph::find_callees(conn, project_id, &pattern.source, limit)?;
Ok(callees
.iter()
.map(|c| {
serde_json::json!({
"symbol": c.callee,
"kind": "",
"file": c.file,
"line": c.line,
})
})
.collect())
} else {
let callees = crate::index::graph::find_callees(conn, project_id, &pattern.source, limit)?;
Ok(callees
.iter()
.filter(|c| c.callee == pattern.target)
.map(|c| {
serde_json::json!({
"symbol": c.callee,
"kind": "",
"file": c.file,
"line": c.line,
})
})
.collect())
}
}
fn execute_incoming(
pattern: &QueryPattern,
project_id: i64,
conn: &Connection,
limit: usize,
) -> anyhow::Result<Vec<serde_json::Value>> {
if pattern.source == "*" {
let mut stmt = conn.prepare(
"SELECT DISTINCT callee AS name, '' AS kind, file, line FROM call_graph
WHERE project_id = ?1
LIMIT ?2",
)?;
let rows = stmt.query_map(rusqlite::params![project_id, limit as i64], |row| {
let name: String = row.get(0)?;
let kind: String = row.get(1)?;
let file: String = row.get(2)?;
let line: i64 = row.get(3)?;
Ok(serde_json::json!({
"symbol": name,
"kind": kind,
"file": file,
"line": line,
}))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
} else if pattern.target == "*" {
let callers = crate::index::graph::find_callers(conn, project_id, &pattern.source, limit)?;
Ok(callers
.iter()
.map(|c| {
serde_json::json!({
"symbol": c.caller,
"kind": "",
"file": c.file,
"line": c.line,
})
})
.collect())
} else {
let callers = crate::index::graph::find_callers(conn, project_id, &pattern.source, limit)?;
Ok(callers
.iter()
.filter(|c| c.caller == pattern.target)
.map(|c| {
serde_json::json!({
"symbol": c.caller,
"kind": "",
"file": c.file,
"line": c.line,
})
})
.collect())
}
}
pub fn execute_query_cli(
pattern_str: &str,
json_flag: bool,
limit: usize,
) -> anyhow::Result<String> {
let (conn, project_id, _root) =
crate::engine::index_bridge::IndexBridge::open_or_create_cwd()?.into_strict_parts()?;
let pattern = parse_query(pattern_str)?;
let results = execute_query(&pattern, project_id, &conn, limit)?;
if json_flag {
Ok(serde_json::to_string_pretty(&results)?)
} else {
format_results(&pattern, &results)
}
}
fn format_results(pattern: &QueryPattern, results: &serde_json::Value) -> anyhow::Result<String> {
let arr = results.as_array().cloned().unwrap_or_default();
if arr.is_empty() {
return Ok(format!(
"No results found for pattern: {}",
describe_pattern(pattern)
));
}
let mut lines = Vec::new();
lines.push(format!(
"Graph query: {} ({})",
describe_pattern(pattern),
arr.len()
));
lines.push("─────────────────────────────────────".to_string());
for item in &arr {
let symbol = item.get("symbol").and_then(|v| v.as_str()).unwrap_or("?");
let kind = item.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let file = item.get("file").and_then(|v| v.as_str()).unwrap_or("?");
let line = item.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
if !kind.is_empty() {
lines.push(format!(" {} {} {}:{}", kind, symbol, file, line));
} else {
lines.push(format!(" {} {}:{}", symbol, file, line));
}
}
Ok(lines.join("\n"))
}
fn describe_pattern(pattern: &QueryPattern) -> String {
match pattern.direction {
QueryDirection::Outgoing => {
if pattern.source == "*" {
"entry points (all top-level functions)".to_string()
} else if pattern.target == "*" {
format!("callees of {}", pattern.source)
} else {
format!("{} -> {}", pattern.source, pattern.target)
}
}
QueryDirection::Incoming => {
if pattern.source == "*" {
"called symbols (have callers)".to_string()
} else if pattern.target == "*" {
format!("callers of {}", pattern.source)
} else {
format!("{} <- {}", pattern.source, pattern.target)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_outgoing_wildcard() {
let p = parse_query("main -> *").unwrap();
assert_eq!(p.source, "main");
assert_eq!(p.target, "*");
assert!(matches!(p.direction, QueryDirection::Outgoing));
}
#[test]
fn parse_incoming_wildcard() {
let p = parse_query("* -> main").unwrap();
assert_eq!(p.source, "*");
assert_eq!(p.target, "main");
assert!(matches!(p.direction, QueryDirection::Outgoing));
}
#[test]
fn parse_incoming_arrow() {
let p = parse_query("main <- *").unwrap();
assert_eq!(p.source, "*");
assert_eq!(p.target, "main");
assert!(matches!(p.direction, QueryDirection::Incoming));
}
#[test]
fn parse_symbol_only() {
let p = parse_query("MyStruct").unwrap();
assert_eq!(p.source, "MyStruct");
assert_eq!(p.target, "*");
assert!(matches!(p.direction, QueryDirection::Outgoing));
}
#[test]
fn parse_specific_target() {
let p = parse_query("AuthService -> authenticate").unwrap();
assert_eq!(p.source, "AuthService");
assert_eq!(p.target, "authenticate");
}
#[test]
fn parse_empty_fails() {
assert!(parse_query("").is_err());
assert!(parse_query(" ").is_err());
}
}