use anyhow::{Context, Result};
use tracing::debug;
use crate::db::traits::StoreChunks;
use crate::db::SqliteStore;
use std::sync::Arc;
pub struct EdgeUpdater {
store: Arc<SqliteStore>,
}
impl EdgeUpdater {
pub fn new(store: Arc<SqliteStore>) -> Self {
Self { store }
}
pub async fn update_edges(&self, file_id: i64) -> Result<()> {
use crate::indexer::edges::{self, ChunkWithId};
debug!(file_id = file_id, "Updating edges for file");
self.delete_edges_for_file(file_id).await?;
let file_metadata = self
.store
.run(move |conn| {
let result = conn.query_row(
"SELECT f.relpath, f.language, w.abs_path
FROM files f
JOIN worktrees w ON f.worktree_id = w.id
WHERE f.id = ?",
rusqlite::params![file_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
))
},
)?;
Ok(result)
})
.await?;
let (relpath, language, root_path) = file_metadata;
let language = match language {
Some(lang) if matches!(lang.as_str(), "ts" | "tsx" | "js" | "jsx") => lang,
_ => {
debug!(
file_id = file_id,
"No edge extraction for language {:?}", language
);
return Ok(());
}
};
let full_path = std::path::Path::new(&root_path).join(&relpath);
let content = std::fs::read_to_string(&full_path).with_context(|| {
format!(
"Failed to read file: {} (root: {}, relpath: {})",
full_path.display(),
root_path,
relpath
)
})?;
let chunks_with_ids: Vec<ChunkWithId> = self
.store
.run(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, symbol_name, kind, start_line, end_line FROM chunks WHERE file_id = ?",
)?;
let chunks = stmt
.query_map(rusqlite::params![file_id], |row| {
Ok(ChunkWithId {
id: row.get(0)?,
symbol_name: row.get(1)?,
kind: row.get(2)?,
start_line: row.get(3)?,
end_line: row.get(4)?,
file_id,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(chunks)
})
.await?;
let edges_to_insert = edges::extract_edges(&content, &language, &chunks_with_ids)?;
for edge in edges_to_insert {
self.store
.insert_chunk_edge(
edge.src_chunk_id,
edge.dst_chunk_id,
edge.edge_type.as_str(),
)
.await?;
}
debug!(file_id = file_id, "Edges updated for file");
Ok(())
}
pub async fn delete_edges_for_file(&self, file_id: i64) -> Result<u64> {
let count = self
.store
.run(move |conn| {
let deleted = conn.execute(
"DELETE FROM chunk_edges WHERE src_chunk_id IN (
SELECT id FROM chunks WHERE file_id = ?1
) OR dst_chunk_id IN (
SELECT id FROM chunks WHERE file_id = ?1
)",
rusqlite::params![file_id],
)?;
Ok(deleted as u64)
})
.await?;
debug!(
file_id = file_id,
edges_deleted = count,
"Deleted edges for file"
);
Ok(count)
}
}
#[derive(Debug, Clone)]
pub struct Edge {
pub src_chunk_id: i64,
pub dst_chunk_id: i64,
pub edge_type: EdgeType,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum EdgeType {
Imports,
Exports,
Calls,
CalledBy,
TestOf,
RouteOf,
}
impl EdgeType {
pub fn as_str(&self) -> &'static str {
match self {
EdgeType::Imports => "imports",
EdgeType::Exports => "exports",
EdgeType::Calls => "calls",
EdgeType::CalledBy => "called_by",
EdgeType::TestOf => "test_of",
EdgeType::RouteOf => "route_of",
}
}
}
#[allow(dead_code)]
async fn compute_edges(_store: &SqliteStore, _chunk_ids: &[i64]) -> Result<Vec<Edge>> {
Ok(Vec::new())
}
#[allow(dead_code)]
fn is_test_chunk(kind: &str, symbol_name: Option<&str>) -> bool {
if kind.contains("test") {
return true;
}
if let Some(name) = symbol_name {
let lower = name.to_lowercase();
if lower.starts_with("test_") || lower.starts_with("it ") || lower.starts_with("describe ")
{
return true;
}
}
false
}
#[allow(dead_code)]
fn is_route_chunk(kind: &str, symbol_name: Option<&str>) -> bool {
if kind == "func" {
if let Some(name) = symbol_name {
let lower = name.to_lowercase();
if lower.contains("route") || lower.contains("handler") {
return true;
}
}
}
false
}
#[allow(dead_code)]
async fn find_test_targets(
_store: &SqliteStore,
_test_chunk_id: i64,
_test_symbol_name: Option<&str>,
) -> Result<Vec<Edge>> {
Ok(Vec::new())
}
#[allow(dead_code)]
async fn insert_edges(store: &SqliteStore, edges: &[Edge]) -> Result<u64> {
if edges.is_empty() {
return Ok(0);
}
let edges = edges.to_vec();
store.run(move |conn| {
let mut stmt = conn.prepare(
"INSERT OR IGNORE INTO chunk_edges (src_chunk_id, dst_chunk_id, type) VALUES (?1, ?2, ?3)"
)?;
let mut count = 0u64;
for edge in &edges {
let rows = stmt.execute(rusqlite::params![
edge.src_chunk_id,
edge.dst_chunk_id,
edge.edge_type.as_str()
])?;
count += rows as u64;
}
Ok(count)
}).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_test_chunk() {
assert!(is_test_chunk("test", None));
assert!(is_test_chunk("func", Some("test_myfunction")));
assert!(is_test_chunk("func", Some("it should work")));
assert!(is_test_chunk("func", Some("describe the feature")));
assert!(!is_test_chunk("func", Some("myFunction")));
assert!(!is_test_chunk("class", Some("MyClass")));
}
#[test]
fn test_is_route_chunk() {
assert!(is_route_chunk("func", Some("handleRoute")));
assert!(is_route_chunk("func", Some("userRouter")));
assert!(!is_route_chunk("func", Some("myFunction")));
assert!(!is_route_chunk("class", Some("RouteHandler")));
}
#[test]
fn test_edge_type_as_str() {
assert_eq!(EdgeType::Imports.as_str(), "imports");
assert_eq!(EdgeType::Exports.as_str(), "exports");
assert_eq!(EdgeType::Calls.as_str(), "calls");
assert_eq!(EdgeType::CalledBy.as_str(), "called_by");
assert_eq!(EdgeType::TestOf.as_str(), "test_of");
assert_eq!(EdgeType::RouteOf.as_str(), "route_of");
}
}