#[cfg(feature = "tree-sitter")]
mod ast;
pub mod brain;
mod extract;
pub mod graph;
pub mod schema;
mod symbols;
pub mod vector;
use std::collections::HashMap;
use std::path::Path;
use rayon::prelude::*;
use rusqlite::Connection;
#[cfg(test)]
use sha2::{Digest, Sha256};
use tracing::{debug, info};
#[allow(unused_imports)]
pub use graph::{CallEdge, CalleeResult, CallerResult, ImpactNode};
pub use symbols::{SearchResult, SymbolKind, SymbolQuery};
pub fn open_global_index() -> anyhow::Result<Connection> {
crate::data_dir::ensure_data_dir()?;
let db_path = crate::data_dir::graph_db_path();
let conn = Connection::open(&db_path)?;
conn.execute_batch(
"PRAGMA journal_mode=WAL;\
PRAGMA foreign_keys=ON;\
PRAGMA synchronous=NORMAL;\
PRAGMA cache_size=-65536;\
PRAGMA mmap_size=268435456;\
PRAGMA temp_store=MEMORY;",
)?;
schema::run_migrations(&conn)?;
debug!("Opened global index at {}", db_path.display());
Ok(conn)
}
pub fn ensure_project(conn: &Connection, root: &Path) -> anyhow::Result<i64> {
let root_str = root.to_string_lossy().to_string();
schema::get_or_create_project(conn, &root_str)
}
pub fn resolve_project_root(start: &Path) -> Option<std::path::PathBuf> {
let dir = if start.is_file() {
start.parent()?
} else {
start
};
const MARKERS: &[&str] = &[".cora.yaml", "Cargo.toml", "package.json", ".git"];
let mut current = dir.to_path_buf();
loop {
for marker in MARKERS {
let candidate = current.join(marker);
if candidate.exists() {
debug!(root = %current.display(), marker, "detected project root");
return Some(current);
}
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => return None,
}
}
}
pub fn resolve_project_id(conn: &Connection) -> anyhow::Result<(i64, std::path::PathBuf)> {
let cwd = std::env::current_dir()?;
let root = resolve_project_root(&cwd).unwrap_or_else(|| cwd.clone());
let project_id = ensure_project(conn, &root)?;
Ok((project_id, root))
}
#[cfg(test)]
pub fn index_file(
conn: &Connection,
project_id: i64,
file_path: &str,
content: &str,
language: &str,
) -> anyhow::Result<usize> {
let fingerprint = file_fingerprint(content);
let extracted = extract::extract_all(content, language, file_path);
let tx = conn.unchecked_transaction()?;
let count = index_file_in_tx(
&tx,
project_id,
file_path,
&fingerprint,
language,
&extracted,
)?;
tx.commit()?;
debug!(
"Indexed {file_path}: {count} symbols, {} call edges ({language})",
extracted.calls.len()
);
Ok(count)
}
fn index_file_in_tx(
tx: &rusqlite::Transaction,
project_id: i64,
file_path: &str,
fingerprint: &str,
language: &str,
extracted: &extract::ExtractedAll,
) -> anyhow::Result<usize> {
tx.execute(
"DELETE FROM symbols WHERE file = ?1 AND project_id = ?2",
rusqlite::params![file_path, project_id],
)?;
tx.execute(
"INSERT INTO files (path, fingerprint, last_indexed, language, symbol_count, project_id)
VALUES (?1, ?2, datetime('now'), ?3, ?4, ?5)
ON CONFLICT(path) DO UPDATE SET
fingerprint = excluded.fingerprint,
last_indexed = excluded.last_indexed,
language = excluded.language,
symbol_count = excluded.symbol_count,
project_id = excluded.project_id",
rusqlite::params![
file_path,
fingerprint,
language,
extracted.symbols.len() as i64,
project_id
],
)?;
let count = extracted.symbols.len();
if !extracted.symbols.is_empty() {
let mut stmt = tx.prepare(
"INSERT INTO symbols (name, kind, file, line, signature, language, project_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for sym in &extracted.symbols {
stmt.execute(rusqlite::params![
sym.name,
sym.kind.as_str(),
sym.file,
sym.line as i64,
sym.signature,
language,
project_id,
])?;
}
}
tx.execute(
"DELETE FROM call_graph WHERE file = ?1 AND project_id = ?2",
rusqlite::params![file_path, project_id],
)?;
if !extracted.calls.is_empty() {
let mut stmt = tx.prepare(
"INSERT INTO call_graph (caller, callee, file, line, project_id) VALUES (?1, ?2, ?3, ?4, ?5)"
)?;
for site in &extracted.calls {
stmt.execute(rusqlite::params![
site.caller,
site.callee,
site.file,
site.line as i64,
project_id,
])?;
}
}
#[cfg(feature = "tree-sitter")]
{
tx.execute(
"DELETE FROM edges WHERE file = ?1 AND project_id = ?2",
rusqlite::params![file_path, project_id],
)?;
if !extracted.kg_edges.is_empty() {
let mut stmt = tx.prepare(
"INSERT INTO edges (source, kind, target, file, line, project_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
)?;
for e in &extracted.kg_edges {
stmt.execute(rusqlite::params![
e.source,
e.kind.as_str(),
e.target,
e.file,
e.line as i64,
project_id,
])?;
}
}
}
Ok(count)
}
fn load_all_fingerprints(
conn: &Connection,
project_id: i64,
) -> anyhow::Result<HashMap<String, String>> {
let mut stmt = conn.prepare("SELECT path, fingerprint FROM files WHERE project_id = ?1")?;
let map: HashMap<String, String> = stmt
.query_map([project_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.filter_map(|r| r.ok())
.collect();
Ok(map)
}
pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::Result<IndexStats> {
let project_id = ensure_project(conn, root)?;
index_project_with_id(conn, project_id, root, verbose)
}
fn index_project_with_id(
conn: &Connection,
project_id: i64,
root: &Path,
verbose: bool,
) -> anyhow::Result<IndexStats> {
let mut stats = IndexStats::default();
let mut files_to_index: Vec<(String, String, String, String)> = Vec::new();
let stored_fingerprints = load_all_fingerprints(conn, project_id).unwrap_or_default();
let walker = ignore::WalkBuilder::new(root)
.hidden(true)
.git_ignore(true)
.git_exclude(true)
.build();
for entry in walker {
let entry = entry?;
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(path);
let rel_str = rel.to_string_lossy().to_string();
let language = crate::engine::diff_parser::detect_language(&rel_str);
if language == "unknown" || language == "text" {
continue;
}
stats.files_scanned += 1;
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => continue,
};
let mtime = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let size = metadata.len();
let cheap_fp = format!("{mtime}:{size}");
if let Some(stored) = stored_fingerprints.get(&rel_str) {
if *stored == cheap_fp {
stats.files_skipped += 1;
continue;
}
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
files_to_index.push((rel_str, content, language.to_string(), cheap_fp));
}
if !files_to_index.is_empty() {
let t_extract = std::time::Instant::now();
let extracted_files: Vec<(String, extract::ExtractedAll, String, String)> = files_to_index
.par_iter()
.map(|(rel_str, content, language, cheap_fp)| {
let extracted = extract::extract_all(content, language, rel_str);
(
rel_str.clone(),
extracted,
language.clone(),
cheap_fp.clone(),
)
})
.collect();
let extract_ms = t_extract.elapsed().as_millis();
let t_db = std::time::Instant::now();
let tx = conn.unchecked_transaction()?;
tx.execute_batch(
"DROP TRIGGER IF EXISTS symbols_fts_insert;\
DROP TRIGGER IF EXISTS symbols_fts_delete;\
DROP TRIGGER IF EXISTS symbols_fts_update;",
)?;
for (rel_str, extracted, language, cheap_fp) in &extracted_files {
match index_file_in_tx(&tx, project_id, rel_str, cheap_fp, language, extracted) {
Ok(n) => {
stats.files_indexed += 1;
stats.symbols_indexed += n;
}
Err(e) => {
stats.errors += 1;
if verbose {
eprintln!(" ⚠ Failed to index {rel_str}: {e}");
}
}
}
}
tx.execute("INSERT INTO symbols_fts(symbols_fts) VALUES('rebuild')", [])?;
tx.execute_batch(
"CREATE TRIGGER IF NOT EXISTS symbols_fts_insert
AFTER INSERT ON symbols
BEGIN
INSERT INTO symbols_fts(rowid, name, signature)
VALUES (new.id, new.name, new.signature);
END;
CREATE TRIGGER IF NOT EXISTS symbols_fts_delete
AFTER DELETE ON symbols
BEGIN
INSERT INTO symbols_fts(symbols_fts, rowid, name, signature)
VALUES ('delete', old.id, old.name, old.signature);
END;
CREATE TRIGGER IF NOT EXISTS symbols_fts_update
AFTER UPDATE ON symbols
BEGIN
INSERT INTO symbols_fts(symbols_fts, rowid, name, signature)
VALUES ('delete', old.id, old.name, old.signature);
INSERT INTO symbols_fts(rowid, name, signature)
VALUES (new.id, new.name, new.signature);
END;",
)?;
tx.commit()?;
tracing::debug!(
"extract={}ms (rayon), db={}ms, files={}",
extract_ms,
t_db.elapsed().as_millis(),
files_to_index.len()
);
}
conn.execute(
"UPDATE projects SET last_indexed = datetime('now') WHERE id = ?1",
rusqlite::params![project_id],
)?;
info!(
"Index complete: {} files scanned, {} indexed, {} symbols, {} errors",
stats.files_scanned, stats.files_indexed, stats.symbols_indexed, stats.errors
);
if stats.files_indexed > 0 {
match brain::embed_project(conn, project_id) {
Ok(n) => {
stats.embedded_symbols = Some(n);
info!("Brain: embedded {n} symbols");
}
Err(e) => {
if verbose {
eprintln!(" ⚠ Embedding failed (non-fatal): {e}");
}
tracing::warn!("Embedding failed: {e}");
}
}
}
Ok(stats)
}
pub fn search(
conn: &Connection,
project_id: i64,
query: &SymbolQuery,
) -> anyhow::Result<Vec<SearchResult>> {
symbols::search(conn, project_id, query)
}
pub fn index_stats(conn: &Connection, project_id: i64) -> anyhow::Result<IndexSummary> {
let total_symbols: i64 = conn.query_row(
"SELECT COUNT(*) FROM symbols WHERE project_id = ?1",
rusqlite::params![project_id],
|row| row.get(0),
)?;
let total_files: i64 = conn.query_row(
"SELECT COUNT(*) FROM files WHERE project_id = ?1",
rusqlite::params![project_id],
|row| row.get(0),
)?;
let db_size: i64 = {
let page_size: i64 = conn
.query_row("PRAGMA page_size", [], |row| row.get(0))
.unwrap_or(4096);
let page_count: i64 = conn
.query_row("PRAGMA page_count", [], |row| row.get(0))
.unwrap_or(0);
page_size * page_count
};
let mut kind_counts: HashMap<String, usize> = HashMap::new();
let mut stmt =
conn.prepare("SELECT kind, COUNT(*) FROM symbols WHERE project_id = ?1 GROUP BY kind")?;
let rows = stmt.query_map(rusqlite::params![project_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
})?;
for row in rows {
let (kind, count) = row?;
kind_counts.insert(kind, count);
}
let mut lang_counts: HashMap<String, usize> = HashMap::new();
let mut stmt = conn.prepare(
"SELECT language, COUNT(*) FROM symbols WHERE project_id = ?1 GROUP BY language",
)?;
let rows = stmt.query_map(rusqlite::params![project_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
})?;
for row in rows {
let (lang, count) = row?;
lang_counts.insert(lang, count);
}
Ok(IndexSummary {
total_symbols: total_symbols as usize,
total_files: total_files as usize,
db_size_bytes: db_size as u64,
symbols_by_kind: kind_counts,
symbols_by_language: lang_counts,
})
}
pub fn prune_deleted(conn: &Connection, project_id: i64, root: &Path) -> anyhow::Result<usize> {
let mut deleted = 0;
let mut stmt = conn.prepare("SELECT path FROM files WHERE project_id = ?1")?;
let paths: Vec<String> = stmt
.query_map(rusqlite::params![project_id], |row| row.get::<_, String>(0))?
.filter_map(|r| r.ok())
.collect();
let to_prune: Vec<&String> = paths
.iter()
.filter(|path| !root.join(path).exists())
.collect();
if !to_prune.is_empty() {
let tx = conn.unchecked_transaction()?;
for path in &to_prune {
tx.execute(
"DELETE FROM symbols WHERE file = ?1 AND project_id = ?2",
rusqlite::params![path, project_id],
)?;
tx.execute(
"DELETE FROM call_graph WHERE file = ?1 AND project_id = ?2",
rusqlite::params![path, project_id],
)?;
tx.execute(
"DELETE FROM files WHERE path = ?1 AND project_id = ?2",
rusqlite::params![path, project_id],
)?;
}
tx.commit()?;
deleted = to_prune.len();
}
if deleted > 0 {
info!("Pruned {deleted} deleted files from index");
}
Ok(deleted)
}
#[cfg(test)]
fn file_fingerprint(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
#[derive(Debug, Clone, Default)]
pub struct IndexStats {
pub files_scanned: usize,
pub files_indexed: usize,
pub files_skipped: usize,
pub symbols_indexed: usize,
pub errors: usize,
pub embedded_symbols: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct IndexSummary {
pub total_symbols: usize,
pub total_files: usize,
pub db_size_bytes: u64,
pub symbols_by_kind: HashMap<String, usize>,
pub symbols_by_language: HashMap<String, usize>,
}
#[cfg(test)]
mod tests {
use super::*;
fn mem_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
schema::run_migrations(&conn).unwrap();
conn
}
fn test_project(conn: &Connection) -> i64 {
schema::get_or_create_project(conn, "/tmp/test-project").unwrap()
}
#[test]
fn test_open_and_migrate() {
let conn = mem_conn();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_index_rust_file() {
let conn = mem_conn();
let project_id = test_project(&conn);
let code = r#"
use std::collections::HashMap;
pub struct Cache {
inner: HashMap<String, String>,
}
impl Cache {
pub fn new() -> Self {
Self { inner: HashMap::new() }
}
pub fn get(&self, key: &str) -> Option<&String> {
self.inner.get(key)
}
}
"#;
let count = index_file(&conn, project_id, "src/cache.rs", code, "rs").unwrap();
assert!(count > 0, "Should extract symbols from Rust code");
}
#[test]
fn test_search() {
let conn = mem_conn();
let project_id = test_project(&conn);
let code = r#"
pub fn authenticate(token: &str) -> bool {
false
}
pub struct AuthService {
secret: String,
}
"#;
index_file(&conn, project_id, "src/auth.rs", code, "rs").unwrap();
let query = SymbolQuery::text("authenticate");
let results = search(&conn, project_id, &query).unwrap();
assert!(!results.is_empty());
assert!(results[0].symbol.name.contains("authenticate"));
}
#[test]
fn test_index_stats() {
let conn = mem_conn();
let project_id = test_project(&conn);
index_file(&conn, project_id, "a.rs", "fn foo() {}", "rs").unwrap();
index_file(&conn, project_id, "b.rs", "struct Bar {}", "rs").unwrap();
let stats = index_stats(&conn, project_id).unwrap();
assert!(stats.total_symbols >= 2);
assert_eq!(stats.total_files, 2);
assert!(stats.symbols_by_kind.contains_key("function"));
assert!(stats.symbols_by_kind.contains_key("struct"));
}
#[test]
fn test_prune_deleted() {
let conn = mem_conn();
let project_id = test_project(&conn);
index_file(&conn, project_id, "gone.rs", "fn removed() {}", "rs").unwrap();
let tmp = tempfile::tempdir().unwrap();
let deleted = prune_deleted(&conn, project_id, tmp.path()).unwrap();
assert_eq!(deleted, 1);
let stats = index_stats(&conn, project_id).unwrap();
assert_eq!(stats.total_symbols, 0);
}
#[test]
fn test_reindex_replaces_symbols() {
let conn = mem_conn();
let project_id = test_project(&conn);
index_file(&conn, project_id, "test.rs", "fn old_name() {}", "rs").unwrap();
index_file(&conn, project_id, "test.rs", "fn new_name() {}", "rs").unwrap();
let stats = index_stats(&conn, project_id).unwrap();
assert_eq!(stats.total_symbols, 1);
}
#[test]
fn test_resolve_project_root_finds_cargo_toml() {
let cwd = std::env::current_dir().unwrap();
let root = resolve_project_root(&cwd);
assert!(root.is_some(), "should find project root from CWD");
let root = root.unwrap();
assert!(
root.join("Cargo.toml").exists(),
"resolved root should contain Cargo.toml"
);
}
#[test]
fn test_resolve_project_root_finds_cora_yaml() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
std::fs::write(root.join(".cora.yaml"), "version: 1\n").unwrap();
let subdir = root.join("src").join("deep").join("nested");
std::fs::create_dir_all(&subdir).unwrap();
let resolved = resolve_project_root(&subdir);
assert_eq!(resolved, Some(root));
}
#[test]
fn test_resolve_project_root_returns_none_in_tmp() {
let tmp = tempfile::TempDir::new().unwrap();
let deep = tmp.path().join("a").join("b").join("c");
std::fs::create_dir_all(&deep).unwrap();
let resolved = resolve_project_root(&deep);
if !std::path::Path::new("/tmp/.git").exists()
&& !std::path::Path::new("/tmp/Cargo.toml").exists()
&& !std::path::Path::new("/tmp/.cora.yaml").exists()
{
assert!(
resolved.is_none(),
"should not find project root in empty tmp dir, got {resolved:?}"
);
}
}
#[test]
fn test_resolve_project_id_uses_project_root() {
let conn = mem_conn();
let (pid, root) = resolve_project_id(&conn).unwrap();
assert!(pid > 0);
assert!(
root.join("Cargo.toml").exists(),
"resolved root should contain Cargo.toml"
);
}
}