From 45bb2cb233c9fd108e771e52c59cd1197c3b6730 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 7 Oct 2026 15:58:45 +0700 Subject: [PATCH] refactor(index): one index session for index/watch/serve/brain/MCP setup Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: ajianaz --- src/commands/index_cmd.rs | 124 ++++++++++++++++ src/commands/mod.rs | 1 + src/commands/serve.rs | 19 ++- src/commands/watch.rs | 43 ++---- src/engine/index_bridge.rs | 5 - src/engine/review.rs | 10 +- src/index/mod.rs | 15 +- src/index/session.rs | 288 +++++++++++++++++++++++++++++++++++++ src/main.rs | 178 +++-------------------- src/mcp/tools.rs | 4 +- 10 files changed, 464 insertions(+), 223 deletions(-) create mode 100644 src/commands/index_cmd.rs create mode 100644 src/index/session.rs diff --git a/src/commands/index_cmd.rs b/src/commands/index_cmd.rs new file mode 100644 index 0000000..735d3a4 --- /dev/null +++ b/src/commands/index_cmd.rs @@ -0,0 +1,124 @@ +//! `cora index` — thin dispatch over [`IndexSession`]. +//! +//! Modes (checked in this order after an optional `--rebuild`): `--stats`, +//! `--prune`, `--watch`, otherwise a normal incremental index. + +use anyhow::Result; +use colored::Colorize; + +use crate::index::session::{ConfigSource, IndexSession}; + +/// Parsed `cora index` flags (verbose already merged with the global flag). +#[derive(Debug, Clone, Copy, Default)] +pub struct IndexOptions { + pub stats: bool, + pub prune: bool, + pub rebuild: bool, + pub watch: bool, + pub verbose: bool, +} + +/// Debounce used when `index --watch` delegates to the `watch` implementation. +pub const WATCH_DEBOUNCE_MS: u64 = 500; + +pub fn run_index(opts: &IndexOptions, config_path: Option<&str>) -> Result<()> { + let mut session = IndexSession::open(ConfigSource::Full(config_path))?; + + if opts.rebuild { + session.rebuild()?; + eprintln!("{}", "Dropped existing index for project.".dimmed()); + } + + if opts.stats { + print_stats(&session)?; + } else if opts.prune { + let deleted = session.prune()?; + println!( + "{}", + format!("Pruned {deleted} deleted files from index.").green() + ); + } else if opts.watch { + super::watch::run_watch(&session, WATCH_DEBOUNCE_MS, false, None, opts.verbose)?; + } else { + run_once(&session, opts.verbose)?; + } + Ok(()) +} + +fn print_stats(session: &IndexSession) -> Result<()> { + let summary = session.summary()?; + println!("{}", "SYMBOL INDEX".cyan().bold()); + println!("{}", "────────────────────────────".dimmed()); + println!(" Total symbols: {}", summary.total_symbols); + println!(" Total files: {}", summary.total_files); + println!( + " Database size: {}", + crate::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}"); + } + Ok(()) +} + +fn run_once(session: &IndexSession, verbose: bool) -> Result<()> { + eprintln!("{}", "🔍 Indexing project...".cyan()); + let stats = session.index(verbose)?; + if stats.files_indexed == 0 && stats.errors == 0 { + // Incremental no-op: fingerprints all matched. Report the + // STORED totals instead of a confusing zeros line (#522). + eprintln!( + "{}", + format!( + "✓ Index up to date ({} files unchanged)", + stats.files_skipped + ) + .green() + ); + if let Ok(summary) = session.summary() { + eprintln!( + "{}", + format!( + " {} symbols across {} files", + summary.total_symbols, summary.total_files + ) + .dimmed() + ); + } + } else { + eprintln!( + "{}", + format!( + "✅ Indexed {} symbols from {} files ({} skipped, {} errors)", + stats.symbols_indexed, stats.files_indexed, stats.files_skipped, stats.errors + ) + .green() + ); + } + if stats.files_excluded > 0 { + eprintln!( + "{}", + format!( + " {} files excluded by ignore patterns", + stats.files_excluded + ) + .dimmed() + ); + } + eprintln!( + "{}", + format!( + " Database: {}", + crate::data_dir::graph_db_path().display() + ) + .dimmed() + ); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4c6d46a..376b6b8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod config_cmd; pub mod debt; pub mod findings; pub mod hook_cmd; +pub mod index_cmd; pub mod init; pub mod install; pub mod profile; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index db2ebf3..0404782 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -1,18 +1,15 @@ //! `cora serve` — start MCP server with automatic reindex on startup. +use crate::index::session::{ConfigSource, IndexSession}; + /// Execute the serve command: auto-reindex the current project, then start the MCP server. -pub fn execute_serve() -> anyhow::Result<()> { +/// +/// `config_path` is the global `--config` flag; the session honors it. +pub fn execute_serve(config_path: Option<&str>) -> anyhow::Result<()> { // 1. Auto-reindex current project (incremental — skips unchanged files) - let (conn, _project_id, project_root) = - crate::engine::index_bridge::IndexBridge::open_or_create_cwd()?.into_strict_parts()?; - - let skip_patterns = crate::index::prepare_index_config(None); - let stats = crate::index::index_project_with_skip( - &conn, - &project_root, - false, - skip_patterns.as_deref(), - )?; + let session = IndexSession::open(ConfigSource::Full(config_path))?; + let stats = session.index(false)?; + drop(session); if stats.files_indexed > 0 { eprintln!( diff --git a/src/commands/watch.rs b/src/commands/watch.rs index a7d8880..c08c8bb 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -10,47 +10,28 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use colored::Colorize; -use crate::index; +use crate::index::session::IndexSession; -/// Entry point for `cora watch`. +/// Entry point for `cora watch` (also backs `cora index --watch`). /// /// Runs an initial index, then polls for changes at the debounce interval. /// On each poll cycle, re-indexes the project and reports updated files/symbols. +/// Config, backend, skip patterns and root all come from the [`IndexSession`]. /// /// # Arguments -/// * `project_root` — Root directory to watch -/// * `config_path` — Optional path to `.cora.yaml` -/// * `debounce_ms` — Minimum time between reindex cycles (default 500ms) -/// * `git_only` — If true, only process files tracked by git -/// * `filter` — Optional glob pattern (e.g. `src/**/*.rs`) -/// * `verbose` — Verbose output -#[allow(clippy::too_many_arguments)] +/// * `session` - configured index session (owns root, DB, skip patterns) +/// * `debounce_ms` - Minimum time between reindex cycles (default 500ms) +/// * `git_only` - If true, only process files tracked by git +/// * `filter` - Optional glob pattern (e.g. `src/**/*.rs`) +/// * `verbose` - Verbose output pub fn run_watch( - project_root: &Path, - config_path: Option<&str>, + session: &IndexSession, debounce_ms: u64, git_only: bool, filter: Option<&str>, verbose: bool, ) -> Result<()> { - let (conn, _project_id, _root) = - crate::engine::index_bridge::IndexBridge::open_or_create(project_root)? - .into_strict_parts()?; - // Load skip patterns + brain embedding backend from config - let config = - crate::config::loader::load_config(config_path, None, None, None, None, false).ok(); - // Same merged exclusion set as `cora index` (#521). - let skip_patterns = crate::index::skip_patterns_from_config(config.as_ref()); - - // Resolve embedding backend - let brain_mode = config - .as_ref() - .map(|c| c.brain.embedding.to_string()) - .unwrap_or_else(|| "auto".to_string()); - crate::embed::resolve_backend(&brain_mode); - crate::index::vector::apply_config_store(config.as_ref()); - - let skip_ref: Option<&[String]> = skip_patterns.as_deref(); + let project_root = session.root(); // Build git-tracked file set if --git-only let git_files: Option> = if git_only { @@ -71,7 +52,7 @@ pub fn run_watch( // Initial index eprintln!("{}", "🔍 Initial index...".cyan()); - let stats = index::index_project_with_skip(&conn, project_root, verbose, skip_ref)?; + let stats = session.index(verbose)?; eprintln!( "{}", format!( @@ -114,7 +95,7 @@ pub fn run_watch( } // Re-index - let stats = index::index_project_with_skip(&conn, project_root, verbose, skip_ref)?; + let stats = session.index(verbose)?; if stats.files_indexed > 0 { eprintln!( diff --git a/src/engine/index_bridge.rs b/src/engine/index_bridge.rs index 7e6f23d..934abb8 100644 --- a/src/engine/index_bridge.rs +++ b/src/engine/index_bridge.rs @@ -54,11 +54,6 @@ impl IndexBridge { crate::index::resolve_project_root(start).unwrap_or_else(|| start.to_path_buf()) } - /// [`Self::resolve_root`] applied to the current working directory. - pub fn current_root() -> anyhow::Result { - Ok(Self::resolve_root(&std::env::current_dir()?)) - } - /// Tolerant open of the global index for the project containing `start`. /// /// Never creates the database and never fails: use [`Self::is_available`]. diff --git a/src/engine/review.rs b/src/engine/review.rs index 98d975c..ed31b17 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -49,6 +49,13 @@ pub fn resolve_system_prompt(inline: Option<&str>, file_path: Option<&str>) -> O } } +/// Exclusion patterns for review-time index scanners: the exact set the +/// indexer uses (`ignore.files` + `index_skip_files`), so review and index +/// never disagree about which files are out of scope. +pub fn index_skip_patterns(config: &Config) -> Vec { + crate::index::skip_patterns_from_config(Some(config)).unwrap_or_default() +} + /// Run a code review on the given diff string with optional streaming and cache control. /// /// When `stream` is true, LLM tokens are printed to stdout in real-time. @@ -180,7 +187,8 @@ async fn review_diff_inner( } else { index_bridge.root().to_path_buf() }; - let skip_patterns = &config.rules_config.index_skip_files; + // Same exclusion set the indexer uses (ignore.files + index_skip_files). + let skip_patterns = &index_skip_patterns(config); let index_unused_findings = crate::engine::index_scanner::scan_unused_imports( &index_bridge, &diff_chunks, diff --git a/src/index/mod.rs b/src/index/mod.rs index a1edcea..c8a8436 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -11,6 +11,7 @@ mod extract; pub mod graph; pub mod queries; pub mod schema; +pub mod session; mod symbols; pub mod vector; @@ -299,20 +300,6 @@ pub fn skip_patterns_from_config( }) } -/// Load config for an index run: resolves the brain embedding backend and -/// vector store, and returns the skip patterns (`None` if config failed to load). -pub fn prepare_index_config(config_path: Option<&str>) -> Option> { - let config = - crate::config::loader::load_config(config_path, None, None, None, None, false).ok(); - let brain_mode = config - .as_ref() - .map(|c| c.brain.embedding.to_string()) - .unwrap_or_else(|| "auto".to_string()); - crate::embed::resolve_backend(&brain_mode); - vector::apply_config_store(config.as_ref()); - skip_patterns_from_config(config.as_ref()) -} - /// Index a project directory with NO skip patterns, respecting .gitignore. /// Test-only: production entry points must go through /// `index_project_with_skip` with the resolved config. diff --git a/src/index/session.rs b/src/index/session.rs new file mode 100644 index 0000000..07ffc13 --- /dev/null +++ b/src/index/session.rs @@ -0,0 +1,288 @@ +//! Index session — the one place that owns +//! config → embedding backend → vector store → skip patterns → project root → +//! incremental index → stats. +//! +//! Every entry point that writes or searches the index (`cora index`, +//! `index --watch`, `cora watch`, `cora serve`, `cora brain`, the MCP brain +//! tool) goes through this module instead of re-assembling the same steps by +//! hand. The project root and database come from +//! [`IndexBridge`](crate::engine::index_bridge::IndexBridge). +//! +//! # Config policy +//! +//! [`ConfigSource`] chooses how config is loaded: +//! - [`ConfigSource::Full`]: the normal CLI stack (global config, explicit +//! `--config` path or discovered `.cora.yaml`). +//! - [`ConfigSource::ProjectOnly`]: MCP stance (#563). Only the project's +//! `.cora.yaml`, merged over defaults. No env, no global config, no API keys. +//! +//! Both feed the *same* [`configure`] step, so the embedding backend +//! (`brain.embedding`), vector store/bits and skip patterns are applied +//! identically. `resolve_backend` only consumes the `brain.embedding` string +//! (a local model choice, not a secret and not a network endpoint), so running +//! it with project-only config does not widen the MCP trust surface. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use rusqlite::Connection; + +use super::{IndexStats, IndexSummary}; +use crate::config::schema::Config; +use crate::embed::Backend; +use crate::engine::index_bridge::IndexBridge; + +/// How an entry point loads config. +#[derive(Debug, Clone, Copy)] +pub enum ConfigSource<'a> { + /// Global config + explicit `--config` path (or discovered `.cora.yaml`). + Full(Option<&'a str>), + /// Project `.cora.yaml` only (MCP: no env/global/secrets). + ProjectOnly, + /// [`Self::ProjectOnly`] discovered from `start` instead of the cwd. + #[cfg_attr(not(test), allow(dead_code))] + ProjectOnlyAt(&'a Path), +} + +/// Load config per `source`. `None` when it cannot be loaded (callers fall +/// back to defaults, as every entry point did before). +pub fn load_config(source: ConfigSource<'_>) -> Option { + match source { + ConfigSource::Full(path) => { + crate::config::loader::load_config(path, None, None, None, None, false).ok() + } + ConfigSource::ProjectOnly => std::env::current_dir() + .ok() + .and_then(|cwd| load_project_only(&cwd)), + ConfigSource::ProjectOnlyAt(start) => load_project_only(start), + } +} + +/// Project-only config found by walking up from `start` (no env/global). +pub fn load_project_only(start: &Path) -> Option { + let mut config = Config::default(); + if let Some((_, cora)) = crate::config::loader::find_cora_file(start).ok()? { + cora.merge_into(&mut config).ok()?; + } + Some(config) +} + +/// Result of [`configure`]: what was applied to the process. +#[derive(Debug, Clone)] +#[cfg_attr(not(test), allow(dead_code))] // brain_mode/backend are the test seam +pub struct Configured { + /// `brain.embedding` string that was handed to `resolve_backend`. + pub brain_mode: String, + /// Backend now active (process-wide; the first resolution wins). + pub backend: Backend, + /// Index exclusion patterns (`None` when no config could be loaded). + pub skip_patterns: Option>, +} + +/// Apply config to process-global state (embedding backend, vector store) and +/// derive the skip patterns. The only caller of `resolve_backend` for index +/// and brain code paths. +pub fn configure(config: Option<&Config>) -> Configured { + let brain_mode = config + .map(|c| c.brain.embedding.to_string()) + .unwrap_or_else(|| "auto".to_string()); + let backend = crate::embed::resolve_backend(&brain_mode); + super::vector::apply_config_store(config); + Configured { + brain_mode, + backend, + skip_patterns: super::skip_patterns_from_config(config), + } +} + +/// Load + [`configure`] for read-only brain search (CLI `brain`, MCP brain). +pub fn configure_for_search(source: ConfigSource<'_>) -> Configured { + configure(load_config(source).as_ref()) +} + +/// An open, configured index for one project. +pub struct IndexSession { + conn: Connection, + project_id: i64, + root: PathBuf, + skip_patterns: Option>, +} + +impl IndexSession { + /// Open (creating if needed) the global index for the project containing + /// the current directory and configure the process. + pub fn open(source: ConfigSource<'_>) -> Result { + Self::open_at(&std::env::current_dir()?, source) + } + + /// [`Self::open`] for the project containing `start`. + pub fn open_at(start: &Path, source: ConfigSource<'_>) -> Result { + let bridge = IndexBridge::open_or_create(start)?; + Self::from_bridge(bridge, load_config(source).as_ref()) + } + + /// Build a session from an already-open bridge and loaded config. + pub fn from_bridge(bridge: IndexBridge, config: Option<&Config>) -> Result { + let (conn, project_id, root) = bridge.into_strict_parts()?; + let configured = configure(config); + Ok(Self { + conn, + project_id, + root, + skip_patterns: configured.skip_patterns, + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn skip_patterns(&self) -> Option<&[String]> { + self.skip_patterns.as_deref() + } + + /// Incremental index with the session's skip patterns. + pub fn index(&self, verbose: bool) -> Result { + super::index_project_with_skip(&self.conn, &self.root, verbose, self.skip_patterns()) + } + + /// Stored totals for this project. + pub fn summary(&self) -> Result { + super::index_stats(&self.conn, self.project_id) + } + + /// Remove index rows for files that no longer exist. Returns the count. + pub fn prune(&self) -> Result { + super::prune_deleted(&self.conn, self.project_id, &self.root) + } + + /// Drop everything stored for this project and re-register it. + pub fn rebuild(&mut self) -> Result<()> { + super::schema::delete_project(&self.conn, self.project_id)?; + self.project_id = + super::schema::get_or_create_project(&self.conn, &self.root.to_string_lossy())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn project(cora_yaml: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + fs::write(root.join(".cora.yaml"), cora_yaml).unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/a.rs"), "pub fn alpha() {}\n").unwrap(); + fs::create_dir_all(root.join("gen")).unwrap(); + fs::write(root.join("gen/b.rs"), "pub fn beta() {}\n").unwrap(); + fs::create_dir_all(root.join("vendor")).unwrap(); + fs::write(root.join("vendor/c.rs"), "pub fn gamma() {}\n").unwrap(); + (dir, root) + } + + const YAML: &str = "brain:\n embedding: hashing\nignore:\n files:\n - \"gen/**\"\nrules_engine:\n index_skip_files:\n - \"vendor/**\"\n"; + + fn memory_bridge(root: &Path) -> IndexBridge { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + crate::index::schema::run_migrations(&conn).unwrap(); + IndexBridge::from_connection(conn, root).unwrap() + } + + #[test] + fn project_only_config_merges_skip_sources_and_resolves_backend() { + let (_d, root) = project(YAML); + let config = load_project_only(&root).expect("config"); + let configured = configure(Some(&config)); + assert_eq!(configured.brain_mode, "hashing"); + assert_eq!(configured.backend, Backend::Hashing); + let pats = configured.skip_patterns.unwrap(); + assert!(pats.contains(&"gen/**".to_string()), "{pats:?}"); + assert!(pats.contains(&"vendor/**".to_string()), "{pats:?}"); + } + + #[test] + fn search_setup_resolves_backend_from_project_config() { + // The MCP brain tool calls configure_for_search(ProjectOnly): the + // backend string must come from the project's .cora.yaml. + let (_d, root) = project(YAML); + let configured = configure_for_search(ConfigSource::ProjectOnlyAt(&root)); + assert_eq!(configured.brain_mode, "hashing"); + assert_eq!(configured.backend, Backend::Hashing); + } + + #[test] + fn explicit_config_path_is_honored_by_full_source() { + // `cora --config X serve` threads X through ConfigSource::Full. + let (_d, root) = project("brain:\n embedding: hashing\n"); + let other = root.join("other.yaml"); + fs::write(&other, "ignore:\n files:\n - \"only-in-explicit/**\"\n").unwrap(); + let config = load_config(ConfigSource::Full(other.to_str())).expect("config"); + let pats = crate::index::skip_patterns_from_config(Some(&config)).unwrap(); + assert!( + pats.contains(&"only-in-explicit/**".to_string()), + "{pats:?}" + ); + } + + #[test] + fn session_indexes_incrementally_and_excludes_skipped() { + let (_d, root) = project(YAML); + let config = load_project_only(&root); + let session = IndexSession::from_bridge(memory_bridge(&root), config.as_ref()).unwrap(); + assert_eq!(session.root(), root.as_path()); + + let first = session.index(false).unwrap(); + assert_eq!(first.files_indexed, 1, "only src/a.rs: {first:?}"); + assert!(first.files_excluded >= 2, "{first:?}"); + assert_eq!(session.summary().unwrap().total_files, 1); + + // Second run: nothing changed. + let second = session.index(false).unwrap(); + assert_eq!(second.files_indexed, 0, "{second:?}"); + assert!(second.files_skipped >= 1, "{second:?}"); + + // Change a file: only it is re-indexed. + fs::write( + root.join("src/a.rs"), + "pub fn alpha() {}\npub fn alpha2() {}\n", + ) + .unwrap(); + let third = session.index(false).unwrap(); + assert_eq!(third.files_indexed, 1, "{third:?}"); + } + + #[test] + fn rebuild_resets_project_and_prune_counts_deleted() { + let (_d, root) = project(YAML); + let config = load_project_only(&root); + let mut session = IndexSession::from_bridge(memory_bridge(&root), config.as_ref()).unwrap(); + session.index(false).unwrap(); + fs::remove_file(root.join("src/a.rs")).unwrap(); + assert_eq!(session.prune().unwrap(), 1); + + session.index(false).unwrap(); + session.rebuild().unwrap(); + assert_eq!(session.summary().unwrap().total_files, 0); + } + + #[test] + fn scanners_and_indexing_share_one_skip_source() { + let (_d, root) = project(YAML); + let config = load_project_only(&root).unwrap(); + let session = IndexSession::from_bridge(memory_bridge(&root), Some(&config)).unwrap(); + let review_patterns = crate::engine::review::index_skip_patterns(&config); + assert_eq!(Some(review_patterns.as_slice()), session.skip_patterns()); + assert!(crate::engine::index_scanner::should_skip_file( + "gen/b.rs", + &review_patterns + )); + assert!(crate::engine::index_scanner::should_skip_file( + "vendor/c.rs", + &review_patterns + )); + } +} diff --git a/src/main.rs b/src/main.rs index c70c573..fa8b198 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,8 +17,6 @@ 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, @@ -713,144 +711,16 @@ async fn main() -> Result<()> { watch, verbose, } => { - let (conn, project_id, project_root) = - engine::index_bridge::IndexBridge::open_or_create_cwd()?.into_strict_parts()?; - - 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())?; - } - - if show_stats { - let summary = index::index_stats(&conn, project_id)?; - 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_id, &project_root)?; - println!( - "{}", - format!("Pruned {deleted} deleted files from index.").green() - ); - } else if watch { - // Initial index - eprintln!("{}", "🔍 Initial index...".cyan()); - let skip_patterns = index::prepare_index_config(cli.global.config.as_deref()); - let stats = index::index_project_with_skip( - &conn, - &project_root, - verbose || cli.global.verbose, - skip_patterns.as_deref(), - )?; - eprintln!( - "{}", - format!( - "✅ Indexed {} symbols. Watching for changes... (Ctrl+C to stop)", - stats.symbols_indexed - ) - .green() - ); - - // Poll loop: re-index changed files every 2 seconds - loop { - std::thread::sleep(std::time::Duration::from_secs(2)); - let stats = index::index_project_with_skip( - &conn, - &project_root, - false, - skip_patterns.as_deref(), - )?; - if stats.files_indexed > 0 { - eprintln!( - "{}", - format!( - "🔄 Updated {} files, {} symbols", - stats.files_indexed, stats.symbols_indexed - ) - .cyan() - ); - } - } - } else { - // Config-hash invalidation, skip patterns, brain embedding backend. - let skip_patterns = index::prepare_index_config(cli.global.config.as_deref()); - - eprintln!("{}", "🔍 Indexing project...".cyan()); - let stats = index::index_project_with_skip( - &conn, - &project_root, - verbose || cli.global.verbose, - skip_patterns.as_deref(), - )?; - if stats.files_indexed == 0 && stats.errors == 0 { - // Incremental no-op: fingerprints all matched. Report the - // STORED totals instead of a confusing zeros line (#522). - eprintln!( - "{}", - format!( - "✓ Index up to date ({} files unchanged)", - stats.files_skipped - ) - .green() - ); - if let Ok(summary) = index::index_stats(&conn, project_id) { - eprintln!( - "{}", - format!( - " {} symbols across {} files", - summary.total_symbols, summary.total_files - ) - .dimmed() - ); - } - } else { - eprintln!( - "{}", - format!( - "✅ Indexed {} symbols from {} files ({} skipped, {} errors)", - stats.symbols_indexed, - stats.files_indexed, - stats.files_skipped, - stats.errors - ) - .green() - ); - } - if stats.files_excluded > 0 { - eprintln!( - "{}", - format!( - " {} files excluded by ignore patterns", - stats.files_excluded - ) - .dimmed() - ); - } - eprintln!( - "{}", - format!( - " Database: {}", - crate::data_dir::graph_db_path().display() - ) - .dimmed() - ); - } + commands::index_cmd::run_index( + &commands::index_cmd::IndexOptions { + stats: show_stats, + prune, + rebuild, + watch, + verbose: verbose || cli.global.verbose, + }, + cli.global.config.as_deref(), + )?; 0 } @@ -1134,22 +1004,10 @@ async fn main() -> Result<()> { let (conn, project_id, _project_root) = open_index_strict_or_exit()?; - // Resolve embedding backend from config for query embedding - let brain_cfg = crate::config::loader::load_config( + // Config → embedding backend → vector store (shared session step). + index::session::configure_for_search(index::session::ConfigSource::Full( cli.global.config.as_deref(), - None, - None, - None, - None, - false, - ) - .ok(); - let brain_mode = brain_cfg - .as_ref() - .map(|c| c.brain.embedding.to_string()) - .unwrap_or_else(|| "auto".to_string()); - crate::embed::resolve_backend(&brain_mode); - index::vector::apply_config_store(brain_cfg.as_ref()); + )); let results = index::brain::brain_search(&conn, project_id, &query_str, limit)?; if json { @@ -1455,11 +1313,11 @@ async fn main() -> Result<()> { git_only, filter, } => { - let project_root = engine::index_bridge::IndexBridge::current_root()?; - let config_path = cli.global.config.as_deref(); + let session = index::session::IndexSession::open(index::session::ConfigSource::Full( + cli.global.config.as_deref(), + ))?; commands::watch::run_watch( - &project_root, - config_path, + &session, debounce, git_only, filter.as_deref(), @@ -1552,7 +1410,7 @@ async fn main() -> Result<()> { 0 } Command::Serve => { - commands::serve::execute_serve()?; + commands::serve::execute_serve(cli.global.config.as_deref())?; 0 } Command::Upgrade { yes, check } => commands::upgrade::run(yes, check).await?, diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 7185d14..00250cf 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -935,7 +935,9 @@ fn handle_brain_search(params: &serde_json::Value) -> ToolResult { Err(e) => return ToolResult::error(e.to_string()), }; - crate::index::vector::apply_config_store(load_project_config().ok().as_ref()); + // Same session step as `cora brain` (backend + vector store), but with + // project-only config: no env/global/API keys (#563). + crate::index::session::configure_for_search(crate::index::session::ConfigSource::ProjectOnly); match crate::index::brain::brain_search(&conn, project_id, query, limit) { Ok(results) => { if results.is_empty() {