diff --git a/.gitignore b/.gitignore index 8bb96b5..a156ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ dist/ *.tar.gz *.zip .cora/history/ +.cora/index.db diff --git a/Cargo.lock b/Cargo.lock index 606841a..b3de1b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -280,6 +292,7 @@ dependencies = [ "predicates", "regex", "reqwest", + "rusqlite", "serde", "serde_json", "serde_yaml_ng", @@ -426,6 +439,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -604,6 +629,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -619,6 +653,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -975,6 +1018,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.28" @@ -1370,6 +1424,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" diff --git a/Cargo.toml b/Cargo.toml index 15cf7d4..2288966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,9 @@ futures-util = "0.3" sha2 = "0.10" chrono = { version = "0.4.44", features = ["serde"] } +# Symbol index (v0.6 — Code Intelligence) +rusqlite = { version = "0.31", features = ["bundled"] } + [dev-dependencies] assert_cmd = "2" predicates = "3" diff --git a/docs/roadmap.md b/docs/roadmap.md index 34a03b3..9c96249 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,13 +75,30 @@ Demand-gated — we build what people actually need. Track progress on [GitHub I - [#232](https://github.com/codecoradev/cora-cli/issues/232) Uteke memory integration — recall + learn — ✓ Done - [#262](https://github.com/codecoradev/cora-cli/issues/262) `cora commit` — review + auto commit message + quality gate — ✓ Done -## v0.6 — Growth & Marketplace +## v0.6 — Code Intelligence + +The foundation layer for structural code understanding — persistent symbol index, semantic search, and deep Uteke integration. + +- [#264](https://github.com/codecoradev/cora-cli/issues/264) `cora index` — Symbol index & SQLite persistence — ◎ Planned +- [#265](https://github.com/codecoradev/cora-cli/issues/265) `cora explore` — Structural + semantic code search — ◎ Planned +- [#235](https://github.com/codecoradev/cora-cli/issues/235) Cora + Uteke cross-product integration bundle — ◎ Planned + +### Also in v0.6 - [#47](https://github.com/codecoradev/cora-cli/issues/47) GitHub Marketplace action — ✓ Done - [#196](https://github.com/codecoradev/cora-cli/issues/196) VitePress docs site — ✓ Done - [#161](https://github.com/codecoradev/cora-cli/issues/161) `cora gain` — local stats + viral sharing — ◎ Planned - [#160](https://github.com/codecoradev/cora-cli/issues/160) Landing page redesign — ◎ Planned +## v0.7 — Multi-Language & Code Graph + +Query layer built on top of the v0.6 index — call graph traversal, test impact analysis, broader language support, and real-time sync. + +- [#266](https://github.com/codecoradev/cora-cli/issues/266) `cora callers` / `cora impact` — Call graph query commands — ◎ Planned +- [#267](https://github.com/codecoradev/cora-cli/issues/267) `cora affected` — Find tests affected by changes — ◎ Planned +- [#268](https://github.com/codecoradev/cora-cli/issues/268) Language expansion — 6 → 15+ language support — ◎ Planned +- [#269](https://github.com/codecoradev/cora-cli/issues/269) Auto-sync file watcher daemon — ◎ Planned + ## Future — What's Next - [#117](https://github.com/codecoradev/cora-cli/issues/117) Lightweight agent follow-up — 1 capped tool-call — → Planned diff --git a/src/index/extract.rs b/src/index/extract.rs new file mode 100644 index 0000000..6450aad --- /dev/null +++ b/src/index/extract.rs @@ -0,0 +1,371 @@ +//! Symbol extraction from source files. +//! +//! Uses regex-based extraction — same approach as `engine/context/extraction.rs` +//! but extracts definitions (not just references). No heavy AST parser needed. + +use regex::Regex; +use std::sync::LazyLock; + +use super::symbols::{IndexedSymbol, SymbolKind}; + +// ─── Rust ─── + +static RE_RUST_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)").unwrap()); + +static RE_RUST_STRUCT: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?struct\s+(\w+)").unwrap()); + +static RE_RUST_ENUM: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?enum\s+(\w+)").unwrap()); + +static RE_RUST_TRAIT: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?trait\s+(\w+)").unwrap()); + +static RE_RUST_TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?type\s+(\w+)").unwrap()); + +static RE_RUST_CONST: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?(?:const|static)\s+(\w+)").unwrap()); + +static RE_RUST_MOD: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?mod\s+(\w+)").unwrap()); + +// RE_RUST_IMPL removed — not needed for symbol definitions + +// ─── Python ─── + +static RE_PY_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:async\s+)?def\s+(\w+)").unwrap()); + +static RE_PY_CLASS: LazyLock = LazyLock::new(|| Regex::new(r"^\s*class\s+(\w+)").unwrap()); + +// ─── TypeScript/JavaScript ─── + +static RE_TS_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)").unwrap()); + +static RE_TS_CLASS: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)").unwrap()); + +static RE_TS_INTERFACE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?interface\s+(\w+)").unwrap()); + +static RE_TS_TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?type\s+(\w+)").unwrap()); + +static RE_TS_CONST: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?const\s+(\w+)").unwrap()); + +// ─── Go ─── + +static RE_GO_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*func\s+(?:\([^)]+\)\s+)?(\w+)").unwrap()); + +static RE_GO_STRUCT: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*type\s+(\w+)\s+struct").unwrap()); + +static RE_GO_INTERFACE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*type\s+(\w+)\s+interface").unwrap()); + +static RE_GO_CONST: LazyLock = LazyLock::new(|| Regex::new(r"^\s*const\s+(\w+)").unwrap()); + +// ─── Java/Kotlin ─── + +static RE_JAVA_CLASS: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|private|protected)?\s*(?:abstract\s+)?class\s+(\w+)").unwrap() +}); + +static RE_JAVA_METHOD: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|private|protected)?\s*(?:static\s+)?[\w<>\[\]]+\s+(\w+)\s*\(") + .unwrap() +}); + +// ─── C/C++ ─── + +static RE_C_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:static\s+)?[\w\*]+\s+(\w+)\s*\([^;]*\)\s*\{").unwrap()); + +static RE_C_STRUCT: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:typedef\s+)?struct\s+(\w+)").unwrap()); + +/// Extract symbols from source code. +/// +/// Returns a list of `IndexedSymbol` entries (without id, which is assigned by the database). +pub fn extract_symbols(content: &str, language: &str, file_path: &str) -> Vec { + let mut symbols = Vec::new(); + + for (line_num, line) in content.lines().enumerate() { + let line_no = (line_num + 1) as u32; + + match language { + "rs" => extract_rust(line, line_no, file_path, line, &mut symbols), + "py" | "pyi" => extract_python(line, line_no, file_path, line, &mut symbols), + "ts" | "tsx" | "js" | "jsx" => { + extract_typescript(line, line_no, file_path, line, &mut symbols) + } + "go" => extract_go(line, line_no, file_path, line, &mut symbols), + "java" | "kt" => extract_java(line, line_no, file_path, line, &mut symbols), + "c" | "cpp" | "cc" | "cxx" | "h" | "hpp" => { + extract_c(line, line_no, file_path, line, &mut symbols) + } + _ => {} + } + } + + // Deduplicate by (name, line) — regex may match multiple times + symbols.dedup_by(|a, b| a.name == b.name && a.line == b.line); + + symbols +} + +/// A symbol definition extracted from source code. +#[derive(Debug, Clone)] +pub struct ExtractedDef { + pub name: String, + pub kind: SymbolKind, + pub file: String, + pub line: u32, + pub signature: String, +} + +impl From<&ExtractedDef> for IndexedSymbol { + fn from(d: &ExtractedDef) -> Self { + Self { + id: 0, + name: d.name.clone(), + kind: d.kind.clone(), + file: d.file.clone(), + line: d.line, + signature: d.signature.clone(), + language: String::new(), + } + } +} + +// ─── Per-language extractors ─── + +fn extract_rust(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_RUST_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_STRUCT.captures(line) { + out.push(def(cap, SymbolKind::Struct, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_ENUM.captures(line) { + out.push(def(cap, SymbolKind::Enum, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_TRAIT.captures(line) { + out.push(def(cap, SymbolKind::Trait, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_TYPE.captures(line) { + out.push(def(cap, SymbolKind::TypeAlias, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_CONST.captures(line) { + out.push(def(cap, SymbolKind::Constant, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_MOD.captures(line) { + out.push(def(cap, SymbolKind::Module, line_no, file, raw)); + } +} + +fn extract_python(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_PY_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_PY_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } +} + +fn extract_typescript( + line: &str, + line_no: u32, + file: &str, + raw: &str, + out: &mut Vec, +) { + if let Some(cap) = RE_TS_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_TS_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } + if let Some(cap) = RE_TS_INTERFACE.captures(line) { + out.push(def(cap, SymbolKind::Interface, line_no, file, raw)); + } + if let Some(cap) = RE_TS_TYPE.captures(line) { + out.push(def(cap, SymbolKind::TypeAlias, line_no, file, raw)); + } + if let Some(cap) = RE_TS_CONST.captures(line) { + out.push(def(cap, SymbolKind::Constant, line_no, file, raw)); + } +} + +fn extract_go(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_GO_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_GO_STRUCT.captures(line) { + out.push(def(cap, SymbolKind::Struct, line_no, file, raw)); + } + if let Some(cap) = RE_GO_INTERFACE.captures(line) { + out.push(def(cap, SymbolKind::Interface, line_no, file, raw)); + } + if let Some(cap) = RE_GO_CONST.captures(line) { + out.push(def(cap, SymbolKind::Constant, line_no, file, raw)); + } +} + +fn extract_java(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_JAVA_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } + if let Some(cap) = RE_JAVA_METHOD.captures(line) { + out.push(def(cap, SymbolKind::Method, line_no, file, raw)); + } +} + +fn extract_c(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_C_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_C_STRUCT.captures(line) { + out.push(def(cap, SymbolKind::Struct, line_no, file, raw)); + } +} + +/// Helper: create an ExtractedDef from a regex capture. +fn def(cap: regex::Captures, kind: SymbolKind, line: u32, file: &str, raw: &str) -> ExtractedDef { + ExtractedDef { + name: cap + .get(1) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(), + kind, + line, + file: file.to_string(), + signature: raw.trim().to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_rust() { + let code = r#" +pub struct Cache { + inner: HashMap, +} + +impl Cache { + pub fn new() -> Self { + Self {} + } + + pub fn get(&self, key: &str) -> Option<&String> { + self.inner.get(key) + } +} + +enum Status { + Active, + Inactive, +} + +const MAX_SIZE: usize = 100; +"#; + let symbols = extract_symbols(code, "rs", "src/cache.rs"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"Cache")); + assert!(names.contains(&"new")); + assert!(names.contains(&"get")); + assert!(names.contains(&"Status")); + assert!(names.contains(&"MAX_SIZE")); + } + + #[test] + fn test_extract_python() { + let code = r#" +class AuthService: + def __init__(self): + self.secret = "" + + async def validate(self, token: str) -> bool: + return False +"#; + let symbols = extract_symbols(code, "py", "auth.py"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"AuthService")); + assert!(names.contains(&"validate")); + } + + #[test] + fn test_extract_typescript() { + let code = r#" +export interface User { + id: string; + name: string; +} + +export class UserService { + async getUser(id: string): Promise { + return {} as User; + } +} + +export type Status = 'active' | 'inactive'; + +export const DEFAULT_TIMEOUT = 5000; +"#; + let symbols = extract_symbols(code, "ts", "user.ts"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"User")); + assert!(names.contains(&"UserService")); + assert!(names.contains(&"Status")); + assert!(names.contains(&"DEFAULT_TIMEOUT")); + } + + #[test] + fn test_extract_go() { + let code = r#" +type Server struct { + port int +} + +func (s *Server) Start() error { + return nil +} + +func NewServer(port int) *Server { + return &Server{port: port} +} + +const DefaultPort = 8080 +"#; + let symbols = extract_symbols(code, "go", "server.go"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"Server")); + assert!(names.contains(&"Start")); + assert!(names.contains(&"NewServer")); + assert!(names.contains(&"DefaultPort")); + } + + #[test] + fn test_extract_unknown_language() { + let symbols = extract_symbols("fn test() {}", "unknown", "test.txt"); + assert!(symbols.is_empty()); + } + + #[test] + fn test_extract_empty() { + let symbols = extract_symbols("", "rs", "empty.rs"); + assert!(symbols.is_empty()); + } +} diff --git a/src/index/mod.rs b/src/index/mod.rs new file mode 100644 index 0000000..c883911 --- /dev/null +++ b/src/index/mod.rs @@ -0,0 +1,399 @@ +//! Symbol index engine — persistent SQLite-backed symbol store. +//! +//! Build, query, and maintain a symbol index for code intelligence. +//! Uses regex-based extraction (same approach as `engine/context/extraction.rs`) +//! stored in SQLite with FTS5 for fast full-text search. + +mod extract; +mod schema; +mod symbols; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use tracing::{debug, info}; + +pub use symbols::{SearchResult, SymbolKind, SymbolQuery}; + +/// Default index database path relative to project root. +pub const INDEX_DB_NAME: &str = ".cora/index.db"; + +/// Open or create the symbol index database. +pub fn open_index(db_path: &Path) -> anyhow::Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = Connection::open(db_path)?; + + // Enable WAL mode for better concurrent read performance + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + + schema::run_migrations(&conn)?; + + debug!("Opened index at {}", db_path.display()); + Ok(conn) +} + +/// Resolve the default index database path for a project. +pub fn default_db_path(project_root: &Path) -> PathBuf { + project_root.join(INDEX_DB_NAME) +} + +/// Index a single file: extract symbols and store in the database. +/// +/// Returns the number of symbols indexed. +pub fn index_file( + conn: &Connection, + file_path: &str, + content: &str, + language: &str, +) -> anyhow::Result { + let fingerprint = file_fingerprint(content); + let symbols = extract::extract_symbols(content, language, file_path); + + // Begin transaction + let tx = conn.unchecked_transaction()?; + + // Delete existing symbols for this file + tx.execute( + "DELETE FROM symbols WHERE file = ?1", + rusqlite::params![file_path], + )?; + + // Update file fingerprint + tx.execute( + "INSERT OR REPLACE INTO files (path, fingerprint, last_indexed, language, symbol_count) + VALUES (?1, ?2, datetime('now'), ?3, ?4)", + rusqlite::params![file_path, fingerprint, language, symbols.len() as i64], + )?; + + // Insert symbols + let mut count = 0; + for sym in &symbols { + tx.execute( + "INSERT INTO symbols (name, kind, file, line, signature, language) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + sym.name, + sym.kind.as_str(), + sym.file, + sym.line as i64, + sym.signature, + language, + ], + )?; + count += 1; + } + + tx.commit()?; + + debug!("Indexed {file_path}: {count} symbols ({language})"); + Ok(count) +} + +/// Check if a file needs re-indexing based on content hash. +pub fn needs_reindex(conn: &Connection, file_path: &str, content: &str) -> bool { + let fingerprint = file_fingerprint(content); + + let stored: Option = conn + .query_row( + "SELECT fingerprint FROM files WHERE path = ?1", + rusqlite::params![file_path], + |row| row.get(0), + ) + .ok(); + + match stored { + Some(fp) => fp != fingerprint, + None => true, + } +} + +/// Index a project directory, respecting .gitignore. +/// +/// Returns summary stats. +pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::Result { + let mut stats = IndexStats::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 content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + + if !needs_reindex(conn, &rel_str, &content) { + stats.files_skipped += 1; + continue; + } + + stats.files_indexed += 1; + match index_file(conn, &rel_str, &content, language) { + Ok(n) => stats.symbols_indexed += n, + Err(e) => { + stats.errors += 1; + if verbose { + eprintln!(" ⚠ Failed to index {rel_str}: {e}"); + } + } + } + } + + info!( + "Index complete: {} files scanned, {} indexed, {} symbols, {} errors", + stats.files_scanned, stats.files_indexed, stats.symbols_indexed, stats.errors + ); + + Ok(stats) +} + +/// Search the symbol index using FTS5 full-text search. +pub fn search(conn: &Connection, query: &SymbolQuery) -> anyhow::Result> { + symbols::search(conn, query) +} + +/// Get index statistics. +pub fn index_stats(conn: &Connection) -> anyhow::Result { + let total_symbols: i64 = + conn.query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))?; + let total_files: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?; + let db_size: i64 = conn + .query_row("PRAGMA page_count", [], |row| row.get(0)) + .unwrap_or(0) + * 4096; // page_size default + + // Symbols by kind + let mut kind_counts: HashMap = HashMap::new(); + let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM symbols GROUP BY kind")?; + let rows = stmt.query_map([], |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); + } + + // Symbols by language + let mut lang_counts: HashMap = HashMap::new(); + let mut stmt = conn.prepare("SELECT language, COUNT(*) FROM symbols GROUP BY language")?; + let rows = stmt.query_map([], |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, + }) +} + +/// Remove symbols for files that no longer exist on disk. +pub fn prune_deleted(conn: &Connection, root: &Path) -> anyhow::Result { + let mut deleted = 0; + + let mut stmt = conn.prepare("SELECT path FROM files")?; + let paths: Vec = stmt + .query_map([], |row| row.get::<_, String>(0))? + .filter_map(|r| r.ok()) + .collect(); + + for path in &paths { + let full = root.join(path); + if !full.exists() { + let tx = conn.unchecked_transaction()?; + tx.execute( + "DELETE FROM symbols WHERE file = ?1", + rusqlite::params![path], + )?; + tx.execute("DELETE FROM files WHERE path = ?1", rusqlite::params![path])?; + tx.commit()?; + deleted += 1; + } + } + + if deleted > 0 { + info!("Pruned {deleted} deleted files from index"); + } + + Ok(deleted) +} + +/// Compute a SHA-256 fingerprint for file content. +fn file_fingerprint(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Statistics from an index build run. +#[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, +} + +/// Summary of the current index state. +#[derive(Debug, Clone)] +pub struct IndexSummary { + pub total_symbols: usize, + pub total_files: usize, + pub db_size_bytes: u64, + pub symbols_by_kind: HashMap, + pub symbols_by_language: HashMap, +} + +#[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 + } + + #[test] + fn test_open_and_migrate() { + let conn = mem_conn(); + // Tables should exist + 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 code = r#" +use std::collections::HashMap; + +pub struct Cache { + inner: HashMap, +} + +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, "src/cache.rs", code, "rs").unwrap(); + assert!(count > 0, "Should extract symbols from Rust code"); + } + + #[test] + fn test_needs_reindex() { + let conn = mem_conn(); + let code = "fn hello() {}"; + + // First time → needs reindex + assert!(needs_reindex(&conn, "test.rs", code)); + + // Index it + index_file(&conn, "test.rs", code, "rs").unwrap(); + + // Same content → no reindex needed + assert!(!needs_reindex(&conn, "test.rs", code)); + + // Changed content → needs reindex + assert!(needs_reindex(&conn, "test.rs", "fn world() {}")); + } + + #[test] + fn test_search() { + let conn = mem_conn(); + let code = r#" +pub fn authenticate(token: &str) -> bool { + false +} + +pub struct AuthService { + secret: String, +} +"#; + index_file(&conn, "src/auth.rs", code, "rs").unwrap(); + + let query = SymbolQuery::text("authenticate"); + let results = search(&conn, &query).unwrap(); + assert!(!results.is_empty()); + assert!(results[0].symbol.name.contains("authenticate")); + } + + #[test] + fn test_index_stats() { + let conn = mem_conn(); + index_file(&conn, "a.rs", "fn foo() {}", "rs").unwrap(); + index_file(&conn, "b.rs", "struct Bar {}", "rs").unwrap(); + + let stats = index_stats(&conn).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(); + index_file(&conn, "gone.rs", "fn removed() {}", "rs").unwrap(); + + // Create temp dir to use as root + let tmp = tempfile::tempdir().unwrap(); + // gone.rs doesn't exist in temp dir → should be pruned + let deleted = prune_deleted(&conn, tmp.path()).unwrap(); + assert_eq!(deleted, 1); + + let stats = index_stats(&conn).unwrap(); + assert_eq!(stats.total_symbols, 0); + } + + #[test] + fn test_reindex_replaces_symbols() { + let conn = mem_conn(); + index_file(&conn, "test.rs", "fn old_name() {}", "rs").unwrap(); + index_file(&conn, "test.rs", "fn new_name() {}", "rs").unwrap(); + + let stats = index_stats(&conn).unwrap(); + // Should have 1 symbol (replaced, not 2) + assert_eq!(stats.total_symbols, 1); + } +} diff --git a/src/index/schema.rs b/src/index/schema.rs new file mode 100644 index 0000000..b4d626a --- /dev/null +++ b/src/index/schema.rs @@ -0,0 +1,152 @@ +//! SQLite schema management for the symbol index. + +use rusqlite::Connection; + +/// Current schema version. +const SCHEMA_VERSION: i32 = 1; + +/// Run database migrations (creates tables if not exist). +pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { + // Schema version tracking + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT DEFAULT (datetime('now')) + );", + )?; + + let current: i32 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |row| { + row.get(0) + }) + .unwrap_or(0); + + if current < 1 { + migrate_v1(conn)?; + } + + Ok(()) +} + +/// Migration v1: Initial schema — symbols, files, FTS5 index. +fn migrate_v1(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + " + -- Symbol definitions extracted from source files + CREATE TABLE IF NOT EXISTS symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + kind TEXT NOT NULL, + file TEXT NOT NULL, + line INTEGER NOT NULL, + signature TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT 'unknown', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- Index for file-based queries + CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file); + + -- Index for name-based lookups + CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name); + + -- Index for kind-based filtering + CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind); + + -- File tracking for incremental indexing + CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + last_indexed TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'unknown', + symbol_count INTEGER NOT NULL DEFAULT 0 + ); + + -- FTS5 virtual table for full-text search on symbol names + CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5( + name, + signature, + content='symbols', + content_rowid='id', + tokenize='unicode61 remove_diacritics 1' + ); + + -- Triggers to keep FTS5 in sync with symbols table + 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; + ", + )?; + + conn.execute( + "INSERT INTO schema_version (version) VALUES (?1)", + rusqlite::params![SCHEMA_VERSION], + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_migration_creates_tables() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + + // Check symbols table + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + + // Check files table + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + + // Check FTS table exists + conn.query_row("SELECT COUNT(*) FROM symbols_fts", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(); + + // Check schema version + let version: i32 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } + + #[test] + fn test_migration_idempotent() { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + // Running again should not error + run_migrations(&conn).unwrap(); + } +} diff --git a/src/index/symbols.rs b/src/index/symbols.rs new file mode 100644 index 0000000..19402b8 --- /dev/null +++ b/src/index/symbols.rs @@ -0,0 +1,374 @@ +//! Symbol types for the index. + +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +/// Kind of a symbol. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SymbolKind { + Function, + Struct, + Enum, + Trait, + Interface, + Class, + Method, + Constant, + Module, + TypeAlias, + Variable, +} + +impl SymbolKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Function => "function", + Self::Struct => "struct", + Self::Enum => "enum", + Self::Trait => "trait", + Self::Interface => "interface", + Self::Class => "class", + Self::Method => "method", + Self::Constant => "constant", + Self::Module => "module", + Self::TypeAlias => "type_alias", + Self::Variable => "variable", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "function" => Self::Function, + "struct" => Self::Struct, + "enum" => Self::Enum, + "trait" => Self::Trait, + "interface" => Self::Interface, + "class" => Self::Class, + "method" => Self::Method, + "constant" => Self::Constant, + "module" => Self::Module, + "type_alias" => Self::TypeAlias, + "variable" => Self::Variable, + _ => Self::Variable, + } + } +} + +impl std::fmt::Display for SymbolKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// A symbol stored in the index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexedSymbol { + pub id: i64, + pub name: String, + pub kind: SymbolKind, + pub file: String, + pub line: u32, + pub signature: String, + pub language: String, +} + +/// Query parameters for searching the index. +#[derive(Debug, Clone, Default)] +pub struct SymbolQuery { + /// FTS5 search text (matches name + signature). + pub text: Option, + /// Filter by symbol kind. + pub kind: Option, + /// Filter by file path (exact or prefix). + pub file_prefix: Option, + /// Filter by language. + pub language: Option, + /// Maximum results. + pub limit: usize, +} + +impl SymbolQuery { + /// Create a text search query. + #[allow(dead_code)] + pub fn text(text: &str) -> Self { + Self { + text: Some(text.to_string()), + limit: 50, + ..Default::default() + } + } + + /// Create a kind-filtered query. + #[allow(dead_code)] + pub fn kind(kind: SymbolKind) -> Self { + Self { + kind: Some(kind), + limit: 50, + ..Default::default() + } + } +} + +/// A search result from the index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + pub symbol: IndexedSymbol, + pub score: f64, +} + +/// Execute a symbol search query against the database. +pub fn search(conn: &Connection, query: &SymbolQuery) -> anyhow::Result> { + let limit = if query.limit > 0 { + query.limit as i64 + } else { + 50 + }; + + if let Some(text) = &query.text { + // FTS5 full-text search + let fts_query = sanitize_fts_query(text); + + let mut sql = String::from( + "SELECT s.id, s.name, s.kind, s.file, s.line, s.signature, s.language, + bm25(symbols_fts) as score + FROM symbols_fts + JOIN symbols s ON s.id = symbols_fts.rowid + WHERE symbols_fts MATCH ?1", + ); + + let mut params: Vec> = vec![Box::new(fts_query.clone())]; + + let mut param_idx = 2; + + if let Some(kind) = &query.kind { + sql.push_str(&format!(" AND s.kind = ?{param_idx}")); + params.push(Box::new(kind.as_str().to_string())); + param_idx += 1; + } + + if let Some(lang) = &query.language { + sql.push_str(&format!(" AND s.language = ?{param_idx}")); + params.push(Box::new(lang.clone())); + param_idx += 1; + } + + if let Some(prefix) = &query.file_prefix { + sql.push_str(&format!(" AND s.file LIKE ?{param_idx}")); + params.push(Box::new(format!("{prefix}%"))); + // param_idx not needed after last push + } + + sql.push_str(&format!(" ORDER BY score ASC LIMIT {limit}")); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Ok(SearchResult { + symbol: IndexedSymbol { + id: row.get(0)?, + name: row.get(1)?, + kind: SymbolKind::from_str(&row.get::<_, String>(2)?), + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + signature: row.get(5)?, + language: row.get(6)?, + }, + // bm25 returns negative scores (more negative = better match) + // Convert to positive score where higher = better + score: { + let raw: f64 = row.get(7)?; + if raw < 0.0 { -raw } else { raw } + }, + }) + })?; + + let mut results: Vec = rows.filter_map(|r| r.ok()).collect(); + + // If FTS returns nothing, try LIKE fallback on name + if results.is_empty() { + results = like_search(conn, text, query, limit)?; + } + + Ok(results) + } else { + // No text query — just filter by kind/file/language + filter_search(conn, query, limit) + } +} + +/// Fallback: LIKE-based search when FTS5 returns nothing. +#[allow(unused_assignments)] +fn like_search( + conn: &Connection, + text: &str, + query: &SymbolQuery, + limit: i64, +) -> anyhow::Result> { + let pattern = format!("%{text}%"); + + let mut sql = String::from( + "SELECT id, name, kind, file, line, signature, language + FROM symbols WHERE name LIKE ?1", + ); + + let mut params: Vec> = vec![Box::new(pattern)]; + let mut idx = 2; + + if let Some(kind) = &query.kind { + sql.push_str(&format!(" AND kind = ?{idx}")); + params.push(Box::new(kind.as_str().to_string())); + idx += 1; + } + + sql.push_str(&format!(" LIMIT {limit}")); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Ok(SearchResult { + symbol: IndexedSymbol { + id: row.get(0)?, + name: row.get(1)?, + kind: SymbolKind::from_str(&row.get::<_, String>(2)?), + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + signature: row.get(5)?, + language: row.get(6)?, + }, + score: 1.0, + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Filter-only search (no FTS text). +#[allow(unused_assignments)] +fn filter_search( + conn: &Connection, + query: &SymbolQuery, + limit: i64, +) -> anyhow::Result> { + let mut sql = String::from( + "SELECT id, name, kind, file, line, signature, language FROM symbols WHERE 1=1", + ); + + let mut params: Vec> = vec![]; + let mut idx = 1; + + if let Some(kind) = &query.kind { + sql.push_str(&format!(" AND kind = ?{idx}")); + params.push(Box::new(kind.as_str().to_string())); + idx += 1; + } + + if let Some(lang) = &query.language { + sql.push_str(&format!(" AND language = ?{idx}")); + params.push(Box::new(lang.clone())); + idx += 1; + } + + if let Some(prefix) = &query.file_prefix { + sql.push_str(&format!(" AND file LIKE ?{idx}")); + params.push(Box::new(format!("{prefix}%"))); + idx += 1; + } + + sql.push_str(&format!(" LIMIT {limit}")); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Ok(SearchResult { + symbol: IndexedSymbol { + id: row.get(0)?, + name: row.get(1)?, + kind: SymbolKind::from_str(&row.get::<_, String>(2)?), + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + signature: row.get(5)?, + language: row.get(6)?, + }, + score: 0.0, + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Sanitize a user query for FTS5 MATCH syntax. +/// Wraps each token in quotes to prevent FTS5 syntax errors. +fn sanitize_fts_query(text: &str) -> String { + // Split on whitespace, quote each token, join with AND + text.split_whitespace() + .map(|token| { + let clean: String = token + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == ':') + .collect(); + if clean.is_empty() { + String::new() + } else { + format!("\"{clean}\"") + } + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_symbol_kind_round_trip() { + let kinds = [ + SymbolKind::Function, + SymbolKind::Struct, + SymbolKind::Enum, + SymbolKind::Trait, + SymbolKind::Method, + SymbolKind::Constant, + ]; + for kind in &kinds { + let s = kind.as_str(); + assert_eq!(SymbolKind::from_str(s), *kind); + } + } + + #[test] + fn test_symbol_kind_display() { + assert_eq!(SymbolKind::Function.to_string(), "function"); + assert_eq!(SymbolKind::Struct.to_string(), "struct"); + } + + #[test] + fn test_sanitize_fts_query() { + assert_eq!(sanitize_fts_query("auth"), "\"auth\""); + assert_eq!(sanitize_fts_query("auth login"), "\"auth\" \"login\""); + assert_eq!( + sanitize_fts_query("auth; DROP TABLE"), + "\"auth\" \"DROP\" \"TABLE\"" + ); + assert_eq!(sanitize_fts_query(""), ""); + } + + #[test] + fn test_symbol_query_text() { + let q = SymbolQuery::text("authenticate"); + assert_eq!(q.text.as_deref(), Some("authenticate")); + assert_eq!(q.limit, 50); + } + + #[test] + fn test_symbol_query_kind() { + let q = SymbolQuery::kind(SymbolKind::Function); + assert_eq!(q.kind, Some(SymbolKind::Function)); + assert_eq!(q.limit, 50); + } +} diff --git a/src/main.rs b/src/main.rs index 8d7b611..d5099c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod error; mod formatters; mod git; mod hook; +mod index; mod mcp; mod progress; @@ -80,6 +81,51 @@ struct GlobalOptions { #[derive(Subcommand, Debug)] enum Command { + /// Build or update the symbol index for code intelligence + Index { + /// Show index statistics instead of building + #[clap(long)] + stats: bool, + + /// Prune deleted files from index + #[clap(long)] + prune: bool, + + /// Rebuild index from scratch (drop existing) + #[clap(long)] + rebuild: bool, + + /// Verbose output + #[clap(long, short)] + verbose: bool, + }, + + /// Search the symbol index for code intelligence + Explore { + /// Search query (symbol name or keyword) + query: Option, + + /// Filter by symbol kind (function, struct, enum, trait, etc.) + #[clap(long)] + kind: Option, + + /// Filter by file path prefix + #[clap(long)] + file: Option, + + /// Filter by language + #[clap(long)] + language: Option, + + /// Maximum results + #[clap(long, default_value = "50")] + limit: usize, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + /// Review staged changes, generate commit message, and commit Commit { /// YOLO mode — auto-commit without prompts @@ -390,6 +436,17 @@ enum ProfileAction { }, } +/// Format bytes as human-readable string. +fn format_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + #[allow(clippy::too_many_lines)] #[tokio::main] async fn main() -> Result<()> { @@ -420,6 +477,139 @@ async fn main() -> Result<()> { // Dispatch based on subcommand let exit_code = match cli.command { + Command::Index { + stats: show_stats, + prune, + rebuild, + verbose, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + + if rebuild && db_path.exists() { + std::fs::remove_file(&db_path)?; + eprintln!("{}", "Dropped existing index.".dimmed()); + } + + let conn = index::open_index(&db_path)?; + + if show_stats { + let summary = index::index_stats(&conn)?; + println!("{}", "SYMBOL INDEX".cyan().bold()); + println!("{}", "────────────────────────────".dimmed()); + println!(" Total symbols: {}", summary.total_symbols); + println!(" Total files: {}", summary.total_files); + println!(" Database size: {}", format_bytes(summary.db_size_bytes)); + println!(); + println!(" {}", "By Kind".cyan()); + for (kind, count) in &summary.symbols_by_kind { + println!(" {kind:<16} {count}"); + } + println!(); + println!(" {}", "By Language".cyan()); + for (lang, count) in &summary.symbols_by_language { + println!(" {lang:<16} {count}"); + } + } else if prune { + let deleted = index::prune_deleted(&conn, &project_root)?; + println!( + "{}", + format!("Pruned {deleted} deleted files from index.").green() + ); + } else { + eprintln!("{}", "🔍 Indexing project...".cyan()); + let stats = + index::index_project(&conn, &project_root, verbose || cli.global.verbose)?; + eprintln!( + "{}", + format!( + "✅ Indexed {} symbols from {} files ({} skipped, {} errors)", + stats.symbols_indexed, + stats.files_indexed, + stats.files_skipped, + stats.errors + ) + .green() + ); + eprintln!("{}", format!(" Database: {}", db_path.display()).dimmed()); + } + 0 + } + + Command::Explore { + query, + kind, + file, + language, + limit, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + + if !db_path.exists() { + eprintln!("{}", "No index found. Run `cora index` first.".yellow()); + std::process::exit(1); + } + + let conn = index::open_index(&db_path)?; + + let sym_kind = kind.as_deref().map(index::SymbolKind::from_str); + + let q = index::SymbolQuery { + text: query, + kind: sym_kind, + file_prefix: file, + language, + limit, + }; + + let results = index::search(&conn, &q)?; + + if json { + let json_results: Vec = results + .iter() + .map(|r| { + serde_json::json!({ + "name": r.symbol.name, + "kind": r.symbol.kind.as_str(), + "file": r.symbol.file, + "line": r.symbol.line, + "signature": r.symbol.signature, + "language": r.symbol.language, + "score": r.score, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_results)?); + } else if results.is_empty() { + eprintln!("{}", "No symbols found.".yellow()); + } else { + println!("{}", format!("Found {} symbols:", results.len()).cyan()); + println!( + "{}", + "───────────────────────────────────────────────".dimmed() + ); + for r in &results { + println!( + " {} {} {}:{}", + r.symbol.kind.as_str().blue(), + r.symbol.name.white().bold(), + r.symbol.file.dimmed(), + r.symbol.line + ); + if !r.symbol.signature.is_empty() { + println!( + " {} {}", + "→".dimmed(), + r.symbol.signature.trim().dimmed() + ); + } + } + } + 0 + } + Command::Commit { yolo, force,