From 719d967803f896bcf9f62c621e1ebc1ea16b031c Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 22 Jul 2026 15:40:50 +0700 Subject: [PATCH 1/2] feat: migrate index DB from per-project to global ~/.codecora/cora-code/graph.db with project_id scoping BREAKING CHANGE: index database moved from .cora/index.db (per-project) to ~/.codecora/cora-code/graph.db (global). Schema v2 adds projects table with project_id FK on files, symbols, and call_graph tables. All queries now scoped by project_id. - Add data_dir module: resolves ~/.codecora/cora-code/graph.db with CODECORA_DATA_DIR env override - Add schema v2 migration: projects table, project_id FK on files/symbols/call_graph (CASCADE) - Replace open_index()/default_db_path() with open_global_index() returning (Connection, project_id) - Update all index functions (search, index_stats, prune_deleted, find_callers, find_callees, impact_analysis, clear_edges_for_file, store_edges) to accept project_id - Update all CLI commands (Index, Explore, Callers, Impact, Affected) to use global index - Update all MCP tools to use global index with project scoping - Remove dead code (open_index, resolve_project_root) - Add CodeCora Data Directory Standard doc - All 703 tests pass, 0 clippy warnings --- docs/standards/codecora-data-directory.md | 54 +++++ src/data_dir.rs | 96 +++++++++ src/index/graph.rs | 65 +++--- src/index/mod.rs | 229 +++++++++++++++------- src/index/schema.rs | 202 ++++++++++++++++++- src/index/symbols.rs | 30 +-- src/main.rs | 78 +++++--- src/mcp/tools.rs | 73 ++++--- 8 files changed, 646 insertions(+), 181 deletions(-) create mode 100644 docs/standards/codecora-data-directory.md create mode 100644 src/data_dir.rs diff --git a/docs/standards/codecora-data-directory.md b/docs/standards/codecora-data-directory.md new file mode 100644 index 0000000..01fea0d --- /dev/null +++ b/docs/standards/codecora-data-directory.md @@ -0,0 +1,54 @@ +# CodeCora Data Directory Standard + +All CodeCora products store their runtime data under `$HOME/.codecora/{product}/`. + +## Layout + +``` +~/.codecora/ +├── cora-code/ # cora-code runtime data +│ ├── graph.db # knowledge graph (Phase 2) +│ └── index/ # embedding cache, index files +├── nginjen/ # nginjen runtime data +│ ├── config.toml # runtime config +│ └── engines/ # cached browser engines +├── trapfall/ # trapfall runtime data +│ └── trapfall.db # error capture database +└── uteke/ # uteke runtime data (future migration from ~/.uteke/) + └── uteke.db # semantic memory database +``` + +## Rules + +1. **Path**: `$HOME/.codecora/{product}/` — always resolve via `dirs::home_dir()` +2. **Product name**: lowercase, kebab-case (`cora-code`, `nginjen`, `trapfall`, `uteke`) +3. **Auto-create**: `create_dir_all()` on first access +4. **SQLite files**: `{product}.db` inside the product directory +5. **Cross-product read**: any CodeCora product MAY read another product's data + (e.g., cora-code reading uteke memories, trapfall reading cora-code graph) +6. **No config here**: config files go elsewhere (env vars, CLI flags, project-local `.cora/`) +7. **Env override**: `{PRODUCT}_DATA_DIR` overrides the default path + +## Reference Implementation (Rust) + +```rust +use std::path::PathBuf; + +pub fn data_dir() -> PathBuf { + if let Ok(dir) = std::env::var("CORA_CODE_DATA_DIR") { + return PathBuf::from(dir); + } + let home = dirs::home_dir() + .expect("home directory not found"); + home.join(".codecora").join("cora-code") +} +``` + +## Adoption Status + +| Product | Status | Notes | +|---------|--------|-------| +| nginjen | ✅ Adopted | `engine.rs` uses `~/.codecora/nginjen/engines`, `config.rs` uses XDG dirs (inconsistency — align to this standard) | +| trapfall | ✅ Adopted | `config.rs` uses `$HOME/.codecora/trapfall/` | +| cora-code | 🔜 Phase 2 | Will store `graph.db` here | +| uteke | ⏸️ Deferred | Currently `~/.uteke/` — established path, low priority migration | \ No newline at end of file diff --git a/src/data_dir.rs b/src/data_dir.rs new file mode 100644 index 0000000..5c0b7c7 --- /dev/null +++ b/src/data_dir.rs @@ -0,0 +1,96 @@ +//! CodeCora data directory resolution. +//! +//! All CodeCora products store runtime data under `$HOME/.codecora/{product}/`. +//! This module provides the shared resolution logic. + +use std::path::PathBuf; + +/// Environment variable to override the CodeCora data root. +/// When set, all products use this as the parent directory instead of `$HOME/.codecora/`. +pub const CODECORA_HOME_ENV: &str = "CODECORA_HOME"; + +/// Returns the CodeCora data directory: `$HOME/.codecora/` (or `CODECORA_HOME` override). +/// +/// ```text +/// CODECORA_HOME=/custom → /custom +/// (not set) → $HOME/.codecora/ +/// ``` +pub fn codecora_home() -> PathBuf { + if let Ok(home) = std::env::var(CODECORA_HOME_ENV) { + PathBuf::from(home) + } else { + dirs::home_dir() + .expect("Cannot determine home directory. Set CODECORA_HOME or HOME.") + .join(".codecora") + } +} + +/// Returns the data directory for a specific CodeCora product. +/// +/// ```text +/// product_data_dir("cora-code") → $HOME/.codecora/cora-code/ +/// ``` +pub fn product_data_dir(product: &str) -> PathBuf { + codecora_home().join(product) +} + +/// Returns the cora-code data directory: `$HOME/.codecora/cora-code/`. +pub fn cora_data_dir() -> PathBuf { + product_data_dir("cora-code") +} + +/// Returns the path to the global graph database. +pub fn graph_db_path() -> PathBuf { + cora_data_dir().join("graph.db") +} + +/// Ensure the cora-code data directory exists. +pub fn ensure_data_dir() -> anyhow::Result { + let dir = cora_data_dir(); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_codecora_home_returns_path() { + let path = codecora_home(); + assert!(path.ends_with(".codecora")); + } + + #[test] + fn test_product_data_dir() { + let path = product_data_dir("cora-code"); + assert!(path.ends_with(".codecora/cora-code")); + } + + #[test] + fn test_graph_db_path() { + let path = graph_db_path(); + assert!(path.ends_with(".codecora/cora-code/graph.db")); + } + + #[test] + fn test_cora_data_dir() { + let path = cora_data_dir(); + assert!(path.ends_with(".codecora/cora-code")); + // Should not have trailing slash + let s = path.to_string_lossy(); + assert!(!s.ends_with('/')); + } + + #[test] + fn test_env_override() { + unsafe { + std::env::set_var(CODECORA_HOME_ENV, "/tmp/test-codecora"); + } + let path = codecora_home(); + assert_eq!(path, PathBuf::from("/tmp/test-codecora")); + unsafe { + std::env::remove_var(CODECORA_HOME_ENV); + } + } +} diff --git a/src/index/graph.rs b/src/index/graph.rs index 09fb4d0..d4e4c7d 100644 --- a/src/index/graph.rs +++ b/src/index/graph.rs @@ -22,15 +22,15 @@ pub struct CallEdge { pub line: u32, } -/// Store call edges in the database. +/// Store call edges in the database, scoped to a project. #[allow(dead_code)] -pub fn store_edges(conn: &Connection, edges: &[CallEdge]) -> anyhow::Result { +pub fn store_edges(conn: &Connection, edges: &[CallEdge], project_id: i64) -> anyhow::Result { let tx = conn.unchecked_transaction()?; let mut count = 0; for edge in edges { tx.execute( - "INSERT INTO call_graph (caller, callee, file, line) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![edge.caller, edge.callee, edge.file, edge.line as i64], + "INSERT INTO call_graph (caller, callee, file, line, project_id) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![edge.caller, edge.callee, edge.file, edge.line as i64, project_id], )?; count += 1; } @@ -38,21 +38,22 @@ pub fn store_edges(conn: &Connection, edges: &[CallEdge]) -> anyhow::Result anyhow::Result<()> { +pub fn clear_edges_for_file(conn: &Connection, file: &str, project_id: i64) -> anyhow::Result<()> { conn.execute( - "DELETE FROM call_graph WHERE file = ?1", - rusqlite::params![file], + "DELETE FROM call_graph WHERE file = ?1 AND project_id = ?2", + rusqlite::params![file, project_id], )?; Ok(()) } -/// Find all callers of a symbol (who calls this?). +/// Find all callers of a symbol (who calls this?), scoped to a project. /// /// Returns symbols that call the given name, grouped by file. pub fn find_callers( conn: &Connection, + project_id: i64, symbol_name: &str, limit: usize, ) -> anyhow::Result> { @@ -61,11 +62,11 @@ pub fn find_callers( let mut stmt = conn.prepare( "SELECT DISTINCT cg.caller, cg.file, cg.line FROM call_graph cg - WHERE cg.callee LIKE ?1 - LIMIT ?2", + WHERE cg.callee LIKE ?1 AND cg.project_id = ?2 + LIMIT ?3", )?; - let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| { + let rows = stmt.query_map(rusqlite::params![pattern, project_id, limit as i64], |row| { Ok(CallerResult { caller: row.get(0)?, file: row.get(1)?, @@ -76,12 +77,13 @@ pub fn find_callers( Ok(rows.filter_map(|r| r.ok()).collect()) } -/// Find all callees of a symbol (what does this call?). +/// Find all callees of a symbol (what does this call?), scoped to a project. /// /// Returns symbols that are called by the given name. #[allow(dead_code)] pub fn find_callees( conn: &Connection, + project_id: i64, symbol_name: &str, limit: usize, ) -> anyhow::Result> { @@ -90,11 +92,11 @@ pub fn find_callees( let mut stmt = conn.prepare( "SELECT DISTINCT cg.callee, cg.file, cg.line FROM call_graph cg - WHERE cg.caller LIKE ?1 - LIMIT ?2", + WHERE cg.caller LIKE ?1 AND cg.project_id = ?2 + LIMIT ?3", )?; - let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| { + let rows = stmt.query_map(rusqlite::params![pattern, project_id, limit as i64], |row| { Ok(CalleeResult { callee: row.get(0)?, file: row.get(1)?, @@ -105,11 +107,12 @@ pub fn find_callees( Ok(rows.filter_map(|r| r.ok()).collect()) } -/// Impact analysis: what breaks if a symbol changes? +/// Impact analysis: what breaks if a symbol changes?, scoped to a project. /// /// Uses reverse traversal: find all callers recursively up to `depth`. pub fn impact_analysis( conn: &Connection, + project_id: i64, symbol_name: &str, depth: u32, ) -> anyhow::Result> { @@ -126,7 +129,7 @@ pub fn impact_analysis( continue; } - let callers = find_callers(conn, sym, 100)?; + let callers = find_callers(conn, project_id, sym, 100)?; for caller in callers { let node = ImpactNode { symbol: caller.caller.clone(), @@ -195,9 +198,15 @@ mod tests { conn } + /// Create a test project and return its project_id. + fn test_project(conn: &Connection) -> i64 { + super::super::schema::get_or_create_project(conn, "/tmp/test-project").unwrap() + } + #[test] fn test_store_and_find_callers() { let conn = mem_conn(); + let project_id = test_project(&conn); let edges = vec![ CallEdge { @@ -213,9 +222,9 @@ mod tests { line: 25, }, ]; - store_edges(&conn, &edges).unwrap(); + store_edges(&conn, &edges, project_id).unwrap(); - let callers = find_callers(&conn, "authenticate", 10).unwrap(); + let callers = find_callers(&conn, project_id, "authenticate", 10).unwrap(); assert_eq!(callers.len(), 2); let names: Vec<&str> = callers.iter().map(|c| c.caller.as_str()).collect(); assert!(names.contains(&"main")); @@ -225,6 +234,7 @@ mod tests { #[test] fn test_find_callees() { let conn = mem_conn(); + let project_id = test_project(&conn); let edges = vec![ CallEdge { @@ -240,15 +250,16 @@ mod tests { line: 10, }, ]; - store_edges(&conn, &edges).unwrap(); + store_edges(&conn, &edges, project_id).unwrap(); - let callees = find_callees(&conn, "main", 10).unwrap(); + let callees = find_callees(&conn, project_id, "main", 10).unwrap(); assert_eq!(callees.len(), 2); } #[test] fn test_clear_edges_for_file() { let conn = mem_conn(); + let project_id = test_project(&conn); store_edges( &conn, &[CallEdge { @@ -257,17 +268,19 @@ mod tests { file: "test.rs".to_string(), line: 1, }], + project_id, ) .unwrap(); - clear_edges_for_file(&conn, "test.rs").unwrap(); - let callers = find_callers(&conn, "b", 10).unwrap(); + clear_edges_for_file(&conn, "test.rs", project_id).unwrap(); + let callers = find_callers(&conn, project_id, "b", 10).unwrap(); assert!(callers.is_empty()); } #[test] fn test_impact_analysis_depth() { let conn = mem_conn(); + let project_id = test_project(&conn); // a → b → c // If c changes, impact should find b (depth 1) and a (depth 2) let edges = vec![ @@ -284,9 +297,9 @@ mod tests { line: 1, }, ]; - store_edges(&conn, &edges).unwrap(); + store_edges(&conn, &edges, project_id).unwrap(); - let impact = impact_analysis(&conn, "c", 3).unwrap(); + let impact = impact_analysis(&conn, project_id, "c", 3).unwrap(); // Should find b at depth 1, a at depth 2 assert!(impact.iter().any(|n| n.symbol == "b" && n.depth == 1)); assert!(impact.iter().any(|n| n.symbol == "a" && n.depth == 2)); diff --git a/src/index/mod.rs b/src/index/mod.rs index 108fdc5..bf95222 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -6,11 +6,11 @@ mod extract; pub mod graph; -mod schema; +pub mod schema; mod symbols; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use rusqlite::Connection; use sha2::{Digest, Sha256}; @@ -20,36 +20,36 @@ use tracing::{debug, info}; pub use graph::{CallEdge, CalleeResult, CallerResult, ImpactNode}; 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)?; +/// Open or create the **global** symbol index database. +/// +/// All projects share a single SQLite database at `~/.codecora/cora-code/graph.db`. +/// Project isolation is handled via the `project_id` foreign key. +pub fn open_global_index() -> anyhow::Result { + crate::data_dir::ensure_data_dir()?; + let db_path = crate::data_dir::graph_db_path(); - // Enable WAL mode for better concurrent read performance + let conn = Connection::open(&db_path)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; - schema::run_migrations(&conn)?; - debug!("Opened index at {}", db_path.display()); + debug!("Opened global 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) +/// Resolve the `project_id` for a given root path, creating the project row if needed. +pub fn ensure_project(conn: &Connection, root: &Path) -> anyhow::Result { + let root_str = root.to_string_lossy().to_string(); + schema::get_or_create_project(conn, &root_str) } /// Index a single file: extract symbols and store in the database. /// +/// `project_id` is written to every row so data is scoped per-project +/// in the global database. +/// /// Returns the number of symbols indexed. pub fn index_file( conn: &Connection, + project_id: i64, file_path: &str, content: &str, language: &str, @@ -57,28 +57,33 @@ pub fn index_file( 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 + // Delete existing symbols for this file within this project tx.execute( - "DELETE FROM symbols WHERE file = ?1", - rusqlite::params![file_path], + "DELETE FROM symbols WHERE file = ?1 AND project_id = ?2", + rusqlite::params![file_path, project_id], )?; - // Update file fingerprint + // Upsert 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 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, symbols.len() as i64, project_id], )?; // 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)", + "INSERT INTO symbols (name, kind, file, line, signature, language, project_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ sym.name, sym.kind.as_str(), @@ -86,18 +91,19 @@ pub fn index_file( sym.line as i64, sym.signature, language, + project_id, ], )?; count += 1; } // Extract and store call graph edges - graph::clear_edges_for_file(&tx, file_path)?; + graph::clear_edges_for_file(&tx, file_path, project_id)?; let call_sites = extract::extract_calls(content, language, file_path); for site in &call_sites { tx.execute( - "INSERT INTO call_graph (caller, callee, file, line) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![site.caller, site.callee, site.file, site.line as i64], + "INSERT INTO call_graph (caller, callee, file, line, project_id) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![site.caller, site.callee, site.file, site.line as i64, project_id], )?; } @@ -111,13 +117,13 @@ pub fn index_file( } /// Check if a file needs re-indexing based on content hash. -pub fn needs_reindex(conn: &Connection, file_path: &str, content: &str) -> bool { +pub fn needs_reindex(conn: &Connection, project_id: i64, 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], + "SELECT fingerprint FROM files WHERE path = ?1 AND project_id = ?2", + rusqlite::params![file_path, project_id], |row| row.get(0), ) .ok(); @@ -132,6 +138,17 @@ pub fn needs_reindex(conn: &Connection, file_path: &str, content: &str) -> bool /// /// Returns summary stats. pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::Result { + let project_id = ensure_project(conn, root)?; + index_project_with_id(conn, project_id, root, verbose) +} + +/// Internal: index a project with an already-resolved `project_id`. +fn index_project_with_id( + conn: &Connection, + project_id: i64, + root: &Path, + verbose: bool, +) -> anyhow::Result { let mut stats = IndexStats::default(); let walker = ignore::WalkBuilder::new(root) @@ -162,13 +179,13 @@ pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::R Err(_) => continue, }; - if !needs_reindex(conn, &rel_str, &content) { + if !needs_reindex(conn, project_id, &rel_str, &content) { stats.files_skipped += 1; continue; } stats.files_indexed += 1; - match index_file(conn, &rel_str, &content, language) { + match index_file(conn, project_id, &rel_str, &content, language) { Ok(n) => stats.symbols_indexed += n, Err(e) => { stats.errors += 1; @@ -179,6 +196,12 @@ pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::R } } + // Update project's last_indexed timestamp + 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 @@ -187,18 +210,24 @@ pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::R Ok(stats) } -/// Search the symbol index using FTS5 full-text search. -pub fn search(conn: &Connection, query: &SymbolQuery) -> anyhow::Result> { - symbols::search(conn, query) +/// Search the symbol index using FTS5 full-text search, scoped to a project. +pub fn search(conn: &Connection, project_id: i64, query: &SymbolQuery) -> anyhow::Result> { + symbols::search(conn, project_id, 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))?; +/// Get index statistics for a specific project. +pub fn index_stats(conn: &Connection, project_id: i64) -> anyhow::Result { + 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 = { - // Use the actual page size instead of assuming 4096 bytes (#23). let page_size: i64 = conn .query_row("PRAGMA page_size", [], |row| row.get(0)) .unwrap_or(4096); @@ -208,10 +237,11 @@ pub fn index_stats(conn: &Connection) -> anyhow::Result { page_size * page_count }; - // 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| { + 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 { @@ -219,10 +249,11 @@ pub fn index_stats(conn: &Connection) -> anyhow::Result { 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| { + 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 { @@ -239,17 +270,18 @@ pub fn index_stats(conn: &Connection) -> anyhow::Result { }) } -/// Remove symbols for files that no longer exist on disk. -pub fn prune_deleted(conn: &Connection, root: &Path) -> anyhow::Result { +/// Remove symbols for files that no longer exist on disk, scoped to a project. +pub fn prune_deleted(conn: &Connection, project_id: i64, root: &Path) -> anyhow::Result { let mut deleted = 0; - let mut stmt = conn.prepare("SELECT path FROM files")?; + let mut stmt = conn.prepare( + "SELECT path FROM files WHERE project_id = ?1", + )?; let paths: Vec = stmt - .query_map([], |row| row.get::<_, String>(0))? + .query_map(rusqlite::params![project_id], |row| row.get::<_, String>(0))? .filter_map(|r| r.ok()) .collect(); - // Batch all deletes in a single transaction instead of per-file let to_prune: Vec<&String> = paths .iter() .filter(|path| !root.join(path).exists()) @@ -259,10 +291,17 @@ pub fn prune_deleted(conn: &Connection, root: &Path) -> anyhow::Result { let tx = conn.unchecked_transaction()?; for path in &to_prune { tx.execute( - "DELETE FROM symbols WHERE file = ?1", - rusqlite::params![path], + "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.execute("DELETE FROM files WHERE path = ?1", rusqlite::params![path])?; } tx.commit()?; deleted = to_prune.len(); @@ -313,6 +352,11 @@ mod tests { conn } + /// Create a test project and return its project_id. + 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(); @@ -326,6 +370,7 @@ mod tests { #[test] fn test_index_rust_file() { let conn = mem_conn(); + let project_id = test_project(&conn); let code = r#" use std::collections::HashMap; @@ -343,31 +388,39 @@ impl Cache { } } "#; - let count = index_file(&conn, "src/cache.rs", code, "rs").unwrap(); + 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_needs_reindex() { let conn = mem_conn(); + let project_id = test_project(&conn); let code = "fn hello() {}"; // First time → needs reindex - assert!(needs_reindex(&conn, "test.rs", code)); + assert!(needs_reindex(&conn, project_id, "test.rs", code)); // Index it - index_file(&conn, "test.rs", code, "rs").unwrap(); + index_file(&conn, project_id, "test.rs", code, "rs").unwrap(); // Same content → no reindex needed - assert!(!needs_reindex(&conn, "test.rs", code)); + assert!(!needs_reindex(&conn, project_id, "test.rs", code)); // Changed content → needs reindex - assert!(needs_reindex(&conn, "test.rs", "fn world() {}")); + assert!(needs_reindex( + &conn, + project_id, + "test.rs", + "fn world() {}" + )); } #[test] fn test_search() { let conn = mem_conn(); + let project_id = test_project(&conn); let code = r#" pub fn authenticate(token: &str) -> bool { false @@ -377,10 +430,10 @@ pub struct AuthService { secret: String, } "#; - index_file(&conn, "src/auth.rs", code, "rs").unwrap(); + index_file(&conn, project_id, "src/auth.rs", code, "rs").unwrap(); let query = SymbolQuery::text("authenticate"); - let results = search(&conn, &query).unwrap(); + let results = search(&conn, project_id, &query).unwrap(); assert!(!results.is_empty()); assert!(results[0].symbol.name.contains("authenticate")); } @@ -388,10 +441,11 @@ pub struct AuthService { #[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 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).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")); @@ -401,25 +455,48 @@ pub struct AuthService { #[test] fn test_prune_deleted() { let conn = mem_conn(); - index_file(&conn, "gone.rs", "fn removed() {}", "rs").unwrap(); + let project_id = test_project(&conn); + index_file( + &conn, + project_id, + "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(); + let deleted = prune_deleted(&conn, project_id, tmp.path()).unwrap(); assert_eq!(deleted, 1); - let stats = index_stats(&conn).unwrap(); + let stats = index_stats(&conn, project_id).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 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).unwrap(); + let stats = index_stats(&conn, project_id).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 index 48ed193..0ef67bb 100644 --- a/src/index/schema.rs +++ b/src/index/schema.rs @@ -3,7 +3,8 @@ use rusqlite::Connection; /// Current schema version. -const SCHEMA_VERSION: i32 = 1; +#[allow(dead_code)] +const SCHEMA_VERSION: i32 = 2; /// Run database migrations (creates tables if not exist). pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { @@ -24,6 +25,9 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { if current < 1 { migrate_v1(conn)?; } + if current < 2 { + migrate_v2(conn)?; + } Ok(()) } @@ -111,22 +115,108 @@ fn migrate_v1(conn: &Connection) -> anyhow::Result<()> { )?; conn.execute( - "INSERT INTO schema_version (version) VALUES (?1)", - rusqlite::params![SCHEMA_VERSION], + "INSERT INTO schema_version (version) VALUES (1)", + [], + )?; + + Ok(()) +} + +/// Migration v2: Multi-project support. +/// +/// Adds `projects` table and `project_id` column to `symbols`, `files`, +/// and `call_graph`. The global DB at `~/.codecora/cora-code/graph.db` +/// stores data for all indexed projects, keyed by absolute path. +fn migrate_v2(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + " + -- Projects table: one row per indexed codebase + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + root_path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + last_indexed TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Add project_id to symbols (nullable for migration compat) + ALTER TABLE symbols ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE; + CREATE INDEX IF NOT EXISTS idx_symbols_project ON symbols(project_id); + + -- Add project_id to files (nullable for migration compat) + ALTER TABLE files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE; + CREATE INDEX IF NOT EXISTS idx_files_project ON files(project_id); + + -- Add project_id to call_graph (nullable for migration compat) + ALTER TABLE call_graph ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE; + CREATE INDEX IF NOT EXISTS idx_cg_project ON call_graph(project_id); + ", + )?; + + conn.execute( + "INSERT INTO schema_version (version) VALUES (2)", + [], )?; Ok(()) } +/// Get or create a project entry by root path. +/// +/// Returns the project ID. +pub fn get_or_create_project(conn: &Connection, root_path: &str) -> anyhow::Result { + // Try to find existing project + let existing: Option = conn + .query_row( + "SELECT id FROM projects WHERE root_path = ?1", + rusqlite::params![root_path], + |row| row.get(0), + ) + .ok(); + + if let Some(id) = existing { + return Ok(id); + } + + // Extract project name from directory name + let name = std::path::Path::new(root_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + + conn.execute( + "INSERT INTO projects (root_path, name) VALUES (?1, ?2)", + rusqlite::params![root_path, name], + )?; + + Ok(conn.last_insert_rowid()) +} + +/// Remove a project and all its associated data (CASCADE). +/// +/// Returns the number of rows deleted. +pub fn delete_project(conn: &Connection, project_id: i64) -> anyhow::Result { + let affected = conn.execute( + "DELETE FROM projects WHERE id = ?1", + rusqlite::params![project_id], + )?; + Ok(affected) +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn test_migration_creates_tables() { + fn mem_conn() -> Connection { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); run_migrations(&conn).unwrap(); + conn + } + + #[test] + fn test_migration_creates_tables() { + let conn = mem_conn(); // Check symbols table let count: i64 = conn @@ -146,6 +236,12 @@ mod tests { }) .unwrap(); + // Check projects table + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM projects", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + // Check schema version let version: i32 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |row| { @@ -157,9 +253,99 @@ mod tests { #[test] fn test_migration_idempotent() { - let conn = Connection::open_in_memory().unwrap(); - run_migrations(&conn).unwrap(); + let conn = mem_conn(); // Running again should not error run_migrations(&conn).unwrap(); } -} + + #[test] + fn test_v2_project_columns_exist() { + let conn = mem_conn(); + + // Verify project_id column exists on symbols + let _: i64 = conn + .query_row("SELECT project_id FROM symbols LIMIT 0", [], |row| { + row.get(0) + }) + .unwrap_or(0); + + // Verify project_id column exists on files + let _: i64 = conn + .query_row("SELECT project_id FROM files LIMIT 0", [], |row| { + row.get(0) + }) + .unwrap_or(0); + + // Verify project_id column exists on call_graph + let _: i64 = conn + .query_row("SELECT project_id FROM call_graph LIMIT 0", [], |row| { + row.get(0) + }) + .unwrap_or(0); + } + + #[test] + fn test_get_or_create_project() { + let conn = mem_conn(); + + // Create project + let id1 = get_or_create_project(&conn, "/home/user/myproject").unwrap(); + assert!(id1 > 0); + + // Same path returns same id + let id2 = get_or_create_project(&conn, "/home/user/myproject").unwrap(); + assert_eq!(id1, id2); + + // Different path returns different id + let id3 = get_or_create_project(&conn, "/home/user/other").unwrap(); + assert_ne!(id1, id3); + + // Check name extraction + let name: String = conn + .query_row( + "SELECT name FROM projects WHERE id = ?1", + rusqlite::params![id1], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(name, "myproject"); + } + + #[test] + fn test_delete_project_cascades() { + let conn = mem_conn(); + + let pid = get_or_create_project(&conn, "/tmp/testproj").unwrap(); + + // Insert symbol and file linked to project + conn.execute( + "INSERT INTO symbols (name, kind, file, line, project_id) VALUES ('test_fn', 'function', 'main.rs', 1, ?1)", + rusqlite::params![pid], + ) + .unwrap(); + conn.execute( + "INSERT INTO files (path, fingerprint, last_indexed, project_id) VALUES ('main.rs', 'abc', datetime('now'), ?1)", + rusqlite::params![pid], + ) + .unwrap(); + + // Delete project + delete_project(&conn, pid).unwrap(); + + // Symbols and files should be cascade-deleted + let sym_count: i64 = conn + .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0)) + .unwrap(); + assert_eq!(sym_count, 0); + + let file_count: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0)) + .unwrap(); + assert_eq!(file_count, 0); + + let proj_count: i64 = conn + .query_row("SELECT COUNT(*) FROM projects", [], |row| row.get(0)) + .unwrap(); + assert_eq!(proj_count, 0); + } +} \ No newline at end of file diff --git a/src/index/symbols.rs b/src/index/symbols.rs index 19402b8..4db7ffb 100644 --- a/src/index/symbols.rs +++ b/src/index/symbols.rs @@ -117,8 +117,8 @@ pub struct SearchResult { pub score: f64, } -/// Execute a symbol search query against the database. -pub fn search(conn: &Connection, query: &SymbolQuery) -> anyhow::Result> { +/// Execute a symbol search query against the database, scoped to a project. +pub fn search(conn: &Connection, project_id: i64, query: &SymbolQuery) -> anyhow::Result> { let limit = if query.limit > 0 { query.limit as i64 } else { @@ -134,12 +134,13 @@ pub fn search(conn: &Connection, query: &SymbolQuery) -> anyhow::Result> = vec![Box::new(fts_query.clone())]; + let mut params: Vec> = + vec![Box::new(fts_query.clone()), Box::new(project_id)]; - let mut param_idx = 2; + let mut param_idx = 3; if let Some(kind) = &query.kind { sql.push_str(&format!(" AND s.kind = ?{param_idx}")); @@ -188,13 +189,13 @@ pub fn search(conn: &Connection, query: &SymbolQuery) -> anyhow::Result anyhow::Result> = vec![Box::new(pattern)]; - let mut idx = 2; + let mut params: Vec> = + vec![Box::new(pattern), Box::new(project_id)]; + let mut idx = 3; if let Some(kind) = &query.kind { sql.push_str(&format!(" AND kind = ?{idx}")); @@ -249,15 +252,16 @@ fn like_search( #[allow(unused_assignments)] fn filter_search( conn: &Connection, + project_id: i64, query: &SymbolQuery, limit: i64, ) -> anyhow::Result> { let mut sql = String::from( - "SELECT id, name, kind, file, line, signature, language FROM symbols WHERE 1=1", + "SELECT id, name, kind, file, line, signature, language FROM symbols WHERE project_id = ?1", ); - let mut params: Vec> = vec![]; - let mut idx = 1; + let mut params: Vec> = vec![Box::new(project_id)]; + let mut idx = 2; if let Some(kind) = &query.kind { sql.push_str(&format!(" AND kind = ?{idx}")); diff --git a/src/main.rs b/src/main.rs index ebc689f..f4241f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use tracing_subscriber::FmtSubscriber; mod commands; mod config; +mod data_dir; mod embed; mod engine; mod error; @@ -16,6 +17,8 @@ mod index; mod mcp; mod progress; +use index::schema; + use commands::{ auth, commit_cmd, completion, config_cmd, debt, hook_cmd, init, profile, providers, review, scan, upload, @@ -546,17 +549,22 @@ async fn main() -> Result<()> { 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_global_index()?; + let project_id = index::ensure_project(&conn, &project_root)?; + + if rebuild { + // Delete all data for this project via CASCADE + schema::delete_project(&conn, project_id)?; + eprintln!("{}", "Dropped existing index for project.".dimmed()); + // Re-register the project (gets a fresh project_id) + let _fresh_id = schema::get_or_create_project( + &conn, + &project_root.to_string_lossy(), + )?; } - let conn = index::open_index(&db_path)?; - if show_stats { - let summary = index::index_stats(&conn)?; + let summary = index::index_stats(&conn, project_id)?; println!("{}", "SYMBOL INDEX".cyan().bold()); println!("{}", "────────────────────────────".dimmed()); println!(" Total symbols: {}", summary.total_symbols); @@ -573,7 +581,7 @@ async fn main() -> Result<()> { println!(" {lang:<16} {count}"); } } else if prune { - let deleted = index::prune_deleted(&conn, &project_root)?; + let deleted = index::prune_deleted(&conn, project_id, &project_root)?; println!( "{}", format!("Pruned {deleted} deleted files from index.").green() @@ -622,7 +630,10 @@ async fn main() -> Result<()> { ) .green() ); - eprintln!("{}", format!(" Database: {}", db_path.display()).dimmed()); + eprintln!( + "{}", + format!(" Database: {}", crate::data_dir::graph_db_path().display()).dimmed() + ); } 0 } @@ -636,14 +647,15 @@ async fn main() -> Result<()> { json, } => { let project_root = std::env::current_dir()?; - let db_path = index::default_db_path(&project_root); + let db_path = crate::data_dir::graph_db_path(); 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 conn = index::open_global_index()?; + let project_id = index::ensure_project(&conn, &project_root)?; let sym_kind = kind.as_deref().map(index::SymbolKind::from_str); @@ -655,7 +667,7 @@ async fn main() -> Result<()> { limit, }; - let results = index::search(&conn, &q)?; + let results = index::search(&conn, project_id, &q)?; if json { let json_results: Vec = results @@ -707,13 +719,14 @@ async fn main() -> Result<()> { json, } => { let project_root = std::env::current_dir()?; - let db_path = index::default_db_path(&project_root); + let db_path = crate::data_dir::graph_db_path(); 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 callers = index::graph::find_callers(&conn, &symbol, limit)?; + let conn = index::open_global_index()?; + let project_id = index::ensure_project(&conn, &project_root)?; + let callers = index::graph::find_callers(&conn, project_id, &symbol, limit)?; if json { println!("{}", serde_json::to_string_pretty(&callers)?); @@ -743,13 +756,14 @@ async fn main() -> Result<()> { json, } => { let project_root = std::env::current_dir()?; - let db_path = index::default_db_path(&project_root); + let db_path = crate::data_dir::graph_db_path(); 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 impact = index::graph::impact_analysis(&conn, &symbol, depth)?; + let conn = index::open_global_index()?; + let project_id = index::ensure_project(&conn, &project_root)?; + let impact = index::graph::impact_analysis(&conn, project_id, &symbol, depth)?; if json { println!("{}", serde_json::to_string_pretty(&impact)?); @@ -790,12 +804,13 @@ async fn main() -> Result<()> { json, } => { let project_root = std::env::current_dir()?; - let db_path = index::default_db_path(&project_root); + let db_path = crate::data_dir::graph_db_path(); if !db_path.exists() { - eprintln!("{}", "No index found. Run 'cora index' first.".yellow()); + eprintln!("{}", "No index found. Run `cora index` first.".yellow()); std::process::exit(1); } - let conn = index::open_index(&db_path)?; + let conn = index::open_global_index()?; + let project_id = index::ensure_project(&conn, &project_root)?; // Gather changed files let mut changed: Vec = files; @@ -847,14 +862,19 @@ async fn main() -> Result<()> { let all_symbols: Vec = { let placeholders: String = changed.iter().map(|_| "?").collect::>().join(","); + let n = changed.len() + 1; let sql = - format!("SELECT DISTINCT name FROM symbols WHERE file IN ({placeholders})"); + format!("SELECT DISTINCT name FROM symbols WHERE file IN ({placeholders}) AND project_id = ?{n}"); let mut stmt = conn.prepare(&sql)?; - let params: Vec<&dyn rusqlite::types::ToSql> = changed + let mut params: Vec> = changed .iter() - .map(|f| f as &dyn rusqlite::types::ToSql) + .map(|f| Box::new(f.clone()) as Box) .collect(); - let rows = stmt.query_map(params.as_slice(), |row| row.get::<_, String>(0))?; + params.push(Box::new(project_id)); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + let rows = + stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))?; rows.filter_map(|r| r.ok()).collect() }; @@ -864,7 +884,7 @@ async fn main() -> Result<()> { std::collections::HashSet::new(); for sym_name in all_symbols { if seen_syms.insert(sym_name.clone()) { - let callers = index::graph::find_callers(&conn, &sym_name, 100)?; + let callers = index::graph::find_callers(&conn, project_id, &sym_name, 100)?; for caller in callers { if patterns.iter().any(|p| caller.file.contains(p.as_str())) { affected_tests.insert(caller.file.clone()); @@ -876,7 +896,7 @@ async fn main() -> Result<()> { // Strategy 2: Direct test file name convention (mod_test.rs, foo_test.go) // Prepare statement once before the loop - let mut stmt = conn.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ?1")?; + let mut stmt = conn.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ?1 AND project_id = ?2")?; for file in &changed { // For Rust: src/foo.rs → tests/foo.rs or src/foo.rs → src/foo_test.rs let stem = file @@ -899,7 +919,7 @@ async fn main() -> Result<()> { for tp in &test_patterns { let pattern = format!("%{tp}"); let rows = - stmt.query_map(rusqlite::params![pattern], |row| row.get::<_, String>(0))?; + stmt.query_map(rusqlite::params![pattern, project_id], |row| row.get::<_, String>(0))?; for f in rows.map_while(Result::ok) { affected_tests.insert(f); } diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index f8b6b66..9290e5b 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -374,14 +374,17 @@ fn handle_list_profiles() -> ToolResult { // ─── Code Intelligence Handlers ─── -/// Open the index database, returning helpful error if not found. -fn open_index_db() -> anyhow::Result { +/// Open the global index database and resolve project_id for the current directory. +/// Returns helpful error if not found. +fn open_index_db() -> anyhow::Result<(rusqlite::Connection, i64)> { let cwd = std::env::current_dir()?; - let db_path = crate::index::default_db_path(&cwd); + let db_path = crate::data_dir::graph_db_path(); if !db_path.exists() { anyhow::bail!("No symbol index found. Run 'cora index' first to build the index."); } - crate::index::open_index(&db_path) + let conn = crate::index::open_global_index()?; + let project_id = crate::index::ensure_project(&conn, &cwd)?; + Ok((conn, project_id)) } fn handle_search_symbols(params: &serde_json::Value) -> ToolResult { @@ -390,8 +393,8 @@ fn handle_search_symbols(params: &serde_json::Value) -> ToolResult { None => return ToolResult::error("Missing required parameter: query"), }; - let conn = match open_index_db() { - Ok(c) => c, + let (conn, project_id) = match open_index_db() { + Ok((c, pid)) => (c, pid), Err(e) => return ToolResult::error(e.to_string()), }; @@ -417,7 +420,7 @@ fn handle_search_symbols(params: &serde_json::Value) -> ToolResult { limit, }; - match crate::index::search(&conn, &query) { + match crate::index::search(&conn, project_id, &query) { Ok(results) => { if results.is_empty() { return ToolResult::text(format!("No symbols found matching '{query_text}'.")); @@ -449,12 +452,12 @@ fn handle_find_callers(params: &serde_json::Value) -> ToolResult { }; let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; - let conn = match open_index_db() { - Ok(c) => c, + let (conn, project_id) = match open_index_db() { + Ok((c, pid)) => (c, pid), Err(e) => return ToolResult::error(e.to_string()), }; - match crate::index::graph::find_callers(&conn, symbol, limit) { + match crate::index::graph::find_callers(&conn, project_id, symbol, limit) { Ok(callers) => { if callers.is_empty() { return ToolResult::text(format!("No callers found for '{symbol}'.")); @@ -482,12 +485,12 @@ fn handle_find_impact(params: &serde_json::Value) -> ToolResult { }; let depth = params.get("depth").and_then(|v| v.as_u64()).unwrap_or(3) as u32; - let conn = match open_index_db() { - Ok(c) => c, + let (conn, project_id) = match open_index_db() { + Ok((c, pid)) => (c, pid), Err(e) => return ToolResult::error(e.to_string()), }; - match crate::index::graph::impact_analysis(&conn, symbol, depth) { + match crate::index::graph::impact_analysis(&conn, project_id, symbol, depth) { Ok(impact) => { if impact.is_empty() { return ToolResult::text(format!("No impact found for '{symbol}'.")); @@ -523,8 +526,8 @@ fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { return ToolResult::error("Parameter 'files' must not be empty"); } - let conn = match open_index_db() { - Ok(c) => c, + let (conn, project_id) = match open_index_db() { + Ok((c, pid)) => (c, pid), Err(e) => return ToolResult::error(e.to_string()), }; @@ -534,16 +537,22 @@ fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { // Batch fetch all symbols for all files in a single query let all_symbols: Vec = { let placeholders = files.iter().map(|_| "?").collect::>().join(","); - let sql = format!("SELECT DISTINCT name FROM symbols WHERE file IN ({placeholders})"); + let n = files.len() + 1; + let sql = format!( + "SELECT DISTINCT name FROM symbols WHERE file IN ({placeholders}) AND project_id = ?{n}" + ); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, Err(e) => return ToolResult::error(format!("DB error: {e}")), }; - let params: Vec<&dyn rusqlite::types::ToSql> = files + let mut params: Vec> = files .iter() - .map(|f| f as &dyn rusqlite::types::ToSql) + .map(|f| Box::new(f.clone()) as Box) .collect(); - let rows = match stmt.query_map(params.as_slice(), |row| row.get::<_, String>(0)) { + params.push(Box::new(project_id)); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + let rows = match stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) { Ok(r) => r, Err(e) => return ToolResult::error(format!("DB error: {e}")), }; @@ -555,7 +564,9 @@ fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { let mut seen_syms: std::collections::HashSet = std::collections::HashSet::new(); for sym_name in &all_symbols { if seen_syms.insert(sym_name.clone()) { - if let Ok(callers) = crate::index::graph::find_callers(&conn, sym_name, 100) { + if let Ok(callers) = + crate::index::graph::find_callers(&conn, project_id, sym_name, 100) + { for caller in callers { if patterns.iter().any(|p| caller.file.contains(*p)) { affected.insert(caller.file.clone()); @@ -586,10 +597,11 @@ fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { ]); } - // Query with a single LIKE batch + // Query with a single LIKE batch, scoped to project { + let n = test_names.len() + 1; let sql = format!( - "SELECT DISTINCT path FROM files WHERE path LIKE '%' || ?1 OR {}", + "SELECT DISTINCT path FROM files WHERE (path LIKE '%' || ?1 OR {}) AND project_id = ?{n}", test_names .iter() .enumerate() @@ -602,11 +614,14 @@ fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { Ok(s) => s, Err(e) => return ToolResult::error(format!("DB error: {e}")), }; - let params: Vec<&dyn rusqlite::types::ToSql> = test_names + let mut params: Vec> = test_names .iter() - .map(|t| t as &dyn rusqlite::types::ToSql) + .map(|t| Box::new(t.clone()) as Box) .collect(); - if let Ok(rows) = stmt.query_map(params.as_slice(), |row| row.get::<_, String>(0)) { + params.push(Box::new(project_id)); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) { for row in rows.map_while(Result::ok) { affected.insert(row); } @@ -624,12 +639,12 @@ fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { } fn handle_index_status() -> ToolResult { - let conn = match open_index_db() { - Ok(c) => c, + let (conn, project_id) = match open_index_db() { + Ok((c, pid)) => (c, pid), Err(e) => return ToolResult::error(e.to_string()), }; - match crate::index::index_stats(&conn) { + match crate::index::index_stats(&conn, project_id) { Ok(stats) => { let json = serde_json::json!({ "exists": true, @@ -776,7 +791,7 @@ fn handle_get_project_info() -> ToolResult { }) .unwrap_or_else(|| "unknown".to_string()); - let index_exists = crate::index::default_db_path(&cwd).exists(); + let index_exists = crate::data_dir::graph_db_path().exists(); let json = serde_json::json!({ "repository": repo_name, From 8fc51023f239a5ee8aa2b2d70b6354d467e6790e Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 22 Jul 2026 15:47:35 +0700 Subject: [PATCH 2/2] style: cargo fmt --- src/index/graph.rs | 40 ++++++++++++++++++----------- src/index/mod.rs | 60 +++++++++++++++----------------------------- src/index/schema.rs | 16 +++--------- src/index/symbols.rs | 9 ++++--- src/main.rs | 32 +++++++++++++---------- 5 files changed, 74 insertions(+), 83 deletions(-) diff --git a/src/index/graph.rs b/src/index/graph.rs index d4e4c7d..45a4bbb 100644 --- a/src/index/graph.rs +++ b/src/index/graph.rs @@ -24,7 +24,11 @@ pub struct CallEdge { /// Store call edges in the database, scoped to a project. #[allow(dead_code)] -pub fn store_edges(conn: &Connection, edges: &[CallEdge], project_id: i64) -> anyhow::Result { +pub fn store_edges( + conn: &Connection, + edges: &[CallEdge], + project_id: i64, +) -> anyhow::Result { let tx = conn.unchecked_transaction()?; let mut count = 0; for edge in edges { @@ -66,13 +70,16 @@ pub fn find_callers( LIMIT ?3", )?; - let rows = stmt.query_map(rusqlite::params![pattern, project_id, limit as i64], |row| { - Ok(CallerResult { - caller: row.get(0)?, - file: row.get(1)?, - line: row.get::<_, i64>(2)? as u32, - }) - })?; + let rows = stmt.query_map( + rusqlite::params![pattern, project_id, limit as i64], + |row| { + Ok(CallerResult { + caller: row.get(0)?, + file: row.get(1)?, + line: row.get::<_, i64>(2)? as u32, + }) + }, + )?; Ok(rows.filter_map(|r| r.ok()).collect()) } @@ -96,13 +103,16 @@ pub fn find_callees( LIMIT ?3", )?; - let rows = stmt.query_map(rusqlite::params![pattern, project_id, limit as i64], |row| { - Ok(CalleeResult { - callee: row.get(0)?, - file: row.get(1)?, - line: row.get::<_, i64>(2)? as u32, - }) - })?; + let rows = stmt.query_map( + rusqlite::params![pattern, project_id, limit as i64], + |row| { + Ok(CalleeResult { + callee: row.get(0)?, + file: row.get(1)?, + line: row.get::<_, i64>(2)? as u32, + }) + }, + )?; Ok(rows.filter_map(|r| r.ok()).collect()) } diff --git a/src/index/mod.rs b/src/index/mod.rs index bf95222..829cb05 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -75,7 +75,13 @@ pub fn index_file( language = excluded.language, symbol_count = excluded.symbol_count, project_id = excluded.project_id", - rusqlite::params![file_path, fingerprint, language, symbols.len() as i64, project_id], + rusqlite::params![ + file_path, + fingerprint, + language, + symbols.len() as i64, + project_id + ], )?; // Insert symbols @@ -211,7 +217,11 @@ fn index_project_with_id( } /// Search the symbol index using FTS5 full-text search, scoped to a project. -pub fn search(conn: &Connection, project_id: i64, query: &SymbolQuery) -> anyhow::Result> { +pub fn search( + conn: &Connection, + project_id: i64, + query: &SymbolQuery, +) -> anyhow::Result> { symbols::search(conn, project_id, query) } @@ -238,9 +248,8 @@ pub fn index_stats(conn: &Connection, project_id: i64) -> anyhow::Result = HashMap::new(); - let mut stmt = conn.prepare( - "SELECT kind, COUNT(*) FROM symbols WHERE project_id = ?1 GROUP BY kind", - )?; + 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)) })?; @@ -274,9 +283,7 @@ pub fn index_stats(conn: &Connection, project_id: i64) -> anyhow::Result anyhow::Result { let mut deleted = 0; - let mut stmt = conn.prepare( - "SELECT path FROM files WHERE project_id = ?1", - )?; + let mut stmt = conn.prepare("SELECT path FROM files WHERE project_id = ?1")?; let paths: Vec = stmt .query_map(rusqlite::params![project_id], |row| row.get::<_, String>(0))? .filter_map(|r| r.ok()) @@ -388,8 +395,7 @@ impl Cache { } } "#; - let count = - index_file(&conn, project_id, "src/cache.rs", code, "rs").unwrap(); + let count = index_file(&conn, project_id, "src/cache.rs", code, "rs").unwrap(); assert!(count > 0, "Should extract symbols from Rust code"); } @@ -409,12 +415,7 @@ impl Cache { assert!(!needs_reindex(&conn, project_id, "test.rs", code)); // Changed content → needs reindex - assert!(needs_reindex( - &conn, - project_id, - "test.rs", - "fn world() {}" - )); + assert!(needs_reindex(&conn, project_id, "test.rs", "fn world() {}")); } #[test] @@ -456,14 +457,7 @@ pub struct AuthService { 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(); + index_file(&conn, project_id, "gone.rs", "fn removed() {}", "rs").unwrap(); // Create temp dir to use as root let tmp = tempfile::tempdir().unwrap(); @@ -479,22 +473,8 @@ pub struct AuthService { 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(); + 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(); // Should have 1 symbol (replaced, not 2) diff --git a/src/index/schema.rs b/src/index/schema.rs index 0ef67bb..3c7548a 100644 --- a/src/index/schema.rs +++ b/src/index/schema.rs @@ -114,10 +114,7 @@ fn migrate_v1(conn: &Connection) -> anyhow::Result<()> { ", )?; - conn.execute( - "INSERT INTO schema_version (version) VALUES (1)", - [], - )?; + conn.execute("INSERT INTO schema_version (version) VALUES (1)", [])?; Ok(()) } @@ -153,10 +150,7 @@ fn migrate_v2(conn: &Connection) -> anyhow::Result<()> { ", )?; - conn.execute( - "INSERT INTO schema_version (version) VALUES (2)", - [], - )?; + conn.execute("INSERT INTO schema_version (version) VALUES (2)", [])?; Ok(()) } @@ -271,9 +265,7 @@ mod tests { // Verify project_id column exists on files let _: i64 = conn - .query_row("SELECT project_id FROM files LIMIT 0", [], |row| { - row.get(0) - }) + .query_row("SELECT project_id FROM files LIMIT 0", [], |row| row.get(0)) .unwrap_or(0); // Verify project_id column exists on call_graph @@ -348,4 +340,4 @@ mod tests { .unwrap(); assert_eq!(proj_count, 0); } -} \ No newline at end of file +} diff --git a/src/index/symbols.rs b/src/index/symbols.rs index 4db7ffb..de537aa 100644 --- a/src/index/symbols.rs +++ b/src/index/symbols.rs @@ -118,7 +118,11 @@ pub struct SearchResult { } /// Execute a symbol search query against the database, scoped to a project. -pub fn search(conn: &Connection, project_id: i64, query: &SymbolQuery) -> anyhow::Result> { +pub fn search( + conn: &Connection, + project_id: i64, + query: &SymbolQuery, +) -> anyhow::Result> { let limit = if query.limit > 0 { query.limit as i64 } else { @@ -215,8 +219,7 @@ fn like_search( FROM symbols WHERE name LIKE ?1 AND project_id = ?2", ); - let mut params: Vec> = - vec![Box::new(pattern), Box::new(project_id)]; + let mut params: Vec> = vec![Box::new(pattern), Box::new(project_id)]; let mut idx = 3; if let Some(kind) = &query.kind { diff --git a/src/main.rs b/src/main.rs index f4241f3..5592609 100644 --- a/src/main.rs +++ b/src/main.rs @@ -557,10 +557,8 @@ async fn main() -> Result<()> { schema::delete_project(&conn, project_id)?; eprintln!("{}", "Dropped existing index for project.".dimmed()); // Re-register the project (gets a fresh project_id) - let _fresh_id = schema::get_or_create_project( - &conn, - &project_root.to_string_lossy(), - )?; + let _fresh_id = + schema::get_or_create_project(&conn, &project_root.to_string_lossy())?; } if show_stats { @@ -632,7 +630,11 @@ async fn main() -> Result<()> { ); eprintln!( "{}", - format!(" Database: {}", crate::data_dir::graph_db_path().display()).dimmed() + format!( + " Database: {}", + crate::data_dir::graph_db_path().display() + ) + .dimmed() ); } 0 @@ -863,8 +865,9 @@ async fn main() -> Result<()> { let placeholders: String = changed.iter().map(|_| "?").collect::>().join(","); let n = changed.len() + 1; - let sql = - format!("SELECT DISTINCT name FROM symbols WHERE file IN ({placeholders}) AND project_id = ?{n}"); + let sql = format!( + "SELECT DISTINCT name FROM symbols WHERE file IN ({placeholders}) AND project_id = ?{n}" + ); let mut stmt = conn.prepare(&sql)?; let mut params: Vec> = changed .iter() @@ -873,8 +876,7 @@ async fn main() -> Result<()> { params.push(Box::new(project_id)); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let rows = - stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))?; + let rows = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))?; rows.filter_map(|r| r.ok()).collect() }; @@ -884,7 +886,8 @@ async fn main() -> Result<()> { std::collections::HashSet::new(); for sym_name in all_symbols { if seen_syms.insert(sym_name.clone()) { - let callers = index::graph::find_callers(&conn, project_id, &sym_name, 100)?; + let callers = + index::graph::find_callers(&conn, project_id, &sym_name, 100)?; for caller in callers { if patterns.iter().any(|p| caller.file.contains(p.as_str())) { affected_tests.insert(caller.file.clone()); @@ -896,7 +899,9 @@ async fn main() -> Result<()> { // Strategy 2: Direct test file name convention (mod_test.rs, foo_test.go) // Prepare statement once before the loop - let mut stmt = conn.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ?1 AND project_id = ?2")?; + let mut stmt = conn.prepare( + "SELECT DISTINCT path FROM files WHERE path LIKE ?1 AND project_id = ?2", + )?; for file in &changed { // For Rust: src/foo.rs → tests/foo.rs or src/foo.rs → src/foo_test.rs let stem = file @@ -918,8 +923,9 @@ async fn main() -> Result<()> { ]; for tp in &test_patterns { let pattern = format!("%{tp}"); - let rows = - stmt.query_map(rusqlite::params![pattern, project_id], |row| row.get::<_, String>(0))?; + let rows = stmt.query_map(rusqlite::params![pattern, project_id], |row| { + row.get::<_, String>(0) + })?; for f in rows.map_while(Result::ok) { affected_tests.insert(f); }