Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/commands/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ pub fn execute_query_cli(
json_flag: bool,
limit: usize,
) -> anyhow::Result<String> {
let conn = crate::index::open_global_index()?;
let (project_id, _root) = crate::index::resolve_project_id(&conn)?;
let (conn, project_id, _root) =
crate::engine::index_bridge::IndexBridge::open_or_create_cwd()?.into_strict_parts()?;

let pattern = parse_query(pattern_str)?;
let results = execute_query(&pattern, project_id, &conn, limit)?;
Expand Down
4 changes: 2 additions & 2 deletions src/commands/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ pub fn execute_routes_cli(
prefix: Option<&str>,
json_flag: bool,
) -> anyhow::Result<String> {
let conn = crate::index::open_global_index()?;
let (project_id, _root) = crate::index::resolve_project_id(&conn)?;
let (conn, project_id, _root) =
crate::engine::index_bridge::IndexBridge::open_or_create_cwd()?.into_strict_parts()?;

let routes = list_routes(&conn, project_id, method, prefix)?;

Expand Down
5 changes: 3 additions & 2 deletions src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ pub async fn execute_scan(
let mut index_skip = config.ignore.files.clone();
index_skip.extend(config.rules_config.index_skip_files.iter().cloned());
index_skip.dedup();
let index_bridge = crate::engine::index_bridge::IndexBridge::open(&root_abs);
let index_findings = crate::engine::index_scanner::scan_project_index(
&root_abs,
&index_bridge,
&files,
config.rules_config.max_findings,
&index_skip,
Expand Down Expand Up @@ -154,7 +155,7 @@ pub async fn execute_scan(
crate::engine::review::build_scan_brain_context(
&files,
config.context_chain.impact_depth,
&root_abs,
&index_bridge,
)
} else {
None
Expand Down
8 changes: 2 additions & 6 deletions src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@
/// Execute the serve command: auto-reindex the current project, then start the MCP server.
pub fn execute_serve() -> anyhow::Result<()> {
// 1. Auto-reindex current project (incremental — skips unchanged files)
let project_root = std::env::current_dir()?;
let project_root =
crate::index::resolve_project_root(&project_root).unwrap_or(project_root.clone());

let conn = crate::index::open_global_index()?;
let _project_id = crate::index::ensure_project(&conn, &project_root)?;
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(
Expand Down
4 changes: 3 additions & 1 deletion src/commands/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ pub fn run_watch(
filter: Option<&str>,
verbose: bool,
) -> Result<()> {
let conn = crate::index::open_global_index()?;
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();
Expand Down
10 changes: 3 additions & 7 deletions src/engine/db_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,10 @@ pub fn resolve_stale_findings(project_root: &str, current_fingerprints: &[String
}

/// Open the global `cora.db` and ensure migrations are up to date.
///
/// Delegates to the shared index opener so PRAGMAs live in one place.
fn open_db() -> anyhow::Result<Connection> {
crate::data_dir::ensure_data_dir()?;
let db_path = crate::data_dir::graph_db_path();
let conn = Connection::open(&db_path)?;
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
conn.execute_batch("PRAGMA journal_mode=WAL;")?;
schema::run_migrations(&conn)?;
Ok(conn)
crate::index::open_global_index()
}

/// Open cora.db in read-only mode (no migrations, no WAL).
Expand Down
265 changes: 223 additions & 42 deletions src/engine/index_bridge.rs
Original file line number Diff line number Diff line change
@@ -1,66 +1,156 @@
//! IndexBridge — lightweight connection between the engine and the symbol index.
//! IndexBridge — the single seam for "which project am I in, and where is its index?".
//!
//! Provides a single struct that wraps an optional `rusqlite::Connection` to the
//! global `cora.db` and the resolved `project_id`. When the index database does
//! not exist or cannot be opened, the bridge reports `is_available() == false`
//! and all query methods return empty results — **zero caller impact**.
//! Every entry point (CLI arms, MCP tools, review-time scanners, the context
//! resolver) goes through this module. It owns three things:
//!
//! The bridge is constructed once at the start of a review/scan run and passed
//! through the context chain pipeline, replacing the ad-hoc
//! `crate::index::open_global_index()` calls scattered throughout resolver.rs.
//! 1. **Root resolution** — a start path is always normalised with
//! [`crate::index::resolve_project_root`], so a run from a subdirectory or a
//! workspace member lands on the same `project_id` as indexing from the root.
//! 2. **Opening the database** — PRAGMAs and migrations live in
//! [`crate::index::open_index_at`]; nobody else opens `cora.db` read-write.
//! 3. **Project id** — resolved once per bridge via `ensure_project`.
//!
//! Two modes:
//! - *tolerant* ([`IndexBridge::open`]): review-time. A missing database or any
//! failure yields an *unavailable* bridge; all queries return empty results.
//! - *strict* ([`IndexBridge::open_strict`]): CLI/MCP. A missing database is a
//! [`NoIndexError`]; the returned bridge is always available.
//!
//! [`IndexBridge::open_or_create`] is for writers (`index`, `serve`, `watch`).

use std::path::Path;
use std::path::{Path, PathBuf};

use rusqlite::Connection;
use tracing::debug;

// ── Public API ────────────────────────────────────────────────────────
/// Returned by [`IndexBridge::open_strict`] when no index database exists yet.
#[derive(Debug)]
pub struct NoIndexError;

impl std::fmt::Display for NoIndexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "No index found. Run `cora index` first.")
}
}

impl std::error::Error for NoIndexError {}

/// Bridge to the cora symbol index.
///
/// Holds an optional SQLite connection + project_id pair. If the index is
/// unavailable (no `cora.db`, migration failure, etc.) the bridge is *unavailable*
/// but still safe to query — all lookups return `None` / empty `Vec`.
#[allow(dead_code)]
/// Holds an optional SQLite connection + project_id pair plus the resolved
/// project root. If the index is unavailable (tolerant mode: no `cora.db`,
/// migration failure, etc.) the bridge is *unavailable* but still safe to
/// query — all lookups return `None` / empty `Vec`.
pub struct IndexBridge {
conn: Option<Connection>,
project_id: Option<i64>,
root: PathBuf,
}

impl IndexBridge {
/// Open the global index and resolve the project id for `project_root`.
/// Normalise `start` to the project root (`.cora.yaml` / workspace / marker),
/// falling back to `start` itself when no marker is found.
pub fn resolve_root(start: &Path) -> PathBuf {
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<PathBuf> {
Ok(Self::resolve_root(&std::env::current_dir()?))
}

/// Tolerant open of the global index for the project containing `start`.
///
/// Returns an `IndexBridge` regardless of whether the index exists.
/// Call `is_available()` to check.
pub fn open(project_root: &Path) -> Self {
let conn = match crate::index::open_global_index() {
Ok(c) => c,
Err(e) => {
debug!(error = %e, "index bridge: global index unavailable");
return Self::unavailable();
}
};
/// Never creates the database and never fails: use [`Self::is_available`].
pub fn open(start: &Path) -> Self {
Self::open_tolerant_at(&crate::data_dir::graph_db_path(), start)
}

/// Tolerant open of the global index for the current working directory.
pub fn open_cwd() -> Self {
match std::env::current_dir() {
Ok(cwd) => Self::open(&cwd),
Err(_) => Self::unavailable(),
}
}

let project_id = match crate::index::ensure_project(&conn, project_root) {
Ok(id) => Some(id),
pub(crate) fn open_tolerant_at(db_path: &Path, start: &Path) -> Self {
let root = Self::resolve_root(start);
if !db_path.exists() {
debug!("index bridge: no index database");
return Self::unavailable_for(root);
}
match Self::open_at(db_path, &root) {
Ok(b) => b,
Err(e) => {
debug!(error = %e, "index bridge: failed to resolve project_id");
return Self::unavailable();
debug!(error = %e, "index bridge: index unavailable");
Self::unavailable_for(root)
}
};
}
}

/// Strict open of the global index: errors with [`NoIndexError`] if the
/// database does not exist. The returned bridge is always available.
pub fn open_strict(start: &Path) -> anyhow::Result<Self> {
Self::open_strict_at(&crate::data_dir::graph_db_path(), start)
}

/// Strict open for the current working directory.
pub fn open_strict_cwd() -> anyhow::Result<Self> {
Self::open_strict(&std::env::current_dir()?)
}

pub(crate) fn open_strict_at(db_path: &Path, start: &Path) -> anyhow::Result<Self> {
if !db_path.exists() {
return Err(NoIndexError.into());
}
Self::open_at(db_path, &Self::resolve_root(start))
}

/// Open the global index, creating it if needed (writers: index/serve/watch).
pub fn open_or_create(start: &Path) -> anyhow::Result<Self> {
let conn = crate::index::open_global_index()?;
Self::from_connection(conn, start)
}

/// [`Self::open_or_create`] for the current working directory.
pub fn open_or_create_cwd() -> anyhow::Result<Self> {
Self::open_or_create(&std::env::current_dir()?)
}

/// Wrap an existing connection (tests, in-memory indexes). Resolves the
/// root from `start` and ensures the project row.
pub fn from_connection(conn: Connection, start: &Path) -> anyhow::Result<Self> {
let root = Self::resolve_root(start);
let project_id = crate::index::ensure_project(&conn, &root)?;
Ok(Self {
conn: Some(conn),
project_id: Some(project_id),
root,
})
}

fn open_at(db_path: &Path, root: &Path) -> anyhow::Result<Self> {
let conn = crate::index::open_index_at(db_path)?;
let project_id = crate::index::ensure_project(&conn, root)?;
debug!(project_id, "index bridge: opened successfully");
Self {
Ok(Self {
conn: Some(conn),
project_id,
}
project_id: Some(project_id),
root: root.to_path_buf(),
})
}

/// Create an explicitly unavailable bridge (no index / cannot open).
pub fn unavailable() -> Self {
Self::unavailable_for(PathBuf::new())
}

fn unavailable_for(root: PathBuf) -> Self {
Self {
conn: None,
project_id: None,
root,
}
}

Expand All @@ -77,6 +167,27 @@ impl IndexBridge {
self.project_id
}

/// The resolved project root (set even when the index is unavailable).
#[inline]
pub fn root(&self) -> &Path {
&self.root
}

/// Connection and project id together, or `None` when unavailable.
#[inline]
pub fn parts(&self) -> Option<(&Connection, i64)> {
Some((self.conn.as_ref()?, self.project_id?))
}

/// Consume an available bridge into `(connection, project_id, root)`.
pub fn into_strict_parts(self) -> anyhow::Result<(Connection, i64, PathBuf)> {
let root = self.root;
match (self.conn, self.project_id) {
(Some(c), Some(id)) => Ok((c, id, root)),
_ => Err(NoIndexError.into()),
}
}

// ── Query helpers ────────────────────────────────────────────────────

/// Search the symbols table via FTS5 for the given query text.
Expand Down Expand Up @@ -189,16 +300,86 @@ mod tests {
assert!(bridge.connection().is_none());
}

fn init_db(dir: &Path) -> PathBuf {
let db = dir.join("cora.db");
crate::index::open_index_at(&db).unwrap();
db
}

#[test]
fn tolerant_open_without_index_is_unavailable_and_creates_nothing() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("cora.db");
let bridge = IndexBridge::open_tolerant_at(&db, dir.path());
assert!(!bridge.is_available());
assert!(bridge.parts().is_none());
assert!(!db.exists(), "tolerant mode must not create the database");
}

#[test]
fn strict_open_without_index_errors_clearly() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("cora.db");
let err = IndexBridge::open_strict_at(&db, dir.path())
.err()
.expect("strict mode must fail without an index");
assert!(err.downcast_ref::<NoIndexError>().is_some());
assert!(err.to_string().contains("cora index"));
}

#[test]
fn subdirectory_resolves_same_project_as_root() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let member = repo.join("crates/member/src");
std::fs::create_dir_all(&member).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::write(
repo.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/member\"]\n",
)
.unwrap();
std::fs::write(
repo.join("crates/member/Cargo.toml"),
"[package]\nname = \"member\"\n",
)
.unwrap();
let db = init_db(dir.path());

let from_root = IndexBridge::open_strict_at(&db, &repo).unwrap();
let from_sub = IndexBridge::open_tolerant_at(&db, &member);
assert!(from_sub.is_available());
assert_eq!(from_root.project_id(), from_sub.project_id());
assert_eq!(from_root.root(), from_sub.root());
}

#[test]
fn from_connection_resolves_root_from_subdirectory() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("a/b");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(dir.path().join(".cora.yaml"), "").unwrap();
let conn = Connection::open_in_memory().unwrap();
crate::index::schema::run_migrations(&conn).unwrap();
let pid = crate::index::ensure_project(&conn, dir.path()).unwrap();
let bridge = IndexBridge::from_connection(conn, &sub).unwrap();
assert_eq!(bridge.project_id(), Some(pid));
}

#[test]
fn open_nonexistent_project_returns_unavailable() {
// Opening with a nonexistent project root should still succeed
// (it creates the project row), but we can verify it opens.
fn pragmas_are_applied_by_the_shared_opener() {
let dir = tempfile::tempdir().unwrap();
// The bridge opens the global index — if it doesn't exist,
// the data_dir crate will create it.
let bridge = IndexBridge::open(dir.path());
// Either available (index was created) or unavailable — both are valid.
// The key invariant: no panic, no crash.
let _ = bridge.is_available();
let db = init_db(dir.path());
let conn = crate::index::open_index_at(&db).unwrap();
let fk: i64 = conn
.query_row("PRAGMA foreign_keys", [], |r| r.get(0))
.unwrap();
let sync: i64 = conn
.query_row("PRAGMA synchronous", [], |r| r.get(0))
.unwrap();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
assert_eq!((fk, sync, mode.as_str()), (1, 1, "wal"));
}
}
Loading
Loading