use std::collections::{HashMap, HashSet};
use crate::db::Database;
use crate::errors::{Result, TokenSaveError};
use crate::types::*;
#[derive(Debug, Clone)]
pub struct NodeMetrics {
pub incoming_edge_count: usize,
pub outgoing_edge_count: usize,
pub call_count: usize,
pub caller_count: usize,
pub child_count: usize,
pub depth: usize,
}
pub struct GraphQueryManager<'a> {
db: &'a Database,
}
fn row_to_node_dead_code(row: &libsql::Row) -> std::result::Result<Node, libsql::Error> {
let kind_str = get_string_lossy(row, 1)?;
let vis_str = get_string_lossy(row, 11)?;
let is_async_int = row.get::<i64>(12)?;
Ok(Node {
id: get_string_lossy(row, 0)?,
kind: NodeKind::from_str(&kind_str).unwrap_or(NodeKind::Function),
name: get_string_lossy(row, 2)?,
qualified_name: get_string_lossy(row, 3)?,
file_path: get_string_lossy(row, 4)?,
start_line: row.get::<u32>(5)?,
end_line: row.get::<u32>(6)?,
start_column: row.get::<u32>(7)?,
end_column: row.get::<u32>(8)?,
signature: get_opt_string_lossy(row, 10)?,
docstring: get_opt_string_lossy(row, 9)?,
visibility: Visibility::from_str(&vis_str).unwrap_or_default(),
is_async: is_async_int != 0,
branches: row.get::<u32>(13)?,
loops: row.get::<u32>(14)?,
returns: row.get::<u32>(15)?,
max_nesting: row.get::<u32>(16)?,
unsafe_blocks: row.get::<u32>(17)?,
unchecked_calls: row.get::<u32>(18)?,
assertions: row.get::<u32>(19)?,
updated_at: row.get::<u64>(20)?,
})
}
fn get_string_lossy(row: &libsql::Row, idx: i32) -> std::result::Result<String, libsql::Error> {
let val = row.get::<libsql::Value>(idx)?;
match val {
libsql::Value::Text(s) => Ok(s),
libsql::Value::Blob(bytes) => Ok(String::from_utf8_lossy(&bytes).into_owned()),
libsql::Value::Null => Ok(String::new()),
libsql::Value::Integer(i) => Ok(i.to_string()),
libsql::Value::Real(f) => Ok(f.to_string()),
}
}
fn get_opt_string_lossy(
row: &libsql::Row,
idx: i32,
) -> std::result::Result<Option<String>, libsql::Error> {
let val = row.get::<libsql::Value>(idx)?;
match val {
libsql::Value::Text(s) => Ok(Some(s)),
libsql::Value::Blob(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
libsql::Value::Null => Ok(None),
libsql::Value::Integer(i) => Ok(Some(i.to_string())),
libsql::Value::Real(f) => Ok(Some(f.to_string())),
}
}
impl<'a> GraphQueryManager<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
pub async fn find_dead_code(&self, kinds: &[NodeKind]) -> Result<Vec<Node>> {
let kind_filter = if kinds.is_empty() {
String::new()
} else {
let kind_strs: Vec<String> =
kinds.iter().map(|k| format!("'{}'", k.as_str())).collect();
format!(" AND kind IN ({})", kind_strs.join(", "))
};
let sql = format!(
"SELECT id, kind, name, qualified_name, file_path, start_line, end_line,
start_column, end_column, docstring, signature, visibility,
is_async, branches, loops, returns, max_nesting, unsafe_blocks,
unchecked_calls, assertions, updated_at
FROM nodes
WHERE name != 'main'
AND name NOT LIKE 'test%'
AND visibility != 'public'
{kind_filter}
AND NOT EXISTS (SELECT 1 FROM edges WHERE target = nodes.id)"
);
let mut rows =
self.db
.conn()
.query(&sql, ())
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to find dead code: {e}"),
operation: "find_dead_code".to_string(),
})?;
let mut dead = Vec::new();
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("failed to read row: {e}"),
operation: "find_dead_code".to_string(),
})? {
let node = row_to_node_dead_code(&row)?;
dead.push(node);
}
Ok(dead)
}
pub async fn get_node_metrics(&self, node_id: &str) -> Result<NodeMetrics> {
let incoming = self.db.get_incoming_edges(node_id, &[]).await?;
let outgoing = self.db.get_outgoing_edges(node_id, &[]).await?;
let caller_count = incoming
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.count();
let call_count = outgoing
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.count();
let child_count = outgoing
.iter()
.filter(|e| e.kind == EdgeKind::Contains)
.count();
let depth = self.compute_depth(node_id).await?;
Ok(NodeMetrics {
incoming_edge_count: incoming.len(),
outgoing_edge_count: outgoing.len(),
call_count,
caller_count,
child_count,
depth,
})
}
pub async fn get_file_dependencies(&self, file_path: &str) -> Result<Vec<String>> {
let nodes = self.db.get_nodes_by_file(file_path).await?;
if nodes.is_empty() {
return Ok(Vec::new());
}
let node_ids: Vec<String> = nodes.iter().map(|n| n.id.clone()).collect();
let placeholders: Vec<String> = (1..=node_ids.len()).map(|i| format!("?{i}")).collect();
let kind_filter = "('uses', 'calls')";
let sql = format!(
"SELECT DISTINCT e.target FROM edges e \
WHERE e.source IN ({}) AND e.kind IN {kind_filter}",
placeholders.join(", ")
);
let param_values: Vec<libsql::Value> = node_ids
.iter()
.map(|id| libsql::Value::Text(id.clone()))
.collect();
let mut rows = self
.db
.conn()
.query(&sql, libsql::params_from_iter(param_values))
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to query file dependencies: {e}"),
operation: "get_file_dependencies".to_string(),
})?;
let mut target_ids: Vec<String> = Vec::new();
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("failed to read target id: {e}"),
operation: "get_file_dependencies".to_string(),
})? {
if let Ok(id) = row.get::<String>(0) {
target_ids.push(id);
}
}
if target_ids.is_empty() {
return Ok(Vec::new());
}
let target_nodes = self.db.get_nodes_by_ids(&target_ids).await?;
let dep_files: HashSet<String> = target_nodes
.into_iter()
.filter(|n| n.file_path != file_path)
.map(|n| n.file_path)
.collect();
let mut result: Vec<String> = dep_files.into_iter().collect();
result.sort();
Ok(result)
}
pub async fn get_file_dependents(&self, file_path: &str) -> Result<Vec<String>> {
let nodes = self.db.get_nodes_by_file(file_path).await?;
if nodes.is_empty() {
return Ok(Vec::new());
}
let node_ids: Vec<String> = nodes.iter().map(|n| n.id.clone()).collect();
let placeholders: Vec<String> = (1..=node_ids.len()).map(|i| format!("?{i}")).collect();
let kind_filter = "('uses', 'calls')";
let sql = format!(
"SELECT DISTINCT e.source FROM edges e \
WHERE e.target IN ({}) AND e.kind IN {kind_filter}",
placeholders.join(", ")
);
let param_values: Vec<libsql::Value> = node_ids
.iter()
.map(|id| libsql::Value::Text(id.clone()))
.collect();
let mut rows = self
.db
.conn()
.query(&sql, libsql::params_from_iter(param_values))
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to query file dependents: {e}"),
operation: "get_file_dependents".to_string(),
})?;
let mut source_ids: Vec<String> = Vec::new();
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("failed to read source id: {e}"),
operation: "get_file_dependents".to_string(),
})? {
if let Ok(id) = row.get::<String>(0) {
source_ids.push(id);
}
}
if source_ids.is_empty() {
return Ok(Vec::new());
}
let source_nodes = self.db.get_nodes_by_ids(&source_ids).await?;
let dependent_files: HashSet<String> = source_nodes
.into_iter()
.filter(|n| n.file_path != file_path)
.map(|n| n.file_path)
.collect();
let mut result: Vec<String> = dependent_files.into_iter().collect();
result.sort();
Ok(result)
}
pub async fn find_circular_dependencies(&self) -> Result<Vec<Vec<String>>> {
let all_files = self.db.get_all_files().await?;
let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
for file in &all_files {
let deps = self.get_file_dependencies(&file.path).await?;
adj.insert(file.path.clone(), deps.into_iter().collect());
}
let mut cycles: Vec<Vec<String>> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
let mut on_stack: HashSet<String> = HashSet::new();
let mut stack: Vec<String> = Vec::new();
let file_paths: Vec<String> = adj.keys().cloned().collect();
for file_path in &file_paths {
if !visited.contains(file_path) {
dfs_cycle_detect(
file_path,
&adj,
&mut visited,
&mut on_stack,
&mut stack,
&mut cycles,
);
}
}
Ok(cycles)
}
pub async fn build_file_adjacency(
&self,
path_prefix: Option<&str>,
) -> Result<HashMap<String, HashSet<String>>> {
let sql = "SELECT DISTINCT n1.file_path AS src_file, n2.file_path AS tgt_file \
FROM edges e \
JOIN nodes n1 ON e.source = n1.id \
JOIN nodes n2 ON e.target = n2.id \
WHERE e.kind IN ('calls', 'uses', 'extends', 'implements') \
AND n1.file_path != n2.file_path";
let mut rows =
self.db
.conn()
.query(sql, ())
.await
.map_err(|e| TokenSaveError::Database {
message: format!("failed to query file adjacency: {e}"),
operation: "build_file_adjacency".to_string(),
})?;
let prefix: Option<String> = path_prefix.map(|p| {
if p.ends_with('/') {
p.to_string()
} else {
format!("{p}/")
}
});
let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("failed to read adjacency row: {e}"),
operation: "build_file_adjacency".to_string(),
})? {
let src: String = row.get(0).unwrap_or_default();
let tgt: String = row.get(1).unwrap_or_default();
if let Some(ref pfx) = prefix {
if !src.starts_with(pfx.as_str()) || !tgt.starts_with(pfx.as_str()) {
continue;
}
}
adj.entry(src).or_default().insert(tgt);
}
let all_files = self.db.get_all_files().await?;
for file in all_files {
if let Some(ref pfx) = prefix {
if !file.path.starts_with(pfx.as_str()) {
continue;
}
}
adj.entry(file.path).or_default();
}
Ok(adj)
}
async fn compute_depth(&self, node_id: &str) -> Result<usize> {
const MAX_DEPTH: usize = 100;
let mut depth: usize = 0;
let mut current_id = node_id.to_string();
let mut visited: HashSet<String> = HashSet::new();
while depth < MAX_DEPTH {
if visited.contains(¤t_id) {
break;
}
visited.insert(current_id.clone());
let incoming = self
.db
.get_incoming_edges(¤t_id, &[EdgeKind::Contains])
.await?;
match incoming.first() {
Some(edge) => {
current_id.clone_from(&edge.source);
depth += 1;
}
None => break,
}
}
Ok(depth)
}
}
fn dfs_cycle_detect(
start: &str,
adj: &HashMap<String, HashSet<String>>,
visited: &mut HashSet<String>,
on_stack: &mut HashSet<String>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
let mut call_stack: Vec<(String, Vec<String>, usize)> = Vec::new();
let neighbors: Vec<String> = adj
.get(start)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
visited.insert(start.to_string());
on_stack.insert(start.to_string());
path.push(start.to_string());
call_stack.push((start.to_string(), neighbors, 0));
while let Some(frame) = call_stack.last_mut() {
let idx = frame.2;
if idx >= frame.1.len() {
let Some((node, _, _)) = call_stack.pop() else {
break;
};
path.pop();
on_stack.remove(&node);
continue;
}
frame.2 += 1;
let neighbor = frame.1[idx].clone();
if !visited.contains(&neighbor) {
let nb_neighbors: Vec<String> = adj
.get(&neighbor)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
visited.insert(neighbor.clone());
on_stack.insert(neighbor.clone());
path.push(neighbor.clone());
call_stack.push((neighbor, nb_neighbors, 0));
} else if on_stack.contains(&neighbor) {
let mut cycle = Vec::new();
let mut found_start = false;
for item in path.iter() {
if item == &neighbor {
found_start = true;
}
if found_start {
cycle.push(item.clone());
}
}
cycle.push(neighbor.clone());
cycles.push(cycle);
}
}
}