diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 77de9e0..862e081 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -13,8 +13,8 @@ concurrency: cancel-in-progress: false permissions: - id-token: write contents: read + deployments: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -23,9 +23,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 24 cache: npm @@ -47,9 +47,9 @@ jobs: fi - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - preCommands: npx wrangler pages project create cora --production-branch=main || true - command: pages deploy docs/.vitepress/dist/ --project-name=cora --commit-dirty=true --branch=main + preCommands: npx wrangler pages project create cora-cli --production-branch=main || true + command: pages deploy docs/.vitepress/dist/ --project-name=cora-cli --commit-dirty=true --branch=main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a52ad3e..ea2ca73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,13 @@ name: Release +# Release is triggered by pushing a vX.Y.Z tag. +# +# PREREQUISITE: Before tagging, merge develop → main via PR. +# The tag must point to a commit on main, NOT develop. +# GitFlow: develop (default) → PR → main → tag → release. +# +# This workflow does NOT sync main — main must already be up to date. + on: push: tags: @@ -10,27 +18,32 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - sync-main: - name: Sync main branch + verify-main: + name: Verify tag is on main runs-on: ubuntu-latest - # Only runs on v* tag pushes (release trigger). Uses force-push because main - # is a release-only mirror of develop — any commits on main not in develop - # are stale snapshots that should be overwritten by the current release. - if: startsWith(github.ref, 'refs/tags/v') - permissions: - contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: - ref: develop - - - name: Push to main + fetch-depth: 0 + - name: Check tag is on main run: | - git push origin HEAD:main --force - echo "✅ main synced to develop at ${{ github.sha }}" + git fetch origin main + TAG_SHA=$(git rev-parse "${{ github.ref }}^{commit}") + MAIN_SHA=$(git rev-parse origin/main) + echo "Tag: $TAG_SHA" + echo "main: $MAIN_SHA" + if [ "$TAG_SHA" = "$MAIN_SHA" ]; then + echo "✅ Tag is main HEAD" + elif git merge-base --is-ancestor "$TAG_SHA" origin/main 2>/dev/null; then + echo "✅ Tag commit $TAG_SHA is in main history" + else + echo "::error::Tag commit $TAG_SHA is NOT on main." + echo "Merge develop → main via PR before tagging." + exit 1 + fi build-release: - needs: sync-main + needs: [verify-main] name: Build ${{ matrix.target }} runs-on: ${{ matrix.os }} strategy: @@ -59,7 +72,7 @@ jobs: ext: zip steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: @@ -106,12 +119,12 @@ jobs: publish-crates: name: Publish to crates.io - needs: [] + needs: [verify-main] runs-on: ubuntu-latest permissions: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable @@ -126,7 +139,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download all artifacts uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 8bb96b5..a156ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ dist/ *.tar.gz *.zip .cora/history/ +.cora/index.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 385d341..18c02ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Cora + Uteke bundle installer** (`install-bundle.sh`) — single command installs both tools (#235) +- **Documentation** — Uteke memory integration docs across README, usage, getting-started, cli-reference + ## [0.5.1] - 2026-06-13 ### Added diff --git a/Cargo.lock b/Cargo.lock index 606841a..b3de1b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -280,6 +292,7 @@ dependencies = [ "predicates", "regex", "reqwest", + "rusqlite", "serde", "serde_json", "serde_yaml_ng", @@ -426,6 +439,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -604,6 +629,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -619,6 +653,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -975,6 +1018,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.28" @@ -1370,6 +1424,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" diff --git a/Cargo.toml b/Cargo.toml index 15cf7d4..2288966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,9 @@ futures-util = "0.3" sha2 = "0.10" chrono = { version = "0.4.44", features = ["serde"] } +# Symbol index (v0.6 — Code Intelligence) +rusqlite = { version = "0.31", features = ["bundled"] } + [dev-dependencies] assert_cmd = "2" predicates = "3" diff --git a/README.md b/README.md index acef6dd..01dad0a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,11 @@ ### Install ```bash +# Cora only (standalone) curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh + +# Or install both Cora + Uteke (code review with memory) +curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-bundle.sh | sh ``` > Pin a version: `CORA_VERSION=v0.5.1 curl -fsSL ... | sh` @@ -148,6 +152,30 @@ Works on **all CI platforms** — [Gitea, GitLab, Bitbucket →](https://codecor See **[CLI Reference →](https://codecora.dev/cli-reference.html)** for all flags and examples. +## Uteke Memory Integration + +Cora works 100% standalone. Install [Uteke](https://github.com/codecoradev/uteke) to unlock **memory-powered reviews** that learn from your codebase history. + +| Mode | Command | What it does | +|------|---------|-------------| +| Standalone (default) | `cora review` | AI review, zero deps | +| Memory recall | `cora review --memory` | Recall project patterns before review | +| Learning | `cora review --memory --learn` | Recall + save findings after review | + +```bash +# Install Uteke separately +curl -fsSL https://raw.githubusercontent.com/codecoradev/uteke/main/install.sh | sh + +# Or install both at once +curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-bundle.sh | sh + +# Enable memory +export PATH="$HOME/.local/bin:$PATH" +cora review --staged --memory --learn +``` + +Your code review gets smarter every sprint. + ## Environment Variables | Variable | Description | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 04ff799..3f4037f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,35 +1,30 @@ import { defineConfig } from 'vitepress' export default defineConfig({ - title: 'cora', + title: 'Cora', description: 'AI-Powered Code Review CLI — BYOK, zero config, runs in your terminal', + base: '/docs/cora/', head: [ ['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }], ['link', { rel: 'alternate icon', type: 'image/png', href: '/favicon.png' }], ['meta', { name: 'theme-color', content: '#6366f1' }], ['meta', { property: 'og:type', content: 'website' }], - ['meta', { property: 'og:title', content: 'cora — AI Code Review CLI' }], + ['meta', { property: 'og:title', content: 'Cora — AI Code Review CLI' }], ['meta', { property: 'og:description', content: 'BYOK, zero config, runs in your terminal' }], - ['meta', { property: 'og:image', content: 'https://codecora.dev/og.png' }], - ['meta', { property: 'og:url', content: 'https://codecora.dev/' }], + ['meta', { property: 'og:image', content: 'https://codecora.dev/docs/cora/og.png' }], + ['meta', { property: 'og:url', content: 'https://codecora.dev/docs/cora/' }], ], themeConfig: { logo: '/logo.svg', nav: [ + { text: 'Codecora', link: 'https://codecora.dev' }, { text: 'Docs', link: '/getting-started' }, { text: 'Examples', link: '/examples' }, { text: 'Changelog', link: '/changelog' }, - { text: 'Roadmap', link: '/roadmap' }, - { - text: 'v0.5.1', - items: [ - { text: 'GitHub', link: 'https://github.com/codecoradev/cora-cli' }, - { text: 'Releases', link: 'https://github.com/codecoradev/cora-cli/releases' }, - ], - }, + { text: 'GitHub', link: 'https://github.com/codecoradev/cora-cli' }, ], sidebar: { diff --git a/docs/changelog.md b/docs/changelog.md index 385d341..18c02ee 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Cora + Uteke bundle installer** (`install-bundle.sh`) — single command installs both tools (#235) +- **Documentation** — Uteke memory integration docs across README, usage, getting-started, cli-reference + ## [0.5.1] - 2026-06-13 ### Added diff --git a/docs/cli-reference.md b/docs/cli-reference.md index dcf0442..9638cfc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -96,3 +96,11 @@ $ cora commit # YOLO mode — auto-commit, no prompts $ cora commit --yolo ``` + +```bash +# Install both Cora + Uteke (code review with memory) +$ curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-bundle.sh | sh + +# Then review with memory: +$ cora review --staged --memory --learn +``` diff --git a/docs/getting-started.md b/docs/getting-started.md index e116165..7770e51 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -150,6 +150,28 @@ Or skip the prompt in CI/trusted workflows: cora commit --yolo # auto-commit, no prompts ``` +### Memory-Powered Reviews (Optional) + +Install [Uteke](https://github.com/codecoradev/uteke) to give Cora a memory: + +```bash +# Install both tools +curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-bundle.sh | sh +``` + +Then enable memory in reviews: + +```bash +cora review --staged --memory # recall project patterns +cora review --staged --memory --learn # recall + save findings +``` + +- `--memory` — Cora recalls past review findings and code patterns from Uteke before reviewing +- `--learn` — After review, Cora saves findings to Uteke for future recall +- Works with any review mode (staged, unpushed, branch) + +Your code review gets smarter every sprint. + ## AI Agent Integration cora includes a built-in MCP server for AI coding agents. After installation: diff --git a/docs/roadmap.md b/docs/roadmap.md index 34a03b3..9c96249 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,13 +75,30 @@ Demand-gated — we build what people actually need. Track progress on [GitHub I - [#232](https://github.com/codecoradev/cora-cli/issues/232) Uteke memory integration — recall + learn — ✓ Done - [#262](https://github.com/codecoradev/cora-cli/issues/262) `cora commit` — review + auto commit message + quality gate — ✓ Done -## v0.6 — Growth & Marketplace +## v0.6 — Code Intelligence + +The foundation layer for structural code understanding — persistent symbol index, semantic search, and deep Uteke integration. + +- [#264](https://github.com/codecoradev/cora-cli/issues/264) `cora index` — Symbol index & SQLite persistence — ◎ Planned +- [#265](https://github.com/codecoradev/cora-cli/issues/265) `cora explore` — Structural + semantic code search — ◎ Planned +- [#235](https://github.com/codecoradev/cora-cli/issues/235) Cora + Uteke cross-product integration bundle — ◎ Planned + +### Also in v0.6 - [#47](https://github.com/codecoradev/cora-cli/issues/47) GitHub Marketplace action — ✓ Done - [#196](https://github.com/codecoradev/cora-cli/issues/196) VitePress docs site — ✓ Done - [#161](https://github.com/codecoradev/cora-cli/issues/161) `cora gain` — local stats + viral sharing — ◎ Planned - [#160](https://github.com/codecoradev/cora-cli/issues/160) Landing page redesign — ◎ Planned +## v0.7 — Multi-Language & Code Graph + +Query layer built on top of the v0.6 index — call graph traversal, test impact analysis, broader language support, and real-time sync. + +- [#266](https://github.com/codecoradev/cora-cli/issues/266) `cora callers` / `cora impact` — Call graph query commands — ◎ Planned +- [#267](https://github.com/codecoradev/cora-cli/issues/267) `cora affected` — Find tests affected by changes — ◎ Planned +- [#268](https://github.com/codecoradev/cora-cli/issues/268) Language expansion — 6 → 15+ language support — ◎ Planned +- [#269](https://github.com/codecoradev/cora-cli/issues/269) Auto-sync file watcher daemon — ◎ Planned + ## Future — What's Next - [#117](https://github.com/codecoradev/cora-cli/issues/117) Lightweight agent follow-up — 1 capped tool-call — → Planned diff --git a/docs/usage.md b/docs/usage.md index 6df816a..0cc01ac 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -175,6 +175,47 @@ $ cora review --staged --exclude "**/*.test.ts" --exclude "**/generated/**" Alternatively, set include/exclude patterns in `.cora.yaml` for persistent configuration. +## Uteke Memory Integration + +Cora works standalone. Install [Uteke](https://github.com/codecoradev/uteke) to unlock reviews that learn from your codebase history. + +### Three Levels + +| Level | Command | Uteke Required | What It Does | +|-------|---------|:---:|-------------| +| **Standalone** | `cora review` | No | AI review, zero deps | +| **Recall** | `cora review --memory` | Yes | Recall project patterns before review | +| **Learning** | `cora review --memory --learn` | Yes | Recall + save findings after review | + +### Install Both + +```bash +curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-bundle.sh | sh +``` + +### Usage + +```bash +# Review with memory recall +cora review --staged --memory + +# Review + save to memory (reviews improve over time) +cora review --staged --memory --learn + +# Works with all review modes +cora review --base main --memory --learn +cora commit --memory # Note: --memory only on review, not commit +``` + +### How It Works + +1. Before review, Cora calls `uteke recall` to find relevant memories (past findings, code patterns) +2. These memories are injected into the LLM prompt as context +3. After review (with `--learn`), findings are saved to Uteke via `uteke remember` +4. Next review benefits from accumulated knowledge + +Cora auto-detects Uteke on PATH. If not installed, memory features are silently disabled. + ## Exit Codes cora uses standard exit codes for scripting and CI integration: diff --git a/install-bundle.sh b/install-bundle.sh new file mode 100755 index 0000000..496e70b --- /dev/null +++ b/install-bundle.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env sh +# +# cora + uteke bundle installer +# Installs both Cora (AI code review) and Uteke (AI memory) in one command. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-bundle.sh | sh +# +# With version pinning: +# CORA_VERSION=v0.5.1 UTEKE_VERSION=v0.1.0 curl -fsSL ... | sh + +set -e + +# Colors +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + CYAN='\033[0;36m' + BOLD='\033[1m' + DIM='\033[2m' + NC='\033[0m' +else + RED='' GREEN='' YELLOW='' CYAN='' BOLD='' DIM='' NC='' +fi + +info() { printf "${CYAN}[INFO]${NC} %s\n" "$1"; } +ok() { printf "${GREEN}[OK]${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$1"; } +error() { printf "${RED}[ERROR]${NC} %s\n" "$1"; exit 1; } + +BINARY_DIR="${HOME}/.local/bin" + +# Ensure binary dir exists +mkdir -p "$BINARY_DIR" + +# ─── Install Cora ─── +install_cora() { + info "Installing Cora — AI code review CLI..." + + if command -v cora >/dev/null 2>&1; then + EXISTING=$(cora --version 2>/dev/null | head -1 || echo "unknown") + info "Cora already installed: $EXISTING" + if [ -z "${CORA_FORCE:-}" ]; then + info "Skipping (set CORA_FORCE=1 to reinstall)" + return 0 + fi + fi + + curl -fsSL "https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh" | sh + ok "Cora installed: $(cora --version 2>/dev/null | head -1)" +} + +# ─── Install Uteke ─── +install_uteke() { + info "Installing Uteke — AI memory engine..." + + if command -v uteke >/dev/null 2>&1; then + EXISTING=$(uteke --version 2>/dev/null | head -1 || echo "unknown") + info "Uteke already installed: $EXISTING" + if [ -z "${UTEKE_FORCE:-}" ]; then + info "Skipping (set UTEKE_FORCE=1 to reinstall)" + return 0 + fi + fi + + curl -fsSL "https://raw.githubusercontent.com/codecoradev/uteke/main/install.sh" | sh + ok "Uteke installed: $(uteke --version 2>/dev/null | head -1)" +} + +# ─── Verify ─── +verify() { + info "Verifying installation..." + + CORA_OK="no" + UTEKE_OK="no" + + if command -v cora >/dev/null 2>&1; then + CORA_VER=$(cora --version 2>/dev/null | head -1) + CORA_OK="yes" + ok "Cora: $CORA_VER" + else + warn "Cora not found in PATH" + fi + + if command -v uteke >/dev/null 2>&1; then + UTEKE_VER=$(uteke --version 2>/dev/null | head -1) + UTEKE_OK="yes" + ok "Uteke: $UTEKE_VER" + else + warn "Uteke not found in PATH" + fi + + echo "" + printf "${BOLD}━━━ Setup Complete ━━━${NC}\n" + echo "" + + if [ "$CORA_OK" = "yes" ] && [ "$UTEKE_OK" = "yes" ]; then + printf "${GREEN}Both tools installed!${NC} You now have AI code review with memory.\n\n" + echo " ${BOLD}Quick start:${NC}" + echo " cora auth login # Set your API key" + echo " cora review --staged # Review staged changes" + echo " cora review --memory # Review with project memory" + echo " cora commit # Review + auto commit message" + echo "" + echo " ${BOLD}Memory workflow:${NC}" + echo " cora review --memory --learn # Review + save to memory" + echo " uteke stats # Check memory stats" + echo "" + printf " ${DIM}Your code review gets smarter every sprint.${NC}\n" + elif [ "$CORA_OK" = "yes" ]; then + printf "${YELLOW}Cora installed.${NC} Install Uteke separately for memory features:\n" + echo " curl -fsSL https://raw.githubusercontent.com/codecoradev/uteke/main/install.sh | sh" + elif [ "$UTEKE_OK" = "yes" ]; then + printf "${YELLOW}Uteke installed.${NC} Install Cora separately for code review:\n" + echo " curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh" + else + error "Neither tool was installed successfully." + fi + + # PATH check + case ":$PATH:" in + *":$BINARY_DIR:"*) ;; + *) + echo "" + warn "Add $BINARY_DIR to your PATH:" + echo " echo 'export PATH=\"$BINARY_DIR:\$PATH\"' >> ~/.bashrc" + echo " source ~/.bashrc" + ;; + esac +} + +# ─── Main ─── +echo "" +printf "${BOLD}${CYAN}╔══════════════════════════════════════════════╗${NC}\n" +printf "${BOLD}${CYAN}║ CodeCoraDev Bundle — Cora + Uteke ║${NC}\n" +printf "${BOLD}${CYAN}║ AI code review that remembers and learns ║${NC}\n" +printf "${BOLD}${CYAN}╚══════════════════════════════════════════════╝${NC}\n" +echo "" + +install_cora +echo "" +install_uteke +echo "" +verify +echo "" diff --git a/src/engine/context/extraction.rs b/src/engine/context/extraction.rs index 53f0fb5..ad871c5 100644 --- a/src/engine/context/extraction.rs +++ b/src/engine/context/extraction.rs @@ -73,7 +73,7 @@ static RE_JAVA_TYPE: LazyLock = const MAX_SYMBOLS_PER_FILE: usize = 50; /// Extract symbols from a single line of code for a given language. -fn extract_symbols_from_line(line: &str, language: &str) -> Vec { +pub fn extract_symbols_from_line(line: &str, language: &str) -> Vec { let mut symbols = Vec::new(); let mut seen = HashSet::new(); diff --git a/src/index/extract.rs b/src/index/extract.rs new file mode 100644 index 0000000..8476615 --- /dev/null +++ b/src/index/extract.rs @@ -0,0 +1,760 @@ +//! Symbol extraction from source files. +//! +//! Uses regex-based extraction — same approach as `engine/context/extraction.rs` +//! but extracts definitions (not just references). No heavy AST parser needed. + +use regex::Regex; +use std::sync::LazyLock; + +use super::symbols::{IndexedSymbol, SymbolKind}; + +// ─── Rust ─── + +static RE_RUST_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)").unwrap()); + +static RE_RUST_STRUCT: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?struct\s+(\w+)").unwrap()); + +static RE_RUST_ENUM: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?enum\s+(\w+)").unwrap()); + +static RE_RUST_TRAIT: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?trait\s+(\w+)").unwrap()); + +static RE_RUST_TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?type\s+(\w+)").unwrap()); + +static RE_RUST_CONST: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?(?:const|static)\s+(\w+)").unwrap()); + +static RE_RUST_MOD: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?mod\s+(\w+)").unwrap()); + +// RE_RUST_IMPL removed — not needed for symbol definitions + +// ─── Python ─── + +static RE_PY_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:async\s+)?def\s+(\w+)").unwrap()); + +static RE_PY_CLASS: LazyLock = LazyLock::new(|| Regex::new(r"^\s*class\s+(\w+)").unwrap()); + +// ─── TypeScript/JavaScript ─── + +static RE_TS_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)").unwrap()); + +static RE_TS_CLASS: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)").unwrap()); + +static RE_TS_INTERFACE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?interface\s+(\w+)").unwrap()); + +static RE_TS_TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?type\s+(\w+)").unwrap()); + +static RE_TS_CONST: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:export\s+)?const\s+(\w+)").unwrap()); + +// ─── Go ─── + +static RE_GO_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*func\s+(?:\([^)]+\)\s+)?(\w+)").unwrap()); + +static RE_GO_STRUCT: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*type\s+(\w+)\s+struct").unwrap()); + +static RE_GO_INTERFACE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*type\s+(\w+)\s+interface").unwrap()); + +static RE_GO_CONST: LazyLock = LazyLock::new(|| Regex::new(r"^\s*const\s+(\w+)").unwrap()); + +// ─── Java/Kotlin ─── + +static RE_JAVA_CLASS: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|private|protected)?\s*(?:abstract\s+)?class\s+(\w+)").unwrap() +}); + +static RE_JAVA_METHOD: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|private|protected)?\s*(?:static\s+)?[\w<>\[\]]+\s+(\w+)\s*\(") + .unwrap() +}); + +// ─── C/C++ ─── + +static RE_C_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:static\s+)?[\w\*]+\s+(\w+)\s*\([^;]*\)\s*\{").unwrap()); + +static RE_C_STRUCT: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:typedef\s+)?struct\s+(\w+)").unwrap()); + +// ─── Ruby ─── + +static RE_RB_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*def\s+(?:self\.)?(\w+)").unwrap()); + +static RE_RB_CLASS: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:class|module)\s+(\w+)").unwrap()); + +// ─── PHP ─── + +static RE_PHP_FN: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|private|protected)?\s*(?:static\s+)?function\s+(\w+)").unwrap() +}); + +static RE_PHP_CLASS: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:abstract\s+)?(?:final\s+)?class\s+(\w+)").unwrap()); + +static RE_PHP_INTERFACE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*interface\s+(\w+)").unwrap()); + +// ─── Swift ─── + +static RE_SW_FN: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|private|internal|fileprivate)?\s*(?:static\s+)?func\s+(\w+)") + .unwrap() +}); + +static RE_SW_TYPE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|internal|final|open)?\s*(?:class|struct|enum|protocol)\s+(\w+)") + .unwrap() +}); + +// ─── Scala ─── + +static RE_SCALA_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:private|protected)?\s*def\s+(\w+)").unwrap()); + +static RE_SCALA_TYPE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:abstract\s+)?(?:sealed\s+)?(?:class|object|trait|case class)\s+(\w+)") + .unwrap() +}); + +// ─── Lua ─── + +static RE_LUA_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:local\s+)?function\s+(?:[\w.:]+\.)?(\w+)").unwrap()); + +// ─── Zig ─── + +static RE_ZIG_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?fn\s+(\w+)").unwrap()); + +static RE_ZIG_CONST: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?const\s+(\w+)").unwrap()); + +/// Extract symbols from source code. +/// +/// Returns a list of `IndexedSymbol` entries (without id, which is assigned by the database). +pub fn extract_symbols(content: &str, language: &str, file_path: &str) -> Vec { + let mut symbols = Vec::new(); + + for (line_num, line) in content.lines().enumerate() { + let line_no = (line_num + 1) as u32; + + match language { + "rs" => extract_rust(line, line_no, file_path, line, &mut symbols), + "py" | "pyi" => extract_python(line, line_no, file_path, line, &mut symbols), + "ts" | "tsx" | "js" | "jsx" => { + extract_typescript(line, line_no, file_path, line, &mut symbols) + } + "go" => extract_go(line, line_no, file_path, line, &mut symbols), + "java" | "kt" => extract_java(line, line_no, file_path, line, &mut symbols), + "c" | "cpp" | "cc" | "cxx" | "h" | "hpp" => { + extract_c(line, line_no, file_path, line, &mut symbols) + } + "rb" => extract_ruby(line, line_no, file_path, line, &mut symbols), + "php" => extract_php(line, line_no, file_path, line, &mut symbols), + "swift" => extract_swift(line, line_no, file_path, line, &mut symbols), + "scala" => extract_scala(line, line_no, file_path, line, &mut symbols), + "lua" => extract_lua(line, line_no, file_path, line, &mut symbols), + "zig" => extract_zig(line, line_no, file_path, line, &mut symbols), + _ => {} + } + } + + // Deduplicate by (name, line) — regex may match multiple times + symbols.dedup_by(|a, b| a.name == b.name && a.line == b.line); + + symbols +} + +/// A symbol definition extracted from source code. +#[derive(Debug, Clone)] +pub struct ExtractedDef { + pub name: String, + pub kind: SymbolKind, + pub file: String, + pub line: u32, + pub signature: String, +} + +impl From<&ExtractedDef> for IndexedSymbol { + fn from(d: &ExtractedDef) -> Self { + Self { + id: 0, + name: d.name.clone(), + kind: d.kind.clone(), + file: d.file.clone(), + line: d.line, + signature: d.signature.clone(), + language: String::new(), + } + } +} + +// ─── Per-language extractors ─── + +fn extract_rust(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_RUST_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_STRUCT.captures(line) { + out.push(def(cap, SymbolKind::Struct, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_ENUM.captures(line) { + out.push(def(cap, SymbolKind::Enum, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_TRAIT.captures(line) { + out.push(def(cap, SymbolKind::Trait, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_TYPE.captures(line) { + out.push(def(cap, SymbolKind::TypeAlias, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_CONST.captures(line) { + out.push(def(cap, SymbolKind::Constant, line_no, file, raw)); + } + if let Some(cap) = RE_RUST_MOD.captures(line) { + out.push(def(cap, SymbolKind::Module, line_no, file, raw)); + } +} + +fn extract_python(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_PY_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_PY_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } +} + +fn extract_typescript( + line: &str, + line_no: u32, + file: &str, + raw: &str, + out: &mut Vec, +) { + if let Some(cap) = RE_TS_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_TS_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } + if let Some(cap) = RE_TS_INTERFACE.captures(line) { + out.push(def(cap, SymbolKind::Interface, line_no, file, raw)); + } + if let Some(cap) = RE_TS_TYPE.captures(line) { + out.push(def(cap, SymbolKind::TypeAlias, line_no, file, raw)); + } + if let Some(cap) = RE_TS_CONST.captures(line) { + out.push(def(cap, SymbolKind::Constant, line_no, file, raw)); + } +} + +fn extract_go(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_GO_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_GO_STRUCT.captures(line) { + out.push(def(cap, SymbolKind::Struct, line_no, file, raw)); + } + if let Some(cap) = RE_GO_INTERFACE.captures(line) { + out.push(def(cap, SymbolKind::Interface, line_no, file, raw)); + } + if let Some(cap) = RE_GO_CONST.captures(line) { + out.push(def(cap, SymbolKind::Constant, line_no, file, raw)); + } +} + +fn extract_java(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_JAVA_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } + if let Some(cap) = RE_JAVA_METHOD.captures(line) { + out.push(def(cap, SymbolKind::Method, line_no, file, raw)); + } +} + +fn extract_c(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_C_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_C_STRUCT.captures(line) { + out.push(def(cap, SymbolKind::Struct, line_no, file, raw)); + } +} + +fn extract_ruby(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_RB_FN.captures(line) { + out.push(def(cap, SymbolKind::Method, line_no, file, raw)); + } + if let Some(cap) = RE_RB_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } +} + +fn extract_php(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_PHP_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_PHP_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } + if let Some(cap) = RE_PHP_INTERFACE.captures(line) { + out.push(def(cap, SymbolKind::Interface, line_no, file, raw)); + } +} + +fn extract_swift(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_SW_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_SW_TYPE.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } +} + +fn extract_scala(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_SCALA_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_SCALA_TYPE.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } +} + +fn extract_lua(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_LUA_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } +} + +fn extract_zig(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_ZIG_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_ZIG_CONST.captures(line) { + out.push(def(cap, SymbolKind::Constant, line_no, file, raw)); + } +} + +/// Helper: create an ExtractedDef from a regex capture. +/// Extract function call sites from source code. +/// Returns (caller_name, callee_name, file, line) tuples. +/// The caller_name is the enclosing function (best-effort via scope tracking). +pub fn extract_calls(content: &str, language: &str, file_path: &str) -> Vec { + use crate::engine::context::extraction as ctx_extract; + use crate::engine::context::types::SymbolKind as CtxSymbolKind; + + let lines: Vec<&str> = content.lines().collect(); + let mut current_fn: Option = None; + let mut brace_depth: i32 = 0; + let mut calls = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + let line_no = (i + 1) as u32; + + // Track current function scope for Rust/Go/C/Java + if language == "rs" + || language == "go" + || language == "c" + || language == "cpp" + || language == "java" + { + // Check if we're entering a function + if let Some(fn_name) = detect_function_entry(line, language) { + current_fn = Some(fn_name); + brace_depth = 0; + } + } + + // Track brace depth for scope + brace_depth += line.chars().filter(|&c| c == '{').count() as i32 + - line.chars().filter(|&c| c == '}').count() as i32; + if brace_depth <= 0 && current_fn.is_some() { + current_fn = None; + } + + // Extract function calls from this line + let symbols = ctx_extract::extract_symbols_from_line(line, language); + for sym in symbols { + if let CtxSymbolKind::FunctionCall(name) = sym { + // Skip self-references and builtins + if name == current_fn.as_deref().unwrap_or("") { + continue; + } + if is_builtin(&name) { + continue; + } + if let Some(caller) = ¤t_fn { + calls.push(CallSite { + caller: caller.clone(), + callee: name, + file: file_path.to_string(), + line: line_no, + }); + } + } + } + } + + calls +} + +/// Detect function entry from a line (returns function name). +fn detect_function_entry(line: &str, language: &str) -> Option { + let trimmed = line.trim(); + + match language { + "rs" => { + // pub fn name( or fn name( + let re = regex::Regex::new(r"(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[(<]").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + "go" => { + let re = regex::Regex::new(r"func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + "c" | "cpp" | "h" | "hpp" => { + let re = regex::Regex::new(r"[\w:*]+\s+(\w+)\s*\([^;]*\)\s*\{").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + "java" | "kt" => { + let re = regex::Regex::new(r"\b(\w+)\s*\([^)]*\)\s*\{").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + "py" => { + let re = regex::Regex::new(r"def\s+(\w+)").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + _ => None, + } +} + +/// Check if a name is a builtin/keyword to skip. +fn is_builtin(name: &str) -> bool { + matches!( + name, + "if" | "for" + | "while" + | "match" + | "return" + | "break" + | "continue" + | "print" + | "println" + | "eprintln" + | "format" + | "write" + | "writeln" + | "vec" + | "Box" + | "Some" + | "None" + | "Ok" + | "Err" + | "Result" + | "Option" + | "String" + | "str" + | "Vec" + | "HashMap" + | "HashSet" + | "dbg" + | "todo" + | "unimplemented" + | "unreachable" + | "panic" + | "assert" + | "assert_eq" + | "assert_ne" + | "len" + | "is_empty" + | "push" + | "pop" + | "insert" + | "remove" + | "get" + | "set" + | "new" + | "default" + | "clone" + | "into" + | "from" + | "iter" + | "collect" + | "map" + | "filter" + | "fold" + | "next" + | "unwrap" + | "expect" + | "as_ref" + | "as_mut" + | "as_str" + | "to_string" + | "to_owned" + | "to_vec" + | "drop" + | "copy" + | "send" + | "sync" + | "main" + ) +} + +/// A function call site extracted from source code. +#[derive(Debug, Clone)] +pub struct CallSite { + pub caller: String, + pub callee: String, + pub file: String, + pub line: u32, +} + +fn def(cap: regex::Captures, kind: SymbolKind, line: u32, file: &str, raw: &str) -> ExtractedDef { + ExtractedDef { + name: cap + .get(1) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(), + kind, + line, + file: file.to_string(), + signature: raw.trim().to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_rust() { + let code = r#" +pub struct Cache { + inner: HashMap, +} + +impl Cache { + pub fn new() -> Self { + Self {} + } + + pub fn get(&self, key: &str) -> Option<&String> { + self.inner.get(key) + } +} + +enum Status { + Active, + Inactive, +} + +const MAX_SIZE: usize = 100; +"#; + let symbols = extract_symbols(code, "rs", "src/cache.rs"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"Cache")); + assert!(names.contains(&"new")); + assert!(names.contains(&"get")); + assert!(names.contains(&"Status")); + assert!(names.contains(&"MAX_SIZE")); + } + + #[test] + fn test_extract_python() { + let code = r#" +class AuthService: + def __init__(self): + self.secret = "" + + async def validate(self, token: str) -> bool: + return False +"#; + let symbols = extract_symbols(code, "py", "auth.py"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"AuthService")); + assert!(names.contains(&"validate")); + } + + #[test] + fn test_extract_typescript() { + let code = r#" +export interface User { + id: string; + name: string; +} + +export class UserService { + async getUser(id: string): Promise { + return {} as User; + } +} + +export type Status = 'active' | 'inactive'; + +export const DEFAULT_TIMEOUT = 5000; +"#; + let symbols = extract_symbols(code, "ts", "user.ts"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"User")); + assert!(names.contains(&"UserService")); + assert!(names.contains(&"Status")); + assert!(names.contains(&"DEFAULT_TIMEOUT")); + } + + #[test] + fn test_extract_go() { + let code = r#" +type Server struct { + port int +} + +func (s *Server) Start() error { + return nil +} + +func NewServer(port int) *Server { + return &Server{port: port} +} + +const DefaultPort = 8080 +"#; + let symbols = extract_symbols(code, "go", "server.go"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"Server")); + assert!(names.contains(&"Start")); + assert!(names.contains(&"NewServer")); + assert!(names.contains(&"DefaultPort")); + } + + #[test] + fn test_extract_unknown_language() { + let symbols = extract_symbols("fn test() {}", "unknown", "test.txt"); + assert!(symbols.is_empty()); + } + + #[test] + fn test_extract_empty() { + let symbols = extract_symbols("", "rs", "empty.rs"); + assert!(symbols.is_empty()); + } + + #[test] + fn test_extract_ruby() { + let code = r#" +class ApplicationController + def authenticate_user + @current_user + end +end + +module Auth + def validate_token(token) + false + end +end +"#; + let symbols = extract_symbols(code, "rb", "app.rb"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"ApplicationController")); + assert!(names.contains(&"authenticate_user")); + assert!(names.contains(&"Auth")); + } + + #[test] + fn test_extract_php() { + let code = r#" +find($id); + } +} + +interface Repository { + public function find($id); +} +"#; + let symbols = extract_symbols(code, "php", "user.php"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"UserController")); + assert!(names.contains(&"show")); + assert!(names.contains(&"Repository")); + } + + #[test] + fn test_extract_swift() { + let code = r#" +public struct User { + var id: String +} + +func authenticate(token: String) -> Bool { + return false +} + +enum AuthError: Error { + case invalidToken +} +"#; + let symbols = extract_symbols(code, "swift", "auth.swift"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"User")); + assert!(names.contains(&"authenticate")); + } + + #[test] + fn test_extract_lua() { + let code = r#" +local function validate(input) + return true +end + +function M.handler(req) + return validate(req.body) +end +"#; + let symbols = extract_symbols(code, "lua", "handler.lua"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"validate")); + assert!(names.contains(&"handler")); + } + + #[test] + fn test_extract_zig() { + let code = r#" +pub fn main() !void { + try run(); +} + +const MAX_RETRIES: u32 = 3; +"#; + let symbols = extract_symbols(code, "zig", "main.zig"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"main")); + assert!(names.contains(&"MAX_RETRIES")); + } +} diff --git a/src/index/graph.rs b/src/index/graph.rs new file mode 100644 index 0000000..09fb4d0 --- /dev/null +++ b/src/index/graph.rs @@ -0,0 +1,294 @@ +//! Call graph traversal for `cora callers` and `cora impact`. +//! +//! Uses the existing `engine/context/extraction.rs` symbol reference extraction +//! to build call edges at index time, then traverse them for queries. + +use rusqlite::Connection; + +#[allow(unused_imports)] +use super::symbols::{IndexedSymbol, SymbolKind}; + +/// A directed edge in the call graph: caller → callee. +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct CallEdge { + /// Symbol that makes the call. + pub caller: String, + /// Symbol that is called. + pub callee: String, + /// File where the call happens. + pub file: String, + /// Line number of the call site. + pub line: u32, +} + +/// Store call edges in the database. +#[allow(dead_code)] +pub fn store_edges(conn: &Connection, edges: &[CallEdge]) -> 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], + )?; + count += 1; + } + tx.commit()?; + Ok(count) +} + +/// Clear call graph edges for a specific file (before re-indexing). +#[allow(dead_code)] +pub fn clear_edges_for_file(conn: &Connection, file: &str) -> anyhow::Result<()> { + conn.execute( + "DELETE FROM call_graph WHERE file = ?1", + rusqlite::params![file], + )?; + Ok(()) +} + +/// Find all callers of a symbol (who calls this?). +/// +/// Returns symbols that call the given name, grouped by file. +pub fn find_callers( + conn: &Connection, + symbol_name: &str, + limit: usize, +) -> anyhow::Result> { + let pattern = format!("%{symbol_name}%"); + + let mut stmt = conn.prepare( + "SELECT DISTINCT cg.caller, cg.file, cg.line + FROM call_graph cg + WHERE cg.callee LIKE ?1 + LIMIT ?2", + )?; + + let rows = stmt.query_map(rusqlite::params![pattern, 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()) +} + +/// Find all callees of a symbol (what does this call?). +/// +/// Returns symbols that are called by the given name. +#[allow(dead_code)] +pub fn find_callees( + conn: &Connection, + symbol_name: &str, + limit: usize, +) -> anyhow::Result> { + let pattern = format!("%{symbol_name}%"); + + let mut stmt = conn.prepare( + "SELECT DISTINCT cg.callee, cg.file, cg.line + FROM call_graph cg + WHERE cg.caller LIKE ?1 + LIMIT ?2", + )?; + + let rows = stmt.query_map(rusqlite::params![pattern, 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()) +} + +/// Impact analysis: what breaks if a symbol changes? +/// +/// Uses reverse traversal: find all callers recursively up to `depth`. +pub fn impact_analysis( + conn: &Connection, + symbol_name: &str, + depth: u32, +) -> anyhow::Result> { + let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + let mut result = Vec::new(); + let mut current_level = vec![symbol_name.to_string()]; + let mut current_depth = 0u32; + + while current_depth < depth && !current_level.is_empty() { + let mut next_level = Vec::new(); + + for sym in ¤t_level { + if !visited.insert(sym.clone()) { + continue; + } + + let callers = find_callers(conn, sym, 100)?; + for caller in callers { + let node = ImpactNode { + symbol: caller.caller.clone(), + file: caller.file.clone(), + line: caller.line, + depth: current_depth + 1, + }; + + if !visited.contains(&caller.caller) { + next_level.push(caller.caller.clone()); + } + + result.push(node); + } + } + + current_level = next_level; + current_depth += 1; + } + + // Sort by depth then file + result.sort_by(|a, b| { + a.depth + .cmp(&b.depth) + .then_with(|| a.file.cmp(&b.file)) + .then_with(|| a.line.cmp(&b.line)) + }); + + Ok(result) +} + +/// A caller result entry. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CallerResult { + pub caller: String, + pub file: String, + pub line: u32, +} + +/// A callee result entry. +#[allow(dead_code)] +#[derive(Debug, Clone, serde::Serialize)] +pub struct CalleeResult { + pub callee: String, + pub file: String, + pub line: u32, +} + +/// An impact analysis node. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ImpactNode { + pub symbol: String, + pub file: String, + pub line: u32, + pub depth: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + super::super::schema::run_migrations(&conn).unwrap(); + conn + } + + #[test] + fn test_store_and_find_callers() { + let conn = mem_conn(); + + let edges = vec![ + CallEdge { + caller: "main".to_string(), + callee: "authenticate".to_string(), + file: "main.rs".to_string(), + line: 10, + }, + CallEdge { + caller: "handler".to_string(), + callee: "authenticate".to_string(), + file: "handler.rs".to_string(), + line: 25, + }, + ]; + store_edges(&conn, &edges).unwrap(); + + let callers = find_callers(&conn, "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")); + assert!(names.contains(&"handler")); + } + + #[test] + fn test_find_callees() { + let conn = mem_conn(); + + let edges = vec![ + CallEdge { + caller: "main".to_string(), + callee: "init".to_string(), + file: "main.rs".to_string(), + line: 5, + }, + CallEdge { + caller: "main".to_string(), + callee: "run".to_string(), + file: "main.rs".to_string(), + line: 10, + }, + ]; + store_edges(&conn, &edges).unwrap(); + + let callees = find_callees(&conn, "main", 10).unwrap(); + assert_eq!(callees.len(), 2); + } + + #[test] + fn test_clear_edges_for_file() { + let conn = mem_conn(); + store_edges( + &conn, + &[CallEdge { + caller: "a".to_string(), + callee: "b".to_string(), + file: "test.rs".to_string(), + line: 1, + }], + ) + .unwrap(); + + clear_edges_for_file(&conn, "test.rs").unwrap(); + let callers = find_callers(&conn, "b", 10).unwrap(); + assert!(callers.is_empty()); + } + + #[test] + fn test_impact_analysis_depth() { + let conn = mem_conn(); + // a → b → c + // If c changes, impact should find b (depth 1) and a (depth 2) + let edges = vec![ + CallEdge { + caller: "b".to_string(), + callee: "c".to_string(), + file: "b.rs".to_string(), + line: 1, + }, + CallEdge { + caller: "a".to_string(), + callee: "b".to_string(), + file: "a.rs".to_string(), + line: 1, + }, + ]; + store_edges(&conn, &edges).unwrap(); + + let impact = impact_analysis(&conn, "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 new file mode 100644 index 0000000..6fe76a5 --- /dev/null +++ b/src/index/mod.rs @@ -0,0 +1,415 @@ +//! Symbol index engine — persistent SQLite-backed symbol store. +//! +//! Build, query, and maintain a symbol index for code intelligence. +//! Uses regex-based extraction (same approach as `engine/context/extraction.rs`) +//! stored in SQLite with FTS5 for fast full-text search. + +mod extract; +pub mod graph; +mod schema; +mod symbols; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use tracing::{debug, info}; + +#[allow(unused_imports)] +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)?; + + // Enable WAL mode for better concurrent read performance + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + + schema::run_migrations(&conn)?; + + debug!("Opened index at {}", db_path.display()); + Ok(conn) +} + +/// Resolve the default index database path for a project. +pub fn default_db_path(project_root: &Path) -> PathBuf { + project_root.join(INDEX_DB_NAME) +} + +/// Index a single file: extract symbols and store in the database. +/// +/// Returns the number of symbols indexed. +pub fn index_file( + conn: &Connection, + file_path: &str, + content: &str, + language: &str, +) -> anyhow::Result { + let fingerprint = file_fingerprint(content); + let symbols = extract::extract_symbols(content, language, file_path); + + // Begin transaction + let tx = conn.unchecked_transaction()?; + + // Delete existing symbols for this file + tx.execute( + "DELETE FROM symbols WHERE file = ?1", + rusqlite::params![file_path], + )?; + + // Update file fingerprint + tx.execute( + "INSERT OR REPLACE INTO files (path, fingerprint, last_indexed, language, symbol_count) + VALUES (?1, ?2, datetime('now'), ?3, ?4)", + rusqlite::params![file_path, fingerprint, language, symbols.len() as i64], + )?; + + // Insert symbols + let mut count = 0; + for sym in &symbols { + tx.execute( + "INSERT INTO symbols (name, kind, file, line, signature, language) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + sym.name, + sym.kind.as_str(), + sym.file, + sym.line as i64, + sym.signature, + language, + ], + )?; + count += 1; + } + + // Extract and store call graph edges + graph::clear_edges_for_file(&tx, file_path)?; + 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], + )?; + } + + tx.commit()?; + + debug!( + "Indexed {file_path}: {count} symbols, {} edges ({language})", + call_sites.len() + ); + Ok(count) +} + +/// Check if a file needs re-indexing based on content hash. +pub fn needs_reindex(conn: &Connection, file_path: &str, content: &str) -> bool { + let fingerprint = file_fingerprint(content); + + let stored: Option = conn + .query_row( + "SELECT fingerprint FROM files WHERE path = ?1", + rusqlite::params![file_path], + |row| row.get(0), + ) + .ok(); + + match stored { + Some(fp) => fp != fingerprint, + None => true, + } +} + +/// Index a project directory, respecting .gitignore. +/// +/// Returns summary stats. +pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::Result { + let mut stats = IndexStats::default(); + + let walker = ignore::WalkBuilder::new(root) + .hidden(true) + .git_ignore(true) + .git_exclude(true) + .build(); + + for entry in walker { + let entry = entry?; + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap_or(path); + let rel_str = rel.to_string_lossy().to_string(); + + let language = crate::engine::diff_parser::detect_language(&rel_str); + if language == "unknown" || language == "text" { + continue; + } + + stats.files_scanned += 1; + + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + + if !needs_reindex(conn, &rel_str, &content) { + stats.files_skipped += 1; + continue; + } + + stats.files_indexed += 1; + match index_file(conn, &rel_str, &content, language) { + Ok(n) => stats.symbols_indexed += n, + Err(e) => { + stats.errors += 1; + if verbose { + eprintln!(" ⚠ Failed to index {rel_str}: {e}"); + } + } + } + } + + info!( + "Index complete: {} files scanned, {} indexed, {} symbols, {} errors", + stats.files_scanned, stats.files_indexed, stats.symbols_indexed, stats.errors + ); + + Ok(stats) +} + +/// Search the symbol index using FTS5 full-text search. +pub fn search(conn: &Connection, query: &SymbolQuery) -> anyhow::Result> { + symbols::search(conn, query) +} + +/// Get index statistics. +pub fn index_stats(conn: &Connection) -> anyhow::Result { + let total_symbols: i64 = + conn.query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))?; + let total_files: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?; + let db_size: i64 = conn + .query_row("PRAGMA page_count", [], |row| row.get(0)) + .unwrap_or(0) + * 4096; // page_size default + + // Symbols by kind + let mut kind_counts: HashMap = HashMap::new(); + let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM symbols GROUP BY kind")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)) + })?; + for row in rows { + let (kind, count) = row?; + kind_counts.insert(kind, count); + } + + // Symbols by language + let mut lang_counts: HashMap = HashMap::new(); + let mut stmt = conn.prepare("SELECT language, COUNT(*) FROM symbols GROUP BY language")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)) + })?; + for row in rows { + let (lang, count) = row?; + lang_counts.insert(lang, count); + } + + Ok(IndexSummary { + total_symbols: total_symbols as usize, + total_files: total_files as usize, + db_size_bytes: db_size as u64, + symbols_by_kind: kind_counts, + symbols_by_language: lang_counts, + }) +} + +/// Remove symbols for files that no longer exist on disk. +pub fn prune_deleted(conn: &Connection, root: &Path) -> anyhow::Result { + let mut deleted = 0; + + let mut stmt = conn.prepare("SELECT path FROM files")?; + let paths: Vec = stmt + .query_map([], |row| row.get::<_, String>(0))? + .filter_map(|r| r.ok()) + .collect(); + + for path in &paths { + let full = root.join(path); + if !full.exists() { + let tx = conn.unchecked_transaction()?; + tx.execute( + "DELETE FROM symbols WHERE file = ?1", + rusqlite::params![path], + )?; + tx.execute("DELETE FROM files WHERE path = ?1", rusqlite::params![path])?; + tx.commit()?; + deleted += 1; + } + } + + if deleted > 0 { + info!("Pruned {deleted} deleted files from index"); + } + + Ok(deleted) +} + +/// Compute a SHA-256 fingerprint for file content. +fn file_fingerprint(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Statistics from an index build run. +#[derive(Debug, Clone, Default)] +pub struct IndexStats { + pub files_scanned: usize, + pub files_indexed: usize, + pub files_skipped: usize, + pub symbols_indexed: usize, + pub errors: usize, +} + +/// Summary of the current index state. +#[derive(Debug, Clone)] +pub struct IndexSummary { + pub total_symbols: usize, + pub total_files: usize, + pub db_size_bytes: u64, + pub symbols_by_kind: HashMap, + pub symbols_by_language: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + schema::run_migrations(&conn).unwrap(); + conn + } + + #[test] + fn test_open_and_migrate() { + let conn = mem_conn(); + // Tables should exist + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_index_rust_file() { + let conn = mem_conn(); + let code = r#" +use std::collections::HashMap; + +pub struct Cache { + inner: HashMap, +} + +impl Cache { + pub fn new() -> Self { + Self { inner: HashMap::new() } + } + + pub fn get(&self, key: &str) -> Option<&String> { + self.inner.get(key) + } +} +"#; + let count = index_file(&conn, "src/cache.rs", code, "rs").unwrap(); + assert!(count > 0, "Should extract symbols from Rust code"); + } + + #[test] + fn test_needs_reindex() { + let conn = mem_conn(); + let code = "fn hello() {}"; + + // First time → needs reindex + assert!(needs_reindex(&conn, "test.rs", code)); + + // Index it + index_file(&conn, "test.rs", code, "rs").unwrap(); + + // Same content → no reindex needed + assert!(!needs_reindex(&conn, "test.rs", code)); + + // Changed content → needs reindex + assert!(needs_reindex(&conn, "test.rs", "fn world() {}")); + } + + #[test] + fn test_search() { + let conn = mem_conn(); + let code = r#" +pub fn authenticate(token: &str) -> bool { + false +} + +pub struct AuthService { + secret: String, +} +"#; + index_file(&conn, "src/auth.rs", code, "rs").unwrap(); + + let query = SymbolQuery::text("authenticate"); + let results = search(&conn, &query).unwrap(); + assert!(!results.is_empty()); + assert!(results[0].symbol.name.contains("authenticate")); + } + + #[test] + fn test_index_stats() { + let conn = mem_conn(); + index_file(&conn, "a.rs", "fn foo() {}", "rs").unwrap(); + index_file(&conn, "b.rs", "struct Bar {}", "rs").unwrap(); + + let stats = index_stats(&conn).unwrap(); + assert!(stats.total_symbols >= 2); + assert_eq!(stats.total_files, 2); + assert!(stats.symbols_by_kind.contains_key("function")); + assert!(stats.symbols_by_kind.contains_key("struct")); + } + + #[test] + fn test_prune_deleted() { + let conn = mem_conn(); + index_file(&conn, "gone.rs", "fn removed() {}", "rs").unwrap(); + + // Create temp dir to use as root + let tmp = tempfile::tempdir().unwrap(); + // gone.rs doesn't exist in temp dir → should be pruned + let deleted = prune_deleted(&conn, tmp.path()).unwrap(); + assert_eq!(deleted, 1); + + let stats = index_stats(&conn).unwrap(); + assert_eq!(stats.total_symbols, 0); + } + + #[test] + fn test_reindex_replaces_symbols() { + let conn = mem_conn(); + index_file(&conn, "test.rs", "fn old_name() {}", "rs").unwrap(); + index_file(&conn, "test.rs", "fn new_name() {}", "rs").unwrap(); + + let stats = index_stats(&conn).unwrap(); + // Should have 1 symbol (replaced, not 2) + assert_eq!(stats.total_symbols, 1); + } +} diff --git a/src/index/schema.rs b/src/index/schema.rs new file mode 100644 index 0000000..48ed193 --- /dev/null +++ b/src/index/schema.rs @@ -0,0 +1,165 @@ +//! SQLite schema management for the symbol index. + +use rusqlite::Connection; + +/// Current schema version. +const SCHEMA_VERSION: i32 = 1; + +/// Run database migrations (creates tables if not exist). +pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { + // Schema version tracking + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT DEFAULT (datetime('now')) + );", + )?; + + let current: i32 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |row| { + row.get(0) + }) + .unwrap_or(0); + + if current < 1 { + migrate_v1(conn)?; + } + + Ok(()) +} + +/// Migration v1: Initial schema — symbols, files, FTS5 index. +fn migrate_v1(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + " + -- Symbol definitions extracted from source files + CREATE TABLE IF NOT EXISTS symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + kind TEXT NOT NULL, + file TEXT NOT NULL, + line INTEGER NOT NULL, + signature TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT 'unknown', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- Index for file-based queries + CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file); + + -- Index for name-based lookups + CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name); + + -- Index for kind-based filtering + CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind); + + -- File tracking for incremental indexing + CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + last_indexed TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'unknown', + symbol_count INTEGER NOT NULL DEFAULT 0 + ); + + -- FTS5 virtual table for full-text search on symbol names + CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5( + name, + signature, + content='symbols', + content_rowid='id', + tokenize='unicode61 remove_diacritics 1' + ); + + -- Call graph edges: caller → callee relationships + CREATE TABLE IF NOT EXISTS call_graph ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + caller TEXT NOT NULL, + callee TEXT NOT NULL, + file TEXT NOT NULL, + line INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_cg_caller ON call_graph(caller); + CREATE INDEX IF NOT EXISTS idx_cg_callee ON call_graph(callee); + CREATE INDEX IF NOT EXISTS idx_cg_file ON call_graph(file); + + -- Triggers to keep FTS5 in sync with symbols table + CREATE TRIGGER IF NOT EXISTS symbols_fts_insert + AFTER INSERT ON symbols + BEGIN + INSERT INTO symbols_fts(rowid, name, signature) + VALUES (new.id, new.name, new.signature); + END; + + CREATE TRIGGER IF NOT EXISTS symbols_fts_delete + AFTER DELETE ON symbols + BEGIN + INSERT INTO symbols_fts(symbols_fts, rowid, name, signature) + VALUES ('delete', old.id, old.name, old.signature); + END; + + CREATE TRIGGER IF NOT EXISTS symbols_fts_update + AFTER UPDATE ON symbols + BEGIN + INSERT INTO symbols_fts(symbols_fts, rowid, name, signature) + VALUES ('delete', old.id, old.name, old.signature); + INSERT INTO symbols_fts(rowid, name, signature) + VALUES (new.id, new.name, new.signature); + END; + ", + )?; + + conn.execute( + "INSERT INTO schema_version (version) VALUES (?1)", + rusqlite::params![SCHEMA_VERSION], + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_migration_creates_tables() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + + // Check symbols table + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + + // Check files table + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + + // Check FTS table exists + conn.query_row("SELECT COUNT(*) FROM symbols_fts", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(); + + // Check schema version + let version: i32 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } + + #[test] + fn test_migration_idempotent() { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + // Running again should not error + run_migrations(&conn).unwrap(); + } +} diff --git a/src/index/symbols.rs b/src/index/symbols.rs new file mode 100644 index 0000000..19402b8 --- /dev/null +++ b/src/index/symbols.rs @@ -0,0 +1,374 @@ +//! Symbol types for the index. + +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +/// Kind of a symbol. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SymbolKind { + Function, + Struct, + Enum, + Trait, + Interface, + Class, + Method, + Constant, + Module, + TypeAlias, + Variable, +} + +impl SymbolKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Function => "function", + Self::Struct => "struct", + Self::Enum => "enum", + Self::Trait => "trait", + Self::Interface => "interface", + Self::Class => "class", + Self::Method => "method", + Self::Constant => "constant", + Self::Module => "module", + Self::TypeAlias => "type_alias", + Self::Variable => "variable", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "function" => Self::Function, + "struct" => Self::Struct, + "enum" => Self::Enum, + "trait" => Self::Trait, + "interface" => Self::Interface, + "class" => Self::Class, + "method" => Self::Method, + "constant" => Self::Constant, + "module" => Self::Module, + "type_alias" => Self::TypeAlias, + "variable" => Self::Variable, + _ => Self::Variable, + } + } +} + +impl std::fmt::Display for SymbolKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// A symbol stored in the index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexedSymbol { + pub id: i64, + pub name: String, + pub kind: SymbolKind, + pub file: String, + pub line: u32, + pub signature: String, + pub language: String, +} + +/// Query parameters for searching the index. +#[derive(Debug, Clone, Default)] +pub struct SymbolQuery { + /// FTS5 search text (matches name + signature). + pub text: Option, + /// Filter by symbol kind. + pub kind: Option, + /// Filter by file path (exact or prefix). + pub file_prefix: Option, + /// Filter by language. + pub language: Option, + /// Maximum results. + pub limit: usize, +} + +impl SymbolQuery { + /// Create a text search query. + #[allow(dead_code)] + pub fn text(text: &str) -> Self { + Self { + text: Some(text.to_string()), + limit: 50, + ..Default::default() + } + } + + /// Create a kind-filtered query. + #[allow(dead_code)] + pub fn kind(kind: SymbolKind) -> Self { + Self { + kind: Some(kind), + limit: 50, + ..Default::default() + } + } +} + +/// A search result from the index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + pub symbol: IndexedSymbol, + pub score: f64, +} + +/// Execute a symbol search query against the database. +pub fn search(conn: &Connection, query: &SymbolQuery) -> anyhow::Result> { + let limit = if query.limit > 0 { + query.limit as i64 + } else { + 50 + }; + + if let Some(text) = &query.text { + // FTS5 full-text search + let fts_query = sanitize_fts_query(text); + + let mut sql = String::from( + "SELECT s.id, s.name, s.kind, s.file, s.line, s.signature, s.language, + bm25(symbols_fts) as score + FROM symbols_fts + JOIN symbols s ON s.id = symbols_fts.rowid + WHERE symbols_fts MATCH ?1", + ); + + let mut params: Vec> = vec![Box::new(fts_query.clone())]; + + let mut param_idx = 2; + + if let Some(kind) = &query.kind { + sql.push_str(&format!(" AND s.kind = ?{param_idx}")); + params.push(Box::new(kind.as_str().to_string())); + param_idx += 1; + } + + if let Some(lang) = &query.language { + sql.push_str(&format!(" AND s.language = ?{param_idx}")); + params.push(Box::new(lang.clone())); + param_idx += 1; + } + + if let Some(prefix) = &query.file_prefix { + sql.push_str(&format!(" AND s.file LIKE ?{param_idx}")); + params.push(Box::new(format!("{prefix}%"))); + // param_idx not needed after last push + } + + sql.push_str(&format!(" ORDER BY score ASC LIMIT {limit}")); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Ok(SearchResult { + symbol: IndexedSymbol { + id: row.get(0)?, + name: row.get(1)?, + kind: SymbolKind::from_str(&row.get::<_, String>(2)?), + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + signature: row.get(5)?, + language: row.get(6)?, + }, + // bm25 returns negative scores (more negative = better match) + // Convert to positive score where higher = better + score: { + let raw: f64 = row.get(7)?; + if raw < 0.0 { -raw } else { raw } + }, + }) + })?; + + let mut results: Vec = rows.filter_map(|r| r.ok()).collect(); + + // If FTS returns nothing, try LIKE fallback on name + if results.is_empty() { + results = like_search(conn, text, query, limit)?; + } + + Ok(results) + } else { + // No text query — just filter by kind/file/language + filter_search(conn, query, limit) + } +} + +/// Fallback: LIKE-based search when FTS5 returns nothing. +#[allow(unused_assignments)] +fn like_search( + conn: &Connection, + text: &str, + query: &SymbolQuery, + limit: i64, +) -> anyhow::Result> { + let pattern = format!("%{text}%"); + + let mut sql = String::from( + "SELECT id, name, kind, file, line, signature, language + FROM symbols WHERE name LIKE ?1", + ); + + let mut params: Vec> = vec![Box::new(pattern)]; + let mut idx = 2; + + if let Some(kind) = &query.kind { + sql.push_str(&format!(" AND kind = ?{idx}")); + params.push(Box::new(kind.as_str().to_string())); + idx += 1; + } + + sql.push_str(&format!(" LIMIT {limit}")); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Ok(SearchResult { + symbol: IndexedSymbol { + id: row.get(0)?, + name: row.get(1)?, + kind: SymbolKind::from_str(&row.get::<_, String>(2)?), + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + signature: row.get(5)?, + language: row.get(6)?, + }, + score: 1.0, + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Filter-only search (no FTS text). +#[allow(unused_assignments)] +fn filter_search( + conn: &Connection, + query: &SymbolQuery, + limit: i64, +) -> anyhow::Result> { + let mut sql = String::from( + "SELECT id, name, kind, file, line, signature, language FROM symbols WHERE 1=1", + ); + + let mut params: Vec> = vec![]; + let mut idx = 1; + + if let Some(kind) = &query.kind { + sql.push_str(&format!(" AND kind = ?{idx}")); + params.push(Box::new(kind.as_str().to_string())); + idx += 1; + } + + if let Some(lang) = &query.language { + sql.push_str(&format!(" AND language = ?{idx}")); + params.push(Box::new(lang.clone())); + idx += 1; + } + + if let Some(prefix) = &query.file_prefix { + sql.push_str(&format!(" AND file LIKE ?{idx}")); + params.push(Box::new(format!("{prefix}%"))); + idx += 1; + } + + sql.push_str(&format!(" LIMIT {limit}")); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Ok(SearchResult { + symbol: IndexedSymbol { + id: row.get(0)?, + name: row.get(1)?, + kind: SymbolKind::from_str(&row.get::<_, String>(2)?), + file: row.get(3)?, + line: row.get::<_, i64>(4)? as u32, + signature: row.get(5)?, + language: row.get(6)?, + }, + score: 0.0, + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Sanitize a user query for FTS5 MATCH syntax. +/// Wraps each token in quotes to prevent FTS5 syntax errors. +fn sanitize_fts_query(text: &str) -> String { + // Split on whitespace, quote each token, join with AND + text.split_whitespace() + .map(|token| { + let clean: String = token + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == ':') + .collect(); + if clean.is_empty() { + String::new() + } else { + format!("\"{clean}\"") + } + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_symbol_kind_round_trip() { + let kinds = [ + SymbolKind::Function, + SymbolKind::Struct, + SymbolKind::Enum, + SymbolKind::Trait, + SymbolKind::Method, + SymbolKind::Constant, + ]; + for kind in &kinds { + let s = kind.as_str(); + assert_eq!(SymbolKind::from_str(s), *kind); + } + } + + #[test] + fn test_symbol_kind_display() { + assert_eq!(SymbolKind::Function.to_string(), "function"); + assert_eq!(SymbolKind::Struct.to_string(), "struct"); + } + + #[test] + fn test_sanitize_fts_query() { + assert_eq!(sanitize_fts_query("auth"), "\"auth\""); + assert_eq!(sanitize_fts_query("auth login"), "\"auth\" \"login\""); + assert_eq!( + sanitize_fts_query("auth; DROP TABLE"), + "\"auth\" \"DROP\" \"TABLE\"" + ); + assert_eq!(sanitize_fts_query(""), ""); + } + + #[test] + fn test_symbol_query_text() { + let q = SymbolQuery::text("authenticate"); + assert_eq!(q.text.as_deref(), Some("authenticate")); + assert_eq!(q.limit, 50); + } + + #[test] + fn test_symbol_query_kind() { + let q = SymbolQuery::kind(SymbolKind::Function); + assert_eq!(q.kind, Some(SymbolKind::Function)); + assert_eq!(q.limit, 50); + } +} diff --git a/src/main.rs b/src/main.rs index 8d7b611..d7963df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod error; mod formatters; mod git; mod hook; +mod index; mod mcp; mod progress; @@ -80,6 +81,101 @@ struct GlobalOptions { #[derive(Subcommand, Debug)] enum Command { + /// Build or update the symbol index for code intelligence + Index { + /// Show index statistics instead of building + #[clap(long)] + stats: bool, + + /// Prune deleted files from index + #[clap(long)] + prune: bool, + + /// Rebuild index from scratch (drop existing) + #[clap(long)] + rebuild: bool, + + /// Watch for file changes and auto-update index + #[clap(long)] + watch: bool, + + /// Verbose output + #[clap(long, short)] + verbose: bool, + }, + + /// Search the symbol index for code intelligence + Explore { + /// Search query (symbol name or keyword) + query: Option, + + /// Filter by symbol kind (function, struct, enum, trait, etc.) + #[clap(long)] + kind: Option, + + /// Filter by file path prefix + #[clap(long)] + file: Option, + + /// Filter by language + #[clap(long)] + language: Option, + + /// Maximum results + #[clap(long, default_value = "50")] + limit: usize, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + + /// Find all callers of a symbol (who calls this?) + Callers { + /// Symbol name to search for + symbol: String, + + /// Maximum results + #[clap(long, default_value = "50")] + limit: usize, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + + /// Analyze the impact of changing a symbol (what breaks?) + Impact { + /// Symbol name to analyze + symbol: String, + + /// Traversal depth (how many levels up the call graph) + #[clap(long, default_value = "3")] + depth: u32, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + + /// Find tests affected by changed files + Affected { + /// Changed files (space-separated). If empty, reads from git diff. + files: Vec, + + /// Read changed files from stdin (pipe from git diff --name-only) + #[clap(long)] + stdin: bool, + + /// Test file glob pattern (default: *test*, *spec*) + #[clap(long)] + filter: Option, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + /// Review staged changes, generate commit message, and commit Commit { /// YOLO mode — auto-commit without prompts @@ -390,6 +486,17 @@ enum ProfileAction { }, } +/// Format bytes as human-readable string. +fn format_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + #[allow(clippy::too_many_lines)] #[tokio::main] async fn main() -> Result<()> { @@ -420,6 +527,385 @@ async fn main() -> Result<()> { // Dispatch based on subcommand let exit_code = match cli.command { + Command::Index { + stats: show_stats, + prune, + rebuild, + watch, + verbose, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + + if rebuild && db_path.exists() { + std::fs::remove_file(&db_path)?; + eprintln!("{}", "Dropped existing index.".dimmed()); + } + + let conn = index::open_index(&db_path)?; + + if show_stats { + let summary = index::index_stats(&conn)?; + println!("{}", "SYMBOL INDEX".cyan().bold()); + println!("{}", "────────────────────────────".dimmed()); + println!(" Total symbols: {}", summary.total_symbols); + println!(" Total files: {}", summary.total_files); + println!(" Database size: {}", format_bytes(summary.db_size_bytes)); + println!(); + println!(" {}", "By Kind".cyan()); + for (kind, count) in &summary.symbols_by_kind { + println!(" {kind:<16} {count}"); + } + println!(); + println!(" {}", "By Language".cyan()); + for (lang, count) in &summary.symbols_by_language { + println!(" {lang:<16} {count}"); + } + } else if prune { + let deleted = index::prune_deleted(&conn, &project_root)?; + println!( + "{}", + format!("Pruned {deleted} deleted files from index.").green() + ); + } else if watch { + // Initial index + eprintln!("{}", "🔍 Initial index...".cyan()); + let stats = + index::index_project(&conn, &project_root, verbose || cli.global.verbose)?; + 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(&conn, &project_root, false)?; + if stats.files_indexed > 0 { + eprintln!( + "{}", + format!( + "🔄 Updated {} files, {} symbols", + stats.files_indexed, stats.symbols_indexed + ) + .cyan() + ); + } + } + } else { + eprintln!("{}", "🔍 Indexing project...".cyan()); + let stats = + index::index_project(&conn, &project_root, verbose || cli.global.verbose)?; + eprintln!( + "{}", + format!( + "✅ Indexed {} symbols from {} files ({} skipped, {} errors)", + stats.symbols_indexed, + stats.files_indexed, + stats.files_skipped, + stats.errors + ) + .green() + ); + eprintln!("{}", format!(" Database: {}", db_path.display()).dimmed()); + } + 0 + } + + Command::Explore { + query, + kind, + file, + language, + limit, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + + if !db_path.exists() { + eprintln!("{}", "No index found. Run `cora index` first.".yellow()); + std::process::exit(1); + } + + let conn = index::open_index(&db_path)?; + + let sym_kind = kind.as_deref().map(index::SymbolKind::from_str); + + let q = index::SymbolQuery { + text: query, + kind: sym_kind, + file_prefix: file, + language, + limit, + }; + + let results = index::search(&conn, &q)?; + + if json { + let json_results: Vec = results + .iter() + .map(|r| { + serde_json::json!({ + "name": r.symbol.name, + "kind": r.symbol.kind.as_str(), + "file": r.symbol.file, + "line": r.symbol.line, + "signature": r.symbol.signature, + "language": r.symbol.language, + "score": r.score, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_results)?); + } else if results.is_empty() { + eprintln!("{}", "No symbols found.".yellow()); + } else { + println!("{}", format!("Found {} symbols:", results.len()).cyan()); + println!( + "{}", + "───────────────────────────────────────────────".dimmed() + ); + for r in &results { + println!( + " {} {} {}:{}", + r.symbol.kind.as_str().blue(), + r.symbol.name.white().bold(), + r.symbol.file.dimmed(), + r.symbol.line + ); + if !r.symbol.signature.is_empty() { + println!( + " {} {}", + "→".dimmed(), + r.symbol.signature.trim().dimmed() + ); + } + } + } + 0 + } + + Command::Callers { + symbol, + limit, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + if !db_path.exists() { + eprintln!("{}", "No index found. Run `cora index` first.".yellow()); + std::process::exit(1); + } + let conn = index::open_index(&db_path)?; + let callers = index::graph::find_callers(&conn, &symbol, limit)?; + + if json { + println!("{}", serde_json::to_string_pretty(&callers)?); + } else if callers.is_empty() { + eprintln!("{}", format!("No callers found for '{symbol}'.").yellow()); + } else { + println!( + "{}", + format!("Callers of '{symbol}' ({}):", callers.len()).cyan() + ); + println!("{}", "─".repeat(50).dimmed()); + for c in &callers { + println!( + " {} {}:{}", + c.caller.white().bold(), + c.file.dimmed(), + c.line + ); + } + } + 0 + } + + Command::Impact { + symbol, + depth, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + if !db_path.exists() { + eprintln!("{}", "No index found. Run 'cora index' first.".yellow()); + std::process::exit(1); + } + let conn = index::open_index(&db_path)?; + let impact = index::graph::impact_analysis(&conn, &symbol, depth)?; + + if json { + println!("{}", serde_json::to_string_pretty(&impact)?); + } else if impact.is_empty() { + eprintln!("{}", format!("No impact found for '{}'.", symbol).yellow()); + } else { + println!( + "{}", + format!( + "Impact of changing '{}' ({} affected):", + symbol, + impact.len() + ) + .cyan() + ); + println!("{}", "\u{2500}".repeat(50).dimmed()); + let mut prev_depth = 0; + for node in &impact { + if node.depth != prev_depth { + prev_depth = node.depth; + println!(" {}", format!("depth {}", node.depth).blue().bold()); + } + println!( + " {} {}:{}", + node.symbol.white(), + node.file.dimmed(), + node.line + ); + } + } + 0 + } + + Command::Affected { + files, + stdin, + filter, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + if !db_path.exists() { + eprintln!("{}", "No index found. Run 'cora index' first.".yellow()); + std::process::exit(1); + } + let conn = index::open_index(&db_path)?; + + // Gather changed files + let mut changed: Vec = files; + if stdin { + use std::io::BufRead; + let stdin = std::io::stdin(); + for line in stdin.lock().lines().map_while(Result::ok) { + let trimmed = line.trim().to_string(); + if !trimmed.is_empty() { + changed.push(trimmed); + } + } + } + + if changed.is_empty() { + // Fallback: get from git diff + let output = std::process::Command::new("git") + .args(["diff", "--name-only", "HEAD"]) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + changed = stdout + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + } + + if changed.is_empty() { + eprintln!("{}", "No changed files detected.".yellow()); + std::process::exit(0); + } + + // Default test patterns + let patterns: Vec = filter.map(|f| vec![f]).unwrap_or_else(|| { + vec![ + "test".to_string(), + "spec".to_string(), + "_test".to_string(), + "_spec".to_string(), + ] + }); + + // Find test files that import/reference changed source files + let mut affected_tests: std::collections::HashSet = + std::collections::HashSet::new(); + + // Strategy 1: Find symbols in changed files, then find callers that are in test files + for file in &changed { + // Get all symbols defined in this file + let symbols_in_file: Vec = { + let mut stmt = + conn.prepare("SELECT DISTINCT name FROM symbols WHERE file = ?1")?; + let rows = + stmt.query_map(rusqlite::params![file], |row| row.get::<_, String>(0))?; + rows.filter_map(|r| r.ok()).collect() + }; + + // For each symbol, find callers + for sym_name in &symbols_in_file { + let callers = index::graph::find_callers(&conn, sym_name, 100)?; + for caller in callers { + // Check if caller file looks like a test file + if patterns.iter().any(|p| caller.file.contains(p.as_str())) { + affected_tests.insert(caller.file.clone()); + } + } + } + } + + // Strategy 2: Direct test file name convention (mod_test.rs, foo_test.go) + for file in &changed { + // For Rust: src/foo.rs → tests/foo.rs or src/foo.rs → src/foo_test.rs + let stem = file + .rsplit('/') + .next() + .unwrap_or(file) + .rsplit('.') + .next() + .unwrap_or(""); + let test_patterns = [ + format!("{stem}_test.rs"), + format!("tests/{stem}.rs"), + format!("test_{stem}.rs"), + format!("{stem}_test.go"), + format!("{stem}_test.py"), + format!("test_{stem}.py"), + format!("{stem}.test.ts"), + format!("{stem}.spec.ts"), + ]; + for tp in &test_patterns { + let mut stmt = + conn.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ?1")?; + let pattern = format!("%{tp}"); + let rows = + stmt.query_map(rusqlite::params![pattern], |row| row.get::<_, String>(0))?; + for f in rows.map_while(Result::ok) { + affected_tests.insert(f); + } + } + } + + let mut sorted: Vec = affected_tests.into_iter().collect(); + sorted.sort(); + + if json { + println!("{}", serde_json::to_string_pretty(&sorted)?); + } else if sorted.is_empty() { + eprintln!("{}", "No affected test files found.".yellow()); + } else { + println!( + "{}", + format!("Affected test files ({}):", sorted.len()).cyan() + ); + println!("{}", "\u{2500}".repeat(50).dimmed()); + for f in &sorted { + println!(" {}", f.white().bold()); + } + } + 0 + } + Command::Commit { yolo, force, diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index b0414ce..760b0c8 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -13,6 +13,7 @@ use super::protocol::{Tool, ToolResult}; /// List all available MCP tools. pub fn list_tools() -> Vec { vec![ + // ─── Review & Security ─── Tool { name: "cora.list_rules".to_string(), description: "List all active review rules, quality profiles, and security patterns for this project.".to_string(), @@ -34,6 +35,7 @@ pub fn list_tools() -> Vec { "required": ["code"] }), }, + // ─── Config ─── Tool { name: "cora.get_quality_gate".to_string(), description: "Get the current quality gate configuration and thresholds.".to_string(), @@ -63,17 +65,141 @@ pub fn list_tools() -> Vec { "required": [] }), }, + // ─── Code Intelligence ─── + Tool { + name: "cora.search_symbols".to_string(), + description: "Search the symbol index for code intelligence. Returns matching symbols with file location, kind, and signature. Requires `cora index` to be run first.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query (symbol name or keyword)" }, + "kind": { "type": "string", "description": "Filter by kind: function, struct, enum, trait, method, constant, module" }, + "file": { "type": "string", "description": "Filter by file path prefix" }, + "language": { "type": "string", "description": "Filter by language (rs, py, ts, go, etc.)" }, + "limit": { "type": "integer", "description": "Max results (default 50)", "default": 50 } + }, + "required": ["query"] + }), + }, + Tool { + name: "cora.find_callers".to_string(), + description: "Find all callers of a symbol (who calls this function/method?). Uses reverse call graph traversal.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Symbol name to find callers for" }, + "limit": { "type": "integer", "description": "Max results (default 50)", "default": 50 } + }, + "required": ["symbol"] + }), + }, + Tool { + name: "cora.find_impact".to_string(), + description: "Analyze the blast radius of changing a symbol. Returns all affected symbols up to the specified depth.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Symbol name to analyze" }, + "depth": { "type": "integer", "description": "Traversal depth (default 3)", "default": 3 } + }, + "required": ["symbol"] + }), + }, + Tool { + name: "cora.find_affected_tests".to_string(), + description: "Find test files affected by source code changes. Uses call graph + naming conventions.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "Changed source files" + } + }, + "required": ["files"] + }), + }, + Tool { + name: "cora.index_status".to_string(), + description: "Check if a symbol index exists and get statistics (total symbols, files, languages).".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + // ─── Review Pipeline (Phase 2) ─── + Tool { + name: "cora.review_diff".to_string(), + description: "Review a git diff using cora's full pipeline (deterministic rules + LLM). Returns issues, quality gate status, and severity breakdown. Note: makes an LLM API call and requires API key.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "diff": { "type": "string", "description": "Git diff text to review" }, + "min_severity": { "type": "string", "description": "Minimum severity to report: info, minor, major, critical (default: info)" } + }, + "required": ["diff"] + }), + }, + Tool { + name: "cora.get_debt".to_string(), + description: "Get tech debt report from review history. Returns quality score, finding counts, severity breakdown, and trend.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "since": { "type": "string", "description": "Filter since date or git tag (e.g., 'v0.5.0')" }, + "branch": { "type": "string", "description": "Filter by branch name" } + }, + "required": [] + }), + }, + // ─── Context Enrichment (Phase 3) ─── + Tool { + name: "cora.get_project_info".to_string(), + description: "Get project context: repository name, current branch, cora version, and whether a symbol index exists.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + Tool { + name: "cora.get_memory".to_string(), + description: "Recall project memories from Uteke (if installed). Returns relevant memories from previous reviews and code patterns.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Recall query (e.g., 'auth patterns', 'review history')" } + }, + "required": ["query"] + }), + }, ] } /// Dispatch a tool call to the appropriate handler. pub fn handle_tool_call(name: &str, params: &serde_json::Value) -> ToolResult { match name { + // Review & Security "cora.list_rules" => handle_list_rules(), "cora.check_snippet" => handle_check_snippet(params), + // Config "cora.get_quality_gate" => handle_get_quality_gate(), "cora.get_config" => handle_get_config(params), "cora.list_profiles" => handle_list_profiles(), + // Code Intelligence + "cora.search_symbols" => handle_search_symbols(params), + "cora.find_callers" => handle_find_callers(params), + "cora.find_impact" => handle_find_impact(params), + "cora.find_affected_tests" => handle_find_affected_tests(params), + "cora.index_status" => handle_index_status(), + // Review Pipeline (Phase 2) + "cora.review_diff" => handle_review_diff(params), + "cora.get_debt" => handle_get_debt(params), + // Context Enrichment (Phase 3) + "cora.get_project_info" => handle_get_project_info(), + "cora.get_memory" => handle_get_memory(params), _ => ToolResult::error(format!("Unknown tool: {name}")), } } @@ -240,6 +366,473 @@ fn handle_list_profiles() -> ToolResult { ToolResult::text(lines.join("\n")) } +// ─── Code Intelligence Handlers ─── + +/// Open the index database, returning helpful error if not found. +fn open_index_db() -> anyhow::Result { + let cwd = std::env::current_dir()?; + let db_path = crate::index::default_db_path(&cwd); + if !db_path.exists() { + anyhow::bail!("No symbol index found. Run 'cora index' first to build the index."); + } + crate::index::open_index(&db_path) +} + +fn handle_search_symbols(params: &serde_json::Value) -> ToolResult { + let query_text = match params.get("query").and_then(|v| v.as_str()) { + Some(q) => q, + None => return ToolResult::error("Missing required parameter: query"), + }; + + let conn = match open_index_db() { + Ok(c) => c, + Err(e) => return ToolResult::error(e.to_string()), + }; + + let kind = params + .get("kind") + .and_then(|v| v.as_str()) + .map(crate::index::SymbolKind::from_str); + let file_prefix = params + .get("file") + .and_then(|v| v.as_str()) + .map(String::from); + let language = params + .get("language") + .and_then(|v| v.as_str()) + .map(String::from); + let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; + + let query = crate::index::SymbolQuery { + text: Some(query_text.to_string()), + kind, + file_prefix, + language, + limit, + }; + + match crate::index::search(&conn, &query) { + Ok(results) => { + if results.is_empty() { + return ToolResult::text(format!("No symbols found matching '{query_text}'.")); + } + let json: Vec = results + .iter() + .map(|r| { + serde_json::json!({ + "name": r.symbol.name, + "kind": r.symbol.kind.as_str(), + "file": r.symbol.file, + "line": r.symbol.line, + "signature": r.symbol.signature, + "language": r.symbol.language, + "score": r.score, + }) + }) + .collect(); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Search failed: {e}")), + } +} + +fn handle_find_callers(params: &serde_json::Value) -> ToolResult { + let symbol = match params.get("symbol").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("Missing required parameter: symbol"), + }; + 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, + Err(e) => return ToolResult::error(e.to_string()), + }; + + match crate::index::graph::find_callers(&conn, symbol, limit) { + Ok(callers) => { + if callers.is_empty() { + return ToolResult::text(format!("No callers found for '{symbol}'.")); + } + let json: Vec = callers + .iter() + .map(|c| { + serde_json::json!({ + "caller": c.caller, + "file": c.file, + "line": c.line, + }) + }) + .collect(); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Find callers failed: {e}")), + } +} + +fn handle_find_impact(params: &serde_json::Value) -> ToolResult { + let symbol = match params.get("symbol").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("Missing required parameter: symbol"), + }; + 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, + Err(e) => return ToolResult::error(e.to_string()), + }; + + match crate::index::graph::impact_analysis(&conn, symbol, depth) { + Ok(impact) => { + if impact.is_empty() { + return ToolResult::text(format!("No impact found for '{symbol}'.")); + } + let json: Vec = impact + .iter() + .map(|n| { + serde_json::json!({ + "symbol": n.symbol, + "file": n.file, + "line": n.line, + "depth": n.depth, + }) + }) + .collect(); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Impact analysis failed: {e}")), + } +} + +fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { + let files: Vec = match params.get("files").and_then(|v| v.as_array()) { + Some(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + None => { + return ToolResult::error("Missing required parameter: files (array of file paths)"); + } + }; + if files.is_empty() { + return ToolResult::error("Parameter 'files' must not be empty"); + } + + let conn = match open_index_db() { + Ok(c) => c, + Err(e) => return ToolResult::error(e.to_string()), + }; + + let patterns = ["test", "spec", "_test", "_spec"]; + let mut affected: std::collections::HashSet = std::collections::HashSet::new(); + + for file in &files { + // Strategy 1: call graph + let symbols_in_file: Vec = { + let mut stmt = match conn.prepare("SELECT DISTINCT name FROM symbols WHERE file = ?1") { + Ok(s) => s, + Err(e) => return ToolResult::error(format!("DB error: {e}")), + }; + let rows = match stmt.query_map(rusqlite::params![file], |row| row.get::<_, String>(0)) + { + Ok(r) => r, + Err(e) => return ToolResult::error(format!("DB error: {e}")), + }; + rows.filter_map(|r| r.ok()).collect() + }; + + for sym_name in &symbols_in_file { + if let Ok(callers) = crate::index::graph::find_callers(&conn, sym_name, 100) { + for caller in callers { + if patterns.iter().any(|p| caller.file.contains(*p)) { + affected.insert(caller.file.clone()); + } + } + } + } + + // Strategy 2: naming convention + let stem = file + .rsplit('/') + .next() + .unwrap_or(file) + .rsplit('.') + .next() + .unwrap_or(""); + let test_names = [ + format!("{stem}_test.rs"), + format!("tests/{stem}.rs"), + format!("{stem}_test.go"), + format!("test_{stem}.py"), + format!("{stem}.test.ts"), + format!("{stem}.spec.ts"), + ]; + for tn in &test_names { + if let Ok(mut stmt) = conn.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ?1") + { + let pattern = format!("%{tn}"); + if let Ok(rows) = + stmt.query_map(rusqlite::params![pattern], |row| row.get::<_, String>(0)) + { + for row in rows.map_while(Result::ok) { + affected.insert(row); + } + } + } + } + } + + let mut sorted: Vec = affected.into_iter().collect(); + sorted.sort(); + + let json = serde_json::json!({ + "affected_tests": sorted, + "count": sorted.len(), + }); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) +} + +fn handle_index_status() -> ToolResult { + let conn = match open_index_db() { + Ok(c) => c, + Err(e) => return ToolResult::error(e.to_string()), + }; + + match crate::index::index_stats(&conn) { + Ok(stats) => { + let json = serde_json::json!({ + "exists": true, + "total_symbols": stats.total_symbols, + "total_files": stats.total_files, + "db_size_bytes": stats.db_size_bytes, + "symbols_by_kind": stats.symbols_by_kind, + "symbols_by_language": stats.symbols_by_language, + }); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Failed to get stats: {e}")), + } +} + +// ─── Review Pipeline Handlers (Phase 2) ─── + +fn handle_review_diff(params: &serde_json::Value) -> ToolResult { + let diff = match params.get("diff").and_then(|v| v.as_str()) { + Some(d) => d, + None => return ToolResult::error("Missing required parameter: diff"), + }; + if diff.trim().is_empty() { + return ToolResult::error("Diff is empty"); + } + + // Load config + build LLM config + let config = match load_project_config() { + Ok(c) => c, + Err(e) => return ToolResult::error(format!("Failed to load config: {e}")), + }; + + let llm_config = match crate::config::loader::build_llm_config(&config, None) { + Ok(c) => c, + Err(e) => { + return ToolResult::error(format!( + "Failed to build LLM config: {e}. Is API key set? Use 'cora auth login'." + )); + } + }; + + // Run review synchronously + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => return ToolResult::error(format!("Failed to create runtime: {e}")), + }; + + let result = rt.block_on(crate::engine::review::review_diff_with_cache( + &config, + &llm_config, + diff, + false, // no stream + true, // use cache + true, // quiet + None, // no memory + )); + + match result { + Ok(response) => { + let json = serde_json::json!({ + "summary": response.summary, + "total_issues": response.issues.len(), + "should_block": response.should_block, + "issues": response.issues.iter().map(|i| serde_json::json!({ + "title": i.title, + "severity": i.severity.label(), + "file": i.file, + "line": i.line, + "type": i.issue_type, + "body": i.body, + })).collect::>(), + "gate": if config.quality_gate.enabled { + let gate = crate::engine::quality_gate::evaluate(&response.issues, &config.quality_gate); + serde_json::json!({ + "status": format!("{:?}", gate.status), + "total_findings": gate.total_findings, + }) + } else { + serde_json::json!({"enabled": false}) + }, + }); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Review failed: {e}")), + } +} + +fn handle_get_debt(params: &serde_json::Value) -> ToolResult { + let _since = params.get("since").and_then(|v| v.as_str()); + let _branch = params.get("branch").and_then(|v| v.as_str()); + + let config = load_project_config().unwrap_or_default(); + + let snapshots = crate::engine::debt_tracker::load_snapshots(config.debt.history_dir.as_deref()); + + if snapshots.is_empty() { + return ToolResult::text( + "No debt snapshots found. Run 'cora review' or 'cora commit' to generate history.", + ); + } + + let report = crate::engine::debt_tracker::aggregate(&snapshots); + + let json = serde_json::json!({ + "quality_score": report.quality_score_avg, + "quality_score_change": report.quality_score_change, + "trend": report.trend, + "total_reviews": report.reviews_analyzed, + "total_findings": report.total_findings, + "change_from_previous": report.change_from_previous, + "findings": report.findings, + "categories": report.categories.iter().map(|c| serde_json::json!({ + "name": c.name, + "count": c.count, + "change": c.change, + "trend": c.trend, + })).collect::>(), + "period_start": report.period_start.map(|t| t.to_rfc3339()), + "period_end": report.period_end.map(|t| t.to_rfc3339()), + }); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) +} + +// ─── Context Enrichment Handlers (Phase 3) ─── + +fn handle_get_project_info() -> ToolResult { + let cwd = match std::env::current_dir() { + Ok(c) => c, + Err(e) => return ToolResult::error(format!("Failed to get cwd: {e}")), + }; + + let repo_name = cwd + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let branch = std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout) + .ok() + .map(|s| s.trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + let index_exists = crate::index::default_db_path(&cwd).exists(); + + let json = serde_json::json!({ + "repository": repo_name, + "branch": branch, + "cora_version": env!("CARGO_PKG_VERSION"), + "index_exists": index_exists, + "working_dir": cwd.to_string_lossy(), + }); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) +} + +fn handle_get_memory(params: &serde_json::Value) -> ToolResult { + let query = match params.get("query").and_then(|v| v.as_str()) { + Some(q) => q, + None => return ToolResult::error("Missing required parameter: query"), + }; + + // Check if uteke is available + if which::which("uteke").is_err() { + return ToolResult::text( + "Uteke is not installed. Memory features require 'uteke' CLI. Install from https://github.com/codecoradev/uteke", + ); + } + + // Determine project name from git + let project = std::process::Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .output() + .ok() + .and_then(|o| { + String::from_utf8(o.stdout).ok().and_then(|s| { + s.trim() + .rsplit('/') + .next() + .map(|s| s.trim_end_matches(".git").to_string()) + }) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let mut backend = crate::engine::memory::MemoryBackend::default(); + backend.detect(); + + if !backend.is_available() { + return ToolResult::text( + "Uteke detected but not accessible. Run 'uteke doctor' to diagnose.", + ); + } + + let memories = backend.recall_context(&project); + + if memories.is_empty() { + return ToolResult::text(format!( + "No memories found for '{query}' in namespace 'cora'." + )); + } + + // Filter memories by query relevance (simple contains check) + let query_lower = query.to_lowercase(); + let filtered: Vec<&String> = memories + .iter() + .filter(|m| m.to_lowercase().contains(&query_lower)) + .collect(); + + let results = if filtered.is_empty() { + memories.iter().take(5).collect() + } else { + filtered + }; + + let json: Vec = results + .iter() + .enumerate() + .map(|(i, m)| { + serde_json::json!({ + "index": i + 1, + "content": m, + }) + }) + .collect(); + + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) +} + /// Load project config safely (no API keys exposed). fn load_project_config() -> anyhow::Result { let mut config = crate::config::schema::Config::default(); @@ -311,4 +904,116 @@ mod tests { assert!(result.content[0].text.contains("security-first")); assert!(result.content[0].text.contains("rust-strict")); } + + // ─── Code Intelligence Tool Tests ─── + + #[test] + fn list_tools_includes_code_intel() { + let tools = list_tools(); + assert!(tools.iter().any(|t| t.name == "cora.search_symbols")); + assert!(tools.iter().any(|t| t.name == "cora.find_callers")); + assert!(tools.iter().any(|t| t.name == "cora.find_impact")); + assert!(tools.iter().any(|t| t.name == "cora.find_affected_tests")); + assert!(tools.iter().any(|t| t.name == "cora.index_status")); + } + + #[test] + fn handle_index_status_no_index() { + // From test dir, there should be no index + let result = handle_tool_call("cora.index_status", &serde_json::json!({})); + // May error if no index — that's acceptable + assert!(result.is_error || result.content[0].text.contains("total_symbols")); + } + + #[test] + fn handle_search_symbols_missing_query() { + let result = handle_tool_call("cora.search_symbols", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_find_callers_missing_symbol() { + let result = handle_tool_call("cora.find_callers", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_find_impact_missing_symbol() { + let result = handle_tool_call("cora.find_impact", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_find_affected_tests_missing_files() { + let result = handle_tool_call("cora.find_affected_tests", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_find_affected_tests_empty_files() { + let result = handle_tool_call( + "cora.find_affected_tests", + &serde_json::json!({"files": []}), + ); + assert!(result.is_error); + } + + // ─── Phase 2 Tool Tests ─── + + #[test] + fn list_tools_includes_phase2() { + let tools = list_tools(); + assert!(tools.iter().any(|t| t.name == "cora.review_diff")); + assert!(tools.iter().any(|t| t.name == "cora.get_debt")); + } + + #[test] + fn handle_review_diff_missing_diff() { + let result = handle_tool_call("cora.review_diff", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_review_diff_empty_diff() { + let result = handle_tool_call("cora.review_diff", &serde_json::json!({"diff": ""})); + assert!(result.is_error); + } + + #[test] + fn handle_get_debt_returns_data_or_error() { + let result = handle_tool_call("cora.get_debt", &serde_json::json!({})); + // Should either return data or error (no snapshots) + // Accept any non-panic result — debt state depends on environment + let _ = result; + } + + // ─── Phase 3 Tool Tests ─── + + #[test] + fn list_tools_includes_phase3() { + let tools = list_tools(); + assert!(tools.iter().any(|t| t.name == "cora.get_project_info")); + assert!(tools.iter().any(|t| t.name == "cora.get_memory")); + } + + #[test] + fn handle_get_project_info() { + let result = handle_tool_call("cora.get_project_info", &serde_json::json!({})); + assert!(!result.is_error); + assert!(result.content[0].text.contains("repository")); + assert!(result.content[0].text.contains("cora_version")); + } + + #[test] + fn handle_get_memory_missing_query() { + let result = handle_tool_call("cora.get_memory", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn total_tool_count() { + let tools = list_tools(); + // Phase 1 (5 existing) + Phase 1 code intel (5) + Phase 2 (2) + Phase 3 (2) = 14 + assert_eq!(tools.len(), 14); + } }