use std::path::{Path, PathBuf};
use petgraph::visit::EdgeRef;
use crate::graph::CodeGraph;
use crate::graph::edge::EdgeKind;
use crate::graph::node::GraphNode;
use crate::parser;
use std::collections::HashMap;
use crate::resolver::{
ResolutionOutcome, build_resolver, discover_workspace_packages, resolve_import,
workspace_map_to_aliases,
};
use super::event::WatchEvent;
pub fn handle_file_event(graph: &mut CodeGraph, event: &WatchEvent, project_root: &Path) -> bool {
match event {
WatchEvent::Modified(path) => {
handle_modified(graph, path, project_root);
true
}
WatchEvent::Deleted(path) => {
handle_deleted(graph, path);
true
}
WatchEvent::ConfigChanged => {
false
}
WatchEvent::CrateRootChanged(_) => {
false
}
}
}
fn handle_modified(graph: &mut CodeGraph, path: &Path, project_root: &Path) {
graph.remove_file_from_graph(path);
let source = match std::fs::read(path) {
Ok(s) => s,
Err(_) => return, };
let language_str = match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
"ts" => "typescript",
"tsx" => "tsx",
"js" | "jsx" => "javascript",
"rs" => "rust",
"py" => "python",
"go" => "go",
_ => return,
};
let result = match parser::parse_file(path, &source) {
Ok(r) => r,
Err(_) => return, };
let file_idx = graph.add_file(path.to_path_buf(), language_str);
for (symbol, children) in &result.symbols {
let sym_idx = graph.add_symbol(file_idx, symbol.clone());
for child in children {
graph.add_child_symbol(sym_idx, child.clone());
}
}
if language_str == "rust" {
for rust_use in &result.rust_uses {
if rust_use.is_pub_use {
graph.graph.add_edge(
file_idx,
file_idx,
EdgeKind::ReExport {
path: rust_use.path.clone(),
},
);
} else {
graph.graph.add_edge(
file_idx,
file_idx,
EdgeKind::RustImport {
path: rust_use.path.clone(),
},
);
}
}
let mut parse_results = HashMap::new();
parse_results.insert(path.to_path_buf(), result);
crate::resolver::resolve_all(graph, project_root, &parse_results, false);
} else if language_str == "python" {
let mut parse_results = HashMap::new();
parse_results.insert(path.to_path_buf(), result);
crate::resolver::resolve_all(graph, project_root, &parse_results, false);
} else if language_str == "go" {
let mut parse_results = HashMap::new();
parse_results.insert(path.to_path_buf(), result);
crate::resolver::resolve_all(graph, project_root, &parse_results, false);
} else {
let workspace_map = discover_workspace_packages(project_root);
let aliases = workspace_map_to_aliases(&workspace_map);
let resolver = build_resolver(project_root, aliases);
for import in &result.imports {
let specifier = &import.module_path;
let outcome = resolve_import(&resolver, path, specifier);
match outcome {
ResolutionOutcome::Resolved(target_path) => {
if let Some(&target_idx) = graph.file_index.get(&target_path) {
graph.add_resolved_import(file_idx, target_idx, specifier);
}
}
ResolutionOutcome::BuiltinModule(_) => {
graph.add_unresolved_import(file_idx, specifier, "builtin");
}
ResolutionOutcome::Unresolved(reason) => {
if is_external_package(specifier) {
let pkg_name = extract_package_name(specifier);
graph.add_external_package(file_idx, pkg_name, specifier);
} else {
graph.add_unresolved_import(file_idx, specifier, &reason);
}
}
}
}
wire_relationships_for_file(graph, &result.relationships, file_idx);
fix_unresolved_pointing_to(graph, path, project_root);
}
crate::query::decorators::enrich_decorator_frameworks(graph);
crate::query::decorators::add_has_decorator_edges(graph);
graph.rebuild_bm25_index();
}
fn handle_deleted(graph: &mut CodeGraph, path: &Path) {
let file_idx = match graph.file_index.get(path).copied() {
Some(idx) => idx,
None => return, };
let importers: Vec<(petgraph::stable_graph::NodeIndex, String)> = graph
.graph
.edges_directed(file_idx, petgraph::Direction::Incoming)
.filter_map(|e| {
if let EdgeKind::ResolvedImport { specifier } = e.weight() {
Some((e.source(), specifier.clone()))
} else {
None
}
})
.collect();
graph.remove_file_from_graph(path);
for (importer_idx, specifier) in importers {
graph.add_unresolved_import(importer_idx, &specifier, "target file deleted");
}
graph.rebuild_bm25_index();
}
fn wire_relationships_for_file(
graph: &mut CodeGraph,
relationships: &[crate::parser::relationships::RelationshipInfo],
file_idx: petgraph::stable_graph::NodeIndex,
) {
use crate::parser::relationships::RelationshipKind;
for rel in relationships {
match rel.kind {
RelationshipKind::Extends
| RelationshipKind::Implements
| RelationshipKind::InterfaceExtends => {
let from_name = match &rel.from_name {
Some(n) => n,
None => continue,
};
let from_candidates = graph
.symbol_index
.get(from_name)
.cloned()
.unwrap_or_default();
let to_candidates = graph
.symbol_index
.get(&rel.to_name)
.cloned()
.unwrap_or_default();
if from_candidates.is_empty() || to_candidates.is_empty() {
continue;
}
let from_sym_idx = from_candidates
.iter()
.copied()
.find(|&idx| graph.graph.edges(file_idx).any(|e| e.target() == idx))
.unwrap_or(from_candidates[0]);
let same_file_to: Vec<_> = to_candidates
.iter()
.copied()
.filter(|&idx| graph.graph.edges(file_idx).any(|e| e.target() == idx))
.collect();
let to_indices = if same_file_to.is_empty() {
to_candidates
} else {
same_file_to
};
for to_sym_idx in to_indices {
match rel.kind {
RelationshipKind::Extends | RelationshipKind::InterfaceExtends => {
graph.add_extends_edge(from_sym_idx, to_sym_idx);
}
RelationshipKind::Implements => {
graph.add_implements_edge(from_sym_idx, to_sym_idx);
}
_ => unreachable!(),
}
}
}
RelationshipKind::Calls
| RelationshipKind::MethodCall
| RelationshipKind::TypeReference => {
let to_candidates = match graph.symbol_index.get(&rel.to_name) {
Some(c) if !c.is_empty() => c.clone(),
_ => continue,
};
if to_candidates.len() == 1 {
graph.add_calls_edge(file_idx, to_candidates[0]);
}
}
}
}
}
fn fix_unresolved_pointing_to(graph: &mut CodeGraph, new_file_path: &Path, project_root: &Path) {
let unresolved: Vec<(
petgraph::stable_graph::NodeIndex,
petgraph::stable_graph::NodeIndex,
String,
)> = graph
.graph
.node_indices()
.filter_map(|idx| {
if let GraphNode::UnresolvedImport { specifier, reason } = &graph.graph[idx]
&& reason != "builtin"
{
let importer = graph
.graph
.edges_directed(idx, petgraph::Direction::Incoming)
.next()
.map(|e| e.source());
if let Some(importer_idx) = importer {
return Some((idx, importer_idx, specifier.clone()));
}
}
None
})
.collect();
if unresolved.is_empty() {
return;
}
let workspace_map = discover_workspace_packages(project_root);
let aliases = workspace_map_to_aliases(&workspace_map);
let resolver = build_resolver(project_root, aliases);
let new_file_idx = match graph.file_index.get(new_file_path).copied() {
Some(idx) => idx,
None => return,
};
for (unresolved_idx, importer_idx, specifier) in unresolved {
let importer_path: PathBuf = match &graph.graph[importer_idx] {
GraphNode::File(info) => info.path.clone(),
_ => continue,
};
let outcome = resolve_import(&resolver, &importer_path, &specifier);
if let ResolutionOutcome::Resolved(resolved_path) = outcome
&& resolved_path == new_file_path
{
graph.graph.remove_node(unresolved_idx);
graph.add_resolved_import(importer_idx, new_file_idx, &specifier);
}
}
}
fn is_external_package(specifier: &str) -> bool {
!specifier.starts_with('.') && !specifier.starts_with('/')
}
fn extract_package_name(specifier: &str) -> &str {
if specifier.starts_with('@') {
let parts: Vec<&str> = specifier.splitn(3, '/').collect();
if parts.len() >= 2 {
let scope_end = parts[0].len() + 1 + parts[1].len();
&specifier[..scope_end]
} else {
specifier
}
} else {
match specifier.find('/') {
Some(idx) => &specifier[..idx],
None => specifier,
}
}
}
#[cfg(feature = "rag")]
pub async fn re_embed_file(
graph: &crate::graph::CodeGraph,
vector_store: &mut crate::rag::vector_store::VectorStore,
engine: &crate::rag::embedding::EmbeddingEngine,
file_path: &str,
) -> anyhow::Result<usize> {
use crate::graph::edge::EdgeKind;
use crate::graph::node::GraphNode;
use crate::rag::vector_store::SymbolMeta;
use petgraph::Direction;
use std::path::Path;
let target_path = Path::new(file_path);
let file_idx = match graph.file_index.get(target_path).copied() {
Some(idx) => idx,
None => {
return Ok(0);
}
};
let symbol_indices: Vec<petgraph::stable_graph::NodeIndex> = graph
.graph
.edges_directed(file_idx, Direction::Outgoing)
.filter_map(|e| {
if matches!(e.weight(), EdgeKind::Contains) {
Some(e.target())
} else {
None
}
})
.collect();
if symbol_indices.is_empty() {
return Ok(0);
}
let mut symbol_descs: Vec<(String, String, usize)> = Vec::new();
let mut symbol_metas: Vec<SymbolMeta> = Vec::new();
for sym_idx in &symbol_indices {
if let GraphNode::Symbol(info) = &graph.graph[*sym_idx] {
let kind_str = format!("{:?}", info.kind).to_lowercase();
let file_str = file_path.to_string();
symbol_descs.push((info.name.clone(), file_str.clone(), info.line));
symbol_metas.push(SymbolMeta {
file_path: file_str,
symbol_name: info.name.clone(),
line_start: info.line,
kind: kind_str,
});
}
}
if symbol_descs.is_empty() {
return Ok(0);
}
let texts: Vec<String> = symbol_descs
.iter()
.map(|(name, path, line)| format!("{} in {}:{}", name, path, line))
.collect();
let embeddings = engine.embed_batch(texts).await?;
vector_store.reserve(embeddings.len())?;
let mut count = 0;
for (embedding, meta) in embeddings.iter().zip(symbol_metas.into_iter()) {
vector_store.add(embedding, meta)?;
count += 1;
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::node::{SymbolInfo, SymbolKind};
use crate::query::decorators::find_by_decorator;
use crate::query::find::bm25_search;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_bm25_rebuilt_after_watcher_event() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let file_path = root.join("src").join("auth.ts");
fs::create_dir_all(file_path.parent().unwrap()).unwrap();
fs::write(
&file_path,
"export function authHandler() { return true; }\n",
)
.unwrap();
let mut graph = CodeGraph::new();
let f = graph.add_file(file_path.clone(), "typescript");
graph.add_symbol(
f,
SymbolInfo {
name: "oldFunction".into(),
kind: SymbolKind::Function,
line: 1,
is_exported: true,
..Default::default()
},
);
graph.rebuild_bm25_index();
let before = bm25_search(&graph, "auth handler", 10);
assert!(
before.is_empty(),
"authHandler should not be in BM25 index before event"
);
let event = WatchEvent::Modified(file_path.clone());
let modified = handle_file_event(&mut graph, &event, root);
assert!(
modified,
"handle_file_event should return true for Modified"
);
let after = bm25_search(&graph, "auth handler", 10);
assert!(
!after.is_empty(),
"authHandler should be in BM25 index after watcher event"
);
assert_eq!(after[0].symbol_name, "authHandler");
}
#[test]
fn test_decorator_enrichment_after_watcher_event() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let src_dir = root.join("src");
fs::create_dir_all(&src_dir).unwrap();
let file_path = src_dir.join("app.controller.ts");
fs::write(
&file_path,
"@Controller('/api')\nexport class AppController {}\n",
)
.unwrap();
let mut graph = CodeGraph::new();
let event = WatchEvent::Modified(file_path.clone());
let modified = handle_file_event(&mut graph, &event, root);
assert!(
modified,
"handle_file_event should return true for Modified"
);
let results = find_by_decorator(&graph, "Controller", None, None, 10)
.expect("find_by_decorator should succeed");
assert!(
!results.is_empty(),
"find_by_decorator should return results after watcher event on @Controller class"
);
assert_eq!(
results[0].symbol_name, "AppController",
"found symbol should be AppController"
);
assert_eq!(
results[0].framework,
Some("nestjs".to_string()),
"framework should be nestjs after enrichment"
);
use petgraph::visit::IntoEdgeReferences;
let has_decorator_edge = graph
.graph
.edge_references()
.any(|e| matches!(e.weight(), EdgeKind::HasDecorator { .. }));
assert!(
has_decorator_edge,
"graph should contain at least one HasDecorator edge after watcher event"
);
}
#[test]
fn test_go_watcher_resolve_imports() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let go_mod = root.join("go.mod");
fs::write(&go_mod, "module example.com/mymod\n\ngo 1.21\n").unwrap();
let pkg_dir = root.join("pkg");
fs::create_dir_all(&pkg_dir).unwrap();
let foo_path = pkg_dir.join("foo.go");
fs::write(&foo_path, "package pkg\n\nfunc Foo() {}\n").unwrap();
let main_path = root.join("main.go");
fs::write(
&main_path,
"package main\n\nimport \"example.com/mymod/pkg\"\n\nfunc main() { pkg.Foo() }\n",
)
.unwrap();
let mut graph = CodeGraph::new();
let foo_src = fs::read(&foo_path).unwrap();
let foo_result = crate::parser::parse_file(&foo_path, &foo_src).expect("parse foo.go");
let foo_idx = graph.add_file(foo_path.clone(), "go");
for (symbol, children) in &foo_result.symbols {
let sym_idx = graph.add_symbol(foo_idx, symbol.clone());
for child in children {
graph.add_child_symbol(sym_idx, child.clone());
}
}
let event = WatchEvent::Modified(main_path.clone());
let modified = handle_file_event(&mut graph, &event, root);
assert!(
modified,
"handle_file_event should return true for Modified"
);
let main_idx = *graph
.file_index
.get(&main_path)
.expect("main.go should be in graph after event");
let foo_idx_after = *graph
.file_index
.get(&foo_path)
.expect("pkg/foo.go should be in graph");
use petgraph::visit::EdgeRef;
let has_resolved_import = graph.graph.edges(main_idx).any(|e| {
matches!(e.weight(), EdgeKind::ResolvedImport { .. }) && e.target() == foo_idx_after
});
assert!(
has_resolved_import,
"graph should contain a ResolvedImport edge from main.go to pkg/foo.go after watcher event"
);
}
#[cfg(feature = "rag")]
#[tokio::test]
async fn test_re_embed_file_returns_symbol_count() {
use crate::graph::node::{SymbolInfo, SymbolKind};
use crate::rag::embedding::EmbeddingEngine;
use crate::rag::vector_store::VectorStore;
let mut graph = CodeGraph::new();
let file_path = std::path::PathBuf::from("/tmp/test_re_embed.rs");
let file_idx = graph.add_file(file_path.clone(), "rust");
for (i, name) in ["alpha", "beta", "gamma"].iter().enumerate() {
graph.add_symbol(
file_idx,
SymbolInfo {
name: name.to_string(),
kind: SymbolKind::Function,
line: i + 1,
is_exported: true,
..Default::default()
},
);
}
let mut vs = VectorStore::new(384).expect("VectorStore::new");
let engine = match EmbeddingEngine::try_new() {
Ok(e) => e,
Err(_) => {
eprintln!("Skipping test_re_embed_file: EmbeddingEngine unavailable");
return;
}
};
let file_path_str = file_path.to_string_lossy().to_string();
let count = super::re_embed_file(&graph, &mut vs, &engine, &file_path_str)
.await
.expect("re_embed_file should succeed");
assert_eq!(
count, 3,
"re_embed_file should return count 3 for 3 symbols"
);
assert_eq!(vs.len(), 3, "vector store should have 3 entries");
}
#[cfg(feature = "rag")]
#[tokio::test]
async fn test_re_embed_file_missing_file_returns_zero() {
use crate::rag::embedding::EmbeddingEngine;
use crate::rag::vector_store::VectorStore;
let graph = CodeGraph::new(); let mut vs = VectorStore::new(384).expect("VectorStore::new");
let engine = match EmbeddingEngine::try_new() {
Ok(e) => e,
Err(_) => {
eprintln!("Skipping test_re_embed_file_missing_file: EmbeddingEngine unavailable");
return;
}
};
let count = super::re_embed_file(&graph, &mut vs, &engine, "/tmp/nonexistent.rs")
.await
.expect("re_embed_file with missing file should return Ok(0)");
assert_eq!(count, 0, "missing file should produce count 0");
assert!(vs.is_empty(), "vector store should remain empty");
}
}