use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use dashmap::DashMap;
use dk_core::{
CallEdge, Error, RawCallEdge, RepoId, Result, Symbol, SymbolId,
};
use sqlx::postgres::PgPool;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::changeset::ChangesetStore;
use crate::git::GitRepository;
use crate::pipeline::PipelineStore;
use crate::graph::{
CallGraphStore, DependencyStore, SearchIndex, SymbolStore, TypeInfoStore,
};
use crate::parser::ParserRegistry;
use crate::workspace::cache::{NoOpCache, WorkspaceCache};
use crate::workspace::session_manager::WorkspaceManager;
#[derive(Debug, Clone)]
pub struct CodebaseSummary {
pub languages: Vec<String>,
pub total_symbols: u64,
pub total_files: u64,
}
pub struct Engine {
pub db: PgPool,
pub search_index: Arc<RwLock<SearchIndex>>,
pub parser: Arc<ParserRegistry>,
pub storage_path: PathBuf,
symbol_store: SymbolStore,
call_graph_store: CallGraphStore,
#[allow(dead_code)]
dep_store: DependencyStore,
type_info_store: TypeInfoStore,
changeset_store: ChangesetStore,
pipeline_store: PipelineStore,
workspace_manager: WorkspaceManager,
repo_locks: DashMap<RepoId, Arc<RwLock<()>>>,
}
impl Engine {
pub fn new(storage_path: PathBuf, db: PgPool) -> Result<Self> {
Self::with_cache(storage_path, db, Arc::new(NoOpCache))
}
pub fn with_cache(
storage_path: PathBuf,
db: PgPool,
cache: Arc<dyn WorkspaceCache>,
) -> Result<Self> {
let search_index = SearchIndex::open(&storage_path.join("search_index"))?;
let parser = ParserRegistry::new();
let symbol_store = SymbolStore::new(db.clone());
let call_graph_store = CallGraphStore::new(db.clone());
let dep_store = DependencyStore::new(db.clone());
let type_info_store = TypeInfoStore::new(db.clone());
let changeset_store = ChangesetStore::new(db.clone());
let pipeline_store = PipelineStore::new(db.clone());
let workspace_manager = WorkspaceManager::with_cache(db.clone(), cache);
Ok(Self {
db,
search_index: Arc::new(RwLock::new(search_index)),
parser: Arc::new(parser),
storage_path,
symbol_store,
call_graph_store,
dep_store,
type_info_store,
changeset_store,
pipeline_store,
workspace_manager,
repo_locks: DashMap::new(),
})
}
pub fn symbol_store(&self) -> &SymbolStore {
&self.symbol_store
}
pub fn changeset_store(&self) -> &ChangesetStore {
&self.changeset_store
}
pub fn pipeline_store(&self) -> &PipelineStore {
&self.pipeline_store
}
pub fn workspace_manager(&self) -> &WorkspaceManager {
&self.workspace_manager
}
pub fn call_graph_store(&self) -> &CallGraphStore {
&self.call_graph_store
}
pub fn dep_store(&self) -> &DependencyStore {
&self.dep_store
}
pub fn parser(&self) -> &ParserRegistry {
&self.parser
}
pub fn repo_lock(&self, repo_id: RepoId) -> Arc<RwLock<()>> {
self.repo_locks
.entry(repo_id)
.or_insert_with(|| Arc::new(RwLock::new(())))
.clone()
}
pub fn remove_repo_lock(&self, repo_id: RepoId) {
self.repo_locks.remove(&repo_id);
}
pub async fn create_repo(&self, name: &str) -> Result<RepoId> {
let repo_id = Uuid::new_v4();
let repo_path = self.storage_path.join("repos").join(repo_id.to_string());
GitRepository::init(&repo_path)?;
sqlx::query(
r#"
INSERT INTO repositories (id, name, path)
VALUES ($1, $2, $3)
"#,
)
.bind(repo_id)
.bind(name)
.bind(repo_path.to_string_lossy().as_ref())
.execute(&self.db)
.await?;
Ok(repo_id)
}
pub async fn get_repo(&self, name: &str) -> Result<(RepoId, GitRepository)> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT id, path FROM repositories WHERE name = $1",
)
.bind(name)
.fetch_optional(&self.db)
.await?;
let row = match row {
Some(r) => r,
None => {
let mut rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT id, path FROM repositories \
WHERE split_part(name, '/', 2) = $1 \
OR (name = split_part($1, '/', 2) AND $1 LIKE '%/%')",
)
.bind(name)
.fetch_all(&self.db)
.await?;
match rows.len() {
0 => return Err(Error::RepoNotFound(name.to_string())),
1 => rows.remove(0),
_ => return Err(Error::AmbiguousRepoName(name.to_string())),
}
}
};
let (repo_id, repo_path) = row;
let git_repo = GitRepository::open(Path::new(&repo_path))?;
Ok((repo_id, git_repo))
}
pub async fn get_repo_by_db_id(&self, repo_id: RepoId) -> Result<(RepoId, GitRepository)> {
let row: (String,) = sqlx::query_as(
"SELECT path FROM repositories WHERE id = $1",
)
.bind(repo_id)
.fetch_optional(&self.db)
.await?
.ok_or_else(|| Error::RepoNotFound(repo_id.to_string()))?;
let git_repo = GitRepository::open(Path::new(&row.0))?;
Ok((repo_id, git_repo))
}
pub async fn index_repo(
&self,
repo_id: RepoId,
git_repo: &GitRepository,
) -> Result<()> {
let root = git_repo.path().to_path_buf();
let files = collect_files(&root, &self.parser);
let mut all_symbols: Vec<Symbol> = Vec::new();
let mut all_raw_edges: Vec<RawCallEdge> = Vec::new();
let mut search_index = self.search_index.write().await;
for file_path in &files {
let relative = file_path
.strip_prefix(&root)
.unwrap_or(file_path);
let source = std::fs::read(file_path).map_err(|e| {
Error::Io(e)
})?;
let analysis = self.parser.parse_file(relative, &source)?;
for sym in &analysis.symbols {
self.symbol_store.upsert_symbol(repo_id, sym).await?;
search_index.index_symbol(repo_id, sym)?;
}
for ti in &analysis.types {
self.type_info_store.upsert_type_info(ti).await?;
}
all_symbols.extend(analysis.symbols);
all_raw_edges.extend(analysis.calls);
}
search_index.commit()?;
drop(search_index);
let edges = resolve_call_edges(&all_raw_edges, &all_symbols, repo_id);
for edge in &edges {
self.call_graph_store.insert_edge(edge).await?;
}
Ok(())
}
pub async fn update_files(
&self,
repo_id: RepoId,
git_repo: &GitRepository,
changed_files: &[PathBuf],
) -> Result<()> {
self.update_files_by_root(repo_id, git_repo.path(), changed_files)
.await
}
pub async fn update_files_by_root(
&self,
repo_id: RepoId,
root: &Path,
changed_files: &[PathBuf],
) -> Result<()> {
let root = root.to_path_buf();
let mut all_symbols: Vec<Symbol> = Vec::new();
let mut all_raw_edges: Vec<RawCallEdge> = Vec::new();
let mut search_index = self.search_index.write().await;
for file_path in changed_files {
let relative = file_path
.strip_prefix(&root)
.unwrap_or(file_path);
let rel_str = relative.to_string_lossy().to_string();
let old_symbols = self
.symbol_store
.find_by_file(repo_id, &rel_str)
.await?;
for old_sym in &old_symbols {
search_index.remove_symbol(old_sym.id)?;
}
self.call_graph_store
.delete_edges_for_file(repo_id, &rel_str)
.await?;
self.symbol_store
.delete_by_file(repo_id, &rel_str)
.await?;
let full_path = root.join(relative);
if !full_path.exists() {
continue;
}
if !self.parser.supports_file(relative) {
continue;
}
let source = std::fs::read(&full_path)?;
let analysis = self.parser.parse_file(relative, &source)?;
for sym in &analysis.symbols {
self.symbol_store.upsert_symbol(repo_id, sym).await?;
search_index.index_symbol(repo_id, sym)?;
}
for ti in &analysis.types {
self.type_info_store.upsert_type_info(ti).await?;
}
all_symbols.extend(analysis.symbols);
all_raw_edges.extend(analysis.calls);
}
search_index.commit()?;
drop(search_index);
let edges = resolve_call_edges(&all_raw_edges, &all_symbols, repo_id);
for edge in &edges {
self.call_graph_store.insert_edge(edge).await?;
}
Ok(())
}
pub async fn query_symbols(
&self,
repo_id: RepoId,
query: &str,
max_results: usize,
) -> Result<Vec<Symbol>> {
let search_index = self.search_index.read().await;
let ids = search_index.search(repo_id, query, max_results)?;
drop(search_index);
self.symbol_store.get_by_ids(&ids).await
}
pub async fn get_call_graph(
&self,
_repo_id: RepoId,
symbol_id: SymbolId,
) -> Result<(Vec<Symbol>, Vec<Symbol>)> {
let caller_edges = self.call_graph_store.find_callers(symbol_id).await?;
let callee_edges = self.call_graph_store.find_callees(symbol_id).await?;
let mut callers = Vec::with_capacity(caller_edges.len());
for edge in &caller_edges {
if let Some(sym) = self.symbol_store.get_by_id(edge.caller).await? {
callers.push(sym);
}
}
let mut callees = Vec::with_capacity(callee_edges.len());
for edge in &callee_edges {
if let Some(sym) = self.symbol_store.get_by_id(edge.callee).await? {
callees.push(sym);
}
}
Ok((callers, callees))
}
pub async fn codebase_summary(&self, repo_id: RepoId) -> Result<CodebaseSummary> {
let total_symbols = self.symbol_store.count(repo_id).await? as u64;
let row: (i64, Vec<String>) = sqlx::query_as(
r#"
SELECT
COUNT(DISTINCT file_path),
COALESCE(
array_agg(DISTINCT substring(file_path FROM '\.([^.]+)$'))
FILTER (WHERE substring(file_path FROM '\.([^.]+)$') IS NOT NULL),
ARRAY[]::text[]
)
FROM symbols
WHERE repo_id = $1
"#,
)
.bind(repo_id)
.fetch_one(&self.db)
.await?;
let total_files = row.0 as u64;
let mut languages = row.1;
languages.sort();
Ok(CodebaseSummary {
languages,
total_symbols,
total_files,
})
}
}
fn collect_files(root: &Path, parser: &ParserRegistry) -> Vec<PathBuf> {
let mut files = Vec::new();
collect_files_recursive(root, root, parser, &mut files);
files
}
fn collect_files_recursive(
root: &Path,
dir: &Path,
parser: &ParserRegistry,
out: &mut Vec<PathBuf>,
) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name == ".git" || name.starts_with('.') {
continue;
}
}
collect_files_recursive(root, &path, parser, out);
} else if path.is_file() {
let relative = path.strip_prefix(root).unwrap_or(&path);
if parser.supports_file(relative) {
out.push(path);
}
}
}
}
fn resolve_call_edges(
raw_edges: &[RawCallEdge],
symbols: &[Symbol],
repo_id: RepoId,
) -> Vec<CallEdge> {
let mut name_to_id: HashMap<String, SymbolId> = HashMap::new();
for sym in symbols {
name_to_id.insert(sym.name.clone(), sym.id);
name_to_id.insert(sym.qualified_name.clone(), sym.id);
}
raw_edges
.iter()
.filter_map(|raw| {
let caller = name_to_id.get(&raw.caller_name)?;
let callee = name_to_id.get(&raw.callee_name)?;
Some(CallEdge {
id: Uuid::new_v4(),
repo_id,
caller: *caller,
callee: *callee,
kind: raw.kind.clone(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_call_edges_basic() {
let sym_a_id = Uuid::new_v4();
let sym_b_id = Uuid::new_v4();
let repo_id = Uuid::new_v4();
let symbols = vec![
Symbol {
id: sym_a_id,
name: "foo".into(),
qualified_name: "crate::foo".into(),
kind: dk_core::SymbolKind::Function,
visibility: dk_core::Visibility::Public,
file_path: "src/lib.rs".into(),
span: dk_core::Span {
start_byte: 0,
end_byte: 100,
},
signature: None,
doc_comment: None,
parent: None,
last_modified_by: None,
last_modified_intent: None,
},
Symbol {
id: sym_b_id,
name: "bar".into(),
qualified_name: "crate::bar".into(),
kind: dk_core::SymbolKind::Function,
visibility: dk_core::Visibility::Public,
file_path: "src/lib.rs".into(),
span: dk_core::Span {
start_byte: 100,
end_byte: 200,
},
signature: None,
doc_comment: None,
parent: None,
last_modified_by: None,
last_modified_intent: None,
},
];
let raw_edges = vec![RawCallEdge {
caller_name: "foo".into(),
callee_name: "bar".into(),
call_site: dk_core::Span {
start_byte: 50,
end_byte: 60,
},
kind: dk_core::CallKind::DirectCall,
}];
let edges = resolve_call_edges(&raw_edges, &symbols, repo_id);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].caller, sym_a_id);
assert_eq!(edges[0].callee, sym_b_id);
assert_eq!(edges[0].repo_id, repo_id);
}
#[test]
fn test_resolve_call_edges_unresolved_skipped() {
let sym_a_id = Uuid::new_v4();
let repo_id = Uuid::new_v4();
let symbols = vec![Symbol {
id: sym_a_id,
name: "foo".into(),
qualified_name: "crate::foo".into(),
kind: dk_core::SymbolKind::Function,
visibility: dk_core::Visibility::Public,
file_path: "src/lib.rs".into(),
span: dk_core::Span {
start_byte: 0,
end_byte: 100,
},
signature: None,
doc_comment: None,
parent: None,
last_modified_by: None,
last_modified_intent: None,
}];
let raw_edges = vec![RawCallEdge {
caller_name: "foo".into(),
callee_name: "unknown".into(),
call_site: dk_core::Span {
start_byte: 50,
end_byte: 60,
},
kind: dk_core::CallKind::DirectCall,
}];
let edges = resolve_call_edges(&raw_edges, &symbols, repo_id);
assert!(edges.is_empty());
}
#[test]
fn test_resolve_call_edges_qualified_name() {
let sym_a_id = Uuid::new_v4();
let sym_b_id = Uuid::new_v4();
let repo_id = Uuid::new_v4();
let symbols = vec![
Symbol {
id: sym_a_id,
name: "foo".into(),
qualified_name: "crate::mod_a::foo".into(),
kind: dk_core::SymbolKind::Function,
visibility: dk_core::Visibility::Public,
file_path: "src/mod_a.rs".into(),
span: dk_core::Span {
start_byte: 0,
end_byte: 100,
},
signature: None,
doc_comment: None,
parent: None,
last_modified_by: None,
last_modified_intent: None,
},
Symbol {
id: sym_b_id,
name: "bar".into(),
qualified_name: "crate::mod_b::bar".into(),
kind: dk_core::SymbolKind::Function,
visibility: dk_core::Visibility::Public,
file_path: "src/mod_b.rs".into(),
span: dk_core::Span {
start_byte: 0,
end_byte: 100,
},
signature: None,
doc_comment: None,
parent: None,
last_modified_by: None,
last_modified_intent: None,
},
];
let raw_edges = vec![RawCallEdge {
caller_name: "crate::mod_a::foo".into(),
callee_name: "crate::mod_b::bar".into(),
call_site: dk_core::Span {
start_byte: 50,
end_byte: 60,
},
kind: dk_core::CallKind::DirectCall,
}];
let edges = resolve_call_edges(&raw_edges, &symbols, repo_id);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].caller, sym_a_id);
assert_eq!(edges[0].callee, sym_b_id);
}
#[test]
fn test_collect_files_skips_git_dir() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git/config"), b"git config").unwrap();
std::fs::write(root.join("main.rs"), b"fn main() {}").unwrap();
std::fs::write(root.join("notes.txt"), b"hello").unwrap();
let parser = ParserRegistry::new();
let files = collect_files(root, &parser);
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("main.rs"));
}
#[test]
fn test_codebase_summary_struct() {
let summary = CodebaseSummary {
languages: vec!["rs".into(), "py".into()],
total_symbols: 42,
total_files: 5,
};
assert_eq!(summary.languages.len(), 2);
assert_eq!(summary.total_symbols, 42);
assert_eq!(summary.total_files, 5);
}
}